diff --git a/openspec/changes/mcp-tool-surface/.openspec.yaml b/openspec/changes/mcp-tool-surface/.openspec.yaml new file mode 100644 index 000000000..95672402a --- /dev/null +++ b/openspec/changes/mcp-tool-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-18 diff --git a/openspec/changes/mcp-tool-surface/design.md b/openspec/changes/mcp-tool-surface/design.md new file mode 100644 index 000000000..f44389818 --- /dev/null +++ b/openspec/changes/mcp-tool-surface/design.md @@ -0,0 +1,83 @@ +# Design — LaunchPad MCP Tool Surface + +## Context + +Product intent: every app action should be automatable by AI under per-agent, default-deny, granularly granted rights (hermiq's scope × reach model), and a user should be able to command the app from chat. LaunchPad's action surface is large (~120 routes) and today entirely invisible to MCP. The design questions were: which mechanism (provider vs schema-derived tools), how to cut ~120 routes into an honest tool catalogue, how to guarantee authorization parity, and what deliberately stays out. + +The fleet reference is `decidesk/lib/Mcp/` — `DecideskToolProvider` (dispatcher + `TOOL_DESCRIPTORS` constant), `McpMeetingGate` (centralised authorise(), explicitly no fail-open), `McpArgumentValidator` (typed argument checks returning structured errors), `McpMeetingScopeResolver` (caller scoping) — registered via the string DI alias `OCA\OpenRegister\Mcp\IMcpToolProvider::decidesk`. + +## Goals / Non-Goals + +**Goals:** + +- Full coverage of launchpad's *user* actions: 14 read + 35 write tools, ids `launchpad.{toolName}`. +- Scope (`read`/`create`/`update`/`delete`) × reach (`self`/`user`/`instance`/`external`) annotations on every tool so hermiq can grant per tool, default-deny writes. +- Authorization parity: tools call the same `PermissionService` / `ActionAuthService` guards as the HTTP controllers — one authorization truth. +- Human-approval flags on destructive (`delete`) and boundary-crossing (`external`) tools; audit-ready structured results. + +**Non-Goals:** + +- Building a chat UI or an agent runtime — hermiq hosts the conversation and the grant/approval/audit machinery. +- Tooling telemetry, binary uploads, editing locks, acknowledgement writes, or one-off admin plumbing (see REQ-MCP-007). +- Replacing `launchpad-ai-dashboard-assistant` — that change renders AI summaries *on* the canvas; this one puts the canvas's actions *under* AI control. No shared code. + +## Decisions + +### D1: Imperative provider for all writes; schema-derived tools for read discovery only + +**Decision**: All writes go through `LaunchpadToolProvider` handlers calling the existing service layer. The `Dashboard` schema additionally gets an `x-openregister-mcp` block (openconnector pattern) deriving only read-only `search`/`get`. + +**Alternatives considered:** + +- **Schema-derived CRUD for everything** (`x-openregister-mcp` with writes enabled): rejected. Dashboard mutations are not raw object writes — `DashboardService::createDashboard()` generates slugs, `updateDashboard()` respects locks and permission levels, widget placement runs collision handling (`PlacementService`), deletes cascade. Auto-derived writes would bypass every one of those invariants. +- **Provider-only, no schema block**: workable, but the two read paths cost one small overlay and give agents uniform object discovery across the fleet. Kept. + +**Rationale**: decidesk proved the provider shape; openconnector proved read-only schema blocks. LaunchPad needs both halves for its split of "rich service invariants on writes" vs "plain object reads". + +### D2: One tool per user action, not per route + +**Decision**: The catalogue aggregates exactly where the HTTP surface aggregates: `updateDashboard` covers rename/description/slug (as `dashboardApi#update` does), `updateWidgetPlacement` covers move/resize/restyle/reconfigure (as `widgetApi#updatePlacement` does), `setPublicationState` covers publish/unpublish/schedule (three routes, one user intent). + +**Alternatives considered:** + +- **One tool per route (~120 tools)**: rejected — floods the model's tool context, and route-level granularity (e.g. separate publish/unpublish tools) adds grant-matrix rows without adding a meaningfully different permission decision. +- **A handful of coarse tools ("manageDashboard")**: rejected — coarse tools break the grant model; hermiq cannot grant "reposition widgets but never delete dashboards" if both hide inside one tool. + +**Rationale**: the grant matrix is the sizing instrument: two actions belong in one tool iff a rights-granter would never want to split them. Rename-vs-describe: never split. Add-widget vs delete-dashboard: always split. + +### D3: Reach classification — kiosk and public links are `external`, sharing is `user`, group/template management is `instance` + +**Decision**: `createPublicShareLink`, `revokePublicShareLink`, and all three kiosk playlist writes carry reach `external`; `shareDashboard`/`replaceShares`/`unshareDashboard`/`setPublicationState` carry `user`; group-dashboard CRUD, `setGroupDefault`, and template CRUD/resync carry `instance`. + +**Alternatives considered:** + +- **Kiosk as `self`** ("it's the caller's playlist"): rejected — a kiosk playlist mints a `#[PublicPage]` token (`kiosk#render`, `/kiosk/{token}`) that serves dashboards to anonymous viewers. What a write *exposes* determines reach, not where the record lives. Same logic as public share links. + +**Rationale**: reach must encode blast radius as the rights-granter experiences it. Anything that changes who-can-see across the auth boundary is `external`; across users inside the instance is `user`; instance topology is `instance`. + +### D4: Authorization is a gate class delegating to the existing guards — never re-implemented + +**Decision**: `McpDashboardGate` wraps `PermissionService::can*()`, `ActionAuthService::requireAction()` (ADR-023), and the admin check; every handler consults it first. Errors deny (no `catch (\Throwable) → null`, per the unsafe-auth-resolver gate). Handlers then call `DashboardService` / `WidgetService` / `TileService` / `DashboardShareService` / `PublicShareService` / `KioskService` / `TemplateService` — never controllers (no HTTP self-calls), never mappers or `ObjectService` directly for writes. + +**Rationale**: The permission logic already exists, is audited, and drifts if duplicated. decidesk's `McpMeetingGate` documents the same invariants (guards return bool, no unconditional true, no Throwable-swallowing) — codified here as spec scenarios. + +### D5: Exclusions are specified, not implied + +**Decision**: REQ-MCP-007 names every real endpoint family that gets no tool and why — telemetry recorders (agents must not fabricate analytics), acknowledgement writes (compliance evidence of a *human* act), binary uploads (not chat-shaped), editing locks (interactive-session mechanics), one-off admin plumbing (wizard, imports, cleanup, bulk ops, settings surfaces). + +**Rationale**: an unlisted omission looks like an oversight and invites a future "completeness" PR to tool the acknowledgement endpoint — the one addition that would be actively harmful. The exclusion list makes the boundary a tested assertion (catalogue-closure scenario) instead of folklore. + +## Risks / Trade-offs + +- **Catalogue size (49 tools)**: large for a single provider. Mitigated by domain-grouped handler classes and the descriptor constant being a plain fixture unit tests assert against (decidesk pattern). If hermiq context budgets demand it, read tools can later be collapsed behind the schema-derived `search`/`get` without touching write semantics. +- **Grant-model drift**: scope/reach vocabulary is hermiq's; if hermiq's axes change, annotations must follow. The descriptors keep annotations as data, so this is a constant edit, not a refactor. +- **Instance-reach tools in agent hands**: template and group-dashboard tools are powerful. Defence in depth: admin gate in `McpDashboardGate` + `instance` reach requiring an explicit hermiq grant + approval gate on deletes. + +## Migration Plan + +Additive only: new `lib/Mcp/` classes, one DI alias line, one register.d overlay. No routes, no schema data changes. Rollback = remove the alias registration (tools disappear from the catalogue) — no data cleanup needed. + +## Open Questions + +- Whether hermiq wants pagination cursors on `listDashboards`/`listTemplates` results or is satisfied with the endpoint's existing limits — decide against hermiq's consumer contract during implementation. +- Whether `launchpad.forkDashboard` should accept a template *gallery id* directly or only dashboard uuids (gallery entries are dashboards, so uuid-only is likely sufficient) — confirm against `template#gallery`'s payload shape. diff --git a/openspec/changes/mcp-tool-surface/hydra.json b/openspec/changes/mcp-tool-surface/hydra.json new file mode 100644 index 000000000..efcddfcec --- /dev/null +++ b/openspec/changes/mcp-tool-surface/hydra.json @@ -0,0 +1,8 @@ +{ + "spec_slug": "mcp-tools", + "title": "MCP tool surface — every launchpad action commandable from chat", + "app": "launchpad", + "repo": "https://github.com/ConductionNL/launchpad", + "depends_on": [], + "issue": null +} diff --git a/openspec/changes/mcp-tool-surface/proposal.md b/openspec/changes/mcp-tool-surface/proposal.md new file mode 100644 index 000000000..28696318b --- /dev/null +++ b/openspec/changes/mcp-tool-surface/proposal.md @@ -0,0 +1,56 @@ +# MCP tool surface — every launchpad action commandable from chat + +## Why + +The product direction is that every Conduction app exposes its actions as MCP tools, so any action can in principle be automated by an AI agent — and so a user can simply chat while their apps execute their commands: "add the calendar widget to my dashboard", "share the sprint dashboard with the sales group". hermiq consumes these tools under its two-axis **scope × reach** grant model: rights are granted per agent, granularly, with writes denied by default, human approval gates on destructive/external actions, and a full audit trail. + +LaunchPad currently exposes **nothing** to that surface. Verified by grep: no `OCA\Launchpad\Mcp` namespace, no `IMcpToolProvider` implementation, no `x-openregister-mcp` block anywhere in `lib/`, `appinfo/`, or `src/` (zero hits). Meanwhile the app has one of the richest user-action surfaces in the fleet — `appinfo/routes.php` registers ~120 routes over dashboards, widgets, tiles, sharing, publication, versioning, templates, locks, and admin operations. + +The fleet mechanism is proven: decidesk ships the reference implementation (`decidesk/lib/Mcp/` — `DecideskToolProvider` implementing `OCA\OpenRegister\Mcp\IMcpToolProvider`, registered under the DI alias `OCA\OpenRegister\Mcp\IMcpToolProvider::decidesk`, tool ids `decidesk.{toolName}`, with `McpMeetingGate` for authorization, `McpArgumentValidator` for input validation, and `McpMeetingScopeResolver` for caller scoping), and openconnector demonstrates schema-level `x-openregister-mcp` blocks for auto-derived read tools. This change gives launchpad the same surface, sized to its real action inventory. + +**Relation to `launchpad-ai-dashboard-assistant`**: that change points AI *at* the dashboard — AI-generated summary widgets rendered on the canvas. This change points AI at launchpad's *actions* — the dashboard becomes something an agent can build, rearrange, and share on the user's behalf. They are complementary and share no code; where the assistant change's widgets need data, they read it through widget endpoints, not through these tools. + +## What Changes + +- New `lib/Mcp/` namespace with `LaunchpadToolProvider` implementing `OCA\OpenRegister\Mcp\IMcpToolProvider`, registered in `AppInfo\Application` under the DI alias `OCA\OpenRegister\Mcp\IMcpToolProvider::launchpad`. Tool ids are `launchpad.{toolName}`. +- A full-coverage tool catalogue derived from the real controller/service surface (see spec REQ-MCP-002/003): 14 read tools and 35 write tools covering dashboard CRUD, active/default selection, forking, widget add/reposition/remove, tile CRUD, sharing, publication state, public links, versioning, metadata, group dashboards, and admin template management. +- Read tools carry `readOnlyHint: true`; every write tool is annotated with **scope** (`create` / `update` / `delete`) and **reach** (`self` / `user` / `instance` / `external`) so hermiq's grant matrix can authorize each tool individually, default-deny. +- Authorization parity: every tool routes through the exact same guards as the HTTP surface — `PermissionService::can*()`, `ActionAuthService::requireAction()` (ADR-023 matrix), and the admin checks — via a `McpDashboardGate` mirroring decidesk's `McpMeetingGate`. Argument validation via a `McpArgumentValidator` counterpart. +- A read-only `x-openregister-mcp` block (`search`/`get`, openconnector pattern) on the `Dashboard` schema for discovery parity; auto-derived CRUD **writes stay disabled** because dashboard mutations must pass through `DashboardService` invariants (slug generation, lock checks, permission levels), never raw object writes. + +## Capabilities + +### New Capabilities + +- `mcp-tools` — launchpad's MCP tool surface: catalogue, scope/reach annotations, authorization parity, and chat-command flows. + +### Modified Capabilities + +(none) + +## Impact + +**Affected code:** + +- `lib/Mcp/LaunchpadToolProvider.php` — dispatcher + tool catalogue (new) +- `lib/Mcp/McpDashboardTools.php`, `lib/Mcp/McpWidgetTools.php`, `lib/Mcp/McpSharingTools.php`, `lib/Mcp/McpAdminTools.php` — handlers grouped by domain (new) +- `lib/Mcp/McpDashboardGate.php`, `lib/Mcp/McpArgumentValidator.php` — authorization + validation (new) +- `lib/AppInfo/Application.php` — DI alias registration (string alias, no autoload at boot, per the ADR-083-safe pattern decidesk uses) +- `lib/Settings/register.d/dashboard-mcp.json` — read-only `x-openregister-mcp` overlay (new) + +**Affected APIs:** + +- No new HTTP routes. Tools call the existing service layer (`DashboardService`, `WidgetService`/`WidgetPlacementService`, `TileService`, `DashboardShareService`, `PublicShareService`, `TemplateService`) — never controllers, never raw ObjectService writes. + +**Dependencies:** + +- OpenRegister providing `OCA\OpenRegister\Mcp\IMcpToolProvider`; hermiq as the consuming agent host. Without OpenRegister the alias resolves lazily and fails only when asked for (decidesk pattern), so launchpad still boots. + +**Migration:** + +- None. Additive surface; no schema or data changes beyond the read-only MCP overlay. + +## Notes + +- Deliberately excluded from the tool surface (REQ-MCP-007): telemetry recorders (view events, tile clicks — agents must not fabricate analytics), binary upload endpoints (resources, template preview images — chat tools take JSON, not multipart), the setup wizard, Confluence import, orphaned-data cleanup, and demo showcases (one-off admin plumbing, high blast radius, no chat use case). +- Sizing note: unlike decidesk's 5-tool exemplar, launchpad's honest action inventory is large. The catalogue is still one-tool-per-user-action, not per-route: e.g. rename/describe/re-slug are all `launchpad.updateDashboard`; move/resize/style are all `launchpad.updateWidgetPlacement` — matching how `dashboardApi#update` and `widgetApi#updatePlacement` already aggregate them. diff --git a/openspec/changes/mcp-tool-surface/specs/mcp-tools/spec.md b/openspec/changes/mcp-tool-surface/specs/mcp-tools/spec.md new file mode 100644 index 000000000..75e4a37b0 --- /dev/null +++ b/openspec/changes/mcp-tool-surface/specs/mcp-tools/spec.md @@ -0,0 +1,256 @@ +--- +capability: mcp-tools +delta: false +status: draft +--- + +# LaunchPad MCP Tool Surface + +## Context + +LaunchPad exposes its user actions as MCP tools so agents (hermiq) can execute them under per-agent, default-deny grants, and so users can command the app from chat. The provider mechanism follows the fleet reference implementation in `decidesk/lib/Mcp/` (`DecideskToolProvider`, `McpMeetingGate`, `McpArgumentValidator`, `McpMeetingScopeResolver`); the catalogue below is derived from launchpad's real route/service surface (`appinfo/routes.php`, `DashboardService`, `WidgetService`, `TileService`, `DashboardShareService`, `PublicShareService`, `TemplateService`, `KioskService`, `ReactionService`, `AcknowledgementService`). LaunchPad currently has no MCP surface at all (verified: zero `IMcpToolProvider` / `x-openregister-mcp` hits in the repo). + +Write tools are annotated on hermiq's two grant axes: + +- **scope** — what kind of mutation: `create` / `update` / `delete` (reads carry `read`). +- **reach** — whose world it touches: `self` (the caller's own dashboards/objects), `user` (grants or removes other users' access), `instance` (instance-wide: group dashboards, admin templates), `external` (crosses the authentication boundary: public links, kiosk tokens). + +## ADDED Requirements + +### Requirement: REQ-MCP-001 Provider class, DI alias, and tool id namespace + +LaunchPad MUST implement `OCA\OpenRegister\Mcp\IMcpToolProvider` in a new class `OCA\LaunchPad\Mcp\LaunchpadToolProvider` (dispatcher + catalogue constant, handlers in domain-grouped classes, per the decidesk layout). It MUST be registered in `lib/AppInfo/Application.php` under the string DI alias `OCA\OpenRegister\Mcp\IMcpToolProvider::launchpad` (string alias so registration triggers no autoload and the app boots without OpenRegister, matching decidesk's `IMcpToolProvider::decidesk` registration). Every tool id MUST use the `launchpad.{toolName}` namespace. + +#### Scenario: Provider resolves through the alias + +- **GIVEN** an instance with OpenRegister and launchpad enabled +- **WHEN** the container resolves `OCA\OpenRegister\Mcp\IMcpToolProvider::launchpad` +- **THEN** it MUST return a `LaunchpadToolProvider` instance +- **AND** every id in its catalogue MUST start with `launchpad.` + +#### Scenario: Boot without OpenRegister + +- **GIVEN** an instance where OpenRegister is not installed +- **WHEN** launchpad boots +- **THEN** app registration MUST complete without error — the alias fails only if something asks for it + +### Requirement: REQ-MCP-002 Read tool catalogue (full read coverage) + +The provider MUST expose the following read tools, each with `readOnlyHint: true` and scope `read`, reach `self` (each returns only what the caller may already see through the corresponding endpoint's guards): + +| Tool id | Backs | +|---|---| +| `launchpad.listDashboards` | `dashboardApi#visible` / `DashboardService::getVisibleToUser()` | +| `launchpad.getDashboard` | `dashboardApi#show` / `#resolved` | +| `launchpad.getActiveDashboard` | `dashboardApi#getActive` / `DashboardService::resolveActiveDashboard()` | +| `launchpad.getDashboardTree` | `dashboardApi#tree` | +| `launchpad.listGroupDashboards` | `dashboardApi#listGroup` | +| `launchpad.listWidgetTypes` | `widgetApi#listAvailable` | +| `launchpad.listTiles` | `tileApi#index` | +| `launchpad.listShares` | `dashboardShareApi#index` | +| `launchpad.listVersions` | `dashboardVersionApi#listVersions` | +| `launchpad.listTemplates` | `template#gallery` | +| `launchpad.getDashboardMetadata` | `dashboardMetadata#getMetadata` | +| `launchpad.getReactions` | `dashboardReactionApi#getReactions` | +| `launchpad.listPendingAcknowledgements` | `acknowledgement#pending` | +| `launchpad.listKioskPlaylists` | `kiosk#index` | + +#### Scenario: Read tools are marked read-only + +- **GIVEN** the provider's catalogue +- **WHEN** hermiq inspects the 14 read tools +- **THEN** each MUST carry `readOnlyHint: true` and scope `read` +- **AND** none of them MUST mutate any state when invoked + +#### Scenario: Reads return only the caller's visible set + +- **GIVEN** a caller who can see dashboards A and B but not C +- **WHEN** `launchpad.listDashboards` is invoked on their behalf +- **THEN** the result MUST contain A and B and MUST NOT contain C — identical to `GET /api/dashboards/visible` + +### Requirement: REQ-MCP-003 Write tool catalogue with scope × reach annotations (full action coverage) + +The provider MUST expose the following write tools — one tool per user action, aggregated exactly as the existing endpoints aggregate them (e.g. rename/describe/re-slug are all `updateDashboard`, matching `dashboardApi#update`; move/resize/style are all `updateWidgetPlacement`, matching `widgetApi#updatePlacement`). Each write tool MUST declare the scope and reach listed: + +**Reach `self` — the caller's own dashboards:** + +| Tool id | Backs | Scope | +|---|---|---| +| `launchpad.createDashboard` | `dashboardApi#create` / `DashboardService::createDashboard()` | create | +| `launchpad.updateDashboard` | `dashboardApi#update` / `DashboardService::updateDashboard()` | update | +| `launchpad.deleteDashboard` | `dashboardApi#delete` / `DashboardService::deleteDashboard()` | delete | +| `launchpad.switchActiveDashboard` | `dashboardApi#setActiveDashboard` / `#activate` | update | +| `launchpad.setDefaultDashboard` | `dashboardApi#setDefaultDashboard` / `DashboardService::setDefaultPreference()` | update | +| `launchpad.forkDashboard` | `dashboardApi#fork` / `DashboardService::forkAsPersonal()` — also the "use a gallery template" path | create | +| `launchpad.addWidget` | `widgetApi#addWidget` | create | +| `launchpad.updateWidgetPlacement` | `widgetApi#updatePlacement` | update | +| `launchpad.removeWidget` | `widgetApi#removePlacement` | delete | +| `launchpad.addTileToDashboard` | `widgetApi#addTile` | create | +| `launchpad.createTile` | `tileApi#create` | create | +| `launchpad.updateTile` | `tileApi#update` | update | +| `launchpad.deleteTile` | `tileApi#destroy` | delete | +| `launchpad.createDashboardVersion` | `dashboardVersionApi#createVersion` | create | +| `launchpad.restoreDashboardVersion` | `dashboardVersionApi#restoreVersion` | update | +| `launchpad.setDashboardMetadata` | `dashboardMetadata#setMetadata` | update | +| `launchpad.addReaction` | `dashboardReactionApi#addReaction` | create | +| `launchpad.removeReaction` | `dashboardReactionApi#removeReaction` | delete | + +**Reach `user` — changes other users' access:** + +| Tool id | Backs | Scope | +|---|---|---| +| `launchpad.shareDashboard` | `dashboardShareApi#create` | create | +| `launchpad.replaceShares` | `dashboardShareApi#replace` | update | +| `launchpad.unshareDashboard` | `dashboardShareApi#destroy` | delete | +| `launchpad.setPublicationState` | `dashboardApi#publish` / `#unpublish` / `#schedule` | update | + +**Reach `external` — mints or revokes anonymous access:** + +| Tool id | Backs | Scope | +|---|---|---| +| `launchpad.createPublicShareLink` | `publicShare#create` | create | +| `launchpad.revokePublicShareLink` | `publicShare#destroy` | delete | +| `launchpad.createKioskPlaylist` | `kiosk#create` | create | +| `launchpad.updateKioskPlaylist` | `kiosk#update` | update | +| `launchpad.deleteKioskPlaylist` | `kiosk#destroy` | delete | + +**Reach `instance` — instance-wide (group dashboards, admin templates):** + +| Tool id | Backs | Scope | +|---|---|---| +| `launchpad.createGroupDashboard` | `dashboardApi#createGroup` / `DashboardService::createGroupShared()` | create | +| `launchpad.updateGroupDashboard` | `dashboardApi#updateGroup` | update | +| `launchpad.deleteGroupDashboard` | `dashboardApi#deleteGroup` | delete | +| `launchpad.setGroupDefault` | `dashboardApi#setGroupDefault` | update | +| `launchpad.createTemplate` | `admin#createTemplate` / `AdminTemplateService` | create | +| `launchpad.updateTemplate` | `admin#updateTemplate` | update | +| `launchpad.deleteTemplate` | `admin#deleteTemplate` | delete | +| `launchpad.resyncTemplate` | `admin#resyncTemplate` / `TemplateResyncService` | update | + +#### Scenario: Every write tool carries both axes + +- **GIVEN** the provider's catalogue +- **WHEN** hermiq inspects any non-read tool +- **THEN** it MUST find a scope of `create`, `update`, or `delete` AND a reach of `self`, `user`, `instance`, or `external` +- **AND** no write tool MUST carry `readOnlyHint: true` + +#### Scenario: Writes are default-deny in hermiq + +- **GIVEN** an agent with no explicit grants for launchpad +- **WHEN** it attempts `launchpad.deleteDashboard` +- **THEN** hermiq MUST refuse before the tool executes — the tool's annotations exist so this decision is per-tool, not app-wide + +#### Scenario: External-reach tools are the narrowest grant + +- **GIVEN** an agent granted `create/self` and `update/self` for launchpad +- **WHEN** it attempts `launchpad.createPublicShareLink` +- **THEN** the call MUST be refused — `external` reach requires its own explicit grant + +### Requirement: REQ-MCP-004 Authorization parity with the HTTP surface + +Every tool handler MUST enforce the exact same authorization as the corresponding controller path, by calling the same service guards: `PermissionService::canViewDashboard()` / `canEditDashboard()` / `canAddWidget()` / `canRemoveWidget()` / `canCreateDashboard()`, `ActionAuthService::requireAction()` for ADR-023-gated actions, and the admin requirement for `instance`-reach tools. This MUST be centralised in a `McpDashboardGate` class (mirroring decidesk's `McpMeetingGate`: authorise() returning an allow/deny result, no `catch (\Throwable) { return null; }` fail-open shapes). A tool MUST NOT reach `ObjectService` or mappers directly for writes — all mutations go through the existing service layer so slug generation, lock checks, placement collision handling, and cascade behaviour hold. + +#### Scenario: Tool denial equals endpoint denial + +- **GIVEN** a caller for whom `PUT /api/dashboard/{id}` would return a permission error +- **WHEN** the same caller's agent invokes `launchpad.updateDashboard` for that dashboard +- **THEN** the tool MUST deny with a structured error +- **AND** no partial mutation MUST occur + +#### Scenario: Admin tools require admin + +- **GIVEN** a non-admin caller +- **WHEN** their agent invokes `launchpad.createTemplate` +- **THEN** the gate MUST deny exactly as `admin#createTemplate` does for that caller + +#### Scenario: No fail-open on gate errors + +- **GIVEN** the authorization service throws during a tool call +- **WHEN** the gate evaluates the request +- **THEN** the result MUST be a denial, never a silent allow + +### Requirement: REQ-MCP-005 Argument validation before any service call + +Every tool MUST validate its arguments against its declared `inputSchema` before touching a service, via a `McpArgumentValidator` (decidesk pattern: typed UUID/date/string-length checks returning a structured error array on failure). Invalid arguments MUST produce a validation error result, never an exception escaping the provider and never a service call with coerced values. + +#### Scenario: Malformed UUID is rejected + +- **GIVEN** an agent invokes `launchpad.getDashboard` with `dashboardUuid: "../../etc"` +- **WHEN** the validator runs +- **THEN** the tool MUST return a validation error without any service or database access + +#### Scenario: Out-of-range grid values are rejected + +- **GIVEN** `launchpad.updateWidgetPlacement` is invoked with a negative `gridWidth` +- **WHEN** the validator runs +- **THEN** the tool MUST return a validation error naming the offending field + +### Requirement: REQ-MCP-006 Destructive and external tools are flagged for human approval and audited + +Tools with scope `delete`, and all tools with reach `external`, MUST carry a destructive/approval annotation so hermiq inserts its human approval gate before execution. Every write tool invocation (allowed or denied) MUST be auditable: the provider returns enough structured context (tool id, target uuid/id, caller uid, outcome) for hermiq's audit trail to record the action. + +#### Scenario: Delete requires approval + +- **GIVEN** a user tells the chat "delete my old sales dashboard" +- **WHEN** the agent resolves this to `launchpad.deleteDashboard` +- **THEN** hermiq MUST present a human approval step before the tool runs +- **AND** the approval and execution MUST both land in the audit trail + +#### Scenario: Public link requires approval + +- **GIVEN** an agent invokes `launchpad.createPublicShareLink` +- **WHEN** hermiq evaluates the call +- **THEN** the external-reach annotation MUST trigger the approval gate even if the agent holds a `create/external` grant + +### Requirement: REQ-MCP-007 Deliberate exclusions from the tool surface + +The following real endpoints MUST NOT get tools, and the catalogue MUST stay closed against them without an explicit spec change: + +- **Telemetry recorders** — `dashboardApi#viewEvent`, `tileAnalytics#recordClick`: an agent invoking these fabricates analytics. +- **Acknowledgements (write)** — `acknowledgement#acknowledge`: a mandatory-read acknowledgement is compliance evidence of a *human* act; an agent must never acknowledge on a user's behalf. (The read side, `listPendingAcknowledgements`, is exposed.) +- **Binary upload endpoints** — `resource#upload`/`#uploadMultipart`, `admin#uploadTemplatePreviewImage`, `filesWidget#upload`: multipart/base64 payloads are not chat-tool shaped; revisit if a file-reference contract lands. +- **Editing locks** — `dashboardLockApi#*`: locks serialize *interactive* editing sessions; tool calls are single atomic service operations and must not hold or break user locks. +- **One-off admin plumbing** — setup wizard, Confluence import, orphaned-data cleanup, demo showcases, bulk operations, export/import, role/metadata-field/org-navigation/footer/settings administration: high blast radius, no chat use case; deliberately out of v1. + +#### Scenario: Catalogue contains no excluded tool + +- **GIVEN** the provider's catalogue +- **WHEN** its ids are compared against the exclusion list +- **THEN** no tool MUST exist for any excluded endpoint +- **AND** the catalogue MUST contain exactly the tools of REQ-MCP-002 and REQ-MCP-003 + +### Requirement: REQ-MCP-008 Chat command flows resolve to tool sequences + +The catalogue MUST be sufficient for hermiq to execute common chat commands as short tool sequences using only declared tools, with all resolution (name → uuid, widget label → widgetKey) done via read tools first. + +#### Scenario: "Add the calendar widget to my dashboard" + +- **GIVEN** a user with an active personal dashboard and an agent granted `read/self` and `create/self` +- **WHEN** the user sends "add the calendar widget to my dashboard" +- **THEN** the agent MUST be able to resolve it as `launchpad.getActiveDashboard` → `launchpad.listWidgetTypes` (find the calendar widgetKey) → `launchpad.addWidget` +- **AND** the widget MUST appear on the dashboard exactly as if added through the UI + +#### Scenario: "Share my team dashboard with the sales group" + +- **GIVEN** an agent granted `read/self` and `create/user` +- **WHEN** the user sends "share my team dashboard with the sales group" +- **THEN** the agent MUST resolve the dashboard via `launchpad.listDashboards`, then invoke `launchpad.shareDashboard` with a group share for `sales` +- **AND** the share MUST be identical to one created via `POST /api/dashboard/{id}/shares` + +#### Scenario: "Start me a dashboard from the sprint template and make it my default" + +- **GIVEN** an agent granted `read/self`, `create/self`, and `update/self` +- **WHEN** the user sends the command +- **THEN** the agent MUST resolve it as `launchpad.listTemplates` → `launchpad.forkDashboard` → `launchpad.setDefaultDashboard` +- **AND** the resulting dashboard MUST be the caller's default landing dashboard + +### Requirement: REQ-MCP-009 Read-only x-openregister-mcp block on the Dashboard schema + +A register.d overlay MUST declare `x-openregister-mcp` on the `Dashboard` schema with auto-derived **read** tools only (`search`, `get` with `readOnlyHint: true`, openconnector pattern). Auto-derived CRUD writes MUST NOT be enabled: dashboard mutations bypass `DashboardService` invariants (slug generation, permission levels, lock checks) if written as raw objects, so all writes stay with the provider tools of REQ-MCP-003. + +#### Scenario: Schema-derived tools are read-only + +- **GIVEN** the imported Dashboard schema +- **WHEN** OpenRegister derives MCP tools from its `x-openregister-mcp` block +- **THEN** only `search` and `get` MUST be derived, both read-only +- **AND** no derived create/update/delete tool MUST exist for the Dashboard schema diff --git a/openspec/changes/mcp-tool-surface/tasks.md b/openspec/changes/mcp-tool-surface/tasks.md new file mode 100644 index 000000000..81aebdfda --- /dev/null +++ b/openspec/changes/mcp-tool-surface/tasks.md @@ -0,0 +1,72 @@ +# Tasks — mcp-tool-surface + +## Provider Skeleton + +- [ ] Task 1: REQ-MCP-001 — Create `lib/Mcp/LaunchpadToolProvider.php` implementing `OCA\OpenRegister\Mcp\IMcpToolProvider`: dispatcher only, tool catalogue as a `TOOL_DESCRIPTORS` class constant (unit-testable fixture), routing to domain handler classes — mirror `decidesk/lib/Mcp/DecideskToolProvider.php` +- [ ] Task 2: REQ-MCP-001 — Register the string DI alias `'OCA\\OpenRegister\\Mcp\\IMcpToolProvider::launchpad'` in `lib/AppInfo/Application::register()` (string alias, no autoload at registration — decidesk's `DomainServiceRegistrar` pattern, ADR-083 boot safety) +- [ ] Task 3: REQ-MCP-001 — All tool ids namespaced `launchpad.{toolName}`; add a unit test asserting the namespace and id uniqueness across the catalogue + +## Gate and Validator + +- [ ] Task 4: REQ-MCP-004 — Create `lib/Mcp/McpDashboardGate.php`: `currentUserId()`, `authorise(toolId, args)` delegating to `PermissionService::canViewDashboard()/canEditDashboard()/canAddWidget()/canRemoveWidget()/canCreateDashboard()`, `ActionAuthService::requireAction()` for ADR-023-gated actions, and the admin check for `instance`-reach tools. No `catch (\Throwable) { return null; }` shapes (hydra-gate-unsafe-auth-resolver) +- [ ] Task 5: REQ-MCP-005 — Create `lib/Mcp/McpArgumentValidator.php`: UUID, id, string-length, enum, and grid-bound checks returning structured error arrays (decidesk's validator as the template) +- [ ] Task 6: REQ-MCP-004 — Every handler: validate → authorise → call the existing service (`DashboardService`, `WidgetService`/`WidgetPlacementService`, `TileService`, `DashboardShareService`, `PublicShareService`, `KioskService`, `TemplateService`/`AdminTemplateService`, `ReactionService`, `AcknowledgementService`, `MetadataService`, `DashboardVersionService`). Never controllers, never mappers/ObjectService directly for writes + +## Read Tools (REQ-MCP-002) + +- [ ] Task 7: Implement the 14 read tools in `lib/Mcp/McpDashboardTools.php` (dashboard reads) and sibling handler classes: listDashboards, getDashboard, getActiveDashboard, getDashboardTree, listGroupDashboards, listWidgetTypes, listTiles, listShares, listVersions, listTemplates, getDashboardMetadata, getReactions, listPendingAcknowledgements, listKioskPlaylists — all `readOnlyHint: true`, scope `read`, reach `self` +- [ ] Task 8: REQ-MCP-002 — Unit test: each read tool's result for a fixture caller equals the visibility the corresponding service call grants that caller (visible-set parity, not superset) + +## Write Tools (REQ-MCP-003) + +- [ ] Task 9: Implement the 18 `self`-reach write tools (dashboard CRUD, active/default, fork, widget add/update/remove, tile CRUD + addTileToDashboard, version create/restore, metadata set, reaction add/remove) with per-tool `inputSchema` +- [ ] Task 10: Implement the 4 `user`-reach tools (shareDashboard, replaceShares, unshareDashboard, setPublicationState) +- [ ] Task 11: Implement the 5 `external`-reach tools (createPublicShareLink, revokePublicShareLink, createKioskPlaylist, updateKioskPlaylist, deleteKioskPlaylist) +- [ ] Task 12: Implement the 8 `instance`-reach tools (group dashboard CRUD, setGroupDefault, template create/update/delete/resync) +- [ ] Task 13: REQ-MCP-003 — Unit test on the descriptor fixture: every write tool declares exactly one scope ∈ {create, update, delete} and one reach ∈ {self, user, instance, external}; no write carries `readOnlyHint: true` +- [ ] Task 14: REQ-MCP-006 — Annotate all `delete`-scope and all `external`-reach tools with the destructive/approval flag hermiq's approval gate consumes; every handler result includes tool id, target identifier, caller uid, and outcome for the audit trail + +## Exclusions (REQ-MCP-007) + +- [ ] Task 15: Unit test asserting catalogue closure: the descriptor set equals exactly the REQ-MCP-002 + REQ-MCP-003 ids, and contains no tool for viewEvent, tile-click recording, `acknowledgement#acknowledge`, resource/preview/files uploads, lock endpoints, wizard, Confluence import, cleanup, demo showcases, bulk ops, export/import, or settings surfaces +- [ ] Task 16: Document the exclusion rationale in the provider's class docblock so a future "completeness" sweep reads why before adding + +## Schema Overlay (REQ-MCP-009) + +- [ ] Task 17: Create `lib/Settings/register.d/dashboard-mcp.json` adding an `x-openregister-mcp` block to the `Dashboard` schema with read-only `search` and `get` tools (`readOnlyHint: true`, scope `read`, sensible `filters` such as title/slug/type/groupId — openconnector's endpoint block as the template). No derived writes +- [ ] Task 18: Verify the imported schema carries the block (check the imported schema via OpenRegister, not the file — the importer can reject silently) + +## Authorization Parity Tests (REQ-MCP-004/005) + +- [ ] Task 19: PHPUnit — for a non-owner caller: updateDashboard, deleteDashboard, addWidget, and shareDashboard tools all deny; assert no state change (subclass `ObjectEntity`-backed fixtures rather than mocking magic accessors) +- [ ] Task 20: PHPUnit — non-admin caller invoking each `instance`-reach tool is denied by the gate +- [ ] Task 21: PHPUnit — gate error path: a throwing PermissionService produces a denial result, never an allow +- [ ] Task 22: PHPUnit — validator rejections: malformed UUID, over-long title, invalid enum, negative grid values — each returns a structured error and performs no service call + +## Integration / Chat Flows (REQ-MCP-008) + +- [ ] Task 23: Integration test — "add the calendar widget": getActiveDashboard → listWidgetTypes → addWidget, assert the placement exists via `GET /api/widgets/items` equivalence +- [ ] Task 24: Integration test — "share with the sales group": listDashboards → shareDashboard(group: sales), assert parity with `POST /api/dashboard/{id}/shares` output +- [ ] Task 25: Integration test — "template to default": listTemplates → forkDashboard → setDefaultDashboard, assert `GET /api/dashboards/default` returns the fork + +## Quality & Integration + +- [ ] Task 26: `composer check:strict` clean (PHPCS, PHPMD, Psalm, PHPStan) including the new `lib/Mcp/` namespace; SPDX headers on every new PHP file (hydra-gate-spdx) +- [ ] Task 27: Run the hydra gates; specifically confirm no orphan-auth finding (every gate method must be called) and no redundant-controller finding (no wrapper controllers were added) +- [ ] Task 28: Boot test without OpenRegister installed: app registers and the launchpad UI loads (alias unresolved is acceptable; boot failure is not) + +## Verification + +`openspec validate` exits clean. Catalogue = exactly 14 read + 35 write tools, all annotated; alias resolves; parity tests green; exclusion-closure test green; the three chat flows execute end-to-end against a dev instance through hermiq or a direct provider harness. + +## Tests (company-wide ADR-009) + +PHPUnit unit tests per Tasks 3, 8, 13, 15, 19–22 (descriptor fixture + gate + validator). Integration tests per Tasks 23–25. No Playwright surface — this change ships no launchpad UI. + +## Documentation (company-wide ADR-010) + +Changelog entry: "LaunchPad actions are now available as MCP tools for AI agents (hermiq), with per-tool scope/reach grants." A docs page listing the tool catalogue with scope/reach per tool, generated from or checked against the descriptor constant so it cannot drift. + +## i18n (company-wide ADR-005) + +Tool names/descriptions are agent-facing contract strings and remain English (per the English-code rule); no user-facing UI strings are introduced. diff --git a/openspec/changes/openregister-leaf-integrations/.openspec.yaml b/openspec/changes/openregister-leaf-integrations/.openspec.yaml new file mode 100644 index 000000000..95672402a --- /dev/null +++ b/openspec/changes/openregister-leaf-integrations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-18 diff --git a/openspec/changes/openregister-leaf-integrations/design.md b/openspec/changes/openregister-leaf-integrations/design.md new file mode 100644 index 000000000..80ee75496 --- /dev/null +++ b/openspec/changes/openregister-leaf-integrations/design.md @@ -0,0 +1,78 @@ +# Design — OpenRegister Leaf Integrations (Talk on Shared Dashboards) + +## Context + +OpenRegister's integration leaves are adopted declaratively (`configuration.linkedTypes` on a schema). LaunchPad has a single `Dashboard` schema and currently adopts zero leaves. The design question for this change was not "how do we build a discussion feature" — the leaf and Talk own that — but three scoping decisions: + +1. Which leaves, if any, honestly fit a schema whose objects are layout configurations rather than domain records? +2. Where does the leaf declaration live — base register file or a `register.d` overlay? +3. What gates the discussion surface — a new ACL, or the existing dashboard view guard? + +## Goals / Non-Goals + +**Goals:** + +- Adopt the talk leaf for shared and group dashboards with zero bespoke chat code. +- Keep the declaration additive and overlay-based so the base register file stays a clean statement of the domain. +- Reuse `PermissionService::canViewDashboard()` as the single authorization truth for discussion access. +- Document explicitly why the remaining ~18 leaves are not adopted, so future sweeps do not re-litigate this. + +**Non-Goals:** + +- Building comments, mentions, or notification features in launchpad — the leaf renders, Talk hosts. +- Adopting the files leaf, mail sidebar linking (`configuration.linkedTypes: ["mail"]`), or `mailObjectTemplate` — see D1. +- Per-widget or per-tile discussions — the conversation unit is the dashboard object. + +## Decisions + +### D1: Adopt exactly one leaf — talk. Every other leaf is scoped out. + +**Decision**: Declare only `"talk"` in `linkedTypes`. No files, no mail, no calendar/contacts/maps/etc. + +**Alternatives considered:** + +- **Adopt files + talk** (the two "generic collaboration" leaves): rejected for files because LaunchPad already owns a resource pipeline for dashboard assets (`ResourceController`/`ResourceService`, REQ-RES-001..014, routes `/api/resources*`) and a files widget for user storage. A generic attachments panel on a layout-configuration object duplicates that machinery with no user story behind it. +- **Adopt the full catalogue for uniformity with sibling apps**: rejected — sibling apps (larpingapp, pipelinq, shillinq) attach leaves to schemas describing people, events, contracts. `Dashboard` describes none of these; declaring leaves that render meaningless panels is padding, and an unused surface is an unaudited surface. + +**Rationale**: The one place LaunchPad has real multi-user collaboration around a single object is shared/group dashboards (manifest features "Dashboard sharing", "Group dashboards"; `sharedWith`, `type: group_shared`, `groupId` in the schema; the whole REQ-SHARE surface). A conversation about *this dashboard* is the only leaf-shaped gap. The schema's own `"mailEnabled": false` confirms mail was already considered and rejected at the domain level. + +### D2: Declaration lives in a `register.d` overlay, not the base register file + +**Decision**: New file `lib/Settings/register.d/dashboard-talk-leaf.json` containing only the `configuration.linkedTypes` addition for the `Dashboard` schema. + +**Alternatives considered:** + +- **Edit `launchpad_register.json` directly**: rejected. The fleet pattern (larpingapp's `register.d/*-leaf.json` overlays) keeps each leaf adoption a self-contained, reviewable, revertable file, and keeps the base file a pure domain statement. LaunchPad's `register.d/` directory already exists (README only) precisely for this. + +**Rationale**: Overlay files make the adoption diff one file, and removing the leaf later is deleting one file rather than surgically editing the 310-line base register. + +### D3: Discussion access = dashboard view access; surface only on shared dashboards + +**Decision**: The discussion surface renders only when the dashboard is collaborative (`group_shared`, or has active shares / non-empty `sharedWith`), and reaching it requires `PermissionService::canViewDashboard()` to pass. Share revocation ends access. + +**Alternatives considered:** + +- **Show discussion on every dashboard, including unshared personal ones**: rejected — a room with one member is noise, and it would create Talk rooms for thousands of personal dashboards nobody discusses. +- **Separate discussion ACL (opt-in per participant)**: rejected — a second ACL diverges from the dashboard's own access over time (the classic parallel-permission drift), and the view guard already encodes owner/share/group logic in one audited place. + +**Rationale**: One authorization truth. `canViewDashboard()` already resolves ownership, user/group shares, and group membership; the leaf simply inherits its answer. + +### D4: Hard fail nowhere — Talk absence degrades to absence of the surface + +**Decision**: With Talk missing/disabled the affordance does not render and nothing errors. LaunchPad's manifest does not gain a Talk dependency. + +**Rationale**: LaunchPad must keep booting and rendering dashboards on instances without Talk (ADR-083-style boot safety); an integration leaf is an enhancement, never a prerequisite. + +## Risks / Trade-offs + +- **Risk**: The leaf's render contract may evolve in OpenRegister; launchpad's wiring is intentionally thin so the blast radius is the overlay file plus one mount point. +- **Trade-off**: Users of unshared personal dashboards get no discussion surface at all. Accepted: share the dashboard first — that is the collaboration signal. +- **Risk**: Schema overlay import failing silently (see the cautionary note inside `launchpad_register.json` about the schema importer rejecting union types). Mitigated by an explicit import verification task. + +## Migration Plan + +None required. The overlay is additive; no data or existing-object changes. Rollback = delete the overlay file and re-import. + +## Open Questions + +- Whether the discussion mounts in the dashboard sidebar or the dashboard settings panel is left to the leaf's standard render surface — decided at implementation against the leaf's contract, not re-specified here. diff --git a/openspec/changes/openregister-leaf-integrations/hydra.json b/openspec/changes/openregister-leaf-integrations/hydra.json new file mode 100644 index 000000000..137b8c0c8 --- /dev/null +++ b/openspec/changes/openregister-leaf-integrations/hydra.json @@ -0,0 +1,8 @@ +{ + "spec_slug": "dashboard-talk-leaf", + "title": "OpenRegister leaf integrations — Talk discussion on shared dashboards", + "app": "launchpad", + "repo": "https://github.com/ConductionNL/launchpad", + "depends_on": [], + "issue": null +} diff --git a/openspec/changes/openregister-leaf-integrations/proposal.md b/openspec/changes/openregister-leaf-integrations/proposal.md new file mode 100644 index 000000000..c97b112b0 --- /dev/null +++ b/openspec/changes/openregister-leaf-integrations/proposal.md @@ -0,0 +1,61 @@ +# OpenRegister leaf integrations — Talk discussion on shared dashboards + +## Why + +OpenRegister ships a catalogue of app-agnostic integration leaves (email, calendar, contacts, files, talk, deck, forms, maps, photos, polls, shares, bookmarks, collectives, notes, activity, time-tracker, xwiki, analytics, cospend, openproject) that apps adopt declaratively via schema-level `configuration.linkedTypes`. LaunchPad currently adopts **none** of them — `grep` for `linkedTypes` across `lib/`, `appinfo/`, and `src/` returns zero hits, and `lib/Settings/register.d/` contains only a README. + +LaunchPad's domain is deliberately narrow: a single `Dashboard` schema (`lib/Settings/launchpad_register.json`) describing a configuration object — slug, title, widgets array, `sharedWith`, `type` (`user` / `admin_template` / `group_shared`), `groupId`, `isDefault`. Most leaves attach to *domain records about the world* (people, cases, events, places) and therefore have no honest fit here. After evaluating the full catalogue against the schema (see "Scoping" below), exactly one leaf earns adoption: **talk**, giving the people who share a dashboard a discussion thread about that dashboard. + +Group and shared dashboards are collaborative artifacts — the manifest advertises "Dashboard sharing" and "Group dashboards" as key features, and the app already ships dashboard reactions (`DashboardReactionApiController`, REQ-RXN-001..004) as a lightweight signal layer. What is missing is an actual conversation: "should we swap the burndown widget for the CI-status widget?" today happens outside the app. The talk leaf closes that gap with zero bespoke chat code. + +## What Changes + +- Add a register.d overlay `lib/Settings/register.d/dashboard-talk-leaf.json` declaring `configuration.linkedTypes: ["talk"]` on the `Dashboard` schema — the same overlay pattern larpingapp uses (`register.d/player-to-contacts-leaf.json`). +- The Talk discussion surface renders only for dashboards that are actually shared (type `group_shared`, or `sharedWith` non-empty, or having active shares via `DashboardShareService`) — a personal, unshared dashboard shows no discussion affordance. +- Access to a dashboard's discussion follows the existing view guard (`PermissionService::canViewDashboard()`); no parallel ACL is introduced. +- No launchpad-side wrapper controller or service for Talk — the leaf is consumed from OpenRegister's leaf service directly (ADR-022, apps consume OR abstractions). + +## Scoping — why the other leaves are out + +Evaluated against the `Dashboard` schema and the real controller surface: + +- **files** (attachments): LaunchPad already has a purpose-built resource pipeline for dashboard assets (`ResourceController`, `ResourceService`, REQ-RES-001..014) and a files *widget* for browsing user storage (`FilesWidgetController`). A dashboard is a layout configuration, not a document-bearing case file; a generic attachments panel would duplicate the resource system while serving no user story. Out. +- **email** (`linkedTypes: ["mail"]` sidebar target, `mailObjectTemplate`): creating or linking a dashboard from an email has no meaning; the schema itself declares `"mailEnabled": false`. Out. +- **calendar, contacts, maps, forms, polls, deck, notes, collectives, bookmarks, photos, time-tracker, xwiki, analytics, cospend, openproject**: these attach to records that represent people, events, places, or work items. A dashboard configuration is none of those; several of these already exist in LaunchPad as *widgets* (calendar widget, people widget, analytics) where they belong — on the dashboard canvas, not on the object. Out. +- **activity**: LaunchPad already integrates the Nextcloud activity stream as a widget and has its own view-analytics capability (REQ-ANLT). Out. + +This is deliberately a one-leaf change. Honest scoping over catalogue completeness. + +## Capabilities + +### New Capabilities + +- `dashboard-talk-leaf` — Talk discussion thread on shared and group dashboards via OpenRegister's talk leaf. + +### Modified Capabilities + +(none) + +## Impact + +**Affected code:** + +- `lib/Settings/register.d/dashboard-talk-leaf.json` — new overlay file (the only schema change) +- Frontend surface where the leaf renders (dashboard sidebar/settings panel) — wiring only, per the leaf's own render contract + +**Affected APIs:** + +- No new launchpad endpoints. Room lifecycle and message traffic go through OpenRegister's talk leaf service and Talk itself. + +**Dependencies:** + +- OpenRegister with the integration-leaves capability; Nextcloud Talk installed and enabled. When Talk is absent the leaf must degrade to not rendering (no error). + +**Migration:** + +- None. The overlay is additive; existing Dashboard objects are untouched. Schema version bump in the overlay only. + +## Notes + +- Reactions (REQ-RXN) stay: they are a one-tap signal on any visible dashboard; the talk leaf is a threaded conversation for dashboards people actually co-own. Different jobs. +- The archived `2026-05-02-dashboard-comments` change explored bespoke comments; adopting the talk leaf supersedes that direction without resurrecting custom comment storage. diff --git a/openspec/changes/openregister-leaf-integrations/specs/dashboard-talk-leaf/spec.md b/openspec/changes/openregister-leaf-integrations/specs/dashboard-talk-leaf/spec.md new file mode 100644 index 000000000..2757bffdd --- /dev/null +++ b/openspec/changes/openregister-leaf-integrations/specs/dashboard-talk-leaf/spec.md @@ -0,0 +1,89 @@ +--- +capability: dashboard-talk-leaf +delta: false +status: draft +--- + +# Dashboard Talk Leaf — Discussion on Shared Dashboards + +## Context + +LaunchPad stores dashboards as OpenRegister objects under a single `Dashboard` schema (`lib/Settings/launchpad_register.json`). OpenRegister ships an app-agnostic **talk** integration leaf that any app can adopt by declaring `configuration.linkedTypes: ["talk"]` on a schema. This capability adopts that leaf — and only that leaf — so the people a dashboard is shared with can discuss it in a Talk conversation bound to the dashboard object. No bespoke chat code, storage, or wrapper controllers are added. + +## ADDED Requirements + +### Requirement: REQ-LEAF-001 Talk leaf declared on the Dashboard schema via register.d overlay + +The talk leaf MUST be declared declaratively, not in code. A new overlay file `lib/Settings/register.d/dashboard-talk-leaf.json` MUST add `configuration.linkedTypes: ["talk"]` to the `Dashboard` schema, following the established fleet overlay shape (`components.schemas.Dashboard.configuration.linkedTypes`, as in larpingapp's `register.d/player-to-contacts-leaf.json`). The base `launchpad_register.json` MUST NOT be edited for this; the overlay is the single source of the declaration. + +#### Scenario: Overlay declares the leaf + +- **GIVEN** the app is installed or upgraded with the overlay file present +- **WHEN** the register/schema import runs +- **THEN** the imported `Dashboard` schema MUST carry `configuration.linkedTypes` containing exactly `"talk"` +- **AND** no other leaf type MUST be declared by launchpad + +#### Scenario: Base register file untouched + +- **GIVEN** the change is implemented +- **WHEN** `lib/Settings/launchpad_register.json` is inspected +- **THEN** it MUST contain no `linkedTypes` key — the declaration lives only in `register.d/dashboard-talk-leaf.json` + +### Requirement: REQ-LEAF-002 Discussion surface renders only on shared dashboards + +The Talk discussion surface MUST render only for dashboards that are genuinely collaborative: `type === 'group_shared'`, or a dashboard with at least one active share (user/group shares via `DashboardShareService`, or a non-empty `sharedWith`). A personal dashboard with no shares MUST NOT show any discussion affordance — there is nobody to talk to. + +#### Scenario: Group dashboard shows discussion + +- **GIVEN** a `group_shared` dashboard scoped to group `marketing` +- **WHEN** a member of `marketing` opens that dashboard +- **THEN** the Talk discussion surface MUST be available for that dashboard + +#### Scenario: Shared personal dashboard shows discussion + +- **GIVEN** a personal dashboard the owner has shared with user `alice` +- **WHEN** the owner or `alice` opens the dashboard +- **THEN** the Talk discussion surface MUST be available + +#### Scenario: Unshared personal dashboard shows nothing + +- **GIVEN** a personal dashboard with no shares and empty `sharedWith` +- **WHEN** its owner opens it +- **THEN** no discussion affordance MUST render +- **AND** no Talk room MUST be created for it + +### Requirement: REQ-LEAF-003 Discussion access follows the existing view guard + +Access to a dashboard's discussion MUST be gated by the same authorization the dashboard itself uses: a user may see or join the discussion only if `PermissionService::canViewDashboard()` grants them view access to that dashboard. The leaf MUST NOT introduce a parallel ACL, and losing view access (share revoked via `DashboardShareApiController::destroy`/`revokeForRecipient`) MUST also end discussion access. + +#### Scenario: Non-viewer cannot reach the discussion + +- **GIVEN** a dashboard user `bob` has no view access to +- **WHEN** `bob` attempts to access that dashboard's Talk discussion +- **THEN** access MUST be denied with the same outcome as accessing the dashboard itself + +#### Scenario: Revoking a share ends discussion access + +- **GIVEN** `alice` had a share on a dashboard and participated in its discussion +- **WHEN** the owner revokes `alice`'s share +- **THEN** `alice` MUST no longer be able to access the dashboard's discussion surface through launchpad + +### Requirement: REQ-LEAF-004 Graceful degradation without Talk + +When the Talk app is not installed or is disabled, the leaf MUST degrade silently: no discussion affordance renders, no error is logged per page view, and every other dashboard function remains unaffected. LaunchPad MUST NOT hard-depend on Talk. + +#### Scenario: Talk absent + +- **GIVEN** an instance where Talk is not installed +- **WHEN** a user opens a `group_shared` dashboard +- **THEN** the dashboard MUST render fully with no discussion surface and no user-visible error + +### Requirement: REQ-LEAF-005 No launchpad-side wrapper code + +Per ADR-022 (apps consume OR abstractions), launchpad MUST NOT add controllers, routes, or services that proxy Talk or the leaf. Room lifecycle, membership, and messages are the leaf's and Talk's responsibility. The only server-side artifact of this capability is the register.d overlay. + +#### Scenario: No proxy endpoints appear + +- **GIVEN** the change is implemented +- **WHEN** `appinfo/routes.php` and `lib/Controller/` are inspected +- **THEN** no new Talk- or discussion-related route or controller MUST exist diff --git a/openspec/changes/openregister-leaf-integrations/tasks.md b/openspec/changes/openregister-leaf-integrations/tasks.md new file mode 100644 index 000000000..3fa9862c5 --- /dev/null +++ b/openspec/changes/openregister-leaf-integrations/tasks.md @@ -0,0 +1,47 @@ +# Tasks — openregister-leaf-integrations + +## Schema Overlay + +- [ ] Task 1: REQ-LEAF-001 — Create `lib/Settings/register.d/dashboard-talk-leaf.json` with the fleet overlay shape: `{"components": {"schemas": {"Dashboard": {"configuration": {"linkedTypes": ["talk"]}}}}` (mirror larpingapp's `register.d/player-to-contacts-leaf.json`) +- [ ] Task 2: REQ-LEAF-001 — Do NOT touch `lib/Settings/launchpad_register.json`; verify with `git diff --stat` that the base register file is unchanged +- [ ] Task 3: REQ-LEAF-001 — Verify the overlay actually imports: after `occ app:enable launchpad` (or the register repair step), fetch the Dashboard schema from OpenRegister and assert `configuration.linkedTypes == ["talk"]`. The register json itself warns that the importer can silently reject a schema — check the imported schema, not the file. + +## Frontend Wiring + +- [ ] Task 4: REQ-LEAF-002 — Mount the talk leaf's render surface on the dashboard view for collaborative dashboards only: `type === 'group_shared'` OR the dashboard has active shares (via the existing shares data from `GET /api/dashboard/{id}/shares`) OR `sharedWith` is non-empty +- [ ] Task 5: REQ-LEAF-002 — Ensure no discussion affordance renders for unshared personal dashboards (no hidden mount, no room creation side effect) +- [ ] Task 6: REQ-LEAF-004 — Feature-detect the leaf/Talk availability; when absent, skip the mount without console errors or server log noise + +## Authorization + +- [ ] Task 7: REQ-LEAF-003 — Confirm the leaf's access path resolves through `PermissionService::canViewDashboard()` (directly or via the object-level guard OpenRegister applies); document the resolved chain in the code comment at the mount point +- [ ] Task 8: REQ-LEAF-003 — Verify share revocation (`dashboardShareApi#destroy`, `dashboardShareApi#revokeForRecipient`) ends discussion access for the revoked user + +## Guardrails + +- [ ] Task 9: REQ-LEAF-005 — Verify no new routes, controllers, or services were added: `appinfo/routes.php` and `lib/Controller/` diff must be empty for this change +- [ ] Task 10: Run `composer check:strict` and the hydra gates; the change must not introduce findings + +## Testing + +- [ ] Task 11: Playwright test — group dashboard (member of the group): discussion surface present +- [ ] Task 12: Playwright test — personal dashboard shared with a second user: discussion surface present for both owner and recipient +- [ ] Task 13: Playwright test — unshared personal dashboard: no discussion affordance in the DOM +- [ ] Task 14: Playwright test — non-viewer attempting the dashboard (and its discussion) is denied identically for both +- [ ] Task 15: Test with Talk disabled — dashboard renders fully, no error surfaced + +## Verification + +`openspec validate` exits clean. The imported Dashboard schema carries `linkedTypes: ["talk"]`; the discussion surface appears exactly on collaborative dashboards; access tracks `canViewDashboard()`; zero new launchpad endpoints; Talk absence degrades silently. + +## Tests (company-wide ADR-009) + +Playwright end-to-end tests per Tasks 11–15. No unit-test surface exists in launchpad for this change (no new PHP or JS logic beyond the mount condition, which the e2e tests cover). + +## Documentation (company-wide ADR-010) + +Changelog entry: "Shared and group dashboards can now be discussed in Talk (OpenRegister talk leaf)". A short docs note in the sharing documentation page stating where the discussion appears and that it requires Talk. + +## i18n (company-wide ADR-005) + +Any launchpad-owned label around the mount (e.g. a section heading, if one is added) gets English and Dutch strings; the leaf's own UI ships its own translations.