From 24212bd2a12c58226eb09f9369ec87b9a037b545 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 18 Aug 2026 15:57:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(openspec):=20change=20proposals=20?= =?UTF-8?q?=E2=80=94=20adopt-integration-leaves=20mcp-full-action-surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifacts only. Every task box is unticked; nothing here is wired to anything yet, and each change is picked up by `/opsx-apply` when it is scheduled. Committed because these were sitting UNTRACKED in the shared checkout across ten apps at once. An untracked directory is one file-sweep away from being swept into an unrelated commit and one branch switch away from being lost, and these carry the design reasoning rather than just a title. --- .../adopt-integration-leaves/.openspec.yaml | 2 + .../adopt-integration-leaves/design.md | 110 +++++++++ .../adopt-integration-leaves/proposal.md | 96 ++++++++ .../specs/catalog-integration-leaves/spec.md | 144 ++++++++++++ .../changes/adopt-integration-leaves/tasks.md | 80 +++++++ .../mcp-full-action-surface/.openspec.yaml | 2 + .../changes/mcp-full-action-surface/design.md | 212 ++++++++++++++++++ .../mcp-full-action-surface/proposal.md | 120 ++++++++++ .../specs/mcp-tool-surface/spec.md | 191 ++++++++++++++++ .../changes/mcp-full-action-surface/tasks.md | 113 ++++++++++ 10 files changed, 1070 insertions(+) create mode 100644 openspec/changes/adopt-integration-leaves/.openspec.yaml create mode 100644 openspec/changes/adopt-integration-leaves/design.md create mode 100644 openspec/changes/adopt-integration-leaves/proposal.md create mode 100644 openspec/changes/adopt-integration-leaves/specs/catalog-integration-leaves/spec.md create mode 100644 openspec/changes/adopt-integration-leaves/tasks.md create mode 100644 openspec/changes/mcp-full-action-surface/.openspec.yaml create mode 100644 openspec/changes/mcp-full-action-surface/design.md create mode 100644 openspec/changes/mcp-full-action-surface/proposal.md create mode 100644 openspec/changes/mcp-full-action-surface/specs/mcp-tool-surface/spec.md create mode 100644 openspec/changes/mcp-full-action-surface/tasks.md diff --git a/openspec/changes/adopt-integration-leaves/.openspec.yaml b/openspec/changes/adopt-integration-leaves/.openspec.yaml new file mode 100644 index 00000000..95672402 --- /dev/null +++ b/openspec/changes/adopt-integration-leaves/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-18 diff --git a/openspec/changes/adopt-integration-leaves/design.md b/openspec/changes/adopt-integration-leaves/design.md new file mode 100644 index 00000000..be99cf97 --- /dev/null +++ b/openspec/changes/adopt-integration-leaves/design.md @@ -0,0 +1,110 @@ +# Design — adopt-integration-leaves + +## 1. Leaf-per-schema mapping and why each pairing is the right one + +| Schema | Leaf | Grounding (real fields / services) | +|---|---|---| +| `contactPerson` | `contacts` | `contactsUid` — "Verwijzing (UID) naar de Nextcloud-contactpersoon in het adresboek (OCP\Contacts\IManager)". The catalogue record is explicitly only the ROLE; identity lives in NC Contacts. The leaf makes the vCard (display name, email, avatar) visible and linkable on `ContactpersoonDetail` instead of a bare UID string. | +| `organization` | `contacts` | `organization.contactsUid` — same convention, "contactpersoon van het type organisatie". | +| `contract` | `calendar` | `contract.startDate` / `contract.endDate` ("De einddatum van het contract (indien van toepassing)"). End dates drive renewal planning; the leaf gives every contract a Meetings/Events tab plus the synced end-date event (section 3). | +| `moduleVersion` | `calendar` | `dateEndSupport` ("Startdatum einde ondersteuning") and `dateWithdrawn`. `dateEndSupport` is machine-maintained by the EOL sync (`eolSource`, `eolUpdatedOn` — "Alleen gezet door de EOL-matcher", `EolSyncService::run()`), so the synced event tracks upstream endoflife.date data. | +| `assessment` | `deck` | Reviews carry moderation state (`status` pending/approved/rejected, forced to `pending` server-side by `ReviewService::submit()`, transitioned only by `ModerationService::approve()/reject()` — see `register.d/catalog-ratings.json`). Follow-ups ("moderate this review", "discuss rating 2/10 for module X with the vendor") are card-shaped work; `DeckProvider` supports both link-existing (`{cardId}`) and create-and-link (`{boardId, stackId, title}`). | +| `module` | `bookmarks` | `module.website` is one URL; real applications have docs, changelog, security advisories, pricing pages. `BookmarksProvider` stores links in OR's own `openregister_bookmark_links` table (survives Bookmarks tag edits, caches title/url for the sidebar). | +| `service` | `bookmarks` | `service.website` — same reasoning for supplier service offerings. | + +Leaf ids verified against openregister at HEAD: +`LinkedEntityService::legacyLinkedTypeIds()` = `files`, `mail`, `contacts`, +`notes`, `todos`, `calendar`, `talk`, `deck`; registered +`IntegrationProvider::getId()` values include `contacts`, `calendar`, +`deck`, `bookmarks` (`openregister/lib/Service/Integration/Providers/`). +`LinkedEntityService::validateType()` throws on anything else, and +`Repair/LogDanglingLinkedTypes` logs schemas whose `linkedTypes` name an +unregistered integration — both act as loud guards against a typo in the +fragment. + +## 2. Fragment mechanics — the `contract` array hazard + +`contract` is the ONE schema that already carries `linkedTypes` +(`["decidesk-decisions"]`, in the monolith). ADR-037 fragments are +deep-merged by `SettingsService::loadSettings()` (`deepMergeConfig()`); for +scalar/object keys the merge is a union, but array-of-scalar semantics +(union vs replace) must not be assumed. The fragment therefore declares the +FULL intended array — `["decidesk-decisions", "calendar"]` — which is +correct under either semantic: + +- replace → the merged value is exactly the full array; +- union → `decidesk-decisions` deduplicates, `calendar` is added. + +Task 1.3 verifies the merged output (`/api/settings/load`) contains both +entries exactly once. Losing `decidesk-decisions` would silently break the +ContractApprovalPanel's decision leaf — this is the highest-risk line of the +whole change, hence its own task and scenario. + +## 3. Lifecycle-date calendar sync + +`CalendarProvider` is a read/render surface: it lists CalDAV VEVENTs that +carry `X-OPENREGISTER-*` properties identifying the owning object +(persistence is owned by the Calendar app; creation flows via OR's +`CalendarEventService`). Declaring `calendar` in `linkedTypes` gives the +tab and manual link/create, but nobody will hand-create "contract ends" +events for every contract — so this change adds a thin app-side sync: + +- `lib/Service/LifecycleCalendarService.php` — `syncContract(objectData)` + and `syncModuleVersion(objectData)`; upserts (creates, moves, or + deletes) one all-day linked event per tracked date field via OR's + calendar link path. Event titles are English per fleet convention + (`feedback_english-code`): "Contract ends: {contractNumber}" / + "End of support: {module name} {version}". +- `lib/Listener/LifecycleCalendarListener.php` — subscribes to + OpenRegister's object-saved event for the voorzieningen register, + filters on the `contract` / `moduleVersion` schema slugs resolved + through `SettingsService` (never hard-coded register ids), and delegates + to the service. Deletion of the object removes the linked event (OR's + `ObjectCleanupListener` already unlinks leaf rows; the listener only + needs to handle date-cleared-on-save). +- Idempotency: the event is looked up by its object link + a + deterministic marker (one tracked field = one event), so re-saves and + EOL re-stamps move the single event instead of accumulating duplicates. +- Fail-soft: calendar unavailable (app disabled, no writable calendar) is + logged and never blocks the object save — same graceful-degradation + posture the register's other integrations use. + +**Which calendar?** The events are personal CalDAV objects; the sync runs +in the saving user's session and writes to that user's default calendar +(the same calendar OR's create-event leaf flow targets). A shared +"portfolio calendar" is a legitimate future improvement, deferred — it +needs an ownership/config decision (`declared-config-enforced-nowhere` is +the failure mode to avoid: no config key is introduced here until +something reads it). + +## 4. Deferred (explicitly out of scope) + +- `connection.dateEndSupport` / `dateWithdrawn` — same calendar shape as + `moduleVersion`, deferred until the koppeling detail surface is + reviewed; adding it later is one fragment line + one listener case. +- `compliancy.url` / `evidenceReference` as bookmarks — compliance + evidence is file/reference-shaped and already has `allowFiles: true` + + `evidenceReference`; forcing it into bookmarks would duplicate an + existing surface. +- `usage` deck leaf (TIME-classification review follow-ups via + `timeReviewDate`) — plausible, but the assessment leaf should prove the + pattern first. +- NC Mail (`configuration.linkedTypes: ["mail"]` sidebar target + + `mailObjectTemplate`) — a separate comms-rule discussion; the manifest + `_note`s document a deliberate "comms hard-rule" that email widgets stay + off these detail pages, and this change does not reopen it. + +## 5. Manifest touch-points + +`src/manifest.json` detail pages affected: `ContactpersoonDetail`, +`ContractDetail`, `ModuleDetail`, `Diensten`/`DienstDetail` equivalent, +`ModuleversieDetail`, `ReviewDetail`, `OrganisatieDetail`. The leaf tabs +render from schema `linkedTypes` via the shared detail-page sidebar +(`CnObjectSidebar` — "so the CnObjectSidebar and dashboard widgets can +render a … tab without per-app glue", per `CalendarProvider`'s own +docblock); no per-page widget wiring is expected, but the two `_note` +strings that assert "declares NO email/calendar linkedType" become false +for `contract`/`contactPerson` and MUST be rewritten to describe the new +state, so the next audit doesn't read a stale premise +(`reference_design-system-adoption-silent-failures`: notes that lie are +worse than no notes). diff --git a/openspec/changes/adopt-integration-leaves/proposal.md b/openspec/changes/adopt-integration-leaves/proposal.md new file mode 100644 index 00000000..cb5a77c8 --- /dev/null +++ b/openspec/changes/adopt-integration-leaves/proposal.md @@ -0,0 +1,96 @@ +--- +kind: code +depends_on: [] +--- + +# softwarecatalog — adopt OpenRegister integration leaves (contacts, calendar, deck, bookmarks) + +## Why + +OpenRegister ships an app-agnostic integration-leaf registry +(`openregister/lib/Service/Integration/IntegrationRegistry.php` + +`Providers/`): a schema that declares a leaf id in +`configuration.linkedTypes` gets that Nextcloud app's link surface (sidebar +tab / widgets on the object detail page) with zero per-app glue — +`ContactsProvider` (id `contacts`), `CalendarProvider` (`calendar`), +`DeckProvider` (`deck`), `BookmarksProvider` (`bookmarks`) all exist today, +alongside the legacy allow-list ids in +`LinkedEntityService::legacyLinkedTypeIds()` (`files`, `mail`, `contacts`, +`notes`, `todos`, `calendar`, `talk`, `deck`). + +Software Catalog consumes almost none of this. Verified against +`lib/Settings/softwarecatalogus_register.json` at HEAD: + +- `allowFiles: true` on exactly 6 schemas (`suite`, `service`, + `organization`, `usage`, `module`, `compliancy`) — the files leaf. +- `linkedTypes` on exactly one schema: `contract` → + `["decidesk-decisions"]` (the ADR-066 approval projection). +- No schema declares `contacts`, `calendar`, `deck`, or `bookmarks`. + +That leaves four gaps the domain data is already shaped for: + +1. **Contacts** — `contactPerson.contactsUid` is literally "Verwijzing + (UID) naar de Nextcloud-contactpersoon in het adresboek + (`OCP\Contacts\IManager`)", and `organization.contactsUid` mirrors it. + The identity IS a Nextcloud contact by design (the + `ContactpersoonDetail` manifest note says communication happens + "through the linked Nextcloud contact via contactsUid"), yet the detail + page renders no contacts leaf — the vCard link exists only as a bare + string property. +2. **Calendar** — `contract.endDate` ("De einddatum van het contract") and + `moduleVersion.dateEndSupport` ("Startdatum einde ondersteuning", + stamped by the EOL matcher per `eolSource`/`eolUpdatedOn`) are the two + dates portfolio managers plan around, and neither is visible in any + calendar. The `ContractDetail` manifest `_note` even hard-codes the + current state: "The contract schema declares NO email/calendar + linkedType". +3. **Deck** — `assessment` records (reviews, live since the + `catalog-ratings` fragment added `auteur` + moderation `status` + pending/approved/rejected, enforced by `ReviewService::submit()` and + `ModerationService::approve()/reject()`) generate follow-up work + (moderate a pending review, chase a vendor about a bad rating) that has + no task surface. +4. **Bookmarks** — `module.website` ("Een URL naar uw applicatie") and + `service.website` are single URL strings; vendors accumulate more than + one relevant link (docs, changelog, status page, pricing) and today + have nowhere structured to put them. + +## What Changes + +- Add a new ADR-037 register fragment + `lib/Settings/register.d/catalog-integration-leaves.json` (never editing + the `softwarecatalogus_register.json` monolith) that declares + `configuration.linkedTypes`: + - `contactPerson`: `["contacts"]` + - `organization`: `["contacts"]` + - `contract`: `["decidesk-decisions", "calendar"]` — restating the + existing `decidesk-decisions` entry so the merged array is correct + regardless of whether the ADR-037 deep-merge unions or replaces + arrays (verified behaviour recorded in `design.md`). + - `moduleVersion`: `["calendar"]` + - `assessment`: `["deck"]` + - `module`: `["bookmarks"]` + - `service`: `["bookmarks"]` +- Add a lifecycle-date calendar sync (`lib/Service/LifecycleCalendarService.php` + + an OR object-saved listener): when `contract.endDate` or + `moduleVersion.dateEndSupport` is set or changed, upsert a linked + all-day VEVENT through OpenRegister's calendar link path (the same + `X-OPENREGISTER-*`-marked events `CalendarProvider::list()` renders), + so the calendar leaf tab shows the end-of-contract / end-of-support + event without manual linking; remove the event when the date is + cleared. EOL-matcher re-stamps of `dateEndSupport` + (`EolSyncService::run()` → `EolMatcherService`) move the event. +- Update the stale `src/manifest.json` detail-page `_note` prose on + `ContractDetail` and `ContactpersoonDetail` (both currently assert "NO + email/calendar linkedType" as the reason no comms widgets are placed) + and verify the leaf tabs render on the `ContractDetail`, + `ContactpersoonDetail`, `ModuleversieDetail`, `ReviewDetail`, + `ModuleDetail`, and `Diensten` detail surfaces. +- No new leaf providers and no OpenRegister changes: everything consumed + here (`contacts`, `calendar`, `deck`, `bookmarks`) is already a + registered `IntegrationProvider` at openregister HEAD. + +Not BREAKING: purely additive configuration plus one new sync service; no +existing route, response shape, or schema property changes. The +`connection.dateEndSupport` and `compliancy.url` fields are deliberately +out of scope (see `design.md` deferrals). diff --git a/openspec/changes/adopt-integration-leaves/specs/catalog-integration-leaves/spec.md b/openspec/changes/adopt-integration-leaves/specs/catalog-integration-leaves/spec.md new file mode 100644 index 00000000..813c6ef2 --- /dev/null +++ b/openspec/changes/adopt-integration-leaves/specs/catalog-integration-leaves/spec.md @@ -0,0 +1,144 @@ +## ADDED Requirements + +### Requirement: Contact persons and organisations MUST expose the contacts leaf +The `contactPerson` and `organization` schemas SHALL declare `contacts` in +`configuration.linkedTypes`, so the detail pages render OpenRegister's +contacts leaf (vCard link rows with role, backed by +`openregister_contact_links` + `X-OPENREGISTER-*` vCard properties) for +the identity that today exists only as the bare `contactsUid` string. + +#### Scenario: Contact role detail shows the linked Nextcloud contact +- GIVEN a `contactPerson` object whose `contactsUid` references an existing + address-book contact +- WHEN a user opens `ContactpersoonDetail` (`/contactpersonen/:id`) +- THEN a contacts leaf tab MUST be present in the object sidebar +- AND it MUST list the linked contact with its display name (not the raw UID) +- @e2e Playwright: seed a contactPerson with a linked contact, open the + detail page, assert the contacts tab and the contact's display name + +#### Scenario: Organisation detail offers link-existing contact +- GIVEN an `organization` object with no linked contact +- WHEN a user opens the organisation detail page and uses the contacts leaf +- THEN the leaf MUST offer linking an existing address-book contact +- AND after linking, the contact MUST appear in the leaf list +- @e2e exclude Link-picker flow is owned and e2e-covered by OpenRegister's + integration-contacts suite; this app only declares the linkedType + +### Requirement: Contract end dates and version end-of-support dates MUST surface as calendar leaf events +The `contract` and `moduleVersion` schemas SHALL declare `calendar` in +`configuration.linkedTypes`, and the app SHALL maintain one linked all-day +calendar event per tracked date field — `contract.endDate` and +`moduleVersion.dateEndSupport` — created, moved, and removed by +`LifecycleCalendarService` when the field is set, changed (including EOL +re-stamps by `EolSyncService`/`EolMatcherService`), or cleared. Sync +failures MUST be logged and MUST NOT block the object save. + +#### Scenario: Setting a contract end date creates the leaf event +- GIVEN a contract whose `endDate` is empty +- WHEN a user saves the contract with `endDate = 2027-03-31` +- THEN a linked all-day event on 2027-03-31 MUST exist for that contract +- AND it MUST be listed in the calendar leaf tab on `ContractDetail` +- @e2e Playwright: set an endDate through the contract modal, open the + detail page, assert the calendar tab lists the end-date event + +#### Scenario: EOL matcher re-stamp moves the end-of-support event +- GIVEN a `moduleVersion` with a synced end-of-support event on 2026-12-01 +- WHEN the EOL sync (`EolSyncService::run()`) re-stamps `dateEndSupport` + to 2027-06-01 +- THEN the SAME linked event MUST now be on 2027-06-01 +- AND no duplicate end-of-support event MUST exist for that version +- @e2e exclude Background-job path with an external-feed dependency; + asserted by a PHPUnit test on `LifecycleCalendarService` upsert idempotency + +#### Scenario: Clearing the date removes the event without failing the save +- GIVEN a contract with a synced end-date event +- WHEN the contract is saved with `endDate` cleared +- THEN the save MUST succeed +- AND the linked end-date event MUST be removed +- @e2e exclude Deletion side-effect; asserted by PHPUnit on the listener + +#### Scenario: Calendar unavailable degrades gracefully +- GIVEN the Calendar app is disabled on the instance +- WHEN a contract with an `endDate` is saved +- THEN the save MUST succeed (HTTP 200 on the object write) +- AND the condition MUST be logged, not thrown +- @e2e exclude Requires disabling a server app mid-suite; asserted by + PHPUnit with a throwing calendar-service double + +### Requirement: Assessments MUST expose the deck leaf for follow-up work +The `assessment` schema SHALL declare `deck` in +`configuration.linkedTypes`, so review follow-ups (moderating a `pending` +review, acting on a low rating) can be tracked as Deck cards linked to the +assessment — supporting both `DeckProvider` create payload shapes +(`{cardId}` link-existing and `{boardId, stackId, title}` create-and-link). + +#### Scenario: A pending review gets a follow-up card +- GIVEN an `assessment` with moderation `status = pending` +- WHEN a moderator opens `ReviewDetail` and creates a card from the deck leaf +- THEN a Deck card linked to that assessment MUST be created +- AND the deck leaf tab MUST list it with its board/stack context +- @e2e Playwright: open a seeded pending review, create a card via the + deck leaf, assert it appears in the tab + +### Requirement: Applications and services MUST expose the bookmarks leaf for vendor and documentation links +The `module` and `service` schemas SHALL declare `bookmarks` in +`configuration.linkedTypes`, complementing the single `website` property +each schema carries with a structured, multi-link surface backed by +`openregister_bookmark_links`. + +#### Scenario: An application accumulates documentation links +- GIVEN a `module` object whose `website` property is set +- WHEN a user links two bookmarks (documentation, changelog) via the leaf + on `ModuleDetail` +- THEN both bookmarks MUST be listed in the bookmarks leaf tab with their + cached titles and URLs +- AND the `website` property MUST be unchanged +- @e2e Playwright: link a bookmark on a module detail page and assert the + tab renders title + URL + +#### Scenario: Bookmarks app uninstalled yields an empty leaf, not an error +- GIVEN the Bookmarks app is not installed +- WHEN a user opens `ModuleDetail` +- THEN the page MUST render without error +- AND the bookmarks leaf MUST present an empty/unavailable state +- @e2e exclude Requires uninstalling a server app; covered by + `BookmarksProvider`'s own contract (returns empty list when uninstalled) + +### Requirement: Leaf declarations MUST live in a register fragment and MUST preserve the contract's decidesk leaf +All `linkedTypes` additions SHALL be declared in a new +`lib/Settings/register.d/catalog-integration-leaves.json` fragment +(ADR-037); `lib/Settings/softwarecatalogus_register.json` MUST NOT be +modified. The fragment MUST declare `contract.configuration.linkedTypes` +as the full array `["decidesk-decisions", "calendar"]` so the existing +`decidesk-decisions` entry survives either array-merge semantic, and every +declared leaf id MUST be one that +`LinkedEntityService::validateType()` accepts at openregister HEAD. + +#### Scenario: Merged contract linkedTypes contain both leaves exactly once +- GIVEN the monolith declaring `contract.linkedTypes = ["decidesk-decisions"]` + and this change's fragment applied +- WHEN the merged settings are read (`GET /api/settings/load`) +- THEN `contract.configuration.linkedTypes` MUST contain + `decidesk-decisions` and `calendar`, each exactly once +- @e2e exclude Config-merge assertion; asserted by a PHPUnit test on + `SettingsService::loadSettings()` output + +#### Scenario: No dangling linked type is introduced +- GIVEN the fragment applied on an instance at openregister HEAD +- WHEN the `LogDanglingLinkedTypes` repair step runs +- THEN it MUST report zero schemas whose `linkedTypes` reference an + unregistered integration +- @e2e exclude Repair-step log assertion; verified via occ output in CI + +### Requirement: Stale manifest notes MUST be corrected +The `src/manifest.json` `_note` strings on `ContractDetail` and +`ContactpersoonDetail` that assert the schemas declare "NO email/calendar +linkedType" SHALL be rewritten to describe the post-change state, keeping +the documented comms hard-rule (no email widgets) intact and accurate. + +#### Scenario: Manifest notes no longer contradict the register +- GIVEN this change applied +- WHEN `src/manifest.json` is searched for "declares NO email/calendar linkedType" +- THEN no detail page whose schema now declares `calendar` or `contacts` + MUST carry that assertion +- @e2e exclude Documentation-string assertion; checked by grep in review diff --git a/openspec/changes/adopt-integration-leaves/tasks.md b/openspec/changes/adopt-integration-leaves/tasks.md new file mode 100644 index 00000000..b20cd1be --- /dev/null +++ b/openspec/changes/adopt-integration-leaves/tasks.md @@ -0,0 +1,80 @@ +# Tasks — adopt-integration-leaves + +## 1. Register fragment + +- [ ] 1.1 Add `lib/Settings/register.d/catalog-integration-leaves.json` + declaring `configuration.linkedTypes` on: `contactPerson` + + `organization` (`["contacts"]`), `contract` + (`["decidesk-decisions", "calendar"]` — full array, see design.md §2), + `moduleVersion` (`["calendar"]`), `assessment` (`["deck"]`), `module` + + `service` (`["bookmarks"]`). Validate with `python3 -m json.tool`. +- [ ] 1.2 Cross-check every leaf id against openregister HEAD: + `LinkedEntityService::legacyLinkedTypeIds()` plus + `IntegrationRegistry::listIds()` must accept all of `contacts`, + `calendar`, `deck`, `bookmarks` (they do at time of writing — re-verify + at apply time, the gate suite changes under you). +- [ ] 1.3 PHPUnit on `SettingsService::loadSettings()` merged output: + `contract.configuration.linkedTypes` contains `decidesk-decisions` AND + `calendar` exactly once each (guards the array-merge semantic either way); + all seven schemas carry their declared leaf. +- [ ] 1.4 Re-import on the dev instance (fragment signature change triggers + re-import) and confirm `Repair/LogDanglingLinkedTypes` reports zero + dangling entries. + +## 2. Lifecycle-date calendar sync + +- [ ] 2.1 Add `lib/Service/LifecycleCalendarService.php` with + `syncContract(array $object): void` and + `syncModuleVersion(array $object): void` — upsert one linked all-day + event per tracked field (`contract.endDate`, + `moduleVersion.dateEndSupport`) through OpenRegister's calendar link + path (the `X-OPENREGISTER-*` event surface `CalendarProvider::list()` + renders); move on change, delete on clear, never duplicate (one tracked + field = one event, deterministic marker). +- [ ] 2.2 Add `lib/Listener/LifecycleCalendarListener.php` subscribed to + OpenRegister's object-saved event; filter to the voorzieningen register + and the `contract`/`moduleVersion` schemas resolved via + `SettingsService` (no hard-coded register/schema ids); register the + listener in `lib/AppInfo/Application.php` alongside the existing OR + event listeners. +- [ ] 2.3 Fail-soft: wrap calendar interaction so an unavailable Calendar + app (or no writable calendar) logs a warning and the object save still + succeeds — mirror the graceful-degradation posture of the other + integrations. +- [ ] 2.4 Event titles in English per fleet convention: + `Contract ends: {contractNumber}`, + `End of support: {module name} {version}`. + +## 3. Frontend / manifest + +- [ ] 3.1 Verify the leaf tabs render from schema `linkedTypes` on the + affected detail pages (`ContactpersoonDetail`, `OrganisatieDetail`, + `ContractDetail`, `ModuleversieDetail`, `ReviewDetail`, `ModuleDetail`, + service detail) — expected zero per-page wiring via the shared object + sidebar; if a page suppresses sidebar tabs, wire it there. +- [ ] 3.2 Rewrite the `_note` strings on `ContractDetail` and + `ContactpersoonDetail` in `src/manifest.json` that currently assert + "declares NO email/calendar linkedType" — keep the comms hard-rule (no + email widgets) documented, describe the new calendar/contacts leaves. + +## 4. Tests + +- [ ] 4.1 PHPUnit `LifecycleCalendarServiceTest`: create-on-set, + move-on-change (including a simulated EOL re-stamp of + `dateEndSupport`), delete-on-clear, idempotent double-save, and + fail-soft when the calendar double throws. +- [ ] 4.2 PHPUnit `LifecycleCalendarListenerTest`: fires only for + `contract`/`moduleVersion` saves in the voorzieningen register; ignores + other schemas. +- [ ] 4.3 Playwright: contacts tab on a seeded contactPerson + (display name, not raw UID); calendar tab on a contract after setting + `endDate`; deck card create-and-link on a pending review; bookmark + link + render on a module detail page — per the @e2e-tagged scenarios + (gate-19 traceability). + +## 5. Spec + docs + +- [ ] 5.1 Sync this change's spec delta into + `openspec/specs/catalog-integration-leaves/spec.md` on archive. +- [ ] 5.2 CHANGELOG entry under Unreleased: contacts/calendar/deck/bookmarks + leaf adoption + lifecycle-date calendar sync. diff --git a/openspec/changes/mcp-full-action-surface/.openspec.yaml b/openspec/changes/mcp-full-action-surface/.openspec.yaml new file mode 100644 index 00000000..95672402 --- /dev/null +++ b/openspec/changes/mcp-full-action-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-18 diff --git a/openspec/changes/mcp-full-action-surface/design.md b/openspec/changes/mcp-full-action-surface/design.md new file mode 100644 index 00000000..1b8d3cd3 --- /dev/null +++ b/openspec/changes/mcp-full-action-surface/design.md @@ -0,0 +1,212 @@ +# Design — mcp-full-action-surface + +## 1. Positioning against `softwarecatalog-mcp-adoption` + +That change (active, unimplemented, `.openspec.yaml: schema: conduction`, +created 2026-07-13) is the read-only declarative half of this surface. Its +reasoning is kept; its artefacts cannot be applied as written: + +| Its assumption | State at HEAD | Consequence | +|---|---|---| +| Slugs `moduleVersie`, `dienst`, `organisatie`, `contactpersoon`, `koppeling`, `gebruik` | Renamed to `moduleVersion`, `service`, `organization`, `contactPerson`, `connection`, `usage` | Its fragment would deep-merge 8 orphan schemas into the register (ADR-037 creates keys it cannot match) — worse than failing loudly | +| Dutch filter names (`naam`, `aanbieder`, `licentietype`, `standaardGemma`, `afnemer`…) | Properties are English (`name`, `provider`, `licentietype` DOES survive on `module`, but e.g. `naam` → `name`, `standaardGemma` → `standardGemma`, `afnemer` → `consumer`) | `McpAnnotationValidator` would reject — every filter list must be re-derived from the HEAD `properties` maps | +| `kwetsbaarheid`/`beoordeeling` excluded as "dead schemas" | `vulnerability` and `assessment` are live: manifest pages `Kwetsbaarheden`/`KwetsbaarheidDetail`, `Reviews`/`ReviewDetail`; `ReviewService`/`ModerationService`; `register.d/catalog-ratings.json` moderation fields | Both belong in the read surface; `vulnerability` is also the safe derived-write candidate | +| Write tools deferred (`DEFERRED_QUESTIONS`) | The concrete need now exists (hermiq grant model + chat commanding) | This change is the deferred `kind: code` follow-up it named | + +**Disposition:** this change supersedes it. Archive +`softwarecatalog-mcp-adoption` as superseded-by `mcp-full-action-surface` +when this lands; do not apply its fragment first. + +## 2. Architecture + +``` +hermiq agent (default-deny grants, scope × reach, approval gate, audit) + -> OpenRegister /api/mcp (JSON-RPC) / chat facade + -> SchemaDerivedToolProvider <- register.d/mcp-full-action-surface.json (layer A+B) + -> IMcpToolProvider::softwarecatalog <- lib/Mcp/SoftwareCatalogToolProvider.php (layer C) + dispatcher only; per tool: + McpArgumentValidator -> per-object gate -> existing workflow service +``` + +Fleet reference: `decidesk/lib/Mcp/` — `DecideskToolProvider` (dispatcher +with a `TOOL_DESCRIPTORS` constant so unit tests assert the catalogue as a +fixture), `McpArgumentValidator`, `McpMeetingGate` (the single +"load object, prove the caller may touch it" ladder: argument validation → +load → not_found → authorise, auth helpers that return real booleans and +are never wrapped in `catch(\Throwable)`), `McpMeetingScopeResolver`. +Software Catalog ports the shape: `McpContractGate` (wraps the existing +`ContractApprovalService::authorizeSubmit(contractUuid, groupNames, +activeOrgUuid)` — the IDOR guard from `contract-approval-ownership-guard`), +`McpPublicationGate` (wraps `PublicationController::authorizeEntry()` +semantics via `PublicationService::resolveEntry()` + the admin / +`aanbod-beheerder` organisation match), and admin checks via +`IGroupManager::isAdmin()`. **Rule: the MCP layer adds no new authority — +every tool runs exactly the guard its REST twin runs.** + +DI alias, mirroring `decidesk/lib/AppInfo/Registrar/DomainServiceRegistrar.php:121`: + +```php +$context->registerServiceAlias( + 'OCA\\OpenRegister\\Mcp\\IMcpToolProvider::softwarecatalog', + SoftwareCatalogToolProvider::class +); +``` + +## 3. Layer A — derived read tools (14 schemas × search/get = 28 tools) + +`register.d/mcp-full-action-surface.json`, `configuration.x-openregister-mcp`, +`enabled: true`, verbs `search` + `get`, `scope: "read"`, +`readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, +implicit `reach: user` (hermiq infers `user` for 3-segment `{app}.{schema}.{search|get}` +ids; we declare it anyway — see §5). Filters below are cross-checked +against the HEAD `properties` maps (every name verified present): + +| Schema | search filters (all real properties) | +|---|---| +| `module` | `name`, `type`, `provider`, `licentietype`, `hostingJurisdiction` | +| `moduleVersion` | `module`, `status`, `dateEndSupport` | +| `service` | `name`, `provider` | +| `organization` | `name`, `type`, `status`, `registrationStatus` | +| `contactPerson` | `organization`, `role` | +| `connection` | `type`, `status`, `integrationType`, `provider` | +| `compliancy` | `module`, `standardGemma` | +| `usage` | `consumer`, `provider`, `status`, `module`, `timeClassification` | +| `contract` | `status`, `contractType`, `service`, `usage`, `endDate` | +| `suite` | `name` | +| `vulnerability` | `name`, `cveCode`, `cvssScore`, `modules` | +| `assessment` | `status`, `rating`, `modules`, `usage` | +| `bioMeasure` | `code`, `name`, `bbnLevel` | +| `sbomComponent` | `name`, `moduleVersion`, `purl`, `vexCveIds` | + +Excluded from derivation, reasoning inherited from the superseded change: +`sector` (2-field taxonomy), `element`/`view`/`model`/ +`property-definition`/`relation` (AMEF bulk-import artifacts; `element` +alone has 80+ properties). Note `view` data IS reachable through curated +`listViews`/`getView` provider tools (layer C), which return the enriched +projection the `ViewController` API serves rather than raw AMEF XML. + +## 4. Layer B — derived writes: `vulnerability` only + +`vulnerability.create` (`scope: create`) and `vulnerability.update` +(`scope: update`), both `reach: instance`, `destructiveHint: false`. +Justification: it is the only live schema with (a) no +`x-openregister-lifecycle` state machine, (b) no decidesk projection +fields, (c) no dedicated workflow service — the app's own UI authors it +via generic OR object CRUD, so a derived MCP write matches the app's +existing authority model exactly (OR RBAC at invoke time). `delete` is +withheld (destructive; no current UI story). Every OTHER schema keeps the +superseded change's "no raw writes" rule: `contract.status = Actief` is a +decidesk projection ("softwarecatalog NEVER sets `status = Actief` on its +own authority" — `register.d/contracts-to-decidesk.json`), and +`moduleVersion`/`connection`/`organization`/`usage`/`contract` carry +lifecycle state machines a raw `update` would bypass. + +## 5. Layer C — curated provider tools (grant-matrix table) + +Reach follows `hermiq/openspec/specs/agent-capability-reach/spec.md`: +`self` < `user` < `instance` < `external`; reach = widest principal set an +invocation can AFFECT or DISCLOSE TO; a read that leaves the instance is +`external`; undeclared reach fail-closes to `external`, so every +descriptor declares one explicitly. + +### Read tools (scope: read) + +| Tool id | Delegates to | Reach | Notes | +|---|---|---|---| +| `softwarecatalog.getMyContactProfile` | `ContactpersonenController::getMe` path (`/api/me` resolution) | user | The caller's own contactPerson + organisation context | +| `softwarecatalog.listOffers` | `AanbodService::getAanbod()` | user | Offers pending for the caller's active organisation | +| `softwarecatalog.listOfferedUsages` | `AangebodenGebruikService::getGebruiksWhereAfnemer()` / `getGebruiksWhereDeelnemers()` | user | Usage records offered to / shared with the caller's organisation | +| `softwarecatalog.getPortfolioReport` | `PortfolioReportService::buildReport(organisationUuid)` | user | Caller's organisation only; gate rejects foreign org uuids for non-admins | +| `softwarecatalog.listPendingModerations` | `ModerationService::listPending()` | user | Admin-gated (same as `moderation#pending`) | +| `softwarecatalog.getReviewAggregate` | `ReviewAggregateService` (`review#aggregate`) | user | Public aggregate numbers | +| `softwarecatalog.getContractApprovalConfig` | `ContractApprovalService::isDelegationConfigured()` (`contractApproval#config`) | user | Lets an agent know whether submit tools can work | +| `softwarecatalog.getSbomImportStatus` | `SbomImportService::getStatus(moduleVersionUuid)` | user | Behind `SbomImportService::userCanReadModule()` | +| `softwarecatalog.listViews` / `softwarecatalog.getView` | `ViewService` (`view#getAllViews` / `#getView`) | user | Enriched ArchiMate view projection, incl. enrichment params | +| `softwarecatalog.previewOrganisationMerge` | `MergeOrganisatieService::dryRun(source, target)` | user | Admin-gated; read-only preview of `mergeOrganisations` | +| `softwarecatalog.getEolSyncStatus` | `EolSyncService::getStatus()` | user | Read of last-run metadata only | + +### Write tools (scope as listed; hermiq default-deny, human approval gate) + +| Tool id | Delegates to | Scope | Reach | Why that reach | +|---|---|---|---|---| +| `softwarecatalog.submitContractApproval` | `ContractApprovalService::submitForApproval(uuid, false)` behind `authorizeSubmit()` | update | instance | Raises a decidesk Decision other users see; flips `approvalState` | +| `softwarecatalog.submitContractRenewal` | `submitForApproval(uuid, true)` | update | instance | Same seam, renewal flavour | +| `softwarecatalog.publishObject` | `PublicationService::publish(objectType, uuid)` | update | external | Sets `publicationDate` → anonymous open-data readers see the record; effect leaves the authenticated instance surface | +| `softwarecatalog.depublishObject` | `PublicationService::depublish(objectType, uuid)` | update | external | Withdraws from the public surface — same boundary | +| `softwarecatalog.approveRegistration` | `ModerationService::approve(uuid, type)` | update | instance | Admits an organisation/review; visible to all users | +| `softwarecatalog.rejectRegistration` | `ModerationService::reject(uuid, type)` | update | instance | | +| `softwarecatalog.submitReview` | `ReviewService::submit(payload, subjectType, subjectId)` | create | instance | Forced to `status: pending` server-side; moderators observe it | +| `softwarecatalog.acceptOffer` | `AanbodService::acceptAanbod(aanbodId)` | update | instance | | +| `softwarecatalog.declineOffer` | `AanbodService::denyAanbod(aanbodId)` | delete | instance | REST twin is a DELETE verb | +| `softwarecatalog.claimUsage` | `AangebodenGebruikService::setGebruikSelfToActiveOrg(gebruikId)` | update | instance | | +| `softwarecatalog.declineUsage` | `AangebodenGebruikService::deleteGebruikAsAfnemer(gebruikId)` | delete | instance | | +| `softwarecatalog.grantOrganisationMembership` | `OrganisationMembersController::grant(uuid, userId)` logic (extract to service if needed) | update | instance | Changes another user's permission set | +| `softwarecatalog.revokeOrganisationMembership` | `::revoke(uuid, userId)` logic | update | instance | | +| `softwarecatalog.mergeOrganisations` | `MergeOrganisatieService::execute(source, target, actorUid)` | update | instance | Admin-gated; tombstones the source (`mergedInto`) | +| `softwarecatalog.registerOrganisation` | `IntakeService::submit(payload)` (+ `validate()`) | create | instance | Creates a `pending` registration for moderators | +| `softwarecatalog.importSbom` | `SbomImportService::importForModuleVersie(...)` | create | instance | Content passed inline (SBOM JSON/XML string), not a file upload | +| `softwarecatalog.triggerEolSync` | `EolSyncService::run()` | update | external | Outbound HTTP to endoflife.date — per hermiq's rule, anything issuing external requests is `external` regardless of verb | + +Descriptor hints: every write tool sets `readOnlyHint: false`; +`destructiveHint: true` only on `declineOffer`, `declineUsage`, +`revokeOrganisationMembership`, and `mergeOrganisations` (tombstoning); +`idempotentHint` per delegate semantics (e.g. `publish` idempotent, +`submitReview` not). + +### Named exclusions (auditable "full coverage" boundary) + +| Surface | Why not a tool | +|---|---| +| `settings#*` config get/set (~50 endpoints: general/sync/AMEF/voorzieningen/email/cronjob/user-group config, auto-configure, force-update, clear-cache, debug, heartbeat) | App configuration, not catalogue operation. An agent misconfiguring register bindings can brick the app for everyone; nothing in the PO intent ("command the app from chat") needs it. Deferred, not denied forever. | +| `contactpersonen#convertToUser`, `changePassword`, `disableUser`, `enableUser`, `updateUserGroups` | Identity/credential administration. Password and account-state changes are outside any sane agent grant in v1. | +| `settings#importArchiMate` / `exportArchiMate` / `downloadArchiMate` + progress streaming | File-upload/-download shaped with an async progress protocol; MCP tool-call ergonomics don't fit yet. `importSbom` is included instead because its payload is inline text. | +| `federation#addPeer/removePeer/pull` | Instance-topology administration touching remote instances; needs its own security review before any agent reach. | +| `dashboard#*`, `preferences#*`, `facet#getFacets`, `settings#getObjectsCounts/Statistics` | UI plumbing; derived `search` covers the data need. | + +## 6. Chat scenarios the surface must support (grounded end-to-end) + +1. **"Which contracts expire this quarter?"** → + `softwarecatalog.contract.search` with a `endDate` range filter + (real property: `contract.endDate`, "De einddatum van het contract"); + scope read / reach user — grantable without approval friction. +2. **"Log a vulnerability against application X."** → + `softwarecatalog.module.search {name: X}` then + `softwarecatalog.vulnerability.create {name, cveCode, cvssScore, + modules: [moduleId]}` (all real `vulnerability` properties); write → + default-deny, first use prompts a grant, invocation passes the human + approval gate and lands in the audit trail. +3. **"Submit contract 2025-0042 for renewal approval."** → + `contract.search {contractNumber}` then + `softwarecatalog.submitContractRenewal {contractUuid}`; the gate runs + `ContractApprovalService::authorizeSubmit()` — a caller whose active + organisation doesn't own the contract gets the same 403-equivalent + `forbidden` error the REST path returns, agent or not. + +## 7. Risks / trade-offs + +- [Risk] The superseded change is applied first with Dutch slugs → + orphan schemas polluting the register. Mitigation: proposal recommends + archiving it as superseded; task 1.1 asserts the fragment only names + slugs present in the HEAD monolith (fails the build otherwise). +- [Risk] MCP write tool drifts from its REST twin's guard (an MCP-only + IDOR). Mitigation: the delegation rule is a spec requirement with + per-gate unit tests mirroring `contract-approval-ownership-guard`'s + 403 cases; the gates REUSE the service-level guards rather than + reimplementing them. +- [Trade-off] `OrganisationMembersController::grant/revoke` logic lives + in the controller today; the provider either extracts it into a small + service (preferred, one-time refactor) or is deferred for those two + tools — decided at apply time, recorded in tasks 5.4. +- [Trade-off] No `delete` tools for catalogue records at all (beyond the + decline/revoke workflow verbs). Deliberate: destructive deletes have no + workflow service and no agent story; bias to fewer. + +## 8. Deferred + +- Curated tools over the excluded admin surfaces (config, ArchiMate, + federation) once hermiq has an "operator agent" grant tier. +- `x-openregister-mcp` on a trimmed AMEF projection schema (inherited + deferral). +- `assessment` derived writes — review submission must stay behind + `softwarecatalog.submitReview` so the server-side `pending` forcing and + `auteur` stamping are never bypassed. diff --git a/openspec/changes/mcp-full-action-surface/proposal.md b/openspec/changes/mcp-full-action-surface/proposal.md new file mode 100644 index 00000000..0abbeca4 --- /dev/null +++ b/openspec/changes/mcp-full-action-surface/proposal.md @@ -0,0 +1,120 @@ +--- +kind: code +depends_on: [] +--- + +# softwarecatalog — full MCP action surface for hermiq (chat-drivable catalogue) + +## Why + +**Product intent:** every Conduction app should expose MCP tooling for ALL +of its user actions, so any action can in principle be automated by an AI +agent — with the user granting rights per agent, granularly, on hermiq's +two-axis grant model (`scope` × `reach`, default-deny for writes, human +approval gates, audit trail — `hermiq/openspec/specs/agent-tool-governance/` +and `agent-capability-reach/spec.md`). Even without automation, a user +should be able to command the app from chat: "which contracts expire this +quarter?" answered, "submit contract 2025-0042 for renewal" queued behind +an approval gate. + +**Current state (verified at HEAD):** Software Catalog has zero MCP +surface. `grep -rn "IMcpToolProvider\|McpTool\|x-openregister-mcp" lib/ +src/ appinfo/` returns nothing outside openspec prose. The mechanism is +proven elsewhere: decidesk ships the fleet reference implementation +(`decidesk/lib/Mcp/DecideskToolProvider.php` — dispatcher + +`TOOL_DESCRIPTORS` catalogue, `McpArgumentValidator`, `McpMeetingGate` +per-object authorisation, `McpMeetingScopeResolver`), registered via the +DI alias `OCA\OpenRegister\Mcp\IMcpToolProvider::decidesk` +(`decidesk/lib/AppInfo/Registrar/DomainServiceRegistrar.php:121`), and +OpenRegister derives CRUD tools from `x-openregister-mcp` schema blocks +(`openregister/lib/Mcp/`). + +**Relationship to `softwarecatalog-mcp-adoption` (active change, +2026-07-13, `schema: conduction`):** that change specifies the read-only +half — derived `search`/`get` tools on 9 curated schemas via a +`register.d` fragment — and explicitly defers every write/action tool +("A future `kind: code` change could promote … a `#[McpTool]` once +there's a concrete agent workflow need", its `DEFERRED_QUESTIONS`). This +change is that deferred follow-up, and it also has to correct the ground +under it: **the register was since migrated to English slugs** and +`softwarecatalog-mcp-adoption`'s fragment is written against schema names +that no longer exist. Verified against +`lib/Settings/softwarecatalogus_register.json` at HEAD: the register +contains `module`, `moduleVersion`, `service`, `organization`, +`contactPerson`, `connection`, `compliancy`, `usage`, `contract`, +`suite`, `sector`, `vulnerability`, `assessment`, `bioMeasure`, +`sbomComponent` + the 5 AMEF schemas — there is no `moduleVersie`, +`dienst`, `organisatie`, `contactpersoon`, `koppeling`, `gebruik`, +`kwetsbaarheid`, or `beoordeeling`. Applying that change's JSON as-is +would deep-merge eight ORPHAN schemas into the register (ADR-037 creates +what it cannot match) instead of annotating the real ones. Two of its +exclusions are also stale: `vulnerability` and `assessment` are live +surfaces now (`Kwetsbaarheden`/`KwetsbaarheidDetail` and +`Reviews`/`ReviewDetail` manifest pages; `ReviewService`, +`ModerationService`, the `catalog-ratings` fragment), despite the +monolith's leftover "niet daadwerkelijk gebruikt" description. + +**This change therefore supersedes `softwarecatalog-mcp-adoption`**: it +retains that change's curation reasoning (read-only derived tools, honest +hints, filters cross-checked against real properties, AMEF exclusion, no +raw writes on lifecycle-governed schemas) and re-grounds it on the English +slugs, then adds the full action layer on top. Recommend archiving +`softwarecatalog-mcp-adoption` as superseded when this change lands. + +## What Changes + +1. **Derived read layer (config)** — new + `lib/Settings/register.d/mcp-full-action-surface.json` fragment + declaring `configuration.x-openregister-mcp` (`search` + `get`, + `scope: read`, `readOnlyHint: true`) on 14 schemas: the 9 from the + superseded change under their current slugs (`module`, `moduleVersion`, + `service`, `organization`, `contactPerson`, `connection`, `compliancy`, + `usage`, `contract`) plus `suite`, `vulnerability`, `assessment`, + `bioMeasure`, `sbomComponent` (all now live surfaces). AMEF schemas + (`element`, `view`, `model`, `property-definition`, `relation`) and + `sector` stay excluded — reasoning inherited, see design.md. +2. **Derived write verbs on `vulnerability` only** — `create`/`update` + (`scope` accordingly, `reach: instance`): the one live schema with no + lifecycle state machine, no projection fields, and no workflow + service; the app's own UI writes it through generic OR object CRUD. + Every other schema's writes stay workflow-only (below). +3. **Hand-written provider (code)** — + `lib/Mcp/SoftwareCatalogToolProvider.php` + (`OCA\SoftwareCatalog\Mcp`, implements + `OCA\OpenRegister\Mcp\IMcpToolProvider`), registered under the DI + alias `OCA\OpenRegister\Mcp\IMcpToolProvider::softwarecatalog`, tool + ids `softwarecatalog.{toolName}`. Dispatcher-only, decidesk-style: + argument validation (`McpArgumentValidator` port) → per-object + authorisation gate → delegation to the EXISTING workflow service. + 12 curated read tools and 17 write tools covering every real + user-facing workflow action found in `lib/Controller/` + + `lib/Service/` — contract approval/renewal + (`ContractApprovalService::submitForApproval()` behind + `authorizeSubmit()`), publish/depublish (`PublicationService`), + moderation (`ModerationService::listPending/approve/reject`), reviews + (`ReviewService::submit`), offers (`AanbodService::getAanbod/ + acceptAanbod/denyAanbod`), offered-usage claim/decline + (`AangebodenGebruikService`), organisation membership + (`OrganisationMembersController` logic), organisation merge + (`MergeOrganisatieService::dryRun/execute`), intake + (`IntakeService::submit`), SBOM import (`SbomImportService`), EOL sync + (`EolSyncService::run`), portfolio report + (`PortfolioReportService::buildReport`). Full catalogue table with + per-tool `scope` and `reach` in design.md. +4. **Grant-matrix annotations** — every descriptor declares `scope` + (read/create/update/delete) AND `reach` (self/user/instance/external) + from hermiq's closed vocabularies, because hermiq fail-closes an + undeclared reach to `external` (its most-restricted class) and we want + reads grantable at `user` reach. Publication tools are honestly + `reach: external` (they alter the anonymous open-data surface), as is + the EOL sync trigger (outbound HTTP to endoflife.date). +5. **Named exclusions, not silent ones** — admin configuration plumbing + (the ~50 `settings#*` config get/set endpoints, email templates, + cronjob config, user-group config), identity/credential operations + (`contactpersonen#convertToUser/changePassword/disable/enable`), + ArchiMate import/export (file-transfer shaped), and federation peer + management are deliberately NOT tools in this change — each with its + rationale recorded in design.md so the coverage claim is auditable. + +Not BREAKING: purely additive — no existing route, controller, or schema +property changes; REST surface untouched. diff --git a/openspec/changes/mcp-full-action-surface/specs/mcp-tool-surface/spec.md b/openspec/changes/mcp-full-action-surface/specs/mcp-tool-surface/spec.md new file mode 100644 index 00000000..d27bcf8b --- /dev/null +++ b/openspec/changes/mcp-full-action-surface/specs/mcp-tool-surface/spec.md @@ -0,0 +1,191 @@ +## ADDED Requirements + +### Requirement: Software Catalog MUST register a hand-written MCP tool provider +The app SHALL ship `OCA\SoftwareCatalog\Mcp\SoftwareCatalogToolProvider` +implementing `OCA\OpenRegister\Mcp\IMcpToolProvider`, registered under the +DI alias `OCA\OpenRegister\Mcp\IMcpToolProvider::softwarecatalog` +(mirroring decidesk's registrar at +`decidesk/lib/AppInfo/Registrar/DomainServiceRegistrar.php:121`). The +provider MUST be a dispatcher only: it owns the tool catalogue (a constant +descriptor table unit tests can assert as a fixture, per +`DecideskToolProvider::TOOL_DESCRIPTORS`) and routes tool ids to handler +classes; it MUST NOT contain business logic. Every tool id MUST be +namespaced `softwarecatalog.{toolName}`. + +#### Scenario: The provider is discoverable through OpenRegister +- GIVEN this change applied and the app enabled +- WHEN OpenRegister resolves registered `IMcpToolProvider` aliases +- THEN `IMcpToolProvider::softwarecatalog` MUST resolve to + `SoftwareCatalogToolProvider` +- AND its listed tools MUST all carry ids starting with `softwarecatalog.` +- @e2e exclude DI-resolution assertion; asserted by PHPUnit bootstrapping + the container + +### Requirement: Every tool descriptor MUST declare scope and reach from hermiq's closed vocabularies +Every descriptor — derived and curated — SHALL declare `scope` (one of +`read`, `create`, `update`, `delete`) and `reach` (one of `self`, `user`, +`instance`, `external`, per +`hermiq/openspec/specs/agent-capability-reach/spec.md`), plus honest +`readOnlyHint`/`destructiveHint`/`idempotentHint` values. Reach MUST be +declared explicitly (hermiq fail-closes an undeclared reach to +`external`). A tool whose invocation issues an outbound HTTP request +(`softwarecatalog.triggerEolSync` → endoflife.date) or alters the +anonymous open-data surface (`publishObject`/`depublishObject`) MUST +declare `reach: external` regardless of its verb. + +#### Scenario: No descriptor ships without both axes +- GIVEN the provider's descriptor table and the derived-tool fragment +- WHEN every entry is inspected +- THEN each MUST carry a `scope` and a `reach` from the closed vocabularies +- AND no read tool MUST carry `readOnlyHint: false` +- @e2e exclude Descriptor-shape fixture assertion; PHPUnit over the + descriptor constant + +#### Scenario: Publication tools are classified as external reach +- GIVEN the descriptors for `softwarecatalog.publishObject` and + `softwarecatalog.depublishObject` +- WHEN their `reach` is read +- THEN it MUST be `external` +- AND their `scope` MUST be `update` +- @e2e exclude Fixture assertion; PHPUnit + +### Requirement: Read tools MUST be side-effect free and separated from write tools +Curated read tools (`getMyContactProfile`, `listOffers`, +`listOfferedUsages`, `getPortfolioReport`, `listPendingModerations`, +`getReviewAggregate`, `getContractApprovalConfig`, `getSbomImportStatus`, +`listViews`, `getView`, `previewOrganisationMerge`, `getEolSyncStatus`) +SHALL delegate only to read paths of the existing services and MUST NOT +persist anything. `previewOrganisationMerge` MUST delegate to +`MergeOrganisatieService::dryRun()` and MUST NOT be able to reach +`execute()`. + +#### Scenario: Merge preview never mutates +- GIVEN two organisation uuids +- WHEN `softwarecatalog.previewOrganisationMerge` is invoked +- THEN the response MUST contain the dry-run impact summary +- AND no object write MUST occur (asserted via a mocked + `MergeOrganisatieService` expecting `dryRun()` once and `execute()` never) +- @e2e exclude MCP JSON-RPC path; PHPUnit on the handler + +### Requirement: Every write tool MUST delegate to the existing workflow service behind its existing guard +Each write tool SHALL delegate to the named workflow method — +`ContractApprovalService::submitForApproval()`, +`PublicationService::publish()/depublish()`, +`ModerationService::approve()/reject()`, `ReviewService::submit()`, +`AanbodService::acceptAanbod()/denyAanbod()`, +`AangebodenGebruikService::setGebruikSelfToActiveOrg()/deleteGebruikAsAfnemer()`, +organisation-membership grant/revoke, `MergeOrganisatieService::execute()`, +`IntakeService::submit()`, `SbomImportService::importForModuleVersie()`, +`EolSyncService::run()` — and MUST run per-object authorisation before the +delegate, structured as the decidesk ladder (argument validation → load → +not_found → authorise → delegate, per `decidesk/lib/Mcp/McpMeetingGate.php`). +The MCP layer MUST NOT grant authority the REST twin denies: in particular +`submitContractApproval`/`submitContractRenewal` MUST pass +`ContractApprovalService::authorizeSubmit()` and fail closed exactly like +the REST 403 path. Raw object writes on lifecycle-governed schemas +(`contract`, `usage`, `organization`, `moduleVersion`, `connection`) MUST +NOT be exposed as MCP tools. + +#### Scenario: A non-owning caller cannot submit a contract via MCP +- GIVEN a contract owned by organisation A +- AND an authenticated caller whose active organisation is B and who is + not an instance admin +- WHEN `softwarecatalog.submitContractApproval` is invoked for that contract +- THEN the tool MUST return a forbidden error +- AND `ContractApprovalService::submitForApproval()` MUST NOT be invoked +- AND no `DecisionRequestedEvent` MUST be dispatched +- @e2e exclude Mirrors the REST 403 cases of + `contract-approval-ownership-guard`; PHPUnit with mocked dispatcher + +#### Scenario: Review submission cannot bypass moderation +- GIVEN any caller +- WHEN `softwarecatalog.submitReview` is invoked with a payload declaring + `status: approved` +- THEN the persisted assessment MUST have `status: pending` (forced + server-side by `ReviewService::submit()`) +- AND the response MUST reflect the pending state +- @e2e exclude Server-side forcing assertion; PHPUnit on the handler + + service + +#### Scenario: Argument validation precedes authorisation and business logic +- GIVEN an invocation of any curated tool with a missing required argument +- WHEN the provider dispatches it +- THEN the tool MUST return a validation error naming the argument +- AND no service method MUST have been called +- @e2e exclude Validator-ladder assertion; PHPUnit + +### Requirement: Derived read tools MUST cover the 14 live catalogue schemas under their current English slugs +`lib/Settings/register.d/mcp-full-action-surface.json` SHALL declare +`configuration.x-openregister-mcp` with `search` + `get` (`scope: read`, +`readOnlyHint: true`) on exactly: `module`, `moduleVersion`, `service`, +`organization`, `contactPerson`, `connection`, `compliancy`, `usage`, +`contract`, `suite`, `vulnerability`, `assessment`, `bioMeasure`, +`sbomComponent`. Every schema name and every `search.filters` entry MUST +exist in the HEAD `softwarecatalogus_register.json` (`McpAnnotationValidator` +must report zero unknown-filter errors, and the fragment MUST NOT +introduce any schema key absent from the monolith). The AMEF schemas +(`element`, `view`, `model`, `property-definition`, `relation`) and +`sector` MUST NOT be annotated. `lib/Settings/softwarecatalogus_register.json` +MUST NOT be modified. + +#### Scenario: Contracts are searchable by end date from chat +- GIVEN the fragment imported and contracts with `endDate` values in Q4 +- WHEN an agent invokes `softwarecatalog.contract.search` with an + `endDate` range filter for the quarter +- THEN the result MUST contain exactly the contracts whose `endDate` + falls in the range the caller may read under OR RBAC +- @e2e exclude MCP JSON-RPC query; covered by OpenRegister's derived-tool + suite plus an app-side import assertion + +#### Scenario: No orphan schema is merged into the register +- GIVEN the fragment applied +- WHEN the merged register is diffed against the monolith's schema key set +- THEN the set of schema keys MUST be unchanged (annotations only, no new + schemas — in particular none of the retired Dutch slugs `moduleVersie`, + `dienst`, `organisatie`, `contactpersoon`, `koppeling`, `gebruik`, + `kwetsbaarheid`, `beoordeeling`) +- @e2e exclude Config-merge assertion; PHPUnit on + `SettingsService::loadSettings()` + +### Requirement: Derived write verbs MUST exist on vulnerability and nowhere else +The fragment SHALL additionally declare `create` and `update` (with +matching `scope`, `reach: instance`, `readOnlyHint: false`) on the +`vulnerability` schema only — the one live schema with no lifecycle state +machine, no projection fields, and no workflow service. No other schema in +the fragment MUST carry a `create`, `update`, or `delete` verb, and +`vulnerability` MUST NOT carry `delete`. + +#### Scenario: An agent logs a vulnerability against an application +- GIVEN an agent granted `softwarecatalog.vulnerability.create` (a write — + hermiq default-denies it until granted, and the invocation passes the + human approval gate) +- WHEN it invokes the tool with `name`, `cveCode`, `cvssScore`, and + `modules` referencing an existing module id +- THEN a `vulnerability` object MUST be created with those values under + the caller's OR RBAC authority +- AND the invocation MUST appear in hermiq's audit trail +- @e2e exclude Cross-app hermiq grant flow; covered by hermiq's + agent-tool-governance suite; app-side PHPUnit asserts the fragment shape + +#### Scenario: Writes on lifecycle-governed schemas stay impossible +- GIVEN the imported merged register +- WHEN the derived tool list for `softwarecatalog` is enumerated +- THEN no `contract.*`, `usage.*`, `organization.*`, `moduleVersion.*`, + or `connection.*` tool with scope `create`, `update`, or `delete` MUST + exist +- @e2e exclude Tool-listing assertion; import check in CI + +### Requirement: This change supersedes softwarecatalog-mcp-adoption +The change SHALL be applied instead of, never after or alongside, the +`softwarecatalog-mcp-adoption` fragment: that change's +`register.d/softwarecatalog-mcp-adoption.json` (Dutch slugs) MUST NOT be +created, and on landing this change the `softwarecatalog-mcp-adoption` +change MUST be archived as superseded with a pointer to +`mcp-full-action-surface`. + +#### Scenario: The stale fragment never lands +- GIVEN this change applied +- WHEN `lib/Settings/register.d/` is listed +- THEN it MUST contain `mcp-full-action-surface.json` +- AND it MUST NOT contain `softwarecatalog-mcp-adoption.json` +- @e2e exclude File-presence assertion; checked in review/CI diff --git a/openspec/changes/mcp-full-action-surface/tasks.md b/openspec/changes/mcp-full-action-surface/tasks.md new file mode 100644 index 00000000..d0853ea6 --- /dev/null +++ b/openspec/changes/mcp-full-action-surface/tasks.md @@ -0,0 +1,113 @@ +# Tasks — mcp-full-action-surface + +## 1. Derived layer (register fragment) + +- [ ] 1.1 Add `lib/Settings/register.d/mcp-full-action-surface.json`: + `configuration.x-openregister-mcp` with `search`/`get` (`scope: read`, + honest hints, explicit `reach: user`) on the 14 schemas in design.md §3, + plus `create`/`update` (`reach: instance`) on `vulnerability` only. + Assert (script or PHPUnit) that every schema key in the fragment exists + in the HEAD monolith — none of the retired Dutch slugs — and validate + with `python3 -m json.tool`. +- [ ] 1.2 Re-derive every `search.filters` list from the HEAD `properties` + maps (design.md table — every name verified against HEAD at + proposal time; re-verify at apply time); the merged register must pass + `McpAnnotationValidator` with zero unknown-filter errors. +- [ ] 1.3 Agent-facing English `description` prose per verb per schema + (what the LLM reads to choose the tool), reusing the superseded + change's descriptions where the schema survived the rename. +- [ ] 1.4 Import on the dev instance and verify the derived tool listing: + 28 read tools + `vulnerability.create`/`.update`, and no write tool on + any lifecycle-governed schema. + +## 2. Provider skeleton + +- [ ] 2.1 Add `lib/Mcp/SoftwareCatalogToolProvider.php` + (`OCA\SoftwareCatalog\Mcp`, implements + `OCA\OpenRegister\Mcp\IMcpToolProvider`): descriptor constant + (id, name, description, inputSchema, scope, reach, hints) + dispatch + table; no business logic (decidesk `DecideskToolProvider` shape). +- [ ] 2.2 Register the DI alias + `OCA\OpenRegister\Mcp\IMcpToolProvider::softwarecatalog` in + `lib/AppInfo/Application.php` (mirror + `decidesk/lib/AppInfo/Registrar/DomainServiceRegistrar.php:121`). +- [ ] 2.3 Add `lib/Mcp/McpArgumentValidator.php` (port of decidesk's: + typed required/optional argument checking, validation error before any + service call). + +## 3. Authorisation gates + +- [ ] 3.1 Add `lib/Mcp/McpContractGate.php`: load contract → + `not_found` → `ContractApprovalService::authorizeSubmit(contractUuid, + groupNames, activeOrgUuid)`; helpers return real booleans, never + wrapped in `catch(\Throwable)` (decidesk `McpMeetingGate` rules). +- [ ] 3.2 Add `lib/Mcp/McpPublicationGate.php` reusing the + `PublicationController::authorizeEntry()` semantics via + `PublicationService::resolveEntry()` (admin OR owning + `aanbod-beheerder`). +- [ ] 3.3 Admin gates for moderation/merge/EOL/membership tools via + `IGroupManager::isAdmin()` — identical posture to the REST twins. + +## 4. Read tools + +- [ ] 4.1 Implement the curated read handlers (design.md §5 read table): + `getMyContactProfile`, `listOffers`, `listOfferedUsages`, + `getPortfolioReport` (reject foreign org uuid for non-admins), + `listPendingModerations`, `getReviewAggregate`, + `getContractApprovalConfig`, `getSbomImportStatus` + (`userCanReadModule()` gate), `listViews`, `getView`, + `previewOrganisationMerge` (dryRun only), `getEolSyncStatus`. + +## 5. Write tools + +- [ ] 5.1 Contract seam: `submitContractApproval`, `submitContractRenewal` + → `ContractApprovalService::submitForApproval()` behind + `McpContractGate`. +- [ ] 5.2 Publication seam: `publishObject`, `depublishObject` → + `PublicationService::publish()/depublish()` behind + `McpPublicationGate`; descriptors declare `reach: external`. +- [ ] 5.3 Moderation/review/intake: `approveRegistration`, + `rejectRegistration` (`ModerationService`), `submitReview` + (`ReviewService::submit()` — pending forced server-side), + `registerOrganisation` (`IntakeService::validate()` + `submit()`). +- [ ] 5.4 Offers/usages/membership/merge: `acceptOffer`, `declineOffer` + (`AanbodService`), `claimUsage`, `declineUsage` + (`AangebodenGebruikService`), `grantOrganisationMembership`, + `revokeOrganisationMembership` (extract the + `OrganisationMembersController` grant/revoke logic into a small + service, or defer these two tools — record the decision here), + `mergeOrganisations` (`MergeOrganisatieService::execute()`, + admin-gated, `destructiveHint: true`). +- [ ] 5.5 Ops: `importSbom` (`SbomImportService::importForModuleVersie()`, + inline payload), `triggerEolSync` (`EolSyncService::run()`, + `reach: external`). + +## 6. Tests + +- [ ] 6.1 PHPUnit descriptor fixture: every entry has `scope` + `reach` + from the closed vocabularies; reads are `readOnlyHint: true`; + publication + EOL tools are `reach: external`; write set matches + design.md exactly. +- [ ] 6.2 PHPUnit per gate: non-owning caller → forbidden AND delegate + never called AND no `DecisionRequestedEvent` (mirror + `contract-approval-ownership-guard`'s cases at the MCP seam); + owning/admin caller passes through. +- [ ] 6.3 PHPUnit validator ladder: missing/badly-typed argument → + validation error, zero service calls. +- [ ] 6.4 PHPUnit `submitReview` pending-forcing; + `previewOrganisationMerge` never reaches `execute()`. +- [ ] 6.5 PHPUnit fragment/merge: schema key set unchanged after merge + (no orphan Dutch slugs); vulnerability the only schema with write verbs. +- [ ] 6.6 `composer check:strict` clean (PHPCS, PHPMD, Psalm, PHPStan) — + fix pre-existing issues encountered in touched files. + +## 7. Supersession + spec/docs + +- [ ] 7.1 Archive `softwarecatalog-mcp-adoption` as superseded by this + change (pointer in its archive note); its + `register.d/softwarecatalog-mcp-adoption.json` is never created. +- [ ] 7.2 Sync this change's spec delta into + `openspec/specs/mcp-tool-surface/spec.md` on archive. +- [ ] 7.3 CHANGELOG entry under Unreleased: full MCP action surface + (28 derived read tools, vulnerability writes, ~29 curated tools) for + hermiq consumption. From 4f03776004229ece83c1e1e6d291b9d825abea94 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 20 Aug 2026 23:28:37 +0200 Subject: [PATCH 2/2] fix(manifest): process and audit fields are no longer editable inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CnObjectDataWidget.editable` defaults to TRUE, so every property named in a data widget's `include` list becomes a text box the user can type into. That put lifecycle state and audit stamps — `status`, `lifecycle`, `submittedAt`, `approvedBy`, `openedAt`, `closedAt`, `publishedAt`, `enactedAt` — in front of users as editable fields. These are written by the backend when a transition lands (`TransitionEngine` stamps them through `saveObject()`), so an input for them is a control that can only ever fail or confuse: the guarded path is the lifecycle buttons, and `LifecycleValidationListener` rejects anything that is not a legal transition. Locked with per-field `overrides..editable: false` rather than `editable: false` on the widget: these panels mix process state with fields the user legitimately edits, and a blanket lock would make those read-only too. NOT fixed here: widgets that declare no `include` at all render EVERY schema property, and enumerating their fields in the manifest would drift the moment the schema changes. 52 such widgets fleet-wide expose 124 process fields. Closing those needs a server-side "system-owned" marker, which OpenRegister does not have — `readOnly:true` has no bypass for backend callers and would break the transition that writes the field. Filed as ConductionNL/openregister#2644. Inserted textually, one compact line per widget, so the diff is the change and nothing else: a full JSON re-serialisation reflowed hand-compacted lines and turned this into thousands of lines of churn. A verifier re-parses both files and asserts the only structural difference is the added overrides, and that no non-process field was locked. --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index b190a8d0..299009d1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -195,7 +195,7 @@ "schema": "organization", "_note": "ADR-062 rollout (round 2): this page did not exist before — organisatie objects (vendors, municipalities, collaborations) were only reachable as index cards with no detail view (codeberg softwarecatalog#76). Vendor/organisation archetype: the organisatie schema itself carries only catalogue-role fields (contactsUid, type, status, registratiestatus, samenwerkingtype, geregistreerdDoor, publicatie dates) because identity (name, website, e-mail, KvK) lives in the linked Nextcloud contact via contactsUid, not on this OR object — so the body leads with those 8 catalogue fields (2-col) rather than name/address. Top-right: a stats-block KPI card counting the organisation's own diensten, modules and contactpersonen (all FK-scoped via aanbieder/organisatie = @objectId) so a reader sees portfolio size at a glance without opening any list. Below: three FK object-lists — Services (dienst.aanbieder) and Applications (module.aanbieder) are the org's supply-side offerings; Contact persons (contactpersoon.organisatie) links to the existing ContactpersoonDetail page. Services still have no dedicated index/detail page (a real fleet gap, follow-up remains). Applications now do — bio-compliance-assessment added Modules/ModuleDetail (BBN level and DPIA tracking needed somewhere to live), so org-modules now carries `rowRoute: ModuleDetail` and a bbnLevel column; org-diensten's rowRoute stays intentionally omitted. An organisation does not communicate itself (contact happens through the linked NC contact), so per the comms hard-rule NO Emails/Meetings widgets appear. Audit trail stays a sidebar tab. Card-click navigation from the Organisaties index required a matching fix in OrganisatieCard.vue (the custom cardComponent never emitted the `click` event CnCardGrid/CnPageRenderer listen on for register+schema route resolution — clicking a card was previously a no-op).", "widgets": [ - { "id": "org-data", "type": "data", "title": "Organisation", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "contactsUid", "type", "status", "registrationStatus", "samenwerkingtype", "registeredBy", "publicationDate", "depublicationDate" ] } }, + { "id": "org-data", "type": "data", "title": "Organisation", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "contactsUid", "type", "status", "registrationStatus", "samenwerkingtype", "registeredBy", "publicationDate", "depublicationDate" ], "overrides": { "status": { "editable": false }, "registeredBy": { "editable": false } } } }, { "id": "org-stats-services", "type": "stats-block", "title": "Services", "icon": "ChartBar", "content": { "entries": [ { "title": "Services", "register": "@resolve:voorzieningen_register", "schema": "service", "metric": "count", "filter": { "provider": "@objectId" } } ] } }, { "id": "org-stats-applications", "type": "stats-block", "title": "Applications", "icon": "ChartBar", "content": { "entries": [ { "title": "Applications", "register": "@resolve:voorzieningen_register", "schema": "module", "metric": "count", "filter": { "provider": "@objectId" } } ] } }, { "id": "org-stats-contact-persons", "type": "stats-block", "title": "Contact persons", "icon": "ChartBar", "content": { "entries": [ { "title": "Contact persons", "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "metric": "count", "filter": { "organization": "@objectId" } } ] } },