diff --git a/openspec/changes/architecture-assistant-drafted-views/.openspec.yaml b/openspec/changes/architecture-assistant-drafted-views/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-assistant-drafted-views/design.md b/openspec/changes/architecture-assistant-drafted-views/design.md new file mode 100644 index 00000000..9fcfd90f --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/design.md @@ -0,0 +1,111 @@ +# Design: architecture-assistant-drafted-views + +Read at development 49e65cb4, with the open changes `architecture-views-editor` and `mcp-full-action-surface` as the ground this change stands on. + +## Where it fits + +| Layer | Touched | Notes | +|---|---|---| +| MCP provider | `lib/Mcp/StackiqToolProvider.php` (created by `mcp-full-action-surface`, its `tasks.md` 2.1) | two descriptors and two dispatch entries, no logic | +| Argument checks | `lib/Mcp/McpArgumentValidator.php` (same change, `tasks.md` 2.3) | reused as is | +| Service | new `lib/Service/ArchitectureViewDraftService.php` | validation, element resolution, relation reuse, writes | +| Register | new fragment `lib/Settings/register.d/architecture-assistant-drafted-views.json` | appends `assistant` to `origin` on `view`, `element` and `relation`, adds `draftedFor` and `draftedAt` to `view` | +| Page | `ViewEditor` from `architecture-views-editor` (`src/views/architecture/ArchitectureViewEditor.vue`) | a notice and a first-open layout | +| Routes | none | the tools are reached through OpenRegister's MCP server (`openregister-ro/appinfo/routes.php:1990`, `POST /api/mcp`) and Hermiq's facade | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`). `deepMergeConfig` (:7338) appends list values, so `"enum": ["assistant"]` under `origin` adds the value to the enum `architecture-views-editor` declares. The fragment bumps `view`, `element` and `relation` once more. + +## Decisions + +### D1. Two curated tools on the provider `mcp-full-action-surface` creates + +| Tool id | Delegates to | Scope | Reach | Hints | +|---|---|---|---|---| +| `stackiq.searchArchitectureElements` | `ArchitectureViewDraftService::searchElements(query, types, limit)` | read | user | `readOnlyHint: true` | +| `stackiq.draftView` | `ArchitectureViewDraftService::draftView(name, description, elements, relations)` | create | instance | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: false` | + +`draftView` is `reach: instance` because a drafted view is visible to the members of the caller's organisation, not only to the caller. Hermiq fail-closes an undeclared reach to `external` (`mcp-full-action-surface` design section 5), so both tools declare one. + +Rejected: an `#[McpTool]` attribute on the service method (ADR-063 Decision 2). `mcp-full-action-surface` builds stackiq's tools as a hand-written provider with one argument validator and per-object gates. A second mechanism in the same app would split where a reviewer looks for stackiq's tools. + +Rejected: derived `x-openregister-mcp` writes on `view`, `element` and `relation`. Both open MCP changes exclude the AMEF schemas for good reason (`element` has more than 80 properties, and a raw `view.create` would take the node list as free JSON). A draft is a composite write across three schemas, which is what a curated tool is for. + +### D2. The input is structure, not a picture + +`draftView` takes: + +```json +{ + "name": "Zaakgericht werken, concept", + "description": "How the case system hands documents to document management.", + "elements": [ + { "key": "a", "uuid": "00000000-0000-0000-0000-000000000000" }, + { "key": "b", "type": "ApplicationComponent", "name": "Documentbeheer" } + ], + "relations": [ + { "source": "a", "target": "b", "type": "Flow" } + ] +} +``` + +An element names either an existing AMEF element by `uuid` or a new one by ArchiMate `type` and `name`. A relation names two element keys and an ArchiMate relation type. Types come from closed lists in the service: the ArchiMate 3.2 element types and the eleven relation types. A draft holds at most 60 elements and 120 relations. An unknown type, a dangling key or a list over the cap returns a validation error before anything is written. + +The result is `{ "uuid": ..., "url": "/apps/stackiq/views/", "created": { "elements": n, "relations": n } }`, so the assistant can hand the user a link. + +### D3. The mark is written with the object (ADR-088) + +Every object the tool writes carries `origin: assistant` in the same `saveObject` call that creates it: the view, each new element and each new relation. The view also carries `draftedFor` (the Nextcloud user id of the session the tool ran in) and `draftedAt`. There is no second write that could fail after the first, so an unmarked draft cannot exist (ADR-088 Decisions 1 and 5). + +ADR-088 Decision 2 asks for the mark in the object's own metadata. OpenRegister's object metadata has no agent or provenance field (`openregister-ro/lib/Db/ObjectEntity.php:174` to :759 holds `owner`, `application`, `organisation` and the like), and `application` reads stackiq for a human save and a tool save alike. So the mark is the schema field `origin`, which the editor already reads and the Views index already facets. + +Rejected: an OpenRegister change that adds an agent field to the object metadata. It is the better long-term home, but it is OpenRegister's to design for every app, and stackiq's field can move there later without losing data. + +Stackiq does not record which agent drafted the view. `ToolRegistryFacade::invokeTool` passes no agent identity to a provider (`openregister-ro/lib/Service/Mcp/ToolRegistryFacade.php:351-353`). Hermiq's tool trace records the agent with the tool id and the returned view uuid (ADR-088 Decision 3), and OpenRegister's invocation audit records the call (ADR-063 Decision 8). The mark says "an assistant drafted this", the trace says which one. + +A person editing a drafted view keeps the mark. The editor shows "Drafted by an assistant for on " on every open, and the Views index shows `assistant` in its origin facet. + +### D4. Drafts carry no positions and are laid out on first open + +The service writes nodes without `x` and `y`. `ArchitectureViewEditor.vue` checks the nodes with `needsFullLayout` and places them with `layoutFlowNodes` (`@conduction/nextcloud-vue` 2.57.1, `src/composables/flowGraphLayout.js:129` and :329), a deterministic layered layout. The first human save stores the positions. A draft has no nested nodes, so the flat layout fits. + +The package root does not export these two helpers: `src/index.js:438` exports `useFlowStore`, which uses them (`src/composables/useFlowStore.js:28`), but not the helpers themselves. The editor imports them through the package's `./src/*` export (`package.json` `exports`), and the vitest spec imports the same path, so a rename in the library fails the test instead of the page. + +Rejected: a layout in PHP. It would be a second layout algorithm next to the one the shared library already ships and tests. + +### D5. The caller's rights decide + +The tool runs in the caller's session (ADR-034 Decision 7). `ArchitectureViewDraftService` writes through OpenRegister's `ObjectService` with RBAC and multitenancy on, so a caller without create rights on `view` gets a forbidden result and nothing is written. The draft is scoped to the caller's active organisation. `searchArchitectureElements` reads the same way, so it only returns elements the caller may see. + +### D6. A draft stays out of shared readers + +`architecture-views-editor` makes `GET /api/views`, `GET /api/views/{viewId}` and the full ArchiMate export keep only views whose `origin` is empty or `imported`. `assistant` is neither, so a draft is never cached for all callers and never exported with the GEMMA model. This change adds no filter of its own and adds a test that the existing readers skip `assistant`. + +## Declarative versus imperative + +The draft is imperative: one call writes up to three kinds of object, resolves keys and reuses relations, which no `x-openregister-*` extension expresses. The status stays under the lifecycle `architecture-views-editor` declares, and the tool cannot move it past draft. No notification, aggregation or widget is added. + +## Seed data + +The fragment changes the `view`, `element` and `relation` schemas, so it seeds one drafted view next to the drawn examples `architecture-views-editor` seeds. All objects live in the `vng-gemma` register. + +### Schema: `view` + +| Field | Object 1 | +|---|---| +| slug | `seed-view-assistant-zaak-dms` | +| name | Zaaksysteem en documentbeheer, concept van de assistent | +| status | draft | +| origin | assistant | +| draftedFor | admin | +| draftedAt | 2026-09-27T09:00:00+00:00 | +| nodes | `seed-el-zaaksysteem` and `seed-el-dms`, without `x` and `y` | +| connections | one Flow connection that reuses `seed-rel-zaak-dms` | + +The seed reuses the drawn example elements and relation, so it adds no element or relation of its own, and a fresh install shows the notice and the first-open layout on a real object. + +## Risks + +- **A wrong element.** The assistant may pick a GEMMA element that does not mean what the user meant. The notice and the draft status tell the reviewer the view is unreviewed, and `searchArchitectureElements` returns the GEMMA type and name so the assistant can show its picks. +- **Duplicate new elements.** An assistant can create "Documentbeheer" when a GEMMA element with that name exists. The service matches a new element's type and name against existing elements in the caller's scope and reuses an exact match. +- **Dependency order.** `lib/Mcp/StackiqToolProvider.php` does not exist until `mcp-full-action-surface` lands. This change is blocked on it. +- **A deep import.** The layout helpers come from `@conduction/nextcloud-vue/src/composables/flowGraphLayout.js`, not the package root. A library release that moves the file breaks the import at build time, which the vitest spec catches. Asking the library to export them from the root is the clean fix and can follow. diff --git a/openspec/changes/architecture-assistant-drafted-views/proposal.md b/openspec/changes/architecture-assistant-drafted-views/proposal.md new file mode 100644 index 00000000..8623d55c --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - mcp-full-action-surface +--- + +# Let an assistant draft an architecture view for review + +## Summary + +A user asks Hermiq's assistant for a view ("draw how our case system talks to document management"). The assistant looks up the GEMMA elements that fit through a stackiq tool, then calls a second stackiq tool that writes the view as a draft. The draft opens in the view editor, marked as drafted by an assistant, and a person reviews, adjusts and saves it. Stackiq owns the two tools and the mark. The chat, the model and the agent's rights are Hermiq's. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-ai-diagram`, "Have a diagram drafted for you by an assistant from a description." No tender, feature request or changelog names it for stackiq. + +- SAP LeanIX rates yes: "AI agents connected to your workspace via the MCP server can now create, populate, and edit diagrams ... You describe what you want to see, and the agent adds fact sheets to a canvas" (https://updates.leanix.net/announcements/build-and-edit-architecture-diagrams-with-ai-agents). +- BlueDolphin rates yes: "Modelling Assistant or BPMN Generator instantly creates BPMN 2.0-compliant process diagrams from a simple prompt or by uploading existing documentation" (https://help.bluedolphin.io/en/articles/12528662-ai-capabilities-of-bluedolphin). + +The lane decided build because two competitors rate yes and architecture is a core area. The matrix notes "Nothing drafts diagrams." + +## What stackiq has today + +- No MCP surface. `grep -rn "IMcpToolProvider\|McpTool" lib appinfo` returns nothing, and `lib/Mcp` does not exist at development 49e65cb4. +- Two open changes plan one. `stackiq-mcp-adoption` excludes `element`, `view`, `model`, `property-definition` and `relation` (its `design.md` exclusion table and Decision 3). `mcp-full-action-surface` keeps that exclusion for derived tools (its `design.md` section 3, "Excluded from derivation"), adds read-only `stackiq.listViews` and `stackiq.getView` over `ViewService` (its `design.md` section 5), and creates `lib/Mcp/StackiqToolProvider.php` and `lib/Mcp/McpArgumentValidator.php` (its `tasks.md` 2.1 and 2.3). Neither writes a view. +- OpenRegister's `IMcpToolProvider` (`openregister-ro/lib/Mcp/IMcpToolProvider.php:47`) runs a tool in the caller's Nextcloud session, and `ToolRegistryFacade::invokeTool` (`openregister-ro/lib/Service/Mcp/ToolRegistryFacade.php:350-365`) passes no agent identity to the provider. +- `architecture-views-editor` adds the editor, the `origin` field on `view`, `element` and `relation`, and the readers that keep only imported views in the shared list and the full export. + +## What this change builds + +- `stackiq.searchArchitectureElements`, a read tool that returns a short projection of AMEF elements (uuid, identifier, name, ArchiMate type, GEMMA type) so a draft reuses existing elements instead of inventing duplicates. +- `stackiq.draftView`, a create tool that takes a name, a description, elements (an existing uuid, or a new ArchiMate type and name) and relations (source, target, ArchiMate relation type), and writes a `view` in status draft. +- `lib/Service/ArchitectureViewDraftService.php`, which validates the input, resolves elements, reuses or creates relations, and writes every object with the assistant mark in the same write (ADR-088). +- An `assistant` value on `origin`, a notice in the editor on a drafted view, and a first-open layout for drafted nodes. + +## Out of scope + +- The chat, the prompt, the model call, the agent's tool grants and the human approval gate. Those are Hermiq's (ADR-034, ADR-063 Decision 4). +- Letting an assistant change an existing view. SAP LeanIX's agents also edit diagrams. This change only drafts new views, and a later change can add an edit tool once drafts have been reviewed in practice. +- Drafting business processes in BPMN, which is BlueDolphin's evidence. Stackiq models processes in `architecture-process-mapping`, and a process draft tool can follow it. +- Reading an uploaded document to draft from it. + +## Risks + +- An agent can create many drafts. Drafts are ordinary organisation objects under OpenRegister RBAC, the tool caps a draft at 60 elements and 120 relations, and Hermiq's approval gate sits in front of every create tool. +- The relation reuse rule exists twice: in the editor's store (`architecture-views-editor` D5) and in this service. Both are tested against the same fixture. diff --git a/openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md b/openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md new file mode 100644 index 00000000..078a390f --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md @@ -0,0 +1,103 @@ +# architecture-assistant-views specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-assistant-drafted-views + +## Purpose + +A user asks Hermiq's assistant to draw a view, and the assistant drafts it through two stackiq MCP tools: one finds the AMEF elements that fit, one writes the draft. The draft is an ordinary `view` in status draft, marked as drafted by an assistant in the same write (ADR-088), and a person reviews it in the view editor from `architecture-views-editor`. The chat, the model, the agent's grants and the approval gate are Hermiq's (ADR-034, ADR-063). Stackiq owns the tools, the input checks and the mark. + +## ADDED Requirements + +### Requirement: REQ-AAV-001 Stackiq SHALL offer a read tool that finds architecture elements for a draft + +The provider `lib/Mcp/StackiqToolProvider.php` SHALL list `stackiq.searchArchitectureElements` with scope read, reach user and `readOnlyHint` true. It SHALL take a search text, an optional list of ArchiMate element types and a limit of at most 50, and SHALL return for each match the uuid, the ArchiMate identifier, the name, the ArchiMate type and the GEMMA type. It SHALL read through OpenRegister with RBAC and multitenancy on, so it returns only elements the caller may read. + +#### Scenario: A sibling app finds the GEMMA element for a case system +@e2e exclude The tool is called over MCP, not through a page; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts the projection fields and the type filter, and tests/Unit/Mcp/StackiqToolProviderDraftToolsTest.php asserts the descriptor's scope, reach and hints. + +- **GIVEN** the imported GEMMA model holds an application component named Zaaksysteem +- **WHEN** Hermiq, the sibling app, calls `stackiq.searchArchitectureElements` with the text zaak and the type ApplicationComponent in a signed-in user's session +- **THEN** the result SHALL list Zaaksysteem with its uuid, identifier, name, ArchiMate type and GEMMA type +- **AND** the result SHALL hold no other element fields + +### Requirement: REQ-AAV-002 Stackiq SHALL offer a create tool that writes a draft view from elements and relations + +The provider SHALL list `stackiq.draftView` with scope create, reach instance, `readOnlyHint` false, `destructiveHint` false and `idempotentHint` false. It SHALL take a name, a description, a list of elements (an existing element uuid, or a new ArchiMate element type and name, each with a key) and a list of relations (a source key, a target key and an ArchiMate relation type). It SHALL check the whole input before it writes anything: an unknown element or relation type, a key no element declares, more than 60 elements or more than 120 relations SHALL return an error result and SHALL write nothing. On success it SHALL write one `view` in status draft, reuse a `relation` of the same type between the same two elements or create one, reuse an existing element of the same type and exact name in the caller's scope or create one, and SHALL return the view's uuid, its editor link `/apps/stackiq/views/` and the number of elements and relations it created. + +#### Scenario: An assistant drafts a view and hands back a link +@e2e tests/e2e/workflows/architecture-assistant-views.spec.ts + +- **GIVEN** an application owner signed in to stackiq and the seeded elements Zaaksysteem and Documentbeheer +- **WHEN** a tool call to `stackiq.draftView` on OpenRegister's MCP endpoint `POST /apps/openregister/api/mcp` names both elements and a Flow relation between them +- **THEN** the result SHALL carry a view uuid and the link `/apps/stackiq/views/` +- **AND** the Views page SHALL list the new view with status draft + +#### Scenario: A dangling key writes nothing +@e2e exclude Input checks are a service concern; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts that a relation naming an undeclared key, an unknown type and a list over the cap each return an error and call no save. + +- **GIVEN** a draft request whose relation names the target key c while only the keys a and b are declared +- **WHEN** Hermiq calls `stackiq.draftView` +- **THEN** the result SHALL be an error that names the key c +- **AND** no `view`, `element` or `relation` object SHALL be written + +#### Scenario: A new element with the name of an existing one is reused +@e2e exclude The match rule is a service concern; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts that a new ApplicationComponent named Documentbeheer resolves to the existing element of that type and name. + +- **GIVEN** an element of type ApplicationComponent named Documentbeheer in the caller's scope +- **WHEN** a draft request asks for a new ApplicationComponent named Documentbeheer +- **THEN** the view SHALL reference the existing element +- **AND** the result SHALL report zero created elements + +### Requirement: REQ-AAV-003 Every object the draft tool writes SHALL carry the assistant mark in the same write + +Every `view`, `element` and `relation` that `stackiq.draftView` creates SHALL carry `origin` set to `assistant` in the save that creates it. The view SHALL also carry `draftedFor`, the Nextcloud user id of the session the tool ran in, and `draftedAt`. A write that fails SHALL return an error result, and no object SHALL be saved first and marked later (ADR-088). A person who edits a drafted view SHALL NOT remove the mark. + +#### Scenario: A reviewer sees who the draft was made for +@e2e tests/e2e/workflows/architecture-assistant-views.spec.ts + +- **GIVEN** a view drafted through `stackiq.draftView` in the session of an application owner +- **WHEN** the application owner opens it in the view editor at `/views/` +- **THEN** the editor SHALL show the notice "Drafted by an assistant for" with their name and the date +- **AND** the notice SHALL still show after they move a node and save + +#### Scenario: New elements and relations carry the mark +@e2e exclude The mark sits in the saved objects; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts every saveObject call for a new view, element and relation carries origin assistant in the same payload. + +- **GIVEN** a draft request with one new element and one new relation +- **WHEN** `stackiq.draftView` writes it +- **THEN** the new element and the new relation SHALL each carry `origin` assistant +- **AND** the reused elements SHALL keep their own `origin` + +### Requirement: REQ-AAV-004 A drafted view SHALL be laid out when it is first opened + +The draft tool SHALL write nodes without positions. When the view editor opens a view whose nodes need a full layout, it SHALL place them with the shared library's layered layout, and the first save by a person SHALL store the positions. The Views page SHALL offer `assistant` in its origin facet. + +#### Scenario: An application owner opens a fresh draft +@e2e tests/e2e/workflows/architecture-assistant-views.spec.ts + +- **GIVEN** a view drafted with three elements and two relations and no positions +- **WHEN** the application owner opens it in the view editor +- **THEN** the three nodes SHALL render at distinct positions with both connections drawn +- **AND** choosing the origin assistant in the Views sidebar SHALL list the draft + +### Requirement: REQ-AAV-005 The draft tools SHALL run with the caller's rights and SHALL keep drafts out of shared readers + +Both tools SHALL run in the caller's Nextcloud session with no substitute account (ADR-034 Decision 7). A caller without create rights on `view` SHALL get a forbidden result and nothing SHALL be written. A drafted view SHALL belong to the caller's active organisation. `GET /api/views`, `GET /api/views/{viewId}` and the full ArchiMate export SHALL leave out views with `origin` assistant, as they leave out drawn views. + +#### Scenario: A caller without create rights gets a forbidden result +@e2e exclude The CI instance runs as admin; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts that a forbidden save from OpenRegister's ObjectService returns a forbidden result and that no later save runs. + +- **GIVEN** a signed-in user whose groups may read but not create `view` objects +- **WHEN** Hermiq calls `stackiq.draftView` in that user's session +- **THEN** the result SHALL be forbidden +- **AND** no object SHALL be written + +#### Scenario: A draft stays out of the shared views list +@e2e exclude The shared readers are PHP; tests/Unit/Service/ViewServiceDrawnViewTest.php and tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php gain a case with origin assistant and assert it is left out. + +- **GIVEN** a view drafted by an assistant +- **WHEN** another user calls `GET /api/views` or a Nextcloud admin runs the full ArchiMate export +- **THEN** the drafted view SHALL NOT be in the response or in the exported file diff --git a/openspec/changes/architecture-assistant-drafted-views/tasks.md b/openspec/changes/architecture-assistant-drafted-views/tasks.md new file mode 100644 index 00000000..d0e86901 --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/tasks.md @@ -0,0 +1,71 @@ +# Tasks: architecture-assistant-drafted-views + +## Implementation tasks + +### Task 1: Register fragment for the assistant mark +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-003-every-object-the-draft-tool-writes-shall-carry-the-assistant-mark-in-the-same-write +- **files**: `lib/Settings/register.d/architecture-assistant-drafted-views.json`, `tests/Unit/Service/ArchitectureViewsRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN `origin` on `view`, `element` and `relation` accepts imported, drawn and assistant + - GIVEN the merged register WHEN it loads THEN `view` has `draftedFor` and `draftedAt` and carries a bumped version + - GIVEN a fresh install WHEN the seed runs THEN the drafted example view exists with `origin` assistant +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewsRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Draft service +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-002-stackiq-shall-offer-a-create-tool-that-writes-a-draft-view-from-elements-and-relations +- **files**: `lib/Service/ArchitectureViewDraftService.php`, `tests/Unit/Service/ArchitectureViewDraftServiceTest.php` +- **acceptance_criteria**: + - GIVEN an unknown type, a dangling key or a list over the cap WHEN `draftView` runs THEN it returns an error and calls no save + - GIVEN a new element whose type and exact name match an existing element WHEN `draftView` runs THEN the existing element is referenced + - GIVEN a relation of the same type between the same two elements WHEN `draftView` runs THEN it is reused + - GIVEN any object the service creates WHEN it is saved THEN `origin` assistant is in the same payload, and the view carries `draftedFor` and `draftedAt` + - GIVEN `searchElements` WHEN it runs THEN it returns only uuid, identifier, name, ArchiMate type and GEMMA type, at most 50 rows +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewDraftServiceTest`) + +### Task 3: Two tools on the stackiq MCP provider +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-001-stackiq-shall-offer-a-read-tool-that-finds-architecture-elements-for-a-draft +- **files**: `lib/Mcp/StackiqToolProvider.php`, `tests/Unit/Mcp/StackiqToolProviderDraftToolsTest.php` +- **acceptance_criteria**: + - GIVEN the provider WHEN it lists its tools THEN `stackiq.searchArchitectureElements` is scope read, reach user, read-only, and `stackiq.draftView` is scope create, reach instance, not read-only, not destructive, not idempotent + - GIVEN a call to either tool WHEN it is dispatched THEN the arguments pass `McpArgumentValidator` and reach the service unchanged +- [ ] Implement +- [ ] Test (PHPUnit `StackiqToolProviderDraftToolsTest`) + +### Task 4: Caller rights and shared readers +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-005-the-draft-tools-shall-run-with-the-callers-rights-and-shall-keep-drafts-out-of-shared-readers +- **files**: `lib/Service/ArchitectureViewDraftService.php`, `tests/Unit/Service/ArchitectureViewDraftServiceTest.php`, `tests/Unit/Service/ViewServiceDrawnViewTest.php`, `tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php` +- **acceptance_criteria**: + - GIVEN OpenRegister refuses the view save WHEN `draftView` runs THEN the result is forbidden and no later save runs + - GIVEN a view with `origin` assistant WHEN `GET /api/views`, `GET /api/views/{viewId}` or the full ArchiMate export runs THEN it is left out +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewDraftServiceTest`, `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest`) + +### Task 5: Draft notice and first-open layout in the editor +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-004-a-drafted-view-shall-be-laid-out-when-it-is-first-opened +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/utils/viewGraph.js`, `tests/vitest/viewGraph.spec.js`, `tests/e2e/workflows/architecture-assistant-views.spec.ts` +- **acceptance_criteria**: + - GIVEN a view with `origin` assistant WHEN it opens THEN the notice names the user it was drafted for and the date, also after a save + - GIVEN nodes without positions WHEN the editor opens them THEN `layoutFlowNodes` places each at a distinct point, and a save stores the points + - GIVEN the Views page WHEN the origin facet is opened THEN assistant is one of its values +- [ ] Implement +- [ ] Test (vitest `viewGraph.spec.js` layout case, Playwright `architecture-assistant-views.spec.ts`) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-003-every-object-the-draft-tool-writes-shall-carry-the-assistant-mark-in-the-same-write +- **files**: `docs/features/architecture-views.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN a section shows a drafted view with its notice in a screenshot and names the two tools + - GIVEN a Dutch instance WHEN a drafted view opens THEN the notice reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshot captured with Playwright) + +## Verification + +- `openspec validate architecture-assistant-drafted-views --type change --strict` +- PHPUnit: `ArchitectureViewDraftServiceTest`, `StackiqToolProviderDraftToolsTest`, `ArchitectureViewsRegisterShapeTest`, `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest` +- vitest: `viewGraph.spec.js` +- Playwright: `tests/e2e/workflows/architecture-assistant-views.spec.ts` +- Documentation in `docs/features/architecture-views.md` with a screenshot (ADR-010) +- English and Dutch strings for the notice and the facet value (ADR-005) diff --git a/openspec/changes/architecture-data-model-and-ggm/.openspec.yaml b/openspec/changes/architecture-data-model-and-ggm/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-data-model-and-ggm/design.md b/openspec/changes/architecture-data-model-and-ggm/design.md new file mode 100644 index 00000000..f415c449 --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/design.md @@ -0,0 +1,102 @@ +# Design: architecture-data-model-and-ggm + +Read at development 49e65cb4. Line numbers below are from that sha. The `origin` field on `element` and `relation` and the Architecture menu group come from `architecture-views-editor` (its D1 and D8). `GebruikDetail` comes from `landscape-usage-registration` (`src/manifest.d/usages.json` in its design). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `usage` (`lib/Settings/softwarecatalogus_register.json:2654`) gains `dataEntities`; `element` (:4130) gains `attributes`; `relation` (:6300) gains `sourceCardinality` and `targetCardinality` | through a new fragment `lib/Settings/register.d/architecture-data-model-and-ggm.json` | +| Pages | new `src/manifest.d/architecture-data-model.json` with `Gegevensmodel` (index) and `GegevensobjectDetail` (detail) | | +| Pages | `GebruikDetail` in `src/manifest.d/usages.json` shows `dataEntities` in its data widget | | +| Menu | `src/menu-layout.json` relocates `Gegevensmodel` under the `Architecture` group | | +| Views | new `src/views/architecture/DataEntityRelations.vue` and `src/views/architecture/DataEntityAttributes.vue`, registered in `src/customComponents.js` | | +| Service, controller, routes | none | reads and writes go through OpenRegister's objects API | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`, `deepMergeConfig` at :7338). It bumps `usage`, `element` and `relation`. + +## Decisions + +### D1. A data entity is an AMEF `element` of type `BusinessObject` + +The GGM entities are already `element` objects after a GEMMA import: 503 `BusinessObject` elements in `lib/Settings/GEMMA_release.xml` carry `GGM-guid` (propid-6, :121153), and 636 Association and 158 Specialization relationships run between them. The import keeps them all (`lib/Service/ArchiMateImportService.php:973-980`) and stores the guid in `ggm-guid` (register.json:5038, through `convertToCamelCase` at :2256). A municipality's own entity is an `element` of type `BusinessObject` with `origin` drawn, so the view editor can place it next to GGM entities. + +Rejected: a new `dataEntity` schema in the `stackiq` register. It would copy 503 GGM entities out of the model they belong to, and a view could no longer place them, because the editor and both exports read the AMEF schemas only. + +### D2. The link lives on the application in use + +`usage.dataEntities` is a list of related `element` objects, with `objectConfiguration.queryParams` `type=BusinessObject`, so the picker offers only data entities. It is facetable, so the usages index can filter on an entity. + +Rejected: the field on `module`, filled by the supplier. Which data an application holds depends on how the municipality uses it: one product serves several reference components, and a municipality may keep only part of its data there. The tender asks for the municipality's own architecture repository. A supplier-declared list can follow as a suggestion when a usage is created. + +### D3. The Data model pages + +`Gegevensmodel` (`/gegevensmodel`) is a `CnIndexPage` (manifest `type: index`) over `@resolve:amef_register` and `element` with `filter` `{"type": "BusinessObject"}`, columns name, ggm-uml-type, gemmaType and origin, and quick filters All, GGM (`origin` imported) and Own (`origin` drawn). Its add dialog uses `createDefaults` `{"type": "BusinessObject", "origin": "drawn"}` and `includeFields` name, documentation and attributes (`@conduction/nextcloud-vue` 2.57.1 `src/components/CnIndexPage/CnIndexPage.vue`, props at :1817 and :1949), because `element` has 86 properties and the default dialog would show them all. + +`GegevensobjectDetail` (`/gegevensmodel/:id`) is a `type: detail` page on the ADR-062 grid with: +- a `data` widget for name, documentation, ggm-guid, ggm-uml-type and origin, +- a body widget `DataEntityRelations` (D4), +- a body widget `DataEntityAttributes` (D5), +- an `object-list` widget `entity-usages` over `@resolve:voorzieningen_register` and `usage` with filter `{"dataEntities": "@objectId"}`, titled "Applications that hold this data", `rowRoute: GebruikDetail`. + +OpenRegister filters an array property on one value with a JSON containment test (`openregister-ro/lib/Db/MagicMapper/MagicSearchHandler.php:1601`). + +### D4. The relations of an entity are drawn around it + +`DataEntityRelations.vue` loads the `relation` objects whose `source` or `target` is the entity's `identifier` and whose `type` is Association, Aggregation, Composition or Specialization, then the elements at the other end. It passes them to `CnRelationshipGraph` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnRelationshipGraph/CnRelationshipGraph.vue`, props `nodes`, `edges`, `layout` and `legend` at :123 to :171) with the radial layout, the entity as root, and an edge label of the relation name, its type, and the cardinalities when set. The component's colour props default to hex values (:150 to :158), so the view passes Nextcloud CSS variables for every colour (ADR-003). Under the graph a table lists every relation as text, and choosing a node or a row opens that entity's page. + +Rejected: `CnGraphCanvas`. The picture is an entity and its direct neighbours, which is what the relationship graph draws; a canvas with pan and zoom suits a whole view, and the view editor already offers it. + +Rejected: a UML class diagram with attribute compartments. GGM entities carry no attributes in the AMEFF file, so the compartments would be empty for 503 of them. + +### D5. Attributes and keys on the municipality's own entities + +`element.attributes` is a list of objects: `name` (required), `dataType` (enum text, number, date, boolean, reference), `isKey` (boolean) and `description`. `DataEntityAttributes.vue` shows them as a table with the key attributes first and marked "key" in text, and edits them for an entity with `origin` drawn. An imported entity shows "The GGM does not publish attributes in this file". + +`relation.sourceCardinality` and `relation.targetCardinality` are enums `0..1`, `1`, `0..*` and `1..*`. They are set on relations with `origin` drawn and shown on the edge label. + +Rejected: attributes as separate `element` objects of their own type. ArchiMate has no attribute element, and the export would write them as elements that Archi does not know how to show. + +## Declarative versus imperative + +- The usage to entity link is a `related-object` list, so OpenRegister keeps it in its relation index, and the entity page's application list is a manifest `object-list` with a filter. No PHP. +- The attribute list and cardinalities are schema properties. No lifecycle, notification or aggregation is added. +- `DataEntityRelations.vue` and `DataEntityAttributes.vue` are the imperative pieces: the first only reads, the second writes one property of one object. + +## Seed data + +`architecture-views-editor` seeds drawn elements in `vng-gemma`. This change adds two own data entities, one relation and one usage link. + +### Schema: `element` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-el-melding` | `seed-el-melder` | +| identifier | `id-seed-el-melding` | `id-seed-el-melder` | +| type | `BusinessObject` | `BusinessObject` | +| name | Melding openbare ruimte | Melder | +| origin | drawn | drawn | +| attributes | meldingnummer (text, key), datum melding (date), locatie (text) | e-mailadres (text, key), naam (text) | + +### Schema: `relation` + +| Field | Object 1 | +|---|---| +| slug | `seed-rel-melding-melder` | +| type | `Association` | +| name | gedaan door | +| source | `id-seed-el-melding` | +| target | `id-seed-el-melder` | +| sourceCardinality | `0..*` | +| targetCardinality | `1` | +| origin | drawn | + +### Schema: `usage` + +The seeded usage `gebruik-topdesk-gem-leiden-deelnemers` (register.json `components.objects`) gets `dataEntities` with the uuid of `seed-el-melding`, so the entity page shows one application on a fresh install. + +## Risks + +- **Identifier lookups.** A relation stores ArchiMate identifiers in `source` and `target`, while an imported element's uuid is its GEMMA object id (`ArchiMateImportService.php:4794-4798`). The view queries on the entity's `identifier` field, not its uuid, and its vitest spec covers an imported and a drawn entity. +- **Two queries per open.** The relations of one entity need a query on `source` and one on `target`. Both are limited to 200 rows, and the text list says when the limit is reached. +- **Schema versions.** The fragment bumps three schemas. The register changelog entry 2.4.4 (register.json:7) records that a deployed version equal to or above the declared one makes the import skip. diff --git a/openspec/changes/architecture-data-model-and-ggm/proposal.md b/openspec/changes/architecture-data-model-and-ggm/proposal.md new file mode 100644 index 00000000..3a20c764 --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/proposal.md @@ -0,0 +1,45 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - landscape-usage-registration +--- + +# Show the data model and link applications to the entities of the GGM + +## Summary + +A municipal information manager opens a Data model page in stackiq and finds the entities of the Gemeentelijk Gegevensmodel (GGM), each with its relations to other entities drawn around it. They record which entities an application in use holds data for, and an entity's page lists those applications. The municipality can add its own data entities with attributes and keys, and relate them with a cardinality. + +## Why + +This change builds two rows of the stackiq parity matrix. Both come from the Helmond architecture repository tender, https://www.tenderned.nl/aankondigingen/overzicht/398728. + +- `stackiq:arch-data-model`, "Model data entities and their relations, as an entity relationship or UML diagram, next to the applications." The matrix note: "Helmond REQ54 asks for entity relationship or UML data models." BlueDolphin rates yes: "Primary keys are unique identifiers for a data object and can be used to create a relationship between data objects" (https://help.bluedolphin.io/en/articles/11967570-keys-and-relationships), with a logical data dictionary (https://help.bluedolphin.io/en/articles/11967568-logical-data-dictionary) and views of type Logical Data (https://help.bluedolphin.io/en/articles/11967483-views-button-explanation). SAP LeanIX rates partial. The lane decided build on tender demand and a core area. +- `stackiq:arch-ggm-link`, "Relate applications to the entities of the Gemeentelijk Gegevensmodel they hold data for." The matrix note: "Helmond's architecture repository tender (REQ3, REQ61) asks to relate the repository to the GGM." BlueDolphin rates partial, through a third-party route: "hiervoor gebruik je het AMEFF-bestand van het GGM uit de GEMMA-repository voor de Architectuur module van BlueDolphin" (https://github.com/Gemeente-Delft/Gemeentelijk-Gegevensmodel/blob/master/README.md). No competitor rates yes. Decided build on tender demand and a core area. + +## What stackiq has today + +- The GGM is already in the data after a GEMMA import. `lib/Settings/GEMMA_release.xml` defines the property `GGM-guid` (propid-6, :121153) and carries it on 503 `BusinessObject` elements, such as Formatieplaats, Werknemer and Aanvraag, and on the relationships between them. The import keeps every element type (`lib/Service/ArchiMateImportService.php:973-980`) and turns a property name into a key by lowercasing it (`convertToCamelCase`, :2256), so `GGM-guid` lands in `ggm-guid`, which the `element` schema declares (`lib/Settings/softwarecatalogus_register.json:5038`, schema at :4130). +- No page shows these entities. The only AMEF page, Standaarden (`src/manifest.json:701`), is filtered to `gemmaType` standaard. +- No application points at a data entity. `module` (register.json:6777) and `usage` (:2654) hold no such field. `usage.amefElements` holds reference component ids that `GebruikSyncService` fills (`lib/Service/GebruikSyncService.php:170-272`). +- `relation` (:6300) has `source`, `target`, `type` and `name`, and no cardinality. `element` has no attribute list. + +## What this change builds + +- A field `dataEntities` on `usage`: the data entities an application in use holds data for. +- A Data model index page over `element` objects of type `BusinessObject`, and a data entity page with its relations drawn around it, its attributes and the applications that hold its data. +- An attribute list with keys on data entities the municipality adds, and a cardinality on relations it draws. +- A picker for data entities on the usage page `GebruikDetail`. + +## Out of scope + +- Importing the GGM separately. The GEMMA release carries it, and a municipality that wants a newer GGM imports that AMEFF file through the existing ArchiMate import. +- The attributes of GGM entities. The GEMMA AMEFF file has entities and relations but no attributes, so a GGM entity shows none. Loading GGM attributes from its UML source is a later change. +- Drawing a data model freehand. The view editor from `architecture-views-editor` draws `BusinessObject` elements and their relations on a canvas; this change adds the entity pages and the fields. +- A field on the catalogue `module` that suppliers fill. See design D2. + +## Risks + +- A GEMMA re-import updates imported entities. The municipality's own entities carry `origin` drawn and the import never writes them (`architecture-views-editor` D2). +- An entity with many relations draws a crowded graph. The graph shows direct neighbours only, and the text list under it holds every relation. diff --git a/openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md b/openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md new file mode 100644 index 00000000..19055165 --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md @@ -0,0 +1,90 @@ +# data-model-and-ggm specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-data-model-and-ggm + +## Purpose + +A municipality sees the entities of the Gemeentelijk Gegevensmodel (GGM) in stackiq, with their relations, and records which entities each application in use holds data for. It can add its own data entities with attributes and keys. Entities are AMEF `element` objects of type `BusinessObject`, and the link is a field on `usage`, both stored in OpenRegister (ADR-001) and shown with `CnIndexPage`, `CnDetailPage` and `CnRelationshipGraph` (ADR-012). + +## ADDED Requirements + +### Requirement: REQ-DMG-001 Stackiq SHALL show the data entities of the imported model on a Data model page + +Stackiq SHALL offer a Data model page at `/gegevensmodel` (page `Gegevensmodel`) that lists the `element` objects of type `BusinessObject`, with columns name, GGM UML type, GEMMA type and origin, and the quick filters All, GGM and Own. It SHALL offer a data entity page at `/gegevensmodel/:id` (page `GegevensobjectDetail`) with the entity's name, documentation, GGM guid and origin. The page SHALL sit under the Architecture menu group. + +#### Scenario: An information manager finds a data entity +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** the seeded own data entity Melding openbare ruimte +- **WHEN** a municipal information manager opens Architecture, then Data model, chooses the quick filter Own and searches Melding +- **THEN** the list SHALL show Melding openbare ruimte with origin drawn +- **AND** opening it SHALL show its name and documentation + +#### Scenario: GGM entities are listed after a GEMMA import +@e2e exclude The CI seed (tests/e2e/ci-seed.sh) imports the register but no GEMMA model; tests/validate-manifest.js asserts that the Gegevensmodel page filters element on type BusinessObject and that the quick filter GGM filters on origin imported. + +- **GIVEN** a stackiq instance where a Nextcloud admin imported the GEMMA model +- **WHEN** a municipal information manager opens the Data model page and chooses the quick filter GGM +- **THEN** the list SHALL show GGM entities such as Werknemer and Aanvraag +- **AND** no application component SHALL be listed + +### Requirement: REQ-DMG-002 A data entity page SHALL draw the entity's relations to other entities + +The data entity page SHALL show the entity with every entity it is related to by an Association, Aggregation, Composition or Specialization relation, drawn on `CnRelationshipGraph` with the entity at the centre and each edge labelled with the relation name or type and its cardinalities. A table under the graph SHALL list the same relations as text. Choosing a node or a row SHALL open that entity's page. All colours SHALL be Nextcloud CSS variables. + +#### Scenario: An information manager sees what a melding relates to +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** the seeded own entities Melding openbare ruimte and Melder with the relation gedaan door +- **WHEN** a municipal information manager opens the page of Melding openbare ruimte +- **THEN** the graph SHALL show Melder connected to it with the label gedaan door, 0..* to 1 +- **AND** the relation table SHALL hold the same relation as text + +#### Scenario: Relations are found by identifier for imported and drawn entities +@e2e exclude A lookup detail; tests/vitest/dataEntityRelations.spec.js asserts that an imported entity, whose uuid differs from its identifier, and a drawn entity both find their relations through the identifier field. + +- **GIVEN** an imported entity whose uuid is its GEMMA object id and whose identifier starts with id- +- **WHEN** its relations load +- **THEN** relations whose source or target is its identifier SHALL be found + +### Requirement: REQ-DMG-003 An application in use SHALL record the data entities it holds data for + +The `usage` schema SHALL have a facetable list `dataEntities` of related `element` objects, and its picker SHALL offer only elements of type `BusinessObject`. The usage page `GebruikDetail` SHALL show and edit the list. The data entity page SHALL list the usages that name it under "Applications that hold this data". + +#### Scenario: An application owner links their application to a data entity +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** an application owner on the page of a usage at `/gebruik/:id` +- **WHEN** they edit the usage, pick the data entity Melding openbare ruimte and save +- **THEN** the usage page SHALL show the entity under data entities +- **AND** the page of Melding openbare ruimte SHALL list the usage under "Applications that hold this data" + +#### Scenario: The picker offers data entities only +@e2e exclude A schema setting; tests/Unit/Settings/DataModelRegisterShapeTest.php asserts that usage.dataEntities is a related element list with the query type=BusinessObject and is facetable. + +- **GIVEN** the merged register +- **WHEN** the shape test reads `usage.dataEntities` +- **THEN** it SHALL relate to `element` filtered on type BusinessObject + +### Requirement: REQ-DMG-004 A municipality SHALL add its own data entities with attributes, keys and cardinalities + +The Data model page SHALL offer New data entity, which SHALL create an `element` of type `BusinessObject` with `origin` drawn and ask only for name, documentation and attributes. An attribute SHALL have a name, a data type (text, number, date, boolean or reference), a key flag and a description. The entity page SHALL list attributes with key attributes first and marked as key in text, and SHALL let an owner edit the attributes of an entity with `origin` drawn. A relation with `origin` drawn SHALL carry a source and a target cardinality of `0..1`, `1`, `0..*` or `1..*`. An imported entity SHALL show that the GGM file publishes no attributes. + +#### Scenario: An information manager adds an entity with a key +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** a municipal information manager on the Data model page +- **WHEN** they choose New data entity, enter the name Vergunning, add the attribute zaaknummer as a text key and save +- **THEN** the Data model page SHALL list Vergunning under the quick filter Own +- **AND** its page SHALL show zaaknummer first, marked key + +#### Scenario: An imported entity cannot be given attributes +@e2e exclude A view rule; tests/vitest/dataEntityAttributes.spec.js asserts that an entity with origin imported renders no edit control and shows the notice about the GGM file. + +- **GIVEN** an imported GGM entity +- **WHEN** its page renders +- **THEN** the attribute section SHALL show no edit control +- **AND** it SHALL say that the GGM file publishes no attributes diff --git a/openspec/changes/architecture-data-model-and-ggm/tasks.md b/openspec/changes/architecture-data-model-and-ggm/tasks.md new file mode 100644 index 00000000..ce280e29 --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/tasks.md @@ -0,0 +1,70 @@ +# Tasks: architecture-data-model-and-ggm + +## Implementation tasks + +### Task 1: Register fragment for data entities, attributes and the usage link +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-003-an-application-in-use-shall-record-the-data-entities-it-holds-data-for +- **files**: `lib/Settings/register.d/architecture-data-model-and-ggm.json`, `tests/Unit/Settings/DataModelRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN `usage.dataEntities` relates to `element` with the query type=BusinessObject and is facetable + - GIVEN the merged register WHEN it loads THEN `element.attributes` has name, dataType, isKey and description, and `relation` has both cardinality enums + - GIVEN the fragment WHEN it is compared with development THEN `usage`, `element` and `relation` carry bumped versions + - GIVEN a fresh install WHEN the seed runs THEN the two own entities, their relation and the usage link exist +- [ ] Implement +- [ ] Test (PHPUnit `DataModelRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Data model index and entity page +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-001-stackiq-shall-show-the-data-entities-of-the-imported-model-on-a-data-model-page +- **files**: `src/manifest.d/architecture-data-model.json`, `src/menu-layout.json` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Gegevensmodel` filters `element` on type BusinessObject with the quick filters All, GGM and Own + - GIVEN the add dialog WHEN it opens THEN it asks only for name, documentation and attributes and creates type BusinessObject with origin drawn + - GIVEN the effective menu WHEN it renders THEN Data model sits under the Architecture group + - GIVEN an entity page WHEN it renders THEN "Applications that hold this data" lists usages filtered on `dataEntities` +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/workflows/data-model.spec.ts`) + +### Task 3: Relations drawn around an entity +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-002-a-data-entity-page-shall-draw-the-entitys-relations-to-other-entities +- **files**: `src/views/architecture/DataEntityRelations.vue`, `src/utils/dataEntityGraph.js`, `src/customComponents.js`, `tests/vitest/dataEntityRelations.spec.js` +- **acceptance_criteria**: + - GIVEN an imported and a drawn entity WHEN their relations load THEN both are found through the identifier field + - GIVEN a relation with cardinalities WHEN it is drawn THEN the edge label holds its name and both cardinalities + - GIVEN the graph WHEN it renders THEN every colour prop is a CSS variable and a text table lists the same relations +- [ ] Implement +- [ ] Test (vitest `dataEntityRelations.spec.js`, Playwright relation scenario) + +### Task 4: Attributes with keys +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-004-a-municipality-shall-add-its-own-data-entities-with-attributes-keys-and-cardinalities +- **files**: `src/views/architecture/DataEntityAttributes.vue`, `tests/vitest/dataEntityAttributes.spec.js` +- **acceptance_criteria**: + - GIVEN a drawn entity WHEN its page renders THEN key attributes come first, marked key in text, and can be edited + - GIVEN an imported entity WHEN its page renders THEN no edit control shows and the notice about the GGM file does +- [ ] Implement +- [ ] Test (vitest `dataEntityAttributes.spec.js`, Playwright new entity scenario) + +### Task 5: Data entities on the usage page +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-003-an-application-in-use-shall-record-the-data-entities-it-holds-data-for +- **files**: `src/manifest.d/usages.json` +- **acceptance_criteria**: + - GIVEN a usage page WHEN it is edited THEN the data entities picker offers business objects only, and the saved list shows on the page +- [ ] Implement +- [ ] Test (Playwright usage link scenario) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-001-stackiq-shall-show-the-data-entities-of-the-imported-model-on-a-data-model-page +- **files**: `docs/features/data-model.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the Data model page, an entity page with its graph and a usage with data entities, each in a screenshot, and says how to import a newer GGM + - GIVEN a Dutch instance WHEN the Data model page renders THEN every new label reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-data-model-and-ggm --type change --strict` +- PHPUnit: `DataModelRegisterShapeTest`, `RegisterFragmentMergeTest` +- vitest: `dataEntityRelations.spec.js`, `dataEntityAttributes.spec.js` +- Playwright: `tests/e2e/workflows/data-model.spec.ts` +- Documentation in `docs/features/data-model.md` with screenshots (ADR-010) +- English and Dutch strings for every new label and enum value (ADR-005) diff --git a/openspec/changes/architecture-decision-register/.openspec.yaml b/openspec/changes/architecture-decision-register/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-decision-register/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-decision-register/design.md b/openspec/changes/architecture-decision-register/design.md new file mode 100644 index 00000000..44499d66 --- /dev/null +++ b/openspec/changes/architecture-decision-register/design.md @@ -0,0 +1,108 @@ +# Design: architecture-decision-register + +Read at development 49e65cb4. Line numbers below are from that sha. The Architecture menu group comes from `architecture-views-editor` (its D8), and `GebruikDetail` from `landscape-usage-registration`. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `stackiq` register (`lib/Settings/softwarecatalogus_register.json:817`), new schema `architectureDecision` | through a new fragment `lib/Settings/register.d/architecture-decision-register.json` | +| Lifecycle guard | new `lib/Lifecycle/ArchitectureDecisionReviewGuard.php`, implementing `OCA\OpenRegister\Lifecycle\LifecycleGuardInterface` (`openregister-ro/lib/Lifecycle/LifecycleGuardInterface.php:37`) | resolved by OpenRegister through the server container by its class name | +| Analysis stub | `tests/Stubs` gains the OpenRegister guard interface and `GuardResult` if psalm and phpstan cannot see them | | +| Pages | new `src/manifest.d/architecture-decision-register.json` with `Architectuurbesluiten` (index) and `ArchitectuurbesluitDetail` (detail) | | +| Pages | `GebruikDetail` in `src/manifest.d/usages.json` and `StandaardDetail` (`src/manifest.json:724`) each gain an `object-list` widget | | +| Menu | `src/menu-layout.json` relocates `Architectuurbesluiten` under the `Architecture` group | | +| Service, controller, routes | none | reads and writes go through OpenRegister's objects API; transitions through OpenRegister's lifecycle actions | + +## Decisions + +### D1. An architecture decision is a stackiq object, not a decidiq decision + +An architecture decision is recorded as an `architectureDecision` object in the `stackiq` register with a lifecycle declared on the schema (ADR-031). When a board formally adopts it in decidiq, the object links to that decidiq decision, in the way `catalogContract.decisions` does (register.json:3450). + +Rejected: every architecture decision as a decidiq decision, projected back as contracts are. An architecture decision record is an architecture artefact: context, options, consequences, links to applications and GEMMA elements, and a chain of decisions that supersede each other. Architects review it; most never reach a board. A decidiq decision is a governance act with meetings, voting and signing. Delegating would make recording any architecture decision depend on decidiq being installed, move architecture fields into decidiq, and cost the fail-closed cross-app event path the contracts carry, including the two event class spellings after the rename (`lib/Service/ContractApprovalService.php:64` to :104). + +### D2. The schema + +`architectureDecision`: + +| Field | Type | Notes | +|---|---|---| +| title | string, required | | +| context | string, long text | why a decision is needed | +| decision | string, long text | what was decided | +| alternatives | list of objects `{option, reasonRejected}` | the options not chosen | +| consequences | string, long text | | +| category | enum application, data, integration, infrastructure, security, standards, facetable | | +| impact | enum low, medium, high, facetable | | +| status | enum draft, in review, accepted, rejected, superseded, deprecated, default draft, facetable | lifecycle in the Declarative section | +| reviewer | string, a Nextcloud user id | required to submit | +| decidedOn | date | set by the owner when accepted | +| applications | list of related `usage` | the applications in use it affects | +| elements | list of related `element` | the GEMMA reference components, standards or other elements it affects | +| supersedes | related `architectureDecision` | | +| supersededBy | related `architectureDecision` | | +| boardDecisions | list of decidiq decision uuids, `x-external-register` decidesk, `referenceType` decision | as `catalogContract.decisions` (register.json:3450) | + +The read and write rules follow `usage` (register.json, `usage.authorization`): the catalogue groups create and update, and read is matched on `_organisation`. OpenRegister multitenancy scopes each decision to the organisation that recorded it. + +### D3. Fixed classification lists + +Category and impact are enums on the schema, so `CnIndexPage` facets and the forms render them without code. + +Rejected: dropdown fields an administrator adds to a decision template, as the LeanIX changelog describes. That needs a field-definition schema and a renderer for its answers next to the schema-driven forms (ADR-012), and properties added to the schema in OpenRegister's editor vanish on the next register import with a version bump. Two fixed lists cover the classification the row asks for, and a list can grow in a later register version. + +### D4. A second pair of eyes through one guard + +`ArchitectureDecisionReviewGuard::check(object, action, userId)` returns: + +| Action | Allowed when | +|---|---| +| submit | `reviewer` names a Nextcloud user and is not the caller | +| accept, reject | the caller is the `reviewer` | +| supersede | `supersededBy` points at a decision in status accepted | + +Other actions pass. The guard reads only; it never writes (the interface contract, `LifecycleGuardInterface.php:31` to :35). OpenRegister runs it for a named transition and for a direct edit of `status` alike (`openregister-ro/openspec/specs/object-lifecycle/spec.md:126` to :134), and denies with a 403 and the guard's message. + +Rejected: the review rule in a stackiq controller in front of the transition. A direct save of `status` through OpenRegister's objects API would pass around it. The guard sits in the save pipeline. + +### D5. Pages + +`Architectuurbesluiten` (`/architectuurbesluiten`) is a `CnIndexPage` over `architectureDecision` with columns title, status, category, impact and reviewer, the facets in the sidebar, and quick filters All, In review, Accepted and "To review by me" (`{"reviewer": "@me"}`, resolved by `@conduction/nextcloud-vue` `src/utils/resolveFilterTokens.js:119`). + +`ArchitectuurbesluitDetail` (`/architectuurbesluiten/:id`) is a `type: detail` page on the ADR-062 grid with a `data` widget for the text fields, a second `data` widget for classification, reviewer and dates, `object-list` widgets for the linked applications and elements, `lifecycleActions` on, and the History tab. + +`GebruikDetail` and `StandaardDetail` each get an `object-list` widget "Architecture decisions" over `architectureDecision` with filter `{"applications": "@objectId"}` or `{"elements": "@objectId"}`, which OpenRegister answers with a JSON containment test (`openregister-ro/lib/Db/MagicMapper/MagicSearchHandler.php:1601`). + +## Declarative versus imperative + +- The lifecycle is declared as `configuration.x-openregister-lifecycle` on `architectureDecision`: field `status`, initial draft, final rejected, superseded and deprecated, transitions submit (draft to in review, `requires` the guard), accept (in review to accepted, `requires` the guard), reject (in review to rejected, `requires` the guard), rework (in review or rejected to draft), supersede (accepted to superseded, `requires` the guard) and deprecate (accepted to deprecated). Every `from` and `to` value is an enum value exactly (register changelog 2.4.4, register.json:7). +- Two notifications are declared in `x-openregister-notifications` on the schema, in the shape `usage` uses (register.json:2662): `review-requested`, trigger `updated` with the condition status equals in review, recipient `{"kind": "field", "field": "reviewer"}` (a single user id, which the resolver checks is a real user, `NotificationRecipientResolver.php:187`); and `review-concluded`, trigger `updated` with status in accepted or rejected, recipient `{"kind": "object-acl", "permission": "manage"}`. Both on the channels `nc-notification` and `email`, with Dutch and English subjects. +- The links are `related-object` properties and manifest `object-list` widgets. +- The guard is the only PHP, and it is a read-only check the platform calls. + +## Seed data + +All objects live in the `stackiq` register. + +### Schema: `architectureDecision` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-ab-zaakgericht-werken` | `seed-ab-api-first` | +| title | Eén zaaksysteem voor alle domeinen | Nieuwe koppelingen alleen via API's | +| context | Drie domeinen gebruiken elk een eigen zaaksysteem. | Bestandsuitwisseling via FTP is niet te volgen. | +| decision | We gaan naar één zaaksysteem, domein voor domein. | Een nieuwe koppeling gebruikt een gedocumenteerde API. | +| category | application | integration | +| impact | high | medium | +| status | accepted | in review | +| reviewer | admin | admin | +| applications | `gebruik-suite4-gem-delft-eigenaar` | | + +The usage slug is one of the three the register seeds (register.json `components.objects`). + +## Risks + +- **Guard resolution.** OpenRegister resolves a guard by class name through the server container and fails closed when it cannot (`openregister-ro/openspec/specs/object-lifecycle/spec.md:593`). A typo in the `requires` value blocks the transition on every instance, so the register shape test asserts the value equals the guard's class name. +- **Payload shape.** The guard reads `reviewer`, `supersededBy` and the linked decision's status from the payload OpenRegister passes. Its unit test builds that payload from a saved object, not by hand. +- **One reviewer.** The notification recipient kind `field` takes one user id, so a decision has one reviewer. A review by a group can follow once the resolver takes a list. diff --git a/openspec/changes/architecture-decision-register/proposal.md b/openspec/changes/architecture-decision-register/proposal.md new file mode 100644 index 00000000..a548a7a2 --- /dev/null +++ b/openspec/changes/architecture-decision-register/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - landscape-usage-registration +--- + +# Record architecture decisions with a review and link them to applications + +## Summary + +A municipal information manager records an architecture decision in stackiq: the context, the decision, the options they rejected and the consequences, classified by category and impact. They name a reviewer and submit it; the reviewer accepts or rejects it, and a later decision can supersede it. Each decision links to the applications in use and the GEMMA elements it affects, and the page of an application in use lists the decisions about it. When a board formally adopts a decision in decidiq, the architecture decision links to that decidiq decision. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-decision-register`, "Record architecture decisions with a status and review flow, and link each decision to the applications it affects." The demand is a changelog entry, https://updates.leanix.net/announcements/classify-architecture-decisions-with-dropdown-fields. + +- SAP LeanIX rates yes: admins "add single-select and multi-select dropdown fields to architecture decision templates", and "Document decisions about enterprise architecture in a structured, template-driven format ... Track decisions through a review process with defined statuses" (https://help.sap.com/docs/leanix/ea/architecture-decisions). +- GLPI rates no; GEMMA Softwarecatalogus, BlueDolphin and TOPdesk are unknown. + +The lane decided build: architecture is a core area. + +## What stackiq has today + +- The only decisions are contract approvals and renewals, delegated to decidiq. `lib/Service/ContractApprovalService.php:254` (`submitForApproval`) dispatches decidiq's `DecisionRequestedEvent` with the decision type `contract` or `contract-renewal` (:129, :135) and projects the outcome onto `approvalDecisionId` and `approvalState` (`lib/Settings/register.d/contracts-to-decidesk.json`). It must handle two event class spellings after decidiq's rename (:64 to :104) and fails closed when decidiq is absent. +- `catalogContract.decisions` (`lib/Settings/softwarecatalogus_register.json:3450`) holds decidiq decision uuids with `x-external-register` decidesk and `referenceType` decision: a link, not a copy. +- No schema holds an architecture decision, and no page lists one. +- OpenRegister runs a lifecycle transition through `requires` guards that an app can supply (`openregister-ro/openspec/specs/object-lifecycle/spec.md:126` to :134, `openregister-ro/lib/Lifecycle/LifecycleGuardInterface.php:37`), and its notification engine resolves a recipient from a user id held in an object field (`openregister-ro/lib/Service/Notification/NotificationRecipientResolver.php:187`). + +## What this change builds + +- A schema `architectureDecision` in the `stackiq` register with the decision text, category, impact, reviewer, links to usages and AMEF elements, supersedes and superseded by, and links to decidiq decisions. +- A declared review lifecycle (draft, in review, accepted, rejected, superseded, deprecated) with one stackiq guard class that enforces a second pair of eyes. +- Notifications to the reviewer on submit and to the owner on the outcome, declared on the schema. +- An Architecture decisions index and a decision page under the Architecture menu group, and a list of decisions on the usage page and on the standard page. + +## Out of scope + +- Making a formal board or council decision. That is decidiq's: its meetings, voting and signing. Stackiq only links to a decidiq decision the organisation already took. Raising an architecture decision in decidiq through `DecisionRequestedEvent` would need decidiq to accept a new decision type, and can follow the contract pattern later. +- Templates an administrator defines. Category and impact are fixed lists; see design D3. +- Decisions about a catalogue product for every municipality. A decision belongs to the organisation that records it. + +## Risks + +- The guard class implements an OpenRegister interface. If OpenRegister cannot resolve the guard, the transition fails closed, so a misconfigured instance blocks acceptance rather than letting anyone accept. +- A reviewer who leaves the organisation keeps pending decisions. The owner can send the decision back to draft and name a new reviewer. diff --git a/openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md b/openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md new file mode 100644 index 00000000..abe18f28 --- /dev/null +++ b/openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md @@ -0,0 +1,87 @@ +# architecture-decision-register specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-decision-register + +## Purpose + +A municipality records its architecture decisions in stackiq with a review by a second person, and links each decision to the applications in use and the GEMMA elements it affects. Decisions are `architectureDecision` objects in the `stackiq` register (ADR-001) with a lifecycle and notifications declared on the schema (ADR-031), shown with `CnIndexPage` and `CnDetailPage` (ADR-012). A formal board decision stays in decidiq, and stackiq links to it. + +## ADDED Requirements + +### Requirement: REQ-ADREG-001 An information manager SHALL record an architecture decision with its context, options and consequences + +Stackiq SHALL offer an Architecture decisions page at `/architectuurbesluiten` (page `Architectuurbesluiten`) and a decision page at `/architectuurbesluiten/:id` (page `ArchitectuurbesluitDetail`) under the Architecture menu group. A decision SHALL have a title, a context, the decision, the rejected alternatives each with a reason, the consequences, a category (application, data, integration, infrastructure, security or standards), an impact (low, medium or high), a status and a reviewer. Category, impact and status SHALL be facetable. Decisions SHALL be scoped to the organisation that recorded them. + +#### Scenario: An information manager records a decision +@e2e tests/e2e/workflows/architecture-decisions.spec.ts + +- **GIVEN** a municipal information manager signed in to stackiq +- **WHEN** they open Architecture, then Architecture decisions, create the decision Nieuwe koppelingen alleen via API's with category integration, impact medium, one rejected alternative and a reviewer, and save +- **THEN** the page SHALL list the decision with status draft +- **AND** its page SHALL show the rejected alternative with its reason + +#### Scenario: Another municipality does not see the decision +@e2e exclude The CI instance has one organisation; tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php asserts the read rules match on _organisation, as usage does. + +- **GIVEN** a decision of municipality A +- **WHEN** a user of municipality B opens the Architecture decisions page +- **THEN** the decision SHALL NOT be listed + +### Requirement: REQ-ADREG-002 A decision SHALL be reviewed by a second person through a declared lifecycle + +The status SHALL move through transitions declared as `x-openregister-lifecycle`: submit (draft to in review), accept and reject (in review to accepted or rejected), rework (in review or rejected to draft), supersede (accepted to superseded) and deprecate (accepted to deprecated), with values that are members of the status enum. Submit SHALL be allowed only when the reviewer is a Nextcloud user other than the caller. Accept and reject SHALL be allowed only for the reviewer. Supersede SHALL be allowed only when the decision names an accepted decision that supersedes it. A denied transition SHALL answer 403 with the reason, whether it was applied as an action or as a direct edit of the status. + +#### Scenario: A reviewer accepts a decision +@e2e tests/e2e/workflows/architecture-decisions.spec.ts + +- **GIVEN** a decision in review whose reviewer is a second test user, created by the test fixture +- **WHEN** that reviewer opens the decision and applies Accept +- **THEN** the decision SHALL read accepted +- **AND** the transition SHALL appear in its History tab + +#### Scenario: The author cannot accept their own decision +@e2e exclude A guard rule; tests/Unit/Lifecycle/ArchitectureDecisionReviewGuardTest.php asserts that submit is denied when the reviewer is the caller and that accept and reject are denied for anyone but the reviewer. + +- **GIVEN** a decision in review whose reviewer is a colleague +- **WHEN** the author applies Accept +- **THEN** the transition SHALL be denied with a 403 naming the reviewer rule + +#### Scenario: A decision is superseded only by an accepted one +@e2e exclude A guard rule; tests/Unit/Lifecycle/ArchitectureDecisionReviewGuardTest.php asserts supersede is allowed when supersededBy points at an accepted decision and denied otherwise. + +- **GIVEN** an accepted decision whose supersededBy names a decision still in draft +- **WHEN** the owner applies Supersede +- **THEN** the transition SHALL be denied + +### Requirement: REQ-ADREG-003 The reviewer and the owner SHALL be notified through declared notifications + +The schema SHALL declare in `x-openregister-notifications` a notification to the reviewer when a decision enters in review, and a notification to the owners of the decision when it is accepted or rejected, on the channels Nextcloud notification and email, with Dutch and English subjects. + +#### Scenario: A reviewer is told a decision waits for them +@e2e exclude Delivery runs in OpenRegister's engine; tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php asserts the review-requested rule uses the field recipient reviewer and the review-concluded rule the object-acl manage recipient, and that both subjects have nl and en. + +- **GIVEN** a decision with a colleague as reviewer +- **WHEN** the author submits it +- **THEN** the colleague SHALL receive a Nextcloud notification that links to the decision + +### Requirement: REQ-ADREG-004 A decision SHALL link to the applications and elements it affects and to board decisions + +A decision SHALL hold a list of the usages it affects, a list of the AMEF elements it affects, the decision it supersedes, the decision that supersedes it, and a list of decidiq decision ids with `x-external-register` decidesk. The usage page `GebruikDetail` and the standard page `StandaardDetail` SHALL each list the architecture decisions that name them. + +#### Scenario: An application owner sees the decisions about their application +@e2e tests/e2e/workflows/architecture-decisions.spec.ts + +- **GIVEN** the seeded accepted decision Eén zaaksysteem voor alle domeinen linked to the seeded Suite4 usage +- **WHEN** an application owner opens that usage at `/gebruik/:id` +- **THEN** the list Architecture decisions SHALL show the decision with status accepted +- **AND** choosing it SHALL open the decision page + +#### Scenario: A decision links a board decision without copying it +@e2e exclude The CI instance runs without decidiq; tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php asserts boardDecisions is a uuid list with x-external-register decidesk and referenceType decision, as catalogContract.decisions is. + +- **GIVEN** a decision adopted by a board in decidiq +- **WHEN** the owner adds the decidiq decision to the architecture decision +- **THEN** the architecture decision SHALL store only the decidiq decision id diff --git a/openspec/changes/architecture-decision-register/tasks.md b/openspec/changes/architecture-decision-register/tasks.md new file mode 100644 index 00000000..9447ed36 --- /dev/null +++ b/openspec/changes/architecture-decision-register/tasks.md @@ -0,0 +1,62 @@ +# Tasks: architecture-decision-register + +## Implementation tasks + +### Task 1: Register fragment with lifecycle and notifications +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-001-an-information-manager-shall-record-an-architecture-decision-with-its-context-options-and-consequences +- **files**: `lib/Settings/register.d/architecture-decision-register.json`, `tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN the `stackiq` register lists `architectureDecision` with magic mapping on and read rules matched on `_organisation` + - GIVEN the lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a status enum value and every `requires` equals the guard's class name + - GIVEN the notifications WHEN the shape test reads them THEN review-requested uses the field recipient reviewer, review-concluded the object-acl manage recipient, and both subjects have nl and en + - GIVEN `boardDecisions` WHEN the shape test reads it THEN it matches the shape of `catalogContract.decisions` + - GIVEN a fresh install WHEN the seed runs THEN the two demo decisions exist +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureDecisionRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Review guard +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-002-a-decision-shall-be-reviewed-by-a-second-person-through-a-declared-lifecycle +- **files**: `lib/Lifecycle/ArchitectureDecisionReviewGuard.php`, `tests/Stubs/`, `tests/Unit/Lifecycle/ArchitectureDecisionReviewGuardTest.php` +- **acceptance_criteria**: + - GIVEN submit WHEN the reviewer is empty, unknown or the caller THEN the guard denies with a message + - GIVEN accept or reject WHEN the caller is not the reviewer THEN the guard denies + - GIVEN supersede WHEN supersededBy is not an accepted decision THEN the guard denies + - GIVEN any action WHEN the guard runs THEN it writes nothing +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureDecisionReviewGuardTest`, psalm and phpstan on the guard) + +### Task 3: Decision pages and menu +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-001-an-information-manager-shall-record-an-architecture-decision-with-its-context-options-and-consequences +- **files**: `src/manifest.d/architecture-decision-register.json`, `src/menu-layout.json`, `tests/e2e/workflows/architecture-decisions.spec.ts` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Architectuurbesluiten` and `ArchitectuurbesluitDetail` exist with the quick filter "To review by me" on `@me` + - GIVEN the effective menu WHEN it renders THEN Architecture decisions sits under the Architecture group + - GIVEN a decision page WHEN it renders THEN its lifecycle actions and History tab show +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright record and accept scenarios) + +### Task 4: Decisions on the usage and standard pages +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-004-a-decision-shall-link-to-the-applications-and-elements-it-affects-and-to-board-decisions +- **files**: `src/manifest.d/usages.json`, `src/manifest.json` +- **acceptance_criteria**: + - GIVEN a decision that names a usage WHEN the usage page opens THEN Architecture decisions lists it + - GIVEN a decision that names a standard WHEN the standard page opens THEN Architecture decisions lists it +- [ ] Implement +- [ ] Test (Playwright usage page scenario) + +### Task 5: Documentation and translations +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-002-a-decision-shall-be-reviewed-by-a-second-person-through-a-declared-lifecycle +- **files**: `docs/features/architecture-decisions.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows a decision page, the review step and the list on a usage page, each in a screenshot, and explains when to use decidiq + - GIVEN a Dutch instance WHEN the pages render THEN every new label and enum value reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-decision-register --type change --strict` +- PHPUnit: `ArchitectureDecisionRegisterShapeTest`, `ArchitectureDecisionReviewGuardTest`, `RegisterFragmentMergeTest` +- Playwright: `tests/e2e/workflows/architecture-decisions.spec.ts` +- Documentation in `docs/features/architecture-decisions.md` with screenshots (ADR-010) +- English and Dutch strings for every new label, enum value and notification subject (ADR-005) diff --git a/openspec/changes/architecture-future-state-scenarios/.openspec.yaml b/openspec/changes/architecture-future-state-scenarios/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-future-state-scenarios/design.md b/openspec/changes/architecture-future-state-scenarios/design.md new file mode 100644 index 00000000..3771b1eb --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/design.md @@ -0,0 +1,149 @@ +# Design: architecture-future-state-scenarios + +Read at development 49e65cb4. Line numbers below are from that sha. `ReferenceComponentCoverageDerivation`, `ReferenceComponentCoverageService` and `OrganisationReportAccess` come from `architecture-reference-component-coverage` (its D1 and D2); the Architecture menu group from `architecture-views-editor` (its D8); `GebruikDetail` and the usage lifecycle on English values from `landscape-usage-registration`. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `stackiq` register (`lib/Settings/softwarecatalogus_register.json:817`), new schemas `scenario` and `scenarioChange`; `usage` (:2654) read and, on apply, written | through a new fragment `lib/Settings/register.d/architecture-future-state-scenarios.json` | +| Derivation | new `lib/Service/LandscapeAtDateDerivation.php` | pure, uses `PortfolioReportDerivation::deriveLifecyclePhase` (`lib/Service/PortfolioReportDerivation.php:58`) | +| Service | new `lib/Service/LandscapeComparisonService.php` | reads usages through `ReferenceComponentCoverageService` and scores both landscapes with `ReferenceComponentCoverageDerivation` | +| Controller and route | new `lib/Controller/LandscapeComparisonController.php`, route `landscapeComparison#index` at `GET /api/landscape-comparison`, next to `portfolioReport#index` (`appinfo/routes.php:303`) | | +| Pages | new `src/manifest.d/architecture-future-state-scenarios.json` with `Scenarios` (index), `ScenarioDetail` (detail) and `LandscapeComparison` (custom) | | +| Views | new `src/views/architecture/LandscapeComparisonView.vue`; `src/views/LifecycleRoadmapView.vue` gains a Compare with the plan button in its header (:4 to :24) | registered in `src/customComponents.js` | +| Store | new `src/store/modules/scenarioApply.js` | writes through OpenRegister's objects API | +| Menu | `src/menu-layout.json` relocates `Scenarios` under the `Architecture` group | | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`, `deepMergeConfig` at :7338) with `components.schemas`, `components.registers.stackiq.schemas` and `components.registers.stackiq.configuration.schemas` entries for both new schemas. + +## Decisions + +### D1. The plan is read from the dates, at any date + +The landscape on date D holds every usage whose phase on D, by `deriveLifecyclePhase($usage, D)` (`PortfolioReportDerivation.php:58`), is In production or To be phased out. A usage whose `plannedReplacementDate` is on or before D leaves the landscape on that date and its `plannedReplacement` module enters it as a planned successor, carrying the usage's `usedForReferenceComponents`. A usage with no phase date but a `status` of In production or To be phased out (the schema default is In production, register.json usage `status`) is in today's landscape with that phase and stays in every later landscape until a date or a scenario change says otherwise, because most usages carry a status and no dates. A usage with neither a phase date nor one of those status values reads Onbekend and is listed apart as not dated. + +Today's landscape is the same function with D set to today. So "today" and "the plan on D" come from one rule. The Portfolio roadmap uses the same date rule in the browser (`src/utils/lifecyclePhase.js:101`); the one difference is the status fallback, and a usage it adds is one the roadmap shows in its Onbekend lane. + +Rejected: a stored snapshot of the landscape per date. It would be a second copy of the usages that drifts when a date is edited, which the archived lifecycle change ruled out for the phase (its design Decision 1). + +### D2. A scenario is a change set on top of the plan + +`scenario`: + +| Field | Type | Notes | +|---|---|---| +| name | string, required | | +| description | string | | +| organisation | related `organization`, required | the landscape it changes; the comparison checks access on it | +| targetDate | date, required | | +| status | enum draft, proposed, adopted, rejected, default draft, facetable | lifecycle in the Declarative section | +| appliedAt | date-time | set when the scenario is applied | + +`scenarioChange`: + +| Field | Type | Notes | +|---|---|---| +| scenario | related `scenario`, required | | +| action | enum add, phase out, replace, required | | +| usage | related `usage` | required for phase out and replace | +| module | related `module` | required for add and replace | +| referenceComponents | list of related `element`, query `gemmaType=referentiecomponent` | for add and replace; the form fills it from `module.referenceComponents` | +| effectiveDate | date | defaults to the scenario's target date | +| note | string | | +| appliedTo | related `usage` | set on apply, so a second apply changes nothing | + +The scenario landscape on its target date is the plan on that date with the changes applied in order of `effectiveDate`. Each difference in the comparison names its source: plan or scenario. + +Rejected: future-state flags on usages, as BlueDolphin does on objects. A flag holds one future; two options for the same decision (replace A by B, or by C) need two change sets side by side. + +Rejected: a copy of every usage per scenario. Copies go stale the moment today's landscape changes, while a change set stays small and is always read against the current data. + +### D3. One comparison endpoint + +`GET /api/landscape-comparison?organisation=&date=&scenario=` (scenario optional; with a scenario, its organisation and target date are used). The controller runs `OrganisationReportAccess::isAuthorised` before any read and fails closed, as the two report controllers do. `LandscapeComparisonService` reads the organisation's usages once with the bounded query of `ReferenceComponentCoverageService`, and reads the scenario's changes with RBAC on. `LandscapeAtDateDerivation` builds both landscapes, and `ReferenceComponentCoverageDerivation` scores each for coverage. The response: + +```json +{ + "organisation": "00000000-0000-0000-0000-000000000000", + "today": "2026-09-27", + "date": "2027-06-30", + "scenario": null, + "applications": [ + { "moduleName": "Zaaksysteem A", "change": "removed", "source": "plan", "date": "2027-03-01" } + ], + "coverage": [ + { "component": "Zaakregistratiecomponent", "today": "overlap", "future": "covered" } + ], + "notDated": 2, + "truncated": false +} +``` + +`change` is one of added, removed, replaced (with `replacedBy`) and unchanged; `coverage` lists only components whose state differs. + +Rejected: computing the comparison in the browser with `lifecyclePhase.js`. The coverage half needs the reference components and the organisation-scoped usage read that `architecture-reference-component-coverage` put behind one bounded endpoint; a second path in the browser would read them differently. + +### D4. The pages + +`Scenarios` (`/scenarios`) is a `CnIndexPage` over `scenario` with columns name, organisation, targetDate and status, and quick filters All, Draft, Proposed and Adopted. `ScenarioDetail` (`/scenarios/:id`) is a `type: detail` page with a `data` widget, an `object-list` widget over `scenarioChange` with filter `{"scenario": "@objectId"}` and columns action, usage, module and effectiveDate, the body widget `LandscapeComparisonView` bound to the scenario, `lifecycleActions` on, and the History tab. + +`LandscapeComparison` (`/landscape-comparison`) is a custom page holding `LandscapeComparisonView` with an organisation picker and a date picker, for the plan alone. The Portfolio roadmap header gets a button Compare with the plan that opens it with the roadmap's organisation. + +`LandscapeComparisonView.vue` shows two columns, Today and the chosen date, with a `CnDataTable` of application differences (a text tag Added, Removed or Replaced by, and the source), a table of coverage differences (gap closed, gap opened, overlap resolved, overlap created) and the not dated count. Colours are Nextcloud CSS variables and every state is also a word. + +### D5. Applying an adopted scenario + +On an adopted scenario that has no `appliedAt`, the scenario page offers Apply to landscape. `src/store/modules/scenarioApply.js` writes each change through OpenRegister's objects API as the signed-in user: + +| Action | Write | +|---|---| +| add | a new `usage` with `consumer` the scenario's organisation, `module`, `usedForReferenceComponents`, `status` Planned and `startDateInProduction` the effective date | +| phase out | `startDateOutPhased` on the usage set to the effective date | +| replace | `plannedReplacement` and `plannedReplacementDate` on the usage | + +It stores the written usage in `appliedTo` after each write and sets `appliedAt` last. A retry skips changes that have `appliedTo`. After the apply, the plan comparison and the Portfolio roadmap show the changes, because they read the same fields. + +Rejected: a PHP service for the apply. It is three plain object writes per change with no rule the platform does not already enforce (ADR-022, config rule "Uses OpenRegister API directly from frontend"). + +## Declarative versus imperative + +- The scenario status lifecycle is declared as `configuration.x-openregister-lifecycle` on `scenario`: initial draft, final adopted, transitions propose (draft to proposed), adopt (proposed to adopted), reject (proposed to rejected) and rework (proposed or rejected to draft). The `from` and `to` values are the enum values exactly (register changelog 2.4.4, register.json:7). +- The scenario to change and change to usage links are `related-object` properties; the change list is a manifest `object-list`. No PHP. +- The comparison is imperative, because it joins usages, planned dates, a change set and reference components across two registers, which no `x-openregister-aggregation` expresses. It is a pure derivation behind one bounded endpoint. +- The apply is imperative and runs in the browser store. + +## Seed data + +All objects live in the `stackiq` register. They use the three usages the register already seeds (register.json `components.objects`). + +### Schema: `scenario` + +| Field | Object 1 | +|---|---| +| slug | `seed-scenario-delft-2027` | +| name | Servicedesk en schuldhulp in 2027 | +| organisation | `gemeente-delft` | +| targetDate | 2027-06-30 | +| status | proposed | + +### Schema: `scenarioChange` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-scenario-change-uitfaseren` | `seed-scenario-change-toevoegen` | +| scenario | `seed-scenario-delft-2027` | `seed-scenario-delft-2027` | +| action | phase out | add | +| usage | `gebruik-suite4-gem-delft-eigenaar` | | +| module | | `topdesk-itsm` | +| effectiveDate | 2027-06-30 | 2027-03-01 | +| note | Schuldhulp gaat naar de regio. | Eigen servicedesk in plaats van de gedeelde. | + +The module slug `topdesk-itsm` is the module the seeded TOPdesk usage of Servicecenter Rijnland points at, so the demo needs no new module. The seeded usages hold `status` `in-gebruik`, which is not an enum value, and no phase dates, so the Suite4 usage reads not dated until its status is set; the demo scenario shows that case on purpose. The Playwright spec builds its own usages with dates through OpenRegister's objects API instead of relying on the seed. + +## Risks + +- **Undated usages.** A usage with no phase date and no current status value is in neither landscape. The count of not dated usages sits next to the comparison so a reader sees why an application is missing. +- **Two sources for one date.** A plan replacement and a scenario change can touch the same usage. The scenario change wins, and the difference says both sources. +- **Partial apply.** A failed write stops the apply with a notice naming the change; `appliedTo` makes the retry safe, and `appliedAt` is set only when every change is written. +- **Schema versions.** Both schemas are new, so no version bump is needed on existing schemas. diff --git a/openspec/changes/architecture-future-state-scenarios/proposal.md b/openspec/changes/architecture-future-state-scenarios/proposal.md new file mode 100644 index 00000000..f0592455 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/proposal.md @@ -0,0 +1,49 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - architecture-reference-component-coverage + - landscape-usage-registration +--- + +# Compare a future landscape with today's + +## Summary + +A municipal information manager picks a date and sees the organisation's landscape on that date next to today's: which applications come in, go out or are replaced, and which reference component gaps and overlaps that closes or opens. The future comes from what the data already plans (planned usages, phase-out dates, planned replacements). On top of that plan they can write a named scenario, a what-if with its own additions, phase-outs and replacements, compare it the same way, take it through a proposal and adoption, and apply an adopted scenario to the landscape as planned changes. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-scenarios`, "Model a future-state landscape and compare it with today's." No tender or feature request names it. + +- SAP LeanIX rates yes: "plan your target architecture and monitor initiative progress" (https://help.sap.com/docs/leanix/ea/sap-leanix-architecture-and-road-map-planning), and "Understanding your architecture across the past, present, and future ... introducing the committed future" (https://updates.leanix.net/announcements/plan-with-consistent-future-architecture-data-introducing-the-committed-future). +- BlueDolphin rates yes: "Objects can be either Current (default) or Future state" (https://help.bluedolphin.io/en/articles/11967531-object-lifecycle-state) and "Map current and future state capabilities" (https://bluedolphin.io/capability-based-planning/). +- GEMMA Softwarecatalogus rates partial: "geplande harmonisaties ... met een status gepland met bijbehorende datum. Zo kan ook het uiteindelijke doel-landschap in 1 overzicht inzichtelijk worden gemaakt" (https://www.softwarecatalogus.nl/node/19703), with no side-by-side comparison. + +The lane decided build because two competitors rate yes and architecture is a core area. The matrix note holds: planned usage and planned replacements exist per record, but there is no future-state model and no comparison with today. + +## What stackiq has today + +- A usage carries five phase start dates, from `startDateAcquisition` to `startDateOutPhased`, a `status` with the value Planned, and `plannedReplacement` with `plannedReplacementDate` (`lib/Settings/softwarecatalogus_register.json:3070` and the usage schema at :2654). The archived change `2026-06-14-application-lifecycle-tracking` added the replacement fields as "an organisation's portfolio decision about its usage" (its design Decision 3). +- The phase of a usage at any moment is a pure function of those dates, in the browser (`src/utils/lifecyclePhase.js:101`, `derivePhase(gebruik, now)`) and in PHP (`lib/Service/PortfolioReportDerivation.php:58`, `deriveLifecyclePhase`). Both take the moment as an argument. +- The Portfolio roadmap page (`src/manifest.json:1013`, `src/views/LifecycleRoadmapView.vue`) groups today's usages by phase and orders them by the nearest EOL, phase-out or replacement date (`buildEntry`, :394). It shows one moment, today, and no comparison. +- `architecture-reference-component-coverage` adds a pure coverage derivation and the shared organisation check `OrganisationReportAccess`. + +## What this change builds + +- A landscape comparison: `lib/Service/LandscapeComparisonService.php` and `GET /api/landscape-comparison`, which builds today's landscape and the landscape on a date (the plan, plus a scenario when one is named) and returns the application differences and the coverage differences. +- Two schemas in the `stackiq` register: `scenario` (name, organisation, target date, status) and `scenarioChange` (add, phase out or replace). +- A Scenarios index and a scenario page with the comparison, under the Architecture menu group, and a Compare with the plan page reached from the Portfolio roadmap. +- An Apply to landscape action on an adopted scenario that writes its changes into the usages as planned dates and replacements. + +## Out of scope + +- Cost of a future landscape. Cost stays with the contract administration, as the archived lifecycle change decided. +- Future states of GEMMA elements or views. BlueDolphin marks objects as future; this change works on the organisation's applications in use. +- Drawing a target architecture view. The view editor from `architecture-views-editor` can draw one; linking a view to a scenario can follow. +- Approval of a scenario by a board. The adopt transition records the decision; routing it to decidiq is `architecture-decision-register`'s question. + +## Risks + +- A plan built from dates is only as good as the dates. Usages without dates have phase Onbekend at every moment; the comparison lists them apart as "not dated" instead of guessing. +- Applying a scenario writes to usages other people own. It runs only on an adopted scenario, as the signed-in user with their rights, and every write shows in the usage's History tab. diff --git a/openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md b/openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md new file mode 100644 index 00000000..c6b1e120 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md @@ -0,0 +1,99 @@ +# future-state-scenarios specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-future-state-scenarios + +## Purpose + +A municipality compares its landscape of today with its landscape on a future date: first as the data already plans it, then with a named scenario of its own on top. The comparison shows which applications come, go or are replaced, and which reference component gaps and overlaps that changes. An adopted scenario can be applied to the usages as planned dates and replacements. Scenarios are objects in the `stackiq` register (ADR-001) with a declared status lifecycle (ADR-031), shown with `CnIndexPage` and `CnDetailPage` (ADR-012). + +## ADDED Requirements + +### Requirement: REQ-FSS-001 Stackiq SHALL derive an organisation's landscape on any date from its usage dates + +The landscape on a date SHALL hold every usage of the organisation whose phase on that date, derived from its phase start dates, is In production or To be phased out. A usage whose planned replacement date is on or before that date SHALL leave the landscape, and its planned replacement module SHALL enter it as a planned successor with the same reference components. A usage with no phase date but a status of In production or To be phased out SHALL be in today's landscape and in every later one until a date or a scenario change removes it. A usage with neither SHALL be counted as not dated and SHALL be in neither landscape. Today's landscape SHALL be the same rule with today's date. + +#### Scenario: A planned replacement shows on its date +@e2e exclude A pure derivation; tests/Unit/Service/LandscapeAtDateDerivationTest.php asserts that a usage with plannedReplacementDate 2027-03-01 is in the landscape on 2027-02-28 and replaced by its successor on 2027-03-01. + +- **GIVEN** a usage in production with a planned replacement by another module on 2027-03-01 +- **WHEN** the landscape is derived for 2027-02-28 and for 2027-03-01 +- **THEN** the first SHALL hold the usage +- **AND** the second SHALL hold the successor module instead, with the usage's reference components + +#### Scenario: A usage with only a status counts, one with nothing is counted apart +@e2e exclude A pure derivation; tests/Unit/Service/LandscapeAtDateDerivationTest.php asserts that a usage with status In production and no dates is in both landscapes, and that a usage with neither a date nor a current status is in neither and raises the not dated count by one. + +- **GIVEN** a usage with status In production and no dates, and a usage with no phase date and the status in-gebruik +- **WHEN** today's landscape and a future landscape are derived +- **THEN** the first SHALL be in both landscapes +- **AND** the second SHALL be in neither, and the not dated count SHALL be one + +### Requirement: REQ-FSS-002 An information manager SHALL compare today's landscape with the plan on a date + +`GET /api/landscape-comparison?organisation=&date=` SHALL return the application differences between today and the date (added, removed, replaced by, unchanged, each with its date and the source plan), the reference components whose coverage state differs, and the not dated count. It SHALL refuse a user not authorised for the organisation before it reads anything. The page `LandscapeComparison` at `/landscape-comparison`, opened from a Compare with the plan button on the Portfolio roadmap, SHALL show both landscapes side by side with these differences in text. + +#### Scenario: An information manager sees what the plan changes by next summer +@e2e tests/e2e/workflows/future-state-scenarios.spec.ts + +- **GIVEN** a usage of the test organisation with a planned replacement on 2027-03-01, created by the test fixture +- **WHEN** a municipal information manager opens Portfolio roadmap, chooses Compare with the plan and picks 2027-06-30 +- **THEN** the comparison SHALL list the usage as replaced by its successor with source plan +- **AND** today's column SHALL still hold the usage + +#### Scenario: Another organisation's comparison is refused +@e2e exclude Needs a second organisation; tests/Unit/Controller/LandscapeComparisonControllerTest.php asserts a 403 before any service call through OrganisationReportAccess. + +- **GIVEN** a user of municipality A +- **WHEN** they call `GET /api/landscape-comparison` for municipality B +- **THEN** the response SHALL be 403 + +### Requirement: REQ-FSS-003 An information manager SHALL write a scenario of additions, phase-outs and replacements + +Stackiq SHALL offer a Scenarios page at `/scenarios` (page `Scenarios`) and a scenario page at `/scenarios/:id` (page `ScenarioDetail`) under the Architecture menu group. A scenario SHALL have a name, a description, an organisation, a target date and a status. A scenario change SHALL be one of add (a module with the reference components it will be used for), phase out (a usage) or replace (a usage by a module), with an optional effective date that defaults to the target date. The scenario page SHALL list its changes and SHALL show the comparison between today and the scenario landscape, where the scenario landscape is the plan on the target date with the changes applied, and every difference SHALL name its source, plan or scenario. + +#### Scenario: An information manager tries a replacement +@e2e tests/e2e/workflows/future-state-scenarios.spec.ts + +- **GIVEN** a usage in production of the test organisation and a second module, created by the test fixture +- **WHEN** a municipal information manager opens Architecture, then Scenarios, creates a scenario for the test organisation with a target date next year, and adds a change that replaces the usage by the second module +- **THEN** the scenario page SHALL list the change +- **AND** the comparison SHALL show the usage as replaced by the second module with source scenario + +#### Scenario: A scenario change wins over the plan +@e2e exclude A pure derivation; tests/Unit/Service/LandscapeAtDateDerivationTest.php asserts that a scenario phase out of a usage that the plan replaces reads as removed with both sources named. + +- **GIVEN** a usage the plan replaces on the target date and a scenario that phases it out +- **WHEN** the scenario landscape is derived +- **THEN** the usage SHALL be removed with the source scenario +- **AND** the difference SHALL also name the plan + +### Requirement: REQ-FSS-004 A scenario SHALL move through a declared lifecycle and an adopted scenario SHALL be applied to the landscape + +The `scenario` status SHALL move through transitions declared as `x-openregister-lifecycle`: propose (draft to proposed), adopt (proposed to adopted), reject (proposed to rejected) and rework (proposed or rejected to draft), with values that are members of the status enum. An adopted scenario without an applied date SHALL offer Apply to landscape, which SHALL write each change as the signed-in user: add creates a usage with status Planned and the effective date as its production start, phase out sets the usage's phased out date, and replace sets its planned replacement and date. Each applied change SHALL record the usage it wrote, and a retry SHALL skip it. The scenario SHALL record when it was applied. + +#### Scenario: An information manager applies an adopted scenario +@e2e tests/e2e/workflows/future-state-scenarios.spec.ts + +- **GIVEN** a proposed scenario of the test organisation, created by the test fixture, that adds a module on 2027-03-01 and phases out a usage on 2027-06-30 +- **WHEN** a municipal information manager applies Adopt and then Apply to landscape +- **THEN** a new usage of that module SHALL exist with status Planned and production start 2027-03-01 +- **AND** the phased out usage SHALL carry the phased out date 2027-06-30 +- **AND** Apply to landscape SHALL no longer be offered + +#### Scenario: A retried apply writes nothing twice +@e2e exclude A store rule; tests/vitest/scenarioApply.spec.js asserts that a change with appliedTo is skipped and that appliedAt is set only after the last write. + +- **GIVEN** an apply that failed after its first change was written +- **WHEN** the information manager applies again +- **THEN** the first change SHALL NOT be written again +- **AND** the scenario SHALL get its applied date once every change is written + +#### Scenario: The lifecycle matches the enum +@e2e exclude The transition engine is OpenRegister's; tests/Unit/Settings/ScenarioRegisterShapeTest.php asserts every lifecycle from and to value is a member of the status enum. + +- **GIVEN** the merged register +- **WHEN** the shape test reads the scenario lifecycle +- **THEN** every `from` and `to` value SHALL be a status enum value diff --git a/openspec/changes/architecture-future-state-scenarios/tasks.md b/openspec/changes/architecture-future-state-scenarios/tasks.md new file mode 100644 index 00000000..d3e82920 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/tasks.md @@ -0,0 +1,72 @@ +# Tasks: architecture-future-state-scenarios + +## Implementation tasks + +### Task 1: Landscape on a date +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-001-stackiq-shall-derive-an-organisations-landscape-on-any-date-from-its-usage-dates +- **files**: `lib/Service/LandscapeAtDateDerivation.php`, `tests/Unit/Service/LandscapeAtDateDerivationTest.php` +- **acceptance_criteria**: + - GIVEN usages with phase dates WHEN the landscape is derived for a date THEN it holds the usages in production or to be phased out on that date + - GIVEN a planned replacement on or before the date WHEN the landscape is derived THEN the successor module replaces the usage with its reference components + - GIVEN a usage with only a current status, and one with nothing WHEN both landscapes are derived THEN the first is in both and the second is counted as not dated + - GIVEN a scenario change and a plan replacement on one usage WHEN the scenario landscape is derived THEN the scenario wins and both sources are named +- [ ] Implement +- [ ] Test (PHPUnit `LandscapeAtDateDerivationTest`) + +### Task 2: Comparison service, controller and route +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-002-an-information-manager-shall-compare-todays-landscape-with-the-plan-on-a-date +- **files**: `lib/Service/LandscapeComparisonService.php`, `lib/Controller/LandscapeComparisonController.php`, `appinfo/routes.php`, `tests/Unit/Service/LandscapeComparisonServiceTest.php`, `tests/Unit/Controller/LandscapeComparisonControllerTest.php` +- **acceptance_criteria**: + - GIVEN a user of another organisation WHEN the endpoint is called THEN it answers 403 before the service runs + - GIVEN an organisation and a date WHEN the endpoint is called THEN it returns application and coverage differences in the shape of design D3 + - GIVEN a scenario id WHEN the endpoint is called THEN the scenario's organisation and target date are used and its changes are read with RBAC on +- [ ] Implement +- [ ] Test (PHPUnit `LandscapeComparisonServiceTest`, `LandscapeComparisonControllerTest`) + +### Task 3: Register fragment for scenarios +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-004-a-scenario-shall-move-through-a-declared-lifecycle-and-an-adopted-scenario-shall-be-applied-to-the-landscape +- **files**: `lib/Settings/register.d/architecture-future-state-scenarios.json`, `tests/Unit/Settings/ScenarioRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN the `stackiq` register lists `scenario` and `scenarioChange` with magic mapping on + - GIVEN the scenario lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a status enum value + - GIVEN a fresh install WHEN the seed runs THEN the demo scenario and its two changes exist +- [ ] Implement +- [ ] Test (PHPUnit `ScenarioRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 4: Comparison view, scenario pages and the roadmap button +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-003-an-information-manager-shall-write-a-scenario-of-additions-phase-outs-and-replacements +- **files**: `src/manifest.d/architecture-future-state-scenarios.json`, `src/menu-layout.json`, `src/views/architecture/LandscapeComparisonView.vue`, `src/views/LifecycleRoadmapView.vue`, `src/customComponents.js`, `tests/e2e/workflows/future-state-scenarios.spec.ts` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Scenarios`, `ScenarioDetail` and `LandscapeComparison` exist and Scenarios sits under the Architecture group + - GIVEN the Portfolio roadmap WHEN Compare with the plan is chosen THEN the comparison page opens for the roadmap's organisation + - GIVEN a comparison WHEN it renders THEN every difference carries its change and source as text +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `future-state-scenarios.spec.ts` plan and scenario scenarios) + +### Task 5: Apply an adopted scenario +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-004-a-scenario-shall-move-through-a-declared-lifecycle-and-an-adopted-scenario-shall-be-applied-to-the-landscape +- **files**: `src/store/modules/scenarioApply.js`, `src/views/architecture/LandscapeComparisonView.vue`, `tests/vitest/scenarioApply.spec.js` +- **acceptance_criteria**: + - GIVEN an adopted scenario without appliedAt WHEN Apply to landscape runs THEN add, phase out and replace write the fields of design D5 + - GIVEN a change with appliedTo WHEN the apply runs again THEN it is skipped + - GIVEN a failed write WHEN the apply stops THEN appliedAt stays empty and the notice names the change +- [ ] Implement +- [ ] Test (vitest `scenarioApply.spec.js`, Playwright apply scenario) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-003-an-information-manager-shall-write-a-scenario-of-additions-phase-outs-and-replacements +- **files**: `docs/features/future-state-scenarios.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the plan comparison, a scenario page and the apply result, each in a screenshot + - GIVEN a Dutch instance WHEN the scenario pages render THEN every new label and enum value reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-future-state-scenarios --type change --strict` +- PHPUnit: `LandscapeAtDateDerivationTest`, `LandscapeComparisonServiceTest`, `LandscapeComparisonControllerTest`, `ScenarioRegisterShapeTest`, `RegisterFragmentMergeTest` +- vitest: `scenarioApply.spec.js` +- Playwright: `tests/e2e/workflows/future-state-scenarios.spec.ts` +- Documentation in `docs/features/future-state-scenarios.md` with screenshots (ADR-010) +- English and Dutch strings for every new label and enum value (ADR-005) diff --git a/openspec/changes/architecture-process-mapping/.openspec.yaml b/openspec/changes/architecture-process-mapping/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-process-mapping/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-process-mapping/design.md b/openspec/changes/architecture-process-mapping/design.md new file mode 100644 index 00000000..fcbc424d --- /dev/null +++ b/openspec/changes/architecture-process-mapping/design.md @@ -0,0 +1,135 @@ +# Design: architecture-process-mapping + +Read at development 49e65cb4. Line numbers below are from that sha. `GebruikDetail` comes from the open change `landscape-usage-registration` (its `design.md`, `src/manifest.d/usages.json`), and the Architecture menu group from `architecture-views-editor` (its design D8). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `stackiq` register (`lib/Settings/softwarecatalogus_register.json:817`), new schemas `process` and `processStep` | through a new fragment `lib/Settings/register.d/architecture-process-mapping.json` | +| Register, read only | `vng-gemma` `element` (:4130) for the reference process, `usage` (:2654) for the supporting applications | no change to either schema | +| Pages | new `src/manifest.d/architecture-process-mapping.json` with `Processen` (index), `ProcesDetail` (detail) and `ProcesStapDetail` (detail) | | +| Pages | `GebruikDetail` in `src/manifest.d/usages.json` (from `landscape-usage-registration`) gains an `object-list` widget | | +| Menu | `src/menu-layout.json` relocates `Processen` under the `Architecture` group | | +| Views | new `src/views/architecture/ProcessStepFlow.vue` and `src/views/architecture/ProcessStepUsages.vue`, registered in `src/customComponents.js` | | +| Service, controller, routes | none | the frontend writes through OpenRegister's objects API (config rule "Uses OpenRegister API directly from frontend") | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`, `deepMergeConfig` at :7338). It carries `components.schemas.process`, `components.schemas.processStep`, `components.registers.stackiq.schemas: ["process", "processStep"]` and `components.registers.stackiq.configuration.schemas.: {"magicMapping": true, "autoCreateTable": true}` for both. + +## Decisions + +### D1. A process is organisation data in the `stackiq` register + +`process` and `processStep` live in the `stackiq` register next to `usage`, with the same authorization shape `usage` has (register.json, `usage.authorization`): create and update for the catalogue groups, read for `gebruik-beheerder` and `aanbod-beheerder` matched on `_organisation`. Processes are the municipality's own, like its usages, so OpenRegister multitenancy scopes them to the organisation that made them. + +`process` carries `configuration.jsonld.type` `https://schema.org/HowTo` and `processStep` carries `https://schema.org/HowToStep`, in the way `module` carries `SoftwareApplication`. + +Rejected: a process as an AMEF `element` of type `BusinessProcess` in the `vng-gemma` register, with steps as child elements and Composition relations. The AMEF register holds VNG's model, `element` has 86 properties built for GEMMA content, and a municipality's process would need the `origin` guards of `architecture-views-editor` D9 on every reader. The supporting applications are `usage` objects in the `stackiq` register, and a relation across the two registers is what the organisation data already does through `usedForReferenceComponents`. + +### D2. The schemas + +`process`: + +| Field | Type | Notes | +|---|---|---| +| name | string, required | | +| description | string | | +| processOwner | related `contactPerson` | picked from the organisation's contact roles, as `landscape-usage-registration` does for owners | +| status | enum draft, active, retired, default draft, facetable | lifecycle in the Declarative section | +| referenceProcess | related `element` in `vng-gemma`, `objectConfiguration.queryParams` `type=BusinessProcess` | the GEMMA reference process this one follows | +| tags | list of strings, facetable | | + +`processStep`: + +| Field | Type | Notes | +|---|---|---| +| process | related `process`, required | | +| name | string, required | | +| description | string | | +| stepType | enum task, event, decision, default task | | +| position | integer, required | the order within the process | +| follows | list of `processStep` uuids | empty means "the step before it by position" | +| usages | list of related `usage` | the applications in use that support the step | +| riskLevel | enum not assessed, low, medium, high, default not assessed, facetable | | +| riskNote | string | | +| complianceCheck | enum not checked, compliant, not compliant, not applicable, default not checked, facetable | | +| complianceNote | string | | +| checkedOn | date | when the compliance check was last done | + +The `referenceProcess` uuid is stable across GEMMA imports, because the import sets an element's uuid to its GEMMA object id (`lib/Service/ArchiMateImportService.php:4794-4798`). + +### D3. Pages under the Architecture group + +`Processen` (`/processen`) is a `CnIndexPage` (manifest `type: index`) over `process` with columns name, status, processOwner and tags, the schema facets in the sidebar, and quick filters All, Active and Draft. + +`ProcesDetail` (`/processen/:id`) is a `type: detail` page on the ADR-062 grid with: +- a `data` widget for name, description, status, owner, reference process and tags, +- an `object-list` widget `process-steps` over `processStep` with filter `{"process": "@objectId"}`, columns position, name, stepType, riskLevel and complianceCheck, sorted on position, `rowRoute: ProcesStapDetail`, +- a body widget `ProcessStepFlow` (see D4), +- `lifecycleActions` on, and the History tab in the sidebar. + +`ProcesStapDetail` (`/processtappen/:id`) is a `type: detail` page with a `data` widget over every step field and a body widget `ProcessStepUsages` that lists the step's usages. It is a small custom component rather than an `object-list`, because it compares the stored uuids with the returned objects to count the hidden ones (see Risks). + +The menu entry `Processen` is relocated under the `Architecture` group that `architecture-views-editor` adds (its D8), so the top-level count does not grow (ADR-097). + +### D4. The step flow is read-only on `CnGraphCanvas` + +`ProcessStepFlow.vue` loads the steps of one process, turns each into a node (name, step type, risk level, and the names of the supporting applications) and draws an edge from each uuid in `follows` to the step, or from the step with the next lower position when `follows` is empty. It passes them to `CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`, props `nodes`, `edges` and `readOnly` at :178, :184 and :196) with `readOnly` true, and places them with `layoutFlowNodes` (`src/composables/flowGraphLayout.js:329`), imported through the package's `./src/*` export as `architecture-assistant-drafted-views` D4 does. A step with a high risk or a failed compliance check gets `--color-error` on its border, and the text on the node says the same, so colour is never the only signal (WCAG 1.4.1). + +Rejected: an editable BPMN canvas. The rows ask to model processes and link applications, and a list with an order and a `follows` list does that. An editor is a second drawing tool next to the view editor, and it can come later on the same data. + +Rejected: OpenRegister flows. A flow (`openregister-ro/appinfo/routes.php:825`, BPMN export) is an automation that runs; a business process here is a description of how the municipality works, with owners, risks and applications. Storing descriptions as flows would put non-runnable flows in the flow engine's list. + +### D5. Fixed step fields, not customer-defined questionnaires + +The step fields are properties of `processStep`: risk level, risk note, compliance check, compliance note and checked on. `CnDetailPage` and `CnFormDialog` render them from the schema, and the facets filter on them. + +Rejected: questions a functional administrator defines per step type, answered per step, as BlueDolphin offers. That needs a question-definition schema and a form renderer for answers of any type, which is a second form system beside the schema-driven one (ADR-012). Letting an administrator add properties to `processStep` in OpenRegister's schema editor was also rejected: the repair step re-imports the register JSON and a version bump replaces the properties, so an added question would vanish on an update. The matrix row names risk level and a compliance check, which the fixed fields cover. + +### D6. The application in use lists the steps it supports + +`GebruikDetail` gets an `object-list` widget `usage-process-steps` over `processStep` with filter `{"usages": "@objectId"}`, columns process, name, riskLevel and complianceCheck, `rowRoute: ProcesStapDetail`, titled "Process steps this application supports". OpenRegister filters an array property on one value with a JSON containment test (`openregister-ro/lib/Db/MagicMapper/MagicSearchHandler.php:1601`), so no index or service is needed. + +## Declarative versus imperative + +- The process status lifecycle is declared as `configuration.x-openregister-lifecycle` on `process`: field `status`, initial draft, transitions activate (draft to active), retire (active to retired) and reopen (retired to draft). The `from` and `to` values are the enum values exactly (register changelog 2.4.4, register.json:7). `lifecycleActions` on `ProcesDetail` renders them. +- The step to process and step to usage links are `related-object` properties, so OpenRegister keeps them in its relation index. No PHP. +- The two lists are manifest `object-list` widgets with filters. No aggregation or notification is added. +- `ProcessStepFlow.vue` is the only imperative piece, and it only reads. + +## Seed data + +All objects live in the `stackiq` register. The reference process is the GEMMA element "Bedrijfsproces Behandelen vergunningaanvraag" (`lib/Settings/GEMMA_release.xml:950`), uuid `01f2e505-8245-44e5-860c-336a831eaae9`. On an instance without a GEMMA import the reference stays empty. + +### Schema: `process` + +| Field | Object 1 | +|---|---| +| slug | `seed-proces-vergunningaanvraag` | +| name | Behandelen vergunningaanvraag | +| description | Van intake tot besluit op een aanvraag voor een vergunning. | +| status | active | +| referenceProcess | `01f2e505-8245-44e5-860c-336a831eaae9` | +| tags | vergunningen | + +### Schema: `processStep` + +| Field | Object 1 | Object 2 | Object 3 | +|---|---|---|---| +| slug | `seed-stap-intake` | `seed-stap-toetsen` | `seed-stap-besluiten` | +| process | `seed-proces-vergunningaanvraag` | `seed-proces-vergunningaanvraag` | `seed-proces-vergunningaanvraag` | +| name | Intake vergunningaanvraag | Toetsen indieningsvereisten | Besluiten vergunningaanvraag | +| stepType | event | task | decision | +| position | 1 | 2 | 3 | +| usages | `gebruik-topdesk-gem-leiden-deelnemers` | none | none | +| riskLevel | low | medium | high | +| complianceCheck | compliant | not checked | not compliant | +| complianceNote | | | Besluit wordt nog niet gearchiveerd volgens de selectielijst. | + +The usage slug is one of the three usages the register already seeds (register.json `components.objects`). + +## Risks + +- **An array filter on MariaDB.** The containment test at `MagicSearchHandler.php:1601` is PostgreSQL SQL. The Playwright scenario for D6 runs on the CI database, and a MariaDB instance needs a check before this ships there. +- **Hidden usages.** RBAC can hide a linked usage from a reader. `ProcesStapDetail` compares the stored uuids with the returned objects and shows "1 linked application is not visible to you" instead of a silent gap. +- **Steps out of order.** Two steps with the same position draw side by side. The index sorts on position and then name, and the form warns on a duplicate position without refusing it. diff --git a/openspec/changes/architecture-process-mapping/proposal.md b/openspec/changes/architecture-process-mapping/proposal.md new file mode 100644 index 00000000..d9e56582 --- /dev/null +++ b/openspec/changes/architecture-process-mapping/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - landscape-usage-registration +--- + +# Model business processes and link their steps to the applications that support them + +## Summary + +A municipal information manager records the organisation's business processes in stackiq, step by step, and names for each step the applications in use that support it. Each step carries a risk level and a compliance check. The process page shows the steps as a flow, and the page of an application in use lists the process steps it supports. A process can point at the GEMMA reference process it follows. + +## Why + +This change builds two rows of the stackiq parity matrix. + +- `stackiq:arch-process-mapping`, "Model business processes and link them to the applications that support them." No tender or feature request names it. SAP LeanIX rates yes: "business context subtype 'Process : Processes show the different steps and interactions', related to applications" (https://help.sap.com/docs/leanix/ea/business-context-modeling-guidelines). BlueDolphin rates yes: "The Create BPMN diagram button allows you to create a diagram directly from a business process" (https://help.bluedolphin.io/en/articles/11967673-process-linked-to-ea-perspectives) and "For each application, you will find different processes in which the selected application is involved" (https://help.bluedolphin.io/en/articles/11967500-getting-started-with-process-publication-portal). The lane decided build because two competitors rate yes and architecture is a core area. +- `stackiq:arch-process-step-fields`, "Record structured fields on individual process steps, such as risk level or a compliance check." The demand is a changelog entry, https://bluedolphin.io/blog/july-2026-bluedolphin-updates/. BlueDolphin rates yes: customers can "define questionnaires directly on BPMN elements such as tasks and events" to "centralize documentation like risk levels, compliance checks, and technical specifications" (https://help.bluedolphin.io/en/articles/15874771-questionnaires-for-bpmn-elements). Decided build: core area. + +The matrix notes for both rows hold: stackiq models no processes. + +## What stackiq has today + +- The register holds 20 schemas (`lib/Settings/softwarecatalogus_register.json`, `components.schemas`) and none is a process. The `stackiq` register (:817) lists 15 of them, the `vng-gemma` register (:916) the five AMEF schemas. +- The ArchiMate import keeps every element type. It copies `xsi:type` into `type` without a filter (`lib/Service/ArchiMateImportService.php:973-980` and :5244-5249), and it sets an element's uuid to its GEMMA object id (:4794-4798), so the uuid survives a re-import. The GEMMA release in `lib/Settings/GEMMA_release.xml` holds 157 `BusinessProcess` elements, such as "Bedrijfsproces Behandelen vergunningaanvraag" (:950). These are VNG's reference processes. No page shows them: the only AMEF page, Standaarden (`src/manifest.json:701`), filters on `gemmaType` standaard. +- The organisation's applications are `usage` objects (register.json:2654). A usage links to reference components (`usedForReferenceComponents`) and, through `GebruikSyncService`, to AMEF element ids in `amefElements` (`lib/Service/GebruikSyncService.php:170-272`), never to a process. +- OpenRegister's flows (`src/manifest.json:1057`, page Flows) are automation flows with a BPMN export (`openregister-ro/appinfo/routes.php:825`). They run work; they do not describe how the municipality works. + +## What this change builds + +- Two schemas in the `stackiq` register through a fragment `lib/Settings/register.d/architecture-process-mapping.json`: `process` and `processStep`, with the step fields risk level, risk note, compliance check, compliance note and checked on. +- A Processes index page and a process detail page with a steps list and a read-only step flow on `CnGraphCanvas`, and a step detail page, under the Architecture menu group. +- A list "Process steps this application supports" on the usage detail page `GebruikDetail`. +- A link from a process to the GEMMA reference process it follows. + +## Out of scope + +- Drawing processes freehand in BPMN. The step flow is read-only and follows the step order. A BPMN editor can follow once the process data is in use. +- Customer-defined questionnaires on steps. This change ships a fixed set of step fields. See design D5 for why. +- Putting processes into the ArchiMate export. The organisation export (`lib/Service/ArchiMateExportService.php:2734`) draws applications into GEMMA views, and adding processes there is a later change. +- Drafting a process with an assistant. `architecture-assistant-drafted-views` drafts views only. +- Automation. Running a process is OpenRegister's flow engine (ADR-065), not this change. + +## Risks + +- A step lists usages across the whole organisation. A usage the reader may not see is left out of the list by OpenRegister RBAC, which can make a step look unsupported. The step detail says how many linked applications are hidden. +- GEMMA reference processes exist only after a GEMMA import. On an instance without one, the reference field offers nothing to pick, which is correct but can look broken. The field's help text says so. diff --git a/openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md b/openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md new file mode 100644 index 00000000..4c4bc588 --- /dev/null +++ b/openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md @@ -0,0 +1,116 @@ +# business-process-mapping specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-process-mapping + +## Purpose + +A municipality describes its business processes in stackiq, step by step, and links each step to the applications in use that support it. Steps carry a risk level and a compliance check. A process (schema.org `HowTo`) and its steps (schema.org `HowToStep`) are objects in the `stackiq` register (ADR-001), shown with `CnIndexPage`, `CnDetailPage` and a read-only `CnGraphCanvas` (ADR-012), with the status lifecycle declared on the schema (ADR-031). + +## ADDED Requirements + +### Requirement: REQ-BPM-001 A municipal information manager SHALL record a business process with ordered steps + +Stackiq SHALL offer a Processes page at `/processen` (page `Processen`) over the `process` schema and a process page at `/processen/:id` (page `ProcesDetail`). A process SHALL have a name, a description, an owner picked from the organisation's contact roles, a status, tags and an optional GEMMA reference process. A step SHALL be a `processStep` object with the process, a name, a description, a step type (task, event or decision), a position and an optional list of steps it follows. The process page SHALL list its steps in position order. Processes and steps SHALL be scoped to the organisation that created them. + +#### Scenario: An information manager adds a process with three steps +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** a municipal information manager signed in to stackiq +- **WHEN** they open Architecture, then Processes, create the process Behandelen melding and add the steps Registreren, Beoordelen and Afhandelen at positions 1, 2 and 3 +- **THEN** the process page SHALL list the three steps in that order +- **AND** the Processes page SHALL list the process with status draft + +#### Scenario: Another municipality does not see the process +@e2e exclude The CI instance has one organisation; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts that the read rules of process and processStep match on _organisation, as usage does. + +- **GIVEN** a process of municipality A +- **WHEN** a user of municipality B opens the Processes page +- **THEN** the process SHALL NOT be listed + +### Requirement: REQ-BPM-002 A step SHALL name the applications in use that support it + +A step SHALL hold a list of `usage` objects: the organisation's applications in use that support the step. The usage page `GebruikDetail` SHALL show a list "Process steps this application supports" with every step that names the usage, its process, its risk level and its compliance check, each row opening the step page at `/processtappen/:id` (page `ProcesStapDetail`). When a linked usage is not visible to the reader, the step page SHALL say how many linked applications are hidden. + +#### Scenario: An application owner sees which process steps their application supports +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** the seeded step Intake vergunningaanvraag linked to the seeded TOPdesk usage +- **WHEN** an application owner opens that usage at `/gebruik/:id` +- **THEN** the list "Process steps this application supports" SHALL show Intake vergunningaanvraag with the process Behandelen vergunningaanvraag +- **AND** choosing the row SHALL open the step page + +#### Scenario: A hidden usage is counted, not dropped +@e2e exclude Needs two organisations with different rights; tests/vitest/processStepUsages.spec.js asserts that two stored usage uuids with one returned object give the notice "1 linked application is not visible to you". + +- **GIVEN** a step linked to two usages, one of which the reader may not read +- **WHEN** the reader opens the step page +- **THEN** the page SHALL list the visible usage +- **AND** it SHALL show that one linked application is not visible to them + +### Requirement: REQ-BPM-003 A step SHALL carry a risk level and a compliance check + +Every `processStep` SHALL have a risk level (not assessed, low, medium or high, default not assessed), a risk note, a compliance check (not checked, compliant, not compliant or not applicable, default not checked), a compliance note and the date of the last check. The risk level and the compliance check SHALL be facetable, and the step list on the process page SHALL show both. + +#### Scenario: An information manager marks a step as not compliant +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** the step Besluiten vergunningaanvraag with compliance check not checked +- **WHEN** a municipal information manager edits the step, sets the risk level to high, the compliance check to not compliant and a compliance note, and saves +- **THEN** the step list on the process page SHALL show high and not compliant for that step +- **AND** the step page SHALL show the compliance note + +#### Scenario: The step fields have defaults +@e2e exclude A schema default; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts the enums and defaults of riskLevel and complianceCheck. + +- **GIVEN** the merged register +- **WHEN** a step is created without a risk level or a compliance check +- **THEN** it SHALL read not assessed and not checked + +### Requirement: REQ-BPM-004 The process page SHALL show the steps as a read-only flow + +The process page SHALL render the steps on a read-only `CnGraphCanvas`: one node per step with its name, type, risk level and supporting applications, and an edge from each step it follows, or from the step before it by position when it follows none. A step with a high risk or a not compliant check SHALL be marked in the error colour and in text. + +#### Scenario: A reader sees the flow of a process +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** the seeded process Behandelen vergunningaanvraag with three steps +- **WHEN** a municipal information manager opens its process page +- **THEN** the flow SHALL show three nodes connected in position order +- **AND** the node Besluiten vergunningaanvraag SHALL read high risk and not compliant + +#### Scenario: A branch follows the follows list +@e2e exclude A pure mapping; tests/vitest/processStepFlow.spec.js asserts that a step whose follows list names two steps gets two incoming edges and no edge from the step before it by position. + +- **GIVEN** a step that follows two earlier steps +- **WHEN** the flow is built +- **THEN** the step SHALL have one incoming edge from each of the two +- **AND** no edge from the step before it by position + +### Requirement: REQ-BPM-005 A process SHALL move through a declared lifecycle and MAY follow a GEMMA reference process + +The `process` status SHALL move through transitions declared as `x-openregister-lifecycle` on the schema: activate (draft to active), retire (active to retired) and reopen (retired to draft), with `from` and `to` values that are members of the status enum. A process MAY reference one AMEF `element` of type `BusinessProcess` as its GEMMA reference process, and the process page SHALL show that reference with a link to it. + +#### Scenario: An owner activates a process +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** a process in status draft +- **WHEN** its owner applies Activate on the process page +- **THEN** the process SHALL read active +- **AND** the transition SHALL appear in its History tab + +#### Scenario: The lifecycle matches the enum +@e2e exclude The transition engine is OpenRegister's; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts every lifecycle from and to value is a member of the status enum. + +- **GIVEN** the merged register +- **WHEN** the shape test reads the process lifecycle +- **THEN** every `from` and `to` value SHALL be a status enum value + +#### Scenario: A process points at its GEMMA reference process +@e2e exclude The CI instance runs without a GEMMA import; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts that referenceProcess is a related element filtered on type BusinessProcess. + +- **GIVEN** an imported GEMMA model with the process Bedrijfsproces Behandelen vergunningaanvraag +- **WHEN** an information manager picks it as the reference process of their process +- **THEN** the process page SHALL show the reference by name with a link to its element page diff --git a/openspec/changes/architecture-process-mapping/tasks.md b/openspec/changes/architecture-process-mapping/tasks.md new file mode 100644 index 00000000..0e8984fa --- /dev/null +++ b/openspec/changes/architecture-process-mapping/tasks.md @@ -0,0 +1,62 @@ +# Tasks: architecture-process-mapping + +## Implementation tasks + +### Task 1: Register fragment for process and processStep +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-003-a-step-shall-carry-a-risk-level-and-a-compliance-check +- **files**: `lib/Settings/register.d/architecture-process-mapping.json`, `tests/Unit/Settings/ProcessMappingRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN the `stackiq` register lists `process` and `processStep` with magic mapping on + - GIVEN `processStep` WHEN the shape test reads it THEN `riskLevel` and `complianceCheck` have the enums and defaults of the design and are facetable + - GIVEN the process lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a status enum value + - GIVEN both schemas WHEN the shape test reads their read rules THEN they match on `_organisation` as `usage` does + - GIVEN a fresh install WHEN the seed runs THEN the process and its three steps exist +- [ ] Implement +- [ ] Test (PHPUnit `ProcessMappingRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Processes index, process page and step page +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-001-a-municipal-information-manager-shall-record-a-business-process-with-ordered-steps +- **files**: `src/manifest.d/architecture-process-mapping.json`, `src/menu-layout.json` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Processen`, `ProcesDetail` and `ProcesStapDetail` exist with the routes of the design + - GIVEN the effective menu WHEN it renders THEN Processes sits under the Architecture group and the top-level count is unchanged + - GIVEN a process page WHEN it renders THEN its steps list is sorted on position and its lifecycle actions show +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/workflows/process-mapping.spec.ts` create and lifecycle scenarios) + +### Task 3: Step flow on CnGraphCanvas +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-004-the-process-page-shall-show-the-steps-as-a-read-only-flow +- **files**: `src/views/architecture/ProcessStepFlow.vue`, `src/utils/processStepFlow.js`, `src/customComponents.js`, `tests/vitest/processStepFlow.spec.js` +- **acceptance_criteria**: + - GIVEN steps without a follows list WHEN the flow is built THEN edges run in position order + - GIVEN a step that follows two steps WHEN the flow is built THEN it has two incoming edges + - GIVEN a step with high risk or not compliant WHEN it renders THEN its node uses `--color-error` and says so in text +- [ ] Implement +- [ ] Test (vitest `processStepFlow.spec.js`, Playwright flow scenario) + +### Task 4: Supporting applications on the usage and step pages +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-002-a-step-shall-name-the-applications-in-use-that-support-it +- **files**: `src/manifest.d/usages.json`, `src/manifest.d/architecture-process-mapping.json`, `src/views/architecture/ProcessStepUsages.vue`, `tests/vitest/processStepUsages.spec.js` +- **acceptance_criteria**: + - GIVEN a step that names a usage WHEN that usage's page opens THEN the list "Process steps this application supports" shows the step and its process + - GIVEN two stored usage uuids of which one is returned WHEN the step page renders THEN it shows the visible usage and says one is hidden +- [ ] Implement +- [ ] Test (vitest `processStepUsages.spec.js`, Playwright usage page scenario) + +### Task 5: Documentation and translations +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-001-a-municipal-information-manager-shall-record-a-business-process-with-ordered-steps +- **files**: `docs/features/business-processes.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the process page with its flow and the usage page with its step list, each in a screenshot + - GIVEN a Dutch instance WHEN the Processes page renders THEN every new label and enum value reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-process-mapping --type change --strict` +- PHPUnit: `ProcessMappingRegisterShapeTest`, `RegisterFragmentMergeTest` +- vitest: `processStepFlow.spec.js`, `processStepUsages.spec.js` +- Playwright: `tests/e2e/workflows/process-mapping.spec.ts` +- Documentation in `docs/features/business-processes.md` with screenshots (ADR-010) +- English and Dutch strings for every new label and enum value (ADR-005) diff --git a/openspec/changes/architecture-reference-component-coverage/.openspec.yaml b/openspec/changes/architecture-reference-component-coverage/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-reference-component-coverage/design.md b/openspec/changes/architecture-reference-component-coverage/design.md new file mode 100644 index 00000000..057a7e4c --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/design.md @@ -0,0 +1,74 @@ +# Design: architecture-reference-component-coverage + +Read at development 49e65cb4. Line numbers below are from that sha. The read-only rendering of a GEMMA view (`src/utils/viewGraph.js` on `CnGraphCanvas`) comes from `architecture-views-editor` (its D3 and D4). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Derivation | new `lib/Service/ReferenceComponentCoverageDerivation.php` | pure, like `lib/Service/PortfolioReportDerivation.php` | +| Service | new `lib/Service/ReferenceComponentCoverageService.php` | reads usages the way `PortfolioReportService::buildRows` does (:229) | +| Service | `lib/Service/PortfolioReportService.php` `buildRow` (:284), `buildReport` (:138), `buildCsv` (:166) | overlap per row, in the summary and the CSV | +| Access check | new `lib/Service/OrganisationReportAccess.php`, taken from `PortfolioReportController::isAuthorisedForOrganisation` (`lib/Controller/PortfolioReportController.php:139`) | both report controllers call it | +| Controller and route | new `lib/Controller/ReferenceComponentCoverageController.php`, route `referenceComponentCoverage#index` at `GET /api/reference-component-coverage` next to `portfolioReport#index` (`appinfo/routes.php:303`) | | +| Pages | new `src/manifest.d/reference-component-coverage.json` with the custom page `ReferenceComponentCoverage` (`/reference-component-coverage`) and a second card on `Reports` (`src/manifest.json:1027`) | | +| Views | new `src/views/organisaties/ReferenceComponentCoverage.vue` and `src/views/organisaties/CoverageViewMap.vue`; `src/views/organisaties/PortfolioReport.vue` gains an overlap section | registered in `src/customComponents.js` next to `PortfolioReportView` (:32, :137) | +| Register | none | | + +## Decisions + +### D1. Coverage is counted on the organisation's usages + +A reference component is covered by a usage of the organisation when the usage names it in `usedForReferenceComponents` (`lib/Settings/softwarecatalogus_register.json:2982`) and its `status` is not Acquisition, Planned or Phased out. The derivation gives each reference component one state: + +| State | Rule | +|---|---| +| gap | no counting usage | +| covered | one counting usage | +| overlap | two or more counting usages with different modules | + +Two usages of the same module (two versions side by side during a migration) are one application for this count. A gap is also marked fillable when a module the organisation already uses declares the component in `module.referenceComponents` (:6992). That is the GEMMA Softwarecatalogus tile "Pakketten met meer mogelijkheden". + +Rejected: counting on `module.referenceComponents`. That says what a product can do, not what the municipality uses it for, and it would mark a component covered by a product the municipality bought for something else. + +### D2. A bounded, organisation-scoped backend endpoint + +`ReferenceComponentCoverageService::build(organisationUuid)` reads the organisation's usages with the same query `PortfolioReportService::buildRows` uses (`consumer` equal to the organisation, bounded by the page size ceiling, :544) and the reference components with `gemmaType=referentiecomponent`, the query the schemas use for their pickers (register.json:2993), bounded at 1,000 as `FacetService::ELEMENT_LOOKUP_LIMIT` is (`lib/Service/FacetService.php:95`). It resolves module names once per module, like `PortfolioReportService::fetchRelation` (:482), and hands everything to the derivation. + +`GET /api/reference-component-coverage?organisation=&format=json|csv` returns `{ organisation, generatedAt, truncated, usagesWithoutComponent, summary: { gap, fillable, covered, overlap }, components: [{ uuid, name, state, fillable, usages: [{ uuid, moduleName, status }] }] }`, or the same rows as CSV. + +The controller runs the organisation check before any query and fails closed, as `PortfolioReportController::index` does (:86). The check moves into `OrganisationReportAccess::isAuthorised(user, organisationUuid)` and both controllers call it, so the two reports keep one rule. + +Rejected: computing coverage in the browser from OpenRegister facets on `usage.usedForReferenceComponents`. A facet has no bucket for a value no row holds, so gaps need the full component list anyway, and overlap needs the usages behind each count. The portfolio report moved the same kind of cross-register join to the backend for the same reason (its manifest note, `src/manifest.json:1040`). + +### D3. The page: summary, table and map + +`ReferenceComponentCoverage.vue` follows `PortfolioReport.vue`: the same organisation picker (:61), Refresh and Export CSV buttons, and a truncation notice. Under that: + +- a summary with the four counts and "N applications in use name no reference component", +- a `CnDataTable` of components with columns name, state, applications and fillable, and quick filters All, Gaps, Fillable gaps and Overlap, +- `CoverageViewMap.vue`: a picker of imported GEMMA views from `GET /api/views` (`appinfo/routes.php:185`), and the chosen view drawn read-only through `viewGraph.js` on `CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`, `readOnly` at :196). Every node whose element is a reference component gets its state: a border in `--color-error` (gap), `--color-success` (covered) or `--color-warning` (overlap), and a text badge with the count and the application names, so colour is never the only signal. + +The page is reached from a second card on the Reports page, "Reference component coverage", next to Portfolio rationalization. No menu entry is added (ADR-097). + +Rejected: drawing the map with `ViewService` enrichment (`include_gebruik`). It groups usages by the single field `elementRef` (`lib/Service/ViewService.php:914`), so a usage for three components lands on at most one node. + +### D4. Overlap in the portfolio report + +`PortfolioReportService::buildRow` adds `referenceComponents` (the names the usage names) and `overlapsWith`: for each shared component, the other modules that cover it. The derivation computes this from the rows `buildRows` already fetched, so the report makes one extra bounded read, the component names. `buildReport` adds `overlap` (the number of rows with at least one overlap) to its payload, and `buildCsv` adds the column `overlapsWith` as `component: module | module`. `PortfolioReport.vue` shows an Overlap section under the quadrant summary that lists each overlapping component with its applications, and marks overlapping rows in the row list. The card text "Overlapping and ageing software across the portfolio." (`src/manifest.json:1031`) then describes what the report does. + +## Declarative versus imperative + +This change adds aggregation, so ADR-031's declarative route was checked first. + +- OpenRegister facets on `usage.usedForReferenceComponents` count usages per component that has one, but return no bucket for a component nobody uses, and gaps are exactly those. +- OpenRegister's aggregation primitive omits empty buckets by contract ("buckets with zero rows SHALL be omitted from the response", `openregister-ro/openspec/specs/aggregation-api/spec.md:32`) and groups one collection, while coverage joins usages in `stackiq` with reference components in `vng-gemma`. + +So the join is imperative, in a pure derivation class with its own unit tests, behind one bounded endpoint. No lifecycle, notification or relation is added, and no schema changes. + +## Risks + +- **Seeded status values.** The three seeded usages hold `status` `in-gebruik` (register.json `components.objects`), which is not an enum value. The derivation counts any status other than Acquisition, Planned and Phased out, so an unknown value counts as in use rather than hiding an application. +- **No GEMMA import.** Without an imported model there are no reference components. The page then shows "Import the GEMMA model to see coverage" instead of an empty table, and the portfolio report shows no Overlap section. +- **`elementRef` enrichment.** `ViewService` keeps grouping on `elementRef`. A later change can move it to `usedForReferenceComponents`; this change does not depend on it. +- **Large organisations.** A usage ceiling that truncates also truncates coverage. The page shows the truncation notice the portfolio report shows. diff --git a/openspec/changes/architecture-reference-component-coverage/proposal.md b/openspec/changes/architecture-reference-component-coverage/proposal.md new file mode 100644 index 00000000..18e2a1fd --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: + - architecture-views-editor +--- + +# Show which reference components your landscape covers, misses and doubles + +## Summary + +A municipal information manager opens a coverage report for their organisation. It lists every GEMMA reference component with the applications in use that fulfil it, and marks each one as a gap (no application), covered (one) or overlap (two or more). The same result is drawn on a GEMMA view of their choice, as a map. The portfolio rationalization report gains the overlap it promises on its card, so a reader sees overlapping and ageing software in one place. + +## Why + +This change builds four rows of the stackiq parity matrix. No tender or feature request names them. + +- `stackiq:arch-capability-map`, "Map applications to business capabilities or functions and see the map." Rated partial, built. SAP LeanIX rates yes: "A business capability is supported by an application" and a "Business capability map" (https://help.sap.com/docs/leanix/ea/meta-model, https://help.sap.com/docs/leanix/ea/application-portfolio-assessment). BlueDolphin rates yes: "Drag and drop multi-layer current and future state capability mapping" (https://bluedolphin.io/capability-based-planning/). GEMMA Softwarecatalogus rates partial: packages are plotted "op een GEMMA architectuurkaart" (https://www.softwarecatalogus.nl/Hoe%20print%20ik%20een%20kaart%3F). The lane decided build: the missing half is a map view of applications on reference components inside stackiq. +- `stackiq:arch-gap-analysis`, "Find reference components that no application in your landscape covers." Rated no. GEMMA Softwarecatalogus rates partial with the tile "Pakketten met meer mogelijkheden" (https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1), SAP LeanIX partial with a Matrix Report for "Coverage gap analysis" (https://help.sap.com/docs/leanix/ea/report-types), BlueDolphin partial: "Identify capability gaps" (https://bluedolphin.io/capability-based-planning/). Decided build: core area. +- `stackiq:life-overlap`, "Find applications that overlap because they fulfil the same reference component." Rated no. GEMMA Softwarecatalogus rates yes: "Deze tegel signaleert dat er meer dan 1 pakket(versie) bij eenzelfde referentiecomponent in productie is" (https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1). BlueDolphin rates yes: "overlapping application functions are quickly made visible" (https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin). Decided build: two competitors rate yes. +- `stackiq:life-rationalisation-report`, "Open a report of overlapping and ageing software for rationalisation." Rated partial, built. SAP LeanIX rates yes: "automated TIME classification, application portfolio and landscape reports ... to streamline application rationalization" (https://help.sap.com/docs/leanix/ea/application-rationalization-evaluate-data). This row rides with `stackiq:life-overlap`: its missing half is overlap in the portfolio report, which is the overlap this change computes. + +## What stackiq has today + +- The mapping exists in the data. `module.referenceComponents` (`lib/Settings/softwarecatalogus_register.json:6992`, module schema at :6777) says which reference components a product implements, and `usage.usedForReferenceComponents` (:2982) which ones the organisation uses it for. Both relate to `element` objects with `gemmaType=referentiecomponent`. The GEMMA release holds 168 reference components (`lib/Settings/GEMMA_release.xml`, elements of type `ApplicationComponent` with GEMMA type Referentiecomponent). +- `lib/Service/FacetService.php` counts modules per reference component across the catalogue (`DIMENSIONS`, :109, built in `buildDimensionValueMap`, :648). It never lists a component with no module, and it works on `module`, not on one organisation's usages. +- The view API enriches a view's nodes with the organisation's usages (`lib/Service/ViewService.php:813`, `getGebruikData`), but it groups usages by the single field `elementRef` (:914), not by `usedForReferenceComponents`, so a usage for three components lands on one node or none. No page renders a view (`architecture-views-editor`, What stackiq has today). +- The organisation export draws applications into copies of GEMMA views (`lib/Service/ArchiMateExportService.php:2734`, `copyAndEnrichViews`), so the map exists only in Archi after an admin export. +- The portfolio report (`GET /api/portfolio-report`, `appinfo/routes.php:303`) is built by `lib/Service/PortfolioReportService.php` from the organisation's usages (`buildRows`, :229) with TIME quadrants, EOL exposure, cloud share and cost, and a CSV (`buildCsv`, :166). No row carries a reference component. The Reports card still reads "Overlapping and ageing software across the portfolio." (`src/manifest.json:1031`). + +## What this change builds + +- `lib/Service/ReferenceComponentCoverageDerivation.php`, pure functions that turn usages and reference components into a coverage list with gap, covered and overlap states. +- `lib/Service/ReferenceComponentCoverageService.php` and `GET /api/reference-component-coverage`, organisation-scoped and bounded like the portfolio report, with a CSV. +- A Reference component coverage page with a table, filters for gaps and overlaps, and a map on a GEMMA view, reached from a new card on the Reports page. +- Overlap in the portfolio report: per row, the reference components it shares with another application in use, a count in the summary and a column in the CSV. + +## Out of scope + +- The organisation's own capability model. The map uses GEMMA reference components and GEMMA views, which is what municipalities share. A self-defined capability tree is a later change. +- Fixing `ViewService` enrichment on `elementRef`. The coverage map reads its own endpoint and leaves the view API as it is. The `elementRef` grouping is named in Risks. +- Planned future coverage. Usages in status Acquisition or Planned are shown but do not count as coverage. `architecture-future-state-scenarios` compares current and planned landscapes. +- The catalogue-wide facet counts on the Modules page, which stay as they are. + +## Risks + +- A usage that names no reference component covers nothing, so a municipality that never filled `usedForReferenceComponents` sees every component as a gap. The page says how many usages name no component, next to the gap count. +- The report reads usages with RBAC off after an organisation check, as the portfolio report does. The check is shared, not copied, so the two reports cannot drift. diff --git a/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md b/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md new file mode 100644 index 00000000..16516fab --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md @@ -0,0 +1,97 @@ +# reference-component-coverage specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-reference-component-coverage + +## Purpose + +A municipality sees, per GEMMA reference component, which of its applications in use fulfil it: none (a gap), one, or several (an overlap). It reads the result as a table, on a GEMMA view as a map, and as overlap in the portfolio rationalization report. The data stays in OpenRegister (ADR-001); the join between the organisation's usages and the GEMMA reference components runs in one bounded, organisation-scoped endpoint, because no declarative aggregation returns empty buckets (ADR-031). + +## ADDED Requirements + +### Requirement: REQ-RCC-001 Stackiq SHALL give each reference component a coverage state for one organisation + +`GET /api/reference-component-coverage?organisation=` SHALL return every reference component with the organisation's usages that name it in `usedForReferenceComponents` and a state: gap when no counting usage names it, covered when one module does, overlap when two or more different modules do. A usage SHALL count unless its status is Acquisition, Planned or Phased out. A gap SHALL be marked fillable when a module the organisation uses declares the component in `module.referenceComponents`. The response SHALL say how many usages name no component. The endpoint SHALL refuse a user who is not authorised for the organisation before it reads anything, with the same check the portfolio report uses, and SHALL offer the same rows as CSV with `format=csv`. + +#### Scenario: Two applications for one component read as overlap +@e2e exclude The rule is a pure derivation; tests/Unit/Service/ReferenceComponentCoverageDerivationTest.php asserts gap, covered and overlap, that two usages of one module count once, and that Planned and Phased out usages do not count. + +- **GIVEN** an organisation with two usages in production of different modules that both name the reference component Zaakregistratiecomponent +- **WHEN** the coverage is derived +- **THEN** Zaakregistratiecomponent SHALL read overlap with both applications +- **AND** a component no usage names SHALL read gap + +#### Scenario: A gap the organisation could fill is marked +@e2e exclude A pure derivation; tests/Unit/Service/ReferenceComponentCoverageDerivationTest.php asserts that a gap is fillable when a used module lists the component in referenceComponents. + +- **GIVEN** a usage of a module whose `referenceComponents` include Documentbeheercomponent, while no usage names Documentbeheercomponent +- **WHEN** the coverage is derived +- **THEN** Documentbeheercomponent SHALL read gap and fillable + +#### Scenario: Another organisation's coverage is refused +@e2e exclude Needs a second organisation; tests/Unit/Controller/ReferenceComponentCoverageControllerTest.php asserts a 403 before any service call for a user of another organisation, and tests/Unit/Service/OrganisationReportAccessTest.php covers the shared rule. + +- **GIVEN** a signed-in user whose organisation is municipality A +- **WHEN** they call `GET /api/reference-component-coverage?organisation=` +- **THEN** the response SHALL be 403 +- **AND** no usage SHALL be read + +### Requirement: REQ-RCC-002 A coverage page SHALL list the components with filters for gaps and overlaps + +Stackiq SHALL offer a Reference component coverage page at `/reference-component-coverage` (page `ReferenceComponentCoverage`), reached from a card on the Reports page. After an organisation is picked it SHALL show the counts of gaps, fillable gaps, covered components and overlaps, the number of usages that name no component, and a table of components with their state and applications, with the quick filters All, Gaps, Fillable gaps and Overlap, and an Export CSV button. Without any reference component it SHALL say that the GEMMA model must be imported. + +#### Scenario: An information manager filters on overlap +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** two reference component elements and two usages of different modules that both name the first, created by the test fixture through OpenRegister's objects API +- **WHEN** a municipal information manager opens Reports, chooses Reference component coverage, picks the organisation and chooses the quick filter Overlap +- **THEN** the table SHALL show the first component with both applications +- **AND** the summary SHALL read one overlap and one gap + +#### Scenario: The page explains an instance without GEMMA +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** an instance with no reference component elements +- **WHEN** a municipal information manager opens the coverage page and picks an organisation +- **THEN** the page SHALL say that the GEMMA model must be imported to see coverage + +### Requirement: REQ-RCC-003 The coverage SHALL be drawn on a GEMMA view as a map + +The coverage page SHALL let the user pick an imported GEMMA view and SHALL draw it read-only on `CnGraphCanvas`. Every node of a reference component SHALL carry its state as a border colour (the error colour for a gap, the success colour for covered, the warning colour for an overlap) and as a text badge with the number and names of its applications. + +#### Scenario: An information manager sees their applications on a GEMMA view +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** the fixture from REQ-RCC-002 and a view created by the fixture that holds both reference components +- **WHEN** the information manager picks that view on the coverage page +- **THEN** the node of the first component SHALL read overlap with two application names +- **AND** the node of the second SHALL read gap + +#### Scenario: Colour is never the only signal +@e2e exclude A rendering rule; tests/vitest/coverageViewMap.spec.js asserts that every reference component node carries a text badge with its state and that colours are CSS variables. + +- **GIVEN** a view with a gap, a covered and an overlap node +- **WHEN** the map renders +- **THEN** each of the three nodes SHALL carry its state in text + +### Requirement: REQ-RCC-004 The portfolio rationalization report SHALL show overlapping applications + +Each row of `GET /api/portfolio-report` SHALL carry the reference components its usage names and, per shared component, the other modules that cover it. The report SHALL count the rows with an overlap, the CSV SHALL have an `overlapsWith` column, and the Portfolio rationalization page SHALL list each overlapping component with its applications and mark overlapping rows. + +#### Scenario: The portfolio report shows overlap next to ageing +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** the fixture from REQ-RCC-002 +- **WHEN** a municipal information manager opens Portfolio rationalization and picks the organisation +- **THEN** an Overlap section SHALL list the first component with both applications +- **AND** both rows SHALL be marked as overlapping in the row list + +#### Scenario: The CSV carries the overlap +@e2e exclude A file body; tests/Unit/Service/PortfolioReportServiceOverlapTest.php asserts the overlapsWith column holds "component: module" for an overlapping row and is empty for a row without overlap. + +- **GIVEN** two overlapping rows and one row without overlap +- **WHEN** the CSV is built +- **THEN** the overlapping rows SHALL name the component and the other module in `overlapsWith` +- **AND** the third row SHALL leave it empty diff --git a/openspec/changes/architecture-reference-component-coverage/tasks.md b/openspec/changes/architecture-reference-component-coverage/tasks.md new file mode 100644 index 00000000..b184fe13 --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/tasks.md @@ -0,0 +1,80 @@ +# Tasks: architecture-reference-component-coverage + +## Implementation tasks + +### Task 1: Coverage derivation +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation +- **files**: `lib/Service/ReferenceComponentCoverageDerivation.php`, `tests/Unit/Service/ReferenceComponentCoverageDerivationTest.php` +- **acceptance_criteria**: + - GIVEN usages and components WHEN the coverage is derived THEN each component reads gap, covered or overlap by the rules of design D1 + - GIVEN two usages of one module WHEN the coverage is derived THEN they count as one application + - GIVEN a gap and a used module that declares it WHEN the coverage is derived THEN the gap is fillable + - GIVEN a usage with an unknown status WHEN the coverage is derived THEN it counts +- [ ] Implement +- [ ] Test (PHPUnit `ReferenceComponentCoverageDerivationTest`) + +### Task 2: Shared organisation check +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation +- **files**: `lib/Service/OrganisationReportAccess.php`, `lib/Controller/PortfolioReportController.php`, `tests/Unit/Service/OrganisationReportAccessTest.php` +- **acceptance_criteria**: + - GIVEN an admin, an ambtenaar, a member and a non-member WHEN the check runs THEN only the non-member is refused, as today + - GIVEN the portfolio report controller WHEN it runs THEN it calls the shared check and its existing tests stay green +- [ ] Implement +- [ ] Test (PHPUnit `OrganisationReportAccessTest`, existing `PortfolioReportControllerTest`) + +### Task 3: Coverage service, controller and route +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation +- **files**: `lib/Service/ReferenceComponentCoverageService.php`, `lib/Controller/ReferenceComponentCoverageController.php`, `appinfo/routes.php`, `tests/Unit/Controller/ReferenceComponentCoverageControllerTest.php`, `tests/Unit/Service/ReferenceComponentCoverageServiceTest.php` +- **acceptance_criteria**: + - GIVEN a user of another organisation WHEN the endpoint is called THEN it answers 403 before the service runs + - GIVEN an organisation WHEN the endpoint is called THEN usages and components are read with bounded limits and the payload has the shape of design D2 + - GIVEN `format=csv` WHEN the endpoint is called THEN the same rows come back as CSV +- [ ] Implement +- [ ] Test (PHPUnit `ReferenceComponentCoverageControllerTest`, `ReferenceComponentCoverageServiceTest`) + +### Task 4: Coverage page and Reports card +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-coverage-page-shall-list-the-components-with-filters-for-gaps-and-overlaps +- **files**: `src/manifest.d/reference-component-coverage.json`, `src/views/organisaties/ReferenceComponentCoverage.vue`, `src/customComponents.js`, `tests/e2e/workflows/reference-component-coverage.spec.ts` +- **acceptance_criteria**: + - GIVEN the Reports page WHEN it renders THEN it shows the card Reference component coverage next to Portfolio rationalization + - GIVEN a picked organisation WHEN the page loads THEN it shows the counts, the table and the four quick filters + - GIVEN no reference components WHEN the page loads THEN it says the GEMMA model must be imported +- [ ] Implement +- [ ] Test (Playwright `reference-component-coverage.spec.ts` overlap filter and empty model scenarios) + +### Task 5: Coverage map on a GEMMA view +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-003-the-coverage-shall-be-drawn-on-a-gemma-view-as-a-map +- **files**: `src/views/organisaties/CoverageViewMap.vue`, `tests/vitest/coverageViewMap.spec.js` +- **acceptance_criteria**: + - GIVEN a picked view WHEN it renders THEN it is read-only and every reference component node carries its state as a CSS variable colour and as text + - GIVEN a node whose element is not a reference component WHEN it renders THEN it carries no state +- [ ] Implement +- [ ] Test (vitest `coverageViewMap.spec.js`, Playwright map scenario) + +### Task 6: Overlap in the portfolio report +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-004-the-portfolio-rationalization-report-shall-show-overlapping-applications +- **files**: `lib/Service/PortfolioReportService.php`, `src/views/organisaties/PortfolioReport.vue`, `tests/Unit/Service/PortfolioReportServiceOverlapTest.php` +- **acceptance_criteria**: + - GIVEN two rows that share a component WHEN the report is built THEN both carry `overlapsWith` and the payload counts two overlapping rows + - GIVEN the CSV WHEN it is built THEN it has the `overlapsWith` column + - GIVEN the page WHEN the report has overlap THEN an Overlap section lists each component with its applications +- [ ] Implement +- [ ] Test (PHPUnit `PortfolioReportServiceOverlapTest`, Playwright portfolio overlap scenario) + +### Task 7: Documentation and translations +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-coverage-page-shall-list-the-components-with-filters-for-gaps-and-overlaps +- **files**: `docs/features/reference-component-coverage.md`, `docs/features/portfolio-rationalization.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature pages WHEN they are read THEN they show the coverage table, the map and the Overlap section, each in a screenshot + - GIVEN a Dutch instance WHEN the coverage page renders THEN every new label reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-reference-component-coverage --type change --strict` +- PHPUnit: `ReferenceComponentCoverageDerivationTest`, `OrganisationReportAccessTest`, `ReferenceComponentCoverageControllerTest`, `ReferenceComponentCoverageServiceTest`, `PortfolioReportServiceOverlapTest`, and the existing portfolio report tests +- vitest: `coverageViewMap.spec.js` +- Playwright: `tests/e2e/workflows/reference-component-coverage.spec.ts` +- Documentation in `docs/features/` with screenshots (ADR-010) +- English and Dutch strings for every new label (ADR-005) diff --git a/openspec/changes/architecture-round-trip-check/.openspec.yaml b/openspec/changes/architecture-round-trip-check/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-round-trip-check/design.md b/openspec/changes/architecture-round-trip-check/design.md new file mode 100644 index 00000000..6977cf2b --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/design.md @@ -0,0 +1,69 @@ +# Design: architecture-round-trip-check + +Read at development 49e65cb4. Line numbers below are from that sha. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Comparator | new `lib/Service/ArchiMateModelComparator.php` | pure | +| Service | new `lib/Service/ArchiMateRoundTripService.php` | uses the import's conversion and the export's generation | +| Import service | `lib/Service/ArchiMateImportService.php`: the steps before the save in `importArchiMateFileFromPathOptimized` (:347), that is `validateArchiMateFile` (:204), `parseArchiMateXml` (:629), `extractModelIdentifier` (:663) and `transformArchiMateXmlToObjectsBatch` (:4175), move into one public method `convertFileToObjects(string $filePath): array`, which the import then calls | | +| Export service | `lib/Service/ArchiMateExportService.php`: a public `generateXmlFromObjects(array $objects): string` that runs `generateXmlDirectly` (:1009) and `runQualityAssuranceChecks` (:2143), which `exportArchiMateXml` (:951) then calls | | +| Controller and route | `SettingsController::checkArchiMateRoundTrip`, route `settings#checkArchiMateRoundTrip` at `POST /api/archimate/round-trip-check`; the old route (`appinfo/routes.php:107`) and `testArchiMateRoundTrip` (`lib/Controller/SettingsController.php:2886`) go | the upload handling of `importArchiMate` (:1490) is the pattern | +| Removed | `ArchiMateService::testRoundTrip` (`lib/Service/ArchiMateService.php:1496`), `createTestArchiMateXml` (:1559), `createTempFile` (:1585); the store action `testRoundTrip` (`src/store/modules/settings.js:1953`) | | +| View | new `src/views/settings/sections/ArchiMateRoundTripCheck.vue`, placed in `src/views/settings/sections/ArchiMateImportExport.vue` after its Export part (:555) | | +| Store | `src/store/modules/settings.js` gains `checkRoundTrip(file, mode)` | | + +## Decisions + +### D1. The check compares two exchange files by identifier + +`ArchiMateModelComparator::compare(string $sourceXml, string $resultXml): array` reads both files into maps keyed by identifier: + +| Category | Compared on | +|---|---| +| elements | type, name, documentation, property values by property definition name | +| relationships | type, source, target, name, property values | +| views | name, viewpoint, the set of node element references, the set of connection relationship references | +| view nodes | per view: element reference, parent, position and size | +| property definitions | name, type | +| organizations | the folder tree and the items under each folder | + +For each category it returns the count in the source, the count in the result, and the identifiers that are missing, extra or changed, each changed one with the fields that differ. It returns the first 50 examples per category with their names and the full counts, so a report on a GEMMA release stays readable. Order of elements in a file is never a difference. + +Rejected: counting objects, which is what the current code tries. Equal counts can hide one element lost and another added, and a count says nothing about a relationship whose target changed. + +### D2. Two modes, neither writes + +`ArchiMateRoundTripService::check(string $filePath, string $mode): array`: + +- `before-import`: `ArchiMateImportService::convertFileToObjects` turns the file into the objects the import would save, `ArchiMateExportService::generateXmlFromObjects` turns those objects into an exchange file, and the comparator compares it with the source. Nothing is saved. This covers loss in the import's conversion and the export's generation. +- `against-imported`: reads the model identifier from the file, reads the stored AMEF objects whose `model_identifier` equals it (the import stamps it on every object, `ArchiMateImportService.php:592`) through the export's reader (`getObjectsFromDatabase`, `ArchiMateExportService.php:808`), generates the exchange file from them and compares. This also covers what OpenRegister dropped when it stored the objects. When no object carries that identifier, it answers that the model is not imported. + +Both modes run the code paths the real import and export run, because the import and the full export call the two new public methods themselves. A check that ran a copy of the conversion would pass while the real import lost data. + +Rejected: importing a test model and exporting it again, which the current code does (`lib/Service/ArchiMateService.php:1504`). It writes a test model into the register every user reads and leaves it there. + +Rejected: a built-in test model. A small hand-written file shows only that the small file survives; the admin's question is whether their GEMMA release does. + +### D3. Admin only, upload only + +`POST /api/archimate/round-trip-check` takes a multipart upload `archiMateFile` and a `mode`, with the same upload handling as the import (:1490): no `file_path` parameter, the presence check of `validateArchiMateFile` (`ArchiMateImportService.php:204`), and the admin check the import makes (`SettingsController.php:1496`). The uploaded temporary file is PHP's own and is not copied. The response is the comparator's result with the mode, the model identifier and the time taken. + +The current endpoint is `@NoAdminRequired` (:2880) and writes to the register, so a signed-in user without admin rights can put objects into the AMEF register today. Removing it closes that. + +### D4. The report on the settings page + +`ArchiMateRoundTripCheck.vue` sits under the ArchiMate import and export on the admin settings page. It holds a file picker, a choice between "Before import (nothing is written)" and "Against the imported model", and a Check button. The result is a table with one row per category (in the file, after the round trip, missing, extra, changed) and, per category, an expandable list of examples with identifier, name and the fields that differ. A summary line says "No losses found" or names the categories with losses. Colours are Nextcloud CSS variables and every state is also written as text. + +## Declarative versus imperative + +The change adds no lifecycle, aggregation, notification, relation or widget behaviour. It is a read-only comparison of two files, imperative by nature, and it removes code. + +## Risks + +- **Moving import steps.** `convertFileToObjects` wraps code the import already runs, in the same order. The existing decomposition tests (`tests/Unit/Service/ArchiMateImportServiceDecompositionTest.php`, `ArchiMateExportServiceDecompositionTest.php`) must stay green, and a new test imports `lib/Settings/GEMMA_testdata_below_1_5mb.xml` through both the old and the new entry and compares the object lists. +- **Findings on day one.** The check will likely report losses on a real GEMMA file, for example property names the import lowercases (`convertToCamelCase`, `ArchiMateImportService.php:2256`). That is the point; the report names them and the fixes are separate changes. +- **Memory.** A before-import check on a GEMMA release holds the converted objects and two XML documents at once. The route runs under the import's limits, and the page warns that a large file takes as long as an import. +- **Tests that mock the old endpoint.** `tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php:458` and :482 mock `testRoundTrip`; they move to the new route. diff --git a/openspec/changes/architecture-round-trip-check/proposal.md b/openspec/changes/architecture-round-trip-check/proposal.md new file mode 100644 index 00000000..23a1b724 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/proposal.md @@ -0,0 +1,41 @@ +--- +kind: code +depends_on: [] +--- + +# Check that an ArchiMate model survives import and export + +## Summary + +A Nextcloud admin uploads an ArchiMate exchange file on the settings page and gets a report of what would be lost if stackiq imported and exported it: elements, relationships, views, view nodes, connections and property values that go missing, appear or change on the way. The check can run before an import, writing nothing, or against a model already imported. It replaces a round-trip endpoint that can never succeed, is open to any signed-in user, and imports a test model into the live register. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-round-trip`, "Check that a model survives import and export without losing elements or relations." The row comes from stackiq's own code (feature archimate-import-and-export); no tender, feature request or competitor names it, and no competitor rates yes. The lane decided build: "Built state but rated no: the code exists and does not work, so the change repairs it." The matrix note: "There is an endpoint, but its comparison is broken (a missing key against a placeholder string) and no page calls it." + +## What stackiq has today + +- `POST /api/archimate/test-round-trip` (`appinfo/routes.php:107`) runs `SettingsController::testArchiMateRoundTrip` (`lib/Controller/SettingsController.php:2886`), marked `@NoAdminRequired`: any signed-in user may call it, while the ArchiMate import itself requires an admin (:1496). +- It calls `ArchiMateService::testRoundTrip` (`lib/Service/ArchiMateService.php:1496`), which imports a built-in test model into the live AMEF register through `importArchiMateFileFromPath` (:1504) and leaves it there. The test model (:1559) uses Archi's native namespace instead of the exchange format, uses the `xsi` prefix without declaring it, and relates `test-element-1` to a `test-element-2` that does not exist. Its temporary file (:1585) is never removed. +- The comparison reads `$importResult['imported_count']` (:1528), a key no import path sets; the import returns per-section `statistics` (`lib/Service/ArchiMateImportService.php:444` and :572). It compares that with `exported_count`, which the export returns as the literal string `calculated_in_export_service` (`lib/Service/ArchiMateService.php:271`), and the export covers the whole register, not the test model. So the check can never report success. The service returns an `error` key while the controller reads `message`. +- The store action `testRoundTrip` (`src/store/modules/settings.js:1953`) has no caller. Only `tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php:458` and :482 exercise the endpoint, with the service mocked. +- The import the settings page runs is the optimised one by default (`SettingsController.php:1594`, `useOptimized` defaults to true). It runs as separate steps: parse (`ArchiMateImportService.php:629`), read the model identifier (:663), convert to objects (`transformArchiMateXmlToObjectsBatch`, :4175) and save (:1291). The export reads objects (`ArchiMateExportService.php:808`), generates XML from them (`generateXmlDirectly`, :1009) and runs quality checks (:2143). Every imported object carries its `model_identifier` (`ArchiMateImportService.php:592`). + +## What this change builds + +- `lib/Service/ArchiMateModelComparator.php`, a pure comparison of two exchange files by identifier: elements, relationships, views with their nodes and connections, property definitions and property values. +- `lib/Service/ArchiMateRoundTripService.php` with two modes: before import (the import's conversion steps and the export's generation in memory, no write) and against the imported model (an export of the stored objects of that model, read-only). +- `POST /api/archimate/round-trip-check`, admin only, taking an uploaded file and a mode. +- A Check a model file part in the ArchiMate section of the admin settings, with a report per category and examples. +- Removal of the old endpoint, the service method, its test model and temporary file, and the unused store action. + +## Out of scope + +- Fixing what the check finds. The report names the losses; repairs to the import or export are their own changes. +- Comparing with Archi's native `.archimate` format. The check works on the Open Group exchange format that the import and export use. +- A schedule that runs the check on every import. + +## Risks + +- A check before import converts the whole file in memory, as an import does. On a GEMMA release that takes the same time and memory as an import, so the page says so and the route keeps the import's limits. +- The before-import mode cannot see what OpenRegister drops when it stores an object. The against-imported mode can, which is why both exist. diff --git a/openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md b/openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md new file mode 100644 index 00000000..60276b87 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md @@ -0,0 +1,88 @@ +# archimate-round-trip-check specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-round-trip-check + +## Purpose + +A Nextcloud admin checks, before or after an import, that an ArchiMate exchange file survives stackiq's import and export without losing or changing elements, relationships, views or property values. The check writes nothing and runs the same conversion and generation code as the real import and export. It replaces a round-trip endpoint that could never succeed, was open to every signed-in user and wrote a test model into the live register. + +## ADDED Requirements + +### Requirement: REQ-ART-001 Stackiq SHALL compare two exchange files by identifier per category + +The comparison SHALL key elements, relationships, views, view nodes, property definitions and organization folders by identifier, and SHALL report per category the count in the source, the count after the round trip, and the missing, extra and changed identifiers, each changed one with the fields that differ. Elements SHALL be compared on type, name, documentation and property values; relationships on type, source, target, name and property values; views on name, viewpoint and their node and connection references; view nodes on element reference, parent, position and size. The order of items in a file SHALL NOT count as a difference. The report SHALL hold at most 50 examples per category and always the full counts. + +#### Scenario: A relationship with a changed target is reported +@e2e exclude A pure comparison; tests/Unit/Service/ArchiMateModelComparatorTest.php compares two files that differ in one relationship target and asserts one changed relationship naming the field target, and no other difference. + +- **GIVEN** two exchange files that are equal except for the target of one relationship +- **WHEN** they are compared +- **THEN** the relationships category SHALL report one changed identifier with the field target +- **AND** every other category SHALL report no difference + +#### Scenario: Reordered elements are not a difference +@e2e exclude A pure comparison; tests/Unit/Service/ArchiMateModelComparatorTest.php compares a file with itself in reversed element order and asserts no difference. + +- **GIVEN** a file and the same file with its elements in reverse order +- **WHEN** they are compared +- **THEN** no category SHALL report a difference + +### Requirement: REQ-ART-002 The check SHALL run before an import or against an imported model without writing + +`POST /api/archimate/round-trip-check` SHALL take an uploaded exchange file and a mode. In the mode before-import it SHALL convert the file with the import's own conversion, generate an exchange file from the result with the export's own generation, and compare it with the upload, saving nothing. In the mode against-imported it SHALL read the stored objects whose model identifier equals the file's, generate an exchange file from them and compare, saving nothing; when no stored object has that identifier it SHALL say the model is not imported. The import and the full export SHALL call the same conversion and generation methods the check calls. + +#### Scenario: A check before import leaves the register untouched +@e2e exclude Needs a GEMMA-sized file and minutes of runtime; tests/Unit/Service/ArchiMateRoundTripServiceTest.php runs the before-import mode on lib/Settings/GEMMA_testdata_below_1_5mb.xml with ObjectService mocked and asserts that no save method is called and a report comes back for every category. + +- **GIVEN** a Nextcloud admin and a GEMMA exchange file +- **WHEN** they run the check before import +- **THEN** the report SHALL list every category with its counts +- **AND** the AMEF register SHALL hold the same objects as before + +#### Scenario: The import and the check convert the same way +@e2e exclude A refactor guard; tests/Unit/Service/ArchiMateImportServiceConvertTest.php converts the fixture through importArchiMateFileFromPathOptimized with the save mocked and through convertFileToObjects, and asserts equal object lists. + +- **GIVEN** the fixture file +- **WHEN** it is converted by the import and by the check +- **THEN** both SHALL produce the same objects + +#### Scenario: A model that was never imported is named as such +@e2e exclude The CI instance has no imported model; tests/Unit/Service/ArchiMateRoundTripServiceTest.php asserts the against-imported mode answers "model not imported" when no stored object carries the file's model identifier. + +- **GIVEN** an exchange file whose model identifier no stored object carries +- **WHEN** a Nextcloud admin runs the check against the imported model +- **THEN** the answer SHALL say the model is not imported + +### Requirement: REQ-ART-003 Only an admin SHALL run the check, and the old round-trip endpoint SHALL be removed + +The check route SHALL refuse a signed-in user who is not a Nextcloud admin with 403 and SHALL accept only a multipart upload, never a file path. `POST /api/archimate/test-round-trip`, `SettingsController::testArchiMateRoundTrip`, `ArchiMateService::testRoundTrip` with its built-in test model and temporary file, and the store action `testRoundTrip` SHALL be removed. + +#### Scenario: A user without admin rights is refused +@e2e exclude The route test covers it without a second user; tests/Unit/Controller/SettingsControllerRoundTripCheckTest.php asserts a 403 for a non-admin and a 400 for a body with file_path, before the service is called. + +- **GIVEN** a signed-in user who is not an admin +- **WHEN** they post a file to `/api/archimate/round-trip-check` +- **THEN** the response SHALL be 403 +- **AND** the service SHALL NOT run + +#### Scenario: The old endpoint is gone +@e2e exclude A route table rule; tests/Unit/SettingsRouteTableTest.php asserts no route named settings#testArchiMateRoundTrip and one route settings#checkArchiMateRoundTrip. + +- **GIVEN** the route table +- **WHEN** it is read +- **THEN** `/api/archimate/test-round-trip` SHALL NOT exist + +### Requirement: REQ-ART-004 The admin settings page SHALL show the round-trip report + +The ArchiMate section of the admin settings SHALL offer Check a model file with a file picker, the choice "Before import (nothing is written)" or "Against the imported model", and a Check button. The result SHALL show a table with one row per category (in the file, after the round trip, missing, extra, changed), an expandable list of examples per category, and a summary that reads "No losses found" or names the categories with losses. + +#### Scenario: An admin checks a small model before importing it +@e2e tests/e2e/workflows/archimate-round-trip-check.spec.ts + +- **GIVEN** a Nextcloud admin on the stackiq admin settings page and a small exchange file from the test fixtures +- **WHEN** they choose Check a model file, pick the file, keep Before import and choose Check +- **THEN** the page SHALL show the table with a row for elements, relationships and views +- **AND** the summary SHALL read "No losses found" or name the categories with losses diff --git a/openspec/changes/architecture-round-trip-check/tasks.md b/openspec/changes/architecture-round-trip-check/tasks.md new file mode 100644 index 00000000..cb480465 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/tasks.md @@ -0,0 +1,69 @@ +# Tasks: architecture-round-trip-check + +## Implementation tasks + +### Task 1: Model comparator +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-001-stackiq-shall-compare-two-exchange-files-by-identifier-per-category +- **files**: `lib/Service/ArchiMateModelComparator.php`, `tests/Unit/Service/ArchiMateModelComparatorTest.php` +- **acceptance_criteria**: + - GIVEN two files that differ in one relationship target WHEN they are compared THEN exactly one changed relationship is reported with the field target + - GIVEN a file and itself in another order WHEN they are compared THEN no difference is reported + - GIVEN more than 50 differences in a category WHEN they are compared THEN 50 examples and the full counts come back +- [ ] Implement +- [ ] Test (PHPUnit `ArchiMateModelComparatorTest`) + +### Task 2: One conversion and one generation entry +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-002-the-check-shall-run-before-an-import-or-against-an-imported-model-without-writing +- **files**: `lib/Service/ArchiMateImportService.php`, `lib/Service/ArchiMateExportService.php`, `tests/Unit/Service/ArchiMateImportServiceConvertTest.php` +- **acceptance_criteria**: + - GIVEN the fixture `lib/Settings/GEMMA_testdata_below_1_5mb.xml` WHEN it is converted by the optimised import with the save mocked and by `convertFileToObjects` THEN the object lists are equal + - GIVEN `exportArchiMateXml` WHEN it runs THEN it calls `generateXmlFromObjects`, and the existing decomposition tests stay green +- [ ] Implement +- [ ] Test (PHPUnit `ArchiMateImportServiceConvertTest`, `ArchiMateImportServiceDecompositionTest`, `ArchiMateExportServiceDecompositionTest`) + +### Task 3: Round-trip service with two modes +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-002-the-check-shall-run-before-an-import-or-against-an-imported-model-without-writing +- **files**: `lib/Service/ArchiMateRoundTripService.php`, `tests/Unit/Service/ArchiMateRoundTripServiceTest.php` +- **acceptance_criteria**: + - GIVEN the before-import mode WHEN it runs on the fixture THEN no save method is called and every category is reported + - GIVEN the against-imported mode and stored objects of the model WHEN it runs THEN it compares only objects with that model identifier + - GIVEN no stored object with the model identifier WHEN the against-imported mode runs THEN it answers that the model is not imported +- [ ] Implement +- [ ] Test (PHPUnit `ArchiMateRoundTripServiceTest`) + +### Task 4: Admin route and removal of the old round trip +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-003-only-an-admin-shall-run-the-check-and-the-old-round-trip-endpoint-shall-be-removed +- **files**: `lib/Controller/SettingsController.php`, `appinfo/routes.php`, `lib/Service/ArchiMateService.php`, `src/store/modules/settings.js`, `tests/Unit/Controller/SettingsControllerRoundTripCheckTest.php`, `tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php`, `tests/Unit/SettingsRouteTableTest.php` +- **acceptance_criteria**: + - GIVEN a non-admin WHEN they post to the check route THEN the answer is 403 before the service runs + - GIVEN a body with `file_path` WHEN it is posted THEN the answer is 400 + - GIVEN the route table WHEN it is read THEN `settings#testArchiMateRoundTrip` is gone and `settings#checkArchiMateRoundTrip` exists + - GIVEN the source WHEN it is searched THEN `testRoundTrip`, `createTestArchiMateXml` and `createTempFile` are gone +- [ ] Implement +- [ ] Test (PHPUnit `SettingsControllerRoundTripCheckTest`, `SettingsRouteTableTest`, updated `SettingsControllerEmailArchiMateContractTest`) + +### Task 5: Report on the admin settings page +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-004-the-admin-settings-page-shall-show-the-round-trip-report +- **files**: `src/views/settings/sections/ArchiMateRoundTripCheck.vue`, `src/views/settings/sections/ArchiMateImportExport.vue`, `src/store/modules/settings.js`, `tests/e2e/workflows/archimate-round-trip-check.spec.ts` +- **acceptance_criteria**: + - GIVEN the admin settings page WHEN the ArchiMate section renders THEN Check a model file offers a file picker, the two modes and a Check button + - GIVEN a result WHEN it renders THEN a row per category shows the counts, examples expand per category, and the summary names the categories with losses or reads "No losses found" +- [ ] Implement +- [ ] Test (Playwright `archimate-round-trip-check.spec.ts`) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-004-the-admin-settings-page-shall-show-the-round-trip-report +- **files**: `docs/features/archimate-import-export.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it explains both modes and shows a report in a screenshot + - GIVEN a Dutch instance WHEN the check renders THEN every label and summary reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshot captured with Playwright) + +## Verification + +- `openspec validate architecture-round-trip-check --type change --strict` +- PHPUnit: `ArchiMateModelComparatorTest`, `ArchiMateImportServiceConvertTest`, `ArchiMateRoundTripServiceTest`, `SettingsControllerRoundTripCheckTest`, `SettingsRouteTableTest`, and the existing ArchiMate decomposition tests +- Playwright: `tests/e2e/workflows/archimate-round-trip-check.spec.ts` +- Documentation in `docs/features/archimate-import-export.md` with a screenshot (ADR-010) +- English and Dutch strings for every new label (ADR-005) diff --git a/openspec/changes/architecture-views-editor/.openspec.yaml b/openspec/changes/architecture-views-editor/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-views-editor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-views-editor/design.md b/openspec/changes/architecture-views-editor/design.md new file mode 100644 index 00000000..da31d622 --- /dev/null +++ b/openspec/changes/architecture-views-editor/design.md @@ -0,0 +1,137 @@ +# Design: architecture-views-editor + +Read at development 49e65cb4. Line numbers below are from that sha. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `vng-gemma` register (`lib/Settings/softwarecatalogus_register.json:916`), schemas `view` (:5299), `element` (:4130), `relation` (:6300) | through a new fragment `lib/Settings/register.d/architecture-views.json` | +| Service | `lib/Service/ViewService.php` `getViewsFromRegister` (:232) and `getViewFromRegister` (:307) | the drawn views filter | +| Service | `lib/Service/ArchiMateExportService.php` `getObjectsFromDatabase` (:808, the read at :844) | the drawn objects filter | +| Routes | none added. `appinfo/routes.php:185-187` stays as it is | | +| Pages | new `src/manifest.d/architecture-views.json` with `Views` (index) and `ViewEditor` (custom); `src/menu-layout.json` relocations | | +| Views | new `src/views/architecture/ArchitectureViewEditor.vue`, `src/views/architecture/ViewVersionCompare.vue` | registered in `src/customComponents.js` | +| Store and utils | new `src/store/modules/architectureView.js`, `src/utils/viewGraph.js`, `src/utils/viewDiff.js` | `src/store/modules/view.js` is left alone | + +The fragment is merged by `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`). `deepMergeConfig` (:7338) appends lists and replaces lists under `authorization`. So the fragment carries `components.schemas.view-version`, `components.registers.vng-gemma.schemas: ["view-version"]` and `components.registers.vng-gemma.configuration.schemas.view-version: {"magicMapping": true, "autoCreateTable": true}`, plus the new properties on `view`, `element` and `relation`. It bumps `view` to 0.0.8, `element` to 0.0.12 and `relation` to 0.0.9. + +## Decisions + +### D1. A drawn view is a `view` object with `origin: drawn` + +A user's view is stored in the same `view` schema as the imported GEMMA views, with `origin` set to `drawn`. The import writes `origin: imported`. A view imported before this change has no `origin` and counts as imported. Every reader that must see only GEMMA content keeps views whose `origin` is empty or `imported`, so a later origin value is left out by default. + +Rejected: a new schema in the `stackiq` register. `ViewService` and both ArchiMate exports read only the AMEF `view` schema, so a second store would split the views list and leave drawn views out of any later export. + +### D2. Imported views open read-only, and Copy to edit makes a drawn copy + +The import saves views with `@self.id` equal to the ArchiMate identifier (`lib/Service/ArchiMateImportService.php:5394`) through `saveObjects` (:1587), which updates the object with that id. An edit to an imported view would be overwritten without a word on the next GEMMA import. The editor therefore opens `origin: imported` views read-only and offers Copy to edit. The copy gets a fresh identifier `id-`, `origin: drawn`, `status: draft` and `basedOn` set to the source view's uuid. + +Rejected: editing in place with a "protected" flag the import respects. The import is 5,961 lines with three save paths (:1369, :1587, :1695), and a flag that one of them forgets fails silently. + +### D3. The editor saves the shape the import writes + +The editor writes `xml.viewNodes` and `xml.viewRelationships` in the import's shape: a flat node list with `viewNodeId`, `parent`, `elementRef`, `x`, `y`, `width`, `height`, `name` and `type` (:2886 to :3058), and connections with `viewRelationshipId`, `modelRelationshipId`, `sourceId`, `targetId`, `type` and `bendpoints` (:3364). It fills the required `nodes` and `connections` fields with the same lists. `ViewService::transformView` (`lib/Service/ViewService.php:1451`) and the exporter then read a drawn view the way they read an imported one. + +`src/utils/viewGraph.js` maps that shape to and from `CnGraphCanvas` nodes and edges. A node with a `parent` becomes a Vue Flow child node of that parent, so a grouping keeps its children. + +Rejected: storing the canvas's own JSON. Two shapes need two readers, and the export would miss drawn views. + +### D4. The canvas is `CnGraphCanvas` + +`CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`) takes `nodes`, `edges` and `readOnly`, emits a connection for pointer and keyboard input alike, and carries labelled zoom controls. The editor renders an ArchiMate element in the `node` slot: the element name, its ArchiMate type and the layer colour from Nextcloud CSS variables (ADR-003). + +Rejected: a separate diagram library. ADR-012 asks for the shared components, and `CnGraphCanvas` already carries the keyboard contract (ADR-059). + +### D5. A drawn connection references a `relation` object + +The ArchiMate exchange format needs every relationship connection to name a relationship. When the user connects two elements with a relation type, the store looks for a `relation` object of that type between the two elements (`source`, `target` and `type`, register.json:6346 and :6376) and reuses it, or creates one with `origin: drawn`. The connection stores its id as `modelRelationshipId`. + +A new element placed from the palette ("New application component", and the other ArchiMate element types) is created as an `element` object with `origin: drawn` and the ArchiMate `type`. An existing GEMMA element is placed by reference and is not changed. + +### D6. Versions are explicit snapshots in `view-version` + +Save version writes a `view-version` object with the view's uuid, a version number, a label, the saving user, the time and a copy of `xml.viewNodes` and `xml.viewRelationships`. Compare versions loads two snapshots, or one snapshot and the current view, and `src/utils/viewDiff.js` sorts every node and connection by id into added, removed, changed and unchanged. A node counts as changed when its name, element, parent, position, size or style differs. `ViewVersionCompare.vue` renders both sides on a read-only `CnGraphCanvas`, marks added in `--color-success`, removed in `--color-error` and changed in `--color-warning`, and lists the same result as text for screen readers. + +Rejected: OpenRegister's audit trail with `CnVersionHistory`. OpenRegister replaces any changed value over 65,536 bytes with a descriptor (`openregister-ro/lib/Db/AuditTrailPayloadHelper.php:51` and :150), so the node list of a large view cannot be rebuilt from its history. And `computeObjectDiff` (`@conduction/nextcloud-vue` `src/utils/computeObjectDiff.js:141`) compares arrays by index, so one node removed from the middle reads as every later node changed. The audit trail still records who saved what, in the sidebar History tab. + +### D7. Tags, status and owner filter the views list + +`view.tags` is a facetable string list. `view.status` is a facetable enum `draft`, `in review`, `published`, `retired` with default `draft`. `view.origin` is facetable. The `Views` index page is a `CnIndexPage` (manifest `type: index`) over `@resolve:amef_register` and `view`, with columns name, viewpoint, status, tags and origin, the schema facets in its sidebar, and quick filters All, Mine (`{"_owner": "@me"}`), Drawn and Imported. `@me` is resolved by `@conduction/nextcloud-vue` (`src/utils/resolveFilterTokens.js:119`). + +### D8. The menu gains a group, not an entry + +ADR-097 caps the main menu at six entries, and stackiq has 13 after relocation. The fragment adds a group `Architecture` (order 50, no route) and `src/menu-layout.json` relocates `Standaarden` and the new `Views` entry under it. The count of top-level entries stays 13. + +### D9. Drawn objects stay inside the organisation + +Drawn views, elements and relations are scoped to the organisation that created them through OpenRegister multitenancy. Three readers bypass that today and each gets a filter: + +- `ViewService::getViewsFromRegister` caches one list for all callers (`views_list`, :234, 30 minutes, :74). It keeps only views whose `origin` is empty or `imported`, so the cache only ever holds GEMMA views. +- `ViewService::getView` (:166) reads one view through `getViewFromRegister` (:307) with `_rbac: false` and `_multitenancy: false` (:328), so `GET /api/views/{viewId}` returns any view to any signed-in user. It answers a view whose `origin` is not empty or `imported` with a 404, the same answer as a missing view, so a uuid does not reveal that a drawn view exists. +- `ArchiMateExportService::getObjectsFromDatabase` reads with `_rbac: false` and `_multitenancy: false` (:844). The full model export keeps only objects whose `origin` is empty or `imported`. + +The editor and the index read drawn views through OpenRegister's objects API, which applies RBAC and multitenancy. + +### D10. One editor at a time + +The editor takes OpenRegister's object lock through `useObjectLock` (`@conduction/nextcloud-vue` `src/composables/useObjectLock.js:66`) before it enters edit mode, and shows who holds the lock otherwise (ADR-033). + +## Declarative versus imperative + +- The view status lifecycle is declared in the fragment as `configuration.x-openregister-lifecycle` on `view`, in the shape `usage` already uses (field `status`, `initial` draft, named transitions): submit (draft to in review), publish (in review to published), rework (in review to draft), retire (published to retired), reopen (retired to draft). The `from` and `to` values are the enum values exactly, because a lifecycle whose values match no row offers no transition and raises no error (register changelog 2.4.4, register.json:7). No PHP. +- Copy to edit, Save version and relation reuse write through OpenRegister's objects API from `src/store/modules/architectureView.js`. They add no controller and no service (ADR-022, config rule "Uses OpenRegister API directly from frontend"). +- The three backend filters in D9 are changes to existing readers, not new behaviour. + +## Seed data + +The fragment seeds a small drawn example so the Views page is not empty on a fresh install. All objects live in the `vng-gemma` register. + +### Schema: `element` + +| Field | Object 1 | Object 2 | Object 3 | +|---|---|---|---| +| slug | `seed-el-zaaksysteem` | `seed-el-dms` | `seed-el-zaakregistratie` | +| identifier | `id-seed-el-zaaksysteem` | `id-seed-el-dms` | `id-seed-el-zaakregistratie` | +| type | `ApplicationComponent` | `ApplicationComponent` | `ApplicationService` | +| name | Zaaksysteem | Documentbeheer | Zaakregistratie | +| origin | drawn | drawn | drawn | + +### Schema: `relation` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-rel-zaak-dms` | `seed-rel-zaak-registratie` | +| type | `Flow` | `Realization` | +| source | `id-seed-el-zaaksysteem` | `id-seed-el-zaaksysteem` | +| target | `id-seed-el-dms` | `id-seed-el-zaakregistratie` | +| origin | drawn | drawn | + +### Schema: `view` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-view-zaakgericht-nu` | `seed-view-zaakgericht-concept` | +| name | Zaakgericht werken, huidige situatie | Zaakgericht werken, concept | +| status | published | draft | +| tags | zaakgericht, applicatielandschap | zaakgericht | +| origin | drawn | drawn | +| nodes | the three elements | the first two elements | + +### Schema: `view-version` + +| Field | Object 1 | +|---|---| +| slug | `seed-view-zaakgericht-nu-v1` | +| view | uuid of `seed-view-zaakgericht-nu` | +| versionNumber | 1 | +| label | Eerste opzet | +| nodes | the first two elements, so a compare with the current view shows one addition | + +## Risks + +- **Nested ArchiMate nodes on Vue Flow.** Vue Flow draws a child node relative to its parent, while the import stores absolute coordinates. `viewGraph.js` converts both ways and its vitest spec round-trips an imported GEMMA view without moving a node. +- **Partial saves.** A save that creates relations and then the view can fail between the two. The store writes relations first, reuses them on a retry, and reports a failed view write without leaving the canvas. +- **Large views.** A GEMMA view with a few hundred nodes is a large object. The editor loads one view, never the whole list with nodes, and the index columns do not include `xml`. +- **Schema versions.** The fragment bumps three AMEF schema versions. The register changelog entry 2.4.4 (register.json:7) records why: a deployed version equal to or above the declared one makes the import skip, and OpenRegister compares only properties, required and authorization, never `configuration`, where the lifecycle lives. diff --git a/openspec/changes/architecture-views-editor/proposal.md b/openspec/changes/architecture-views-editor/proposal.md new file mode 100644 index 00000000..bfcc11dc --- /dev/null +++ b/openspec/changes/architecture-views-editor/proposal.md @@ -0,0 +1,53 @@ +--- +kind: code +depends_on: [] +--- + +# Draw, tag and compare architecture views in stackiq + +## Summary + +An application owner can draw an ArchiMate view in stackiq: place elements, connect them, save the view and open it again. Views get tags, a status and an owner, and the views list filters on all three. An owner can save a named version of a view and compare two versions, with additions, removals and changes marked on the canvas and listed as text. Imported GEMMA views open read-only, and Copy to edit makes a drawn copy. + +## Why + +Three rows of the stackiq parity matrix are built by this change. + +- `stackiq:arch-modelling`, "Draw and edit architecture models yourself inside the tool." SAP LeanIX rates yes: "free draw and data flow diagrams edited in the diagram editor, with an ArchiMate 3.2 shape template" (https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams). BlueDolphin rates yes: "users create and edit ArchiMate architecture views in the view editor" (https://help.bluedolphin.io/en/articles/11967507-create-an-architecture-view). No tender or feature request names it. The lane decided build because two competitors rate yes and architecture is a core area. +- `stackiq:arch-diagram-version-compare`, "Compare two saved versions of an architecture diagram and see what was added, removed or changed." The demand is a changelog entry, https://updates.leanix.net/announcements/compare-the-content-of-different-diagram-versions. SAP LeanIX rates yes: "Selecting Compare Changes ... on a prior diagram version shows color-coded highlighting (green for additions, red for removals, yellow for modifications), a side-by-side view of differences, and an additional text-based summary". The matrix holds no evidence URL beyond the changelog entry. Decided build: core area. +- `stackiq:arch-view-tags`, "Tag saved diagrams and views and filter the view list by tag, owner or status to find them again." The demand is a changelog entry, https://help.bluedolphin.io/en/articles/16096602-discover-and-manage-views-in-the-views-list. BlueDolphin rates yes: "Bring structure to your Views list with tags", with view filters "Owner Contributors Tags Favorited Private Project Status Type" (https://bluedolphin.io/product-news/). Decided build: core area. + +The rated rows say stackiq renders no architecture view. The pending row `stackiq:arch-gemma-views` rates stackiq partial with state built. Both hold, because they describe different things. The API that serves enriched views exists (`appinfo/routes.php:185-187`), and so does the organisation export that draws applications into copies of GEMMA views (`lib/Service/ArchiMateExportService.php:2734`). No page draws a view: `src/store/modules/view.js:15` defines `useViewStore`, nothing under `src/` imports it, and nothing under `src/` reads `viewNodes` or `viewRelationships`. The pending row's own note says the same. + +## What stackiq has today + +- The AMEF register `vng-gemma` (`lib/Settings/softwarecatalogus_register.json:916`) holds the schemas `element` (:4130), `view` (:5299), `model` (:5689), `property-definition` (:6140) and `relation` (:6300). The `view` schema is at version 0.0.7 (:5304) and its authorization only grants public read (:5678). It has no tag, status or owner field of its own. +- The ArchiMate import writes each view with `@self.id` set to the ArchiMate identifier (`lib/Service/ArchiMateImportService.php:5394`) and stores the diagram under `xml.viewNodes` and `xml.viewRelationships`: a flat node list with `parent` references, `elementRef`, `x`, `y`, `width` and `height` (:2886 to :3058), and connections with `modelRelationshipId`, `sourceId` and `targetId` (:3364). It saves through `saveObjects` (:1587), which updates an object with the same id. A re-import overwrites every imported view. +- `lib/Controller/ViewController.php:82` and `lib/Service/ViewService.php:108` serve `GET /api/views`, and `ViewService::transformView` (:1451) turns `xml.viewNodes` into `viewNodes` with a `position` and a `style`. The list is cached for all callers under one key, `views_list` (:234), for 30 minutes (:74). +- The full ArchiMate export reads every AMEF object with `_rbac: false` and `_multitenancy: false` (`lib/Service/ArchiMateExportService.php:844`). +- `src/manifest.json` has one page over the AMEF register, Standaarden (:701), filtered to `gemmaType` standaard. The main menu has 15 entries next to Dashboard, 13 after `src/menu-layout.json` relocates two of them. +- OpenRegister keeps an audit trail per object and can revert to a version (`openregister-ro/appinfo/routes.php:1344` and :1453). It replaces any changed value over 65,536 bytes with a descriptor (`openregister-ro/lib/Db/AuditTrailPayloadHelper.php:51` and :150). +- `@conduction/nextcloud-vue` 2.57.1 ships `CnGraphCanvas` (`src/components/CnGraphCanvas/CnGraphCanvas.vue`, a Vue Flow canvas with `nodes`, `edges` and `readOnly` props), `CnVersionHistory` and the `useObjectLock` composable (`src/composables/useObjectLock.js:66`). + +## What this change builds + +- A register fragment `lib/Settings/register.d/architecture-views.json` that adds `tags`, `status`, `origin` and `basedOn` to `view`, adds `origin` to `element` and `relation`, declares the view status lifecycle, and adds a `view-version` schema to the `vng-gemma` register. +- A Views index page and a view editor page in `src/manifest.d/architecture-views.json`, reached from an Architecture menu group that takes the place of the top-level Standards entry. +- A canvas editor on `CnGraphCanvas` that places elements, draws connections backed by `relation` objects, and saves in the shape the import already writes. +- Copy to edit for imported views, which open read-only. +- Save version and Compare versions, with a diff keyed by node and connection id. +- Backend guards: drawn views stay out of the shared `/api/views` list, the single view read `/api/views/{viewId}` and the full ArchiMate export. + +## Out of scope + +- Drawing the organisation's own applications (stackiq `module` objects) into a view. The organisation export does that into copies of GEMMA views today, and the pending row `stackiq:arch-gemma-views` covers it. +- Drafting a view with an assistant. That is `architecture-assistant-drafted-views`, which depends on this change. +- Putting a view into Word or PowerPoint. That is `architecture-views-to-office-documents`. +- Business processes on a view. That is `architecture-process-mapping`. +- Editing an imported GEMMA view in place, and writing a drawn view back into the GEMMA model. +- Live co-editing. One editor holds the lock (ADR-033), others read. + +## Risks + +- A drawn view in the AMEF register sits next to VNG's GEMMA content. The `origin` field and the three backend guards keep them apart. A future import path that forgets the guard would mix them, so the guards get their own tests. +- Snapshots are copies of the node list. A view with many versions grows the register. Versions are only saved on an explicit action, not on every save. diff --git a/openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md b/openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md new file mode 100644 index 00000000..68c2f3fc --- /dev/null +++ b/openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md @@ -0,0 +1,127 @@ +# architecture-views-editor specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-views-editor + +## Purpose + +An application owner draws ArchiMate views in stackiq instead of only importing them from Archi. Views carry tags, a status and an owner, so a municipality finds its views again, and saved versions can be compared to see what changed. Imported GEMMA views stay as VNG publishes them. Data lives in OpenRegister's AMEF register (ADR-001), the canvas is `CnGraphCanvas` (ADR-012), and the status lifecycle is declared on the schema (ADR-031). + +## ADDED Requirements + +### Requirement: REQ-AVE-001 An application owner SHALL draw and save an architecture view + +Stackiq SHALL offer a view editor at `/views/:id` (page `ViewEditor`) on `CnGraphCanvas`. An application owner SHALL be able to create a view, place existing AMEF elements and new elements of an ArchiMate element type, connect two elements with an ArchiMate relation type, move and resize nodes, and save. The saved view SHALL be a `view` object in the `vng-gemma` register with `origin` set to `drawn`, and its diagram SHALL be stored in `xml.viewNodes` and `xml.viewRelationships` in the shape the ArchiMate import writes, so `ViewService` reads it unchanged. A new element SHALL be saved as an `element` object with `origin` set to `drawn`. + +#### Scenario: An application owner draws a view and opens it again +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** an application owner signed in to stackiq +- **WHEN** they open the Views page, choose New view, place two application components, connect them with a flow relation and save +- **THEN** the Views page SHALL list the view with status draft and origin drawn +- **AND** opening it again SHALL show both nodes at the saved positions and the connection between them + +#### Scenario: A saved drawn view reads like an imported one +@e2e exclude The shape is not visible in the browser; tests/vitest/viewGraph.spec.js round-trips a drawn canvas and an imported GEMMA view through the store's save shape, and tests/Unit/Service/ViewServiceDrawnViewTest.php reads a drawn view through ViewService::transformView. + +- **GIVEN** a drawn view saved by the editor +- **WHEN** `ViewService::transformView` reads it +- **THEN** every node SHALL carry `identifier`, `position` and `elementRef` as it does for an imported view +- **AND** every child node SHALL keep its `parent` + +### Requirement: REQ-AVE-002 Imported GEMMA views SHALL open read-only and SHALL be copied before editing + +A view with `origin` set to `imported` SHALL open in the editor without edit controls. Its Copy to edit action SHALL create a new `view` with a fresh identifier, `origin` set to `drawn`, `status` set to `draft` and `basedOn` set to the source view's uuid, and SHALL open the copy in edit mode. A later ArchiMate import SHALL NOT change a drawn view. + +#### Scenario: A municipal information manager copies a GEMMA view to adapt it +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** an imported GEMMA view on the Views page +- **WHEN** a municipal information manager opens it +- **THEN** the canvas SHALL show no edit controls and SHALL offer Copy to edit +- **AND** choosing Copy to edit SHALL open a new drawn view whose Based on field names the GEMMA view + +#### Scenario: A re-import leaves a drawn copy alone +@e2e exclude An import needs a GEMMA model file and minutes of runtime; tests/Unit/Service/ArchitectureViewsImportIsolationTest.php imports a view whose identifier differs from the copy's and asserts the copy's nodes are unchanged. + +- **GIVEN** a drawn copy of a GEMMA view +- **WHEN** a Nextcloud admin imports the GEMMA model again +- **THEN** the imported view SHALL be updated +- **AND** the drawn copy SHALL keep its nodes, connections and status + +### Requirement: REQ-AVE-003 A drawn connection SHALL reference a relation object + +When the user connects two elements, the editor SHALL reuse a `relation` object of the chosen type between the same source and target, or SHALL create one with `origin` set to `drawn`. The connection SHALL store that relation's id as `modelRelationshipId`, so the ArchiMate export writes a valid relationship reference. + +#### Scenario: Connecting the same two elements twice reuses one relation +@e2e exclude The relation lookup is a store concern; tests/vitest/architectureViewStore.spec.js asserts one relation is created for the first connection and reused for the second. + +- **GIVEN** a drawn view with a flow connection from Zaaksysteem to Documentbeheer +- **WHEN** the application owner draws a second flow connection between the same two elements on another view +- **THEN** no second `relation` object SHALL be created +- **AND** both connections SHALL carry the same `modelRelationshipId` + +### Requirement: REQ-AVE-004 The views list SHALL filter on tag, status and owner + +The `Views` page at `/views` SHALL be a `CnIndexPage` over the `view` schema with columns name, viewpoint, status, tags and origin. `tags` SHALL be a facetable list of strings, `status` a facetable enum of draft, in review, published and retired, and `origin` a facetable enum of imported and drawn. The page SHALL offer the quick filters All, Mine, Drawn and Imported, where Mine filters on the signed-in user as owner. The status transitions SHALL be declared as `x-openregister-lifecycle` on the `view` schema. + +#### Scenario: A municipal information manager finds views by tag +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** two drawn views, one tagged zaakgericht and one tagged financien +- **WHEN** a municipal information manager selects the tag zaakgericht in the Views sidebar +- **THEN** the list SHALL show only the view tagged zaakgericht + +#### Scenario: Mine shows only the signed-in user's views +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** a drawn view owned by the signed-in application owner and one owned by a colleague +- **WHEN** the application owner chooses the quick filter Mine +- **THEN** the list SHALL show only their own view + +#### Scenario: A published view moves through the declared lifecycle +@e2e exclude The transition engine is OpenRegister's; tests/Unit/Service/ArchitectureViewsRegisterShapeTest.php asserts every lifecycle from and to value is a member of the status enum and the view schema version is bumped. + +- **GIVEN** a drawn view in status in review +- **WHEN** the owner applies the publish transition +- **THEN** the view SHALL read published +- **AND** the transition SHALL appear in the view's History tab + +### Requirement: REQ-AVE-005 An owner SHALL save named versions and compare two of them + +The editor SHALL offer Save version, which SHALL write a `view-version` object with the view's uuid, the next version number, a label, the saving user, the time and a copy of the view's nodes and connections. Compare versions SHALL let the user pick two versions, or one version and the current view, and SHALL show both on read-only canvases with added items marked in the success colour, removed items in the error colour and changed items in the warning colour, next to a text list of the same changes. A node SHALL count as changed when its name, element, parent, position, size or style differs. + +#### Scenario: An application owner sees what changed since the last version +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** a drawn view with a saved version 1 holding two nodes +- **WHEN** the application owner adds a third node, saves, and compares version 1 with the current view +- **THEN** the third node SHALL be marked as added on the current side +- **AND** the text list SHALL read one added node, no removed nodes and no changed nodes + +#### Scenario: A reordered node list is not a change +@e2e exclude A pure function; tests/vitest/viewDiff.spec.js asserts that two snapshots with the same nodes in a different order give no added, removed or changed entries. + +- **GIVEN** two snapshots with the same nodes in a different order +- **WHEN** `viewDiff` compares them +- **THEN** it SHALL report no added, removed or changed nodes + +### Requirement: REQ-AVE-006 Drawn views SHALL stay inside the organisation that drew them + +Drawn views, elements and relations SHALL be scoped to the organisation that created them. `GET /api/views` SHALL return only views whose `origin` is empty or `imported`, because its list is cached for all callers. `GET /api/views/{viewId}` SHALL answer 404 for a view whose `origin` is neither, because it reads without RBAC. The full ArchiMate export (`POST /api/archimate/export`) SHALL keep only objects whose `origin` is empty or `imported`. The editor SHALL take OpenRegister's object lock before edit mode and SHALL show who holds the lock when another user has it. + +#### Scenario: Another municipality does not see a drawn view +@e2e exclude The CI instance has one organisation; tests/Unit/Service/ViewServiceDrawnViewTest.php asserts the views query keeps only an empty or imported origin and the single view read answers 404 for a drawn view, and tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php asserts the full export skips drawn objects. + +- **GIVEN** a drawn view of municipality A +- **WHEN** a user of municipality B calls `GET /api/views` or `GET /api/views/{viewId}` with its uuid, or a Nextcloud admin runs the full ArchiMate export +- **THEN** the drawn view SHALL NOT be in the response or in the exported file + +#### Scenario: A second editor sees the lock +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** an application owner editing a drawn view +- **WHEN** a colleague opens the same view +- **THEN** the colleague SHALL see the view read-only with a notice naming who is editing it diff --git a/openspec/changes/architecture-views-editor/tasks.md b/openspec/changes/architecture-views-editor/tasks.md new file mode 100644 index 00000000..b26534cd --- /dev/null +++ b/openspec/changes/architecture-views-editor/tasks.md @@ -0,0 +1,101 @@ +# Tasks: architecture-views-editor + +## Implementation tasks + +### Task 1: Register fragment for drawn views and versions +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-004-the-views-list-shall-filter-on-tag-status-and-owner +- **files**: `lib/Settings/register.d/architecture-views.json`, `tests/Unit/Service/ArchitectureViewsRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN `view` has `tags`, `status`, `origin` and `basedOn`, and `element` and `relation` have `origin` + - GIVEN the merged register WHEN it loads THEN `vng-gemma` lists `view-version` with magic mapping on + - GIVEN the view lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a member of the status enum + - GIVEN the fragment WHEN it is compared with development THEN `view`, `element` and `relation` carry bumped versions +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewsRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Views index page and Architecture menu group +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-004-the-views-list-shall-filter-on-tag-status-and-owner +- **files**: `src/manifest.d/architecture-views.json`, `src/menu-layout.json` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Views` is an index page at `/views` over `@resolve:amef_register` and `view` with the quick filters All, Mine, Drawn and Imported + - GIVEN the effective menu WHEN it renders THEN Standards and Views sit under an Architecture group and the top-level count is unchanged +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/workflows/architecture-views.spec.ts` tag and Mine filters) + +### Task 3: Canvas shape mapping +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-001-an-application-owner-shall-draw-and-save-an-architecture-view +- **files**: `src/utils/viewGraph.js`, `tests/vitest/viewGraph.spec.js` +- **acceptance_criteria**: + - GIVEN an imported GEMMA view WHEN it is mapped to canvas nodes and back THEN every node keeps its absolute position, size and parent + - GIVEN a canvas with a child node WHEN it is mapped to the save shape THEN the child carries its parent's `viewNodeId` +- [ ] Implement +- [ ] Test (vitest `viewGraph.spec.js`) + +### Task 4: View editor page on CnGraphCanvas +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-001-an-application-owner-shall-draw-and-save-an-architecture-view +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/store/modules/architectureView.js`, `src/customComponents.js`, `src/manifest.d/architecture-views.json` +- **acceptance_criteria**: + - GIVEN the editor WHEN the user places two elements, connects them and saves THEN a `view` with `origin` drawn holds both nodes and the connection + - GIVEN a new element from the palette WHEN the view is saved THEN an `element` with `origin` drawn and the chosen ArchiMate type exists + - GIVEN a colleague holds the lock WHEN the user opens the view THEN it is read-only with a notice naming the colleague +- [ ] Implement +- [ ] Test (Playwright `architecture-views.spec.ts` draw and reopen, lock notice) + +### Task 5: Relations behind connections +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-003-a-drawn-connection-shall-reference-a-relation-object +- **files**: `src/store/modules/architectureView.js`, `tests/vitest/architectureViewStore.spec.js` +- **acceptance_criteria**: + - GIVEN no relation of the chosen type between two elements WHEN they are connected THEN one `relation` with `origin` drawn is created + - GIVEN such a relation exists WHEN they are connected again THEN it is reused + - GIVEN the view write fails after the relation write WHEN the user saves again THEN no second relation is created +- [ ] Implement +- [ ] Test (vitest `architectureViewStore.spec.js`) + +### Task 6: Read-only imported views and Copy to edit +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-002-imported-gemma-views-shall-open-read-only-and-shall-be-copied-before-editing +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/store/modules/architectureView.js`, `tests/Unit/Service/ArchitectureViewsImportIsolationTest.php` +- **acceptance_criteria**: + - GIVEN an imported view WHEN it opens THEN no edit control renders and Copy to edit is offered + - GIVEN Copy to edit WHEN it completes THEN a drawn view with a fresh identifier and `basedOn` opens in edit mode + - GIVEN a re-import WHEN it runs THEN the drawn copy is unchanged +- [ ] Implement +- [ ] Test (Playwright copy flow, PHPUnit `ArchitectureViewsImportIsolationTest`) + +### Task 7: Save version and compare versions +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-005-an-owner-shall-save-named-versions-and-compare-two-of-them +- **files**: `src/utils/viewDiff.js`, `src/views/architecture/ViewVersionCompare.vue`, `src/store/modules/architectureView.js`, `tests/vitest/viewDiff.spec.js` +- **acceptance_criteria**: + - GIVEN Save version WHEN it completes THEN a `view-version` holds the next number, the label and a copy of the nodes and connections + - GIVEN two snapshots WHEN they are compared THEN added, removed and changed are keyed by node and connection id, and order alone is no change + - GIVEN the compare page WHEN it renders THEN colours use `--color-success`, `--color-error` and `--color-warning` and the text list names every change +- [ ] Implement +- [ ] Test (vitest `viewDiff.spec.js`, Playwright compare scenario) + +### Task 8: Keep drawn objects out of the shared list, the single view read and the full export +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-006-drawn-views-shall-stay-inside-the-organisation-that-drew-them +- **files**: `lib/Service/ViewService.php`, `lib/Service/ArchiMateExportService.php`, `tests/Unit/Service/ViewServiceDrawnViewTest.php`, `tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php` +- **acceptance_criteria**: + - GIVEN a drawn and an imported view WHEN `GET /api/views` runs THEN only the imported view is returned and cached + - GIVEN a drawn view WHEN `GET /api/views/{viewId}` is called with its uuid THEN the answer is 404 + - GIVEN drawn objects WHEN the full ArchiMate export runs THEN none of them is in the file + - GIVEN a view imported before this change with no origin WHEN either reader runs THEN it is kept as imported +- [ ] Implement +- [ ] Test (PHPUnit `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest`) + +### Task 9: Documentation and translations +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-001-an-application-owner-shall-draw-and-save-an-architecture-view +- **files**: `docs/features/architecture-views.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the editor, the tag filter and a compare, each with a screenshot + - GIVEN a Dutch instance WHEN the Views page renders THEN every new string reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-views-editor --type change --strict` +- PHPUnit: `ArchitectureViewsRegisterShapeTest`, `ArchitectureViewsImportIsolationTest`, `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest` +- vitest: `viewGraph.spec.js`, `viewDiff.spec.js`, `architectureViewStore.spec.js` +- Playwright: `tests/e2e/workflows/architecture-views.spec.ts` +- Documentation in `docs/features/architecture-views.md` with screenshots (ADR-010) +- English and Dutch strings for every new label (ADR-005) diff --git a/openspec/changes/architecture-views-to-office-documents/.openspec.yaml b/openspec/changes/architecture-views-to-office-documents/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-views-to-office-documents/design.md b/openspec/changes/architecture-views-to-office-documents/design.md new file mode 100644 index 00000000..2c8c42c2 --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/design.md @@ -0,0 +1,56 @@ +# Design: architecture-views-to-office-documents + +Read at development 49e65cb4. Line numbers below are from that sha. The view page (`ViewEditor`, `src/views/architecture/ArchitectureViewEditor.vue`) and the guard on `ViewService::getView` come from `architecture-views-editor` (its D3 and D9). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Service | new `lib/Service/ViewImageService.php` | reads `xml.viewNodes` and `xml.viewRelationships` in the import's shape (`lib/Service/ArchiMateImportService.php:2886` to :3058, :3364) | +| Service | new `lib/Service/ViewDocumentGateway.php` | the one place stackiq talks to filinq | +| Controller and routes | `lib/Controller/ViewController.php` gains `getViewImage` and `createViewDocument`; routes `view#getViewImage` at `GET /api/views/{viewId}/image.svg` and `view#createViewDocument` at `POST /api/views/{viewId}/document`, next to `view#getView` (`appinfo/routes.php:187`) | `ViewController::getView` (:218) is the pattern | +| Initial state | `lib/AppInfo/Application.php` provides `view_document_export` next to `amef_register` (:908) | the frontend reads it with `loadState` | +| View | `src/views/architecture/ArchitectureViewEditor.vue` gains an Export menu | | +| Register, pages | none | | + +## Decisions + +### D1. Stackiq draws the picture on the server, as SVG + +`ViewImageService::renderSvg(array $view): string` walks the stored nodes and connections and writes one SVG: a rectangle per node at its absolute `x`, `y`, `width` and `height`, children inside their parents, the element name and ArchiMate type as text, and a path per connection through its bendpoints with the arrowhead of its relation type. A caption holds the view name and the date. The palette is the ArchiMate layer convention (business, application, technology, motivation, grouping) in one constant, because an exported file is read outside Nextcloud where the theme's CSS variables do not resolve (ADR-003 governs the UI, not a file). + +Rejected: capturing the canvas in the browser. `CnGraphCanvas` draws nodes as HTML and edges as SVG, the library ships no image export, and adding one would mean a DOM snapshot library whose output depends on the browser, the zoom and the theme. The stored geometry gives the same picture on every call, and filinq can take it without a browser. + +Rejected: an SVG made from the canvas's own node list. It would picture what the canvas shows after pan and zoom; the stored view is what the user saved. + +### D2. The image route reads with RBAC on + +`GET /api/views/{viewId}/image.svg` (`#[NoAdminRequired]`) reads the view through OpenRegister's `ObjectService::find` with RBAC and multitenancy on, not through `ViewService::getView`, which reads with both off (`lib/Service/ViewService.php:328`). A reader gets imported GEMMA views and the drawn views of their own organisation; any other uuid gets a 404. The response is `image/svg+xml` with a download file name built from the view name. + +Text from the view (names, the caption) is escaped before it goes into the SVG, and the SVG holds no script, no external reference and no `foreignObject`, so a view name cannot inject markup into a file that Word or a browser opens. + +### D3. Word and PowerPoint through one gateway to filinq + +`ViewDocumentGateway::isAvailable(): bool` and `requestDocument(string $format, array $content, string $userId): array` are the only code that knows filinq. `$format` is `docx` or `pptx`. `$content` holds the view name, description, viewpoint, a legend of the element types on the view, the generation date, a link back to the view page, and the SVG from D1. The result is the Files path and file id of the new document, which the frontend opens. + +The gateway consumes filinq's published document contract, resolved the way ADR-075 Decision 2 prescribes: never a container lookup of filinq internals, never a loopback HTTP call to filinq routes, never a guessed endpoint. `isAvailable` is true only when that contract resolves; "filinq is installed" is not the probe (ADR-087 Decision 5 makes the same point for office suites). When it is false, `POST /api/views/{viewId}/document` answers 503 with a message, and nothing is written. + +The contract does not exist at the shas read (proposal, Risks). The gateway is written against the contract's declared operations, render-template-with-data (ADR-075 Decision 1), and its unit tests run against a stub of that contract, so the stackiq half is done and tested when filinq publishes. + +Rejected: generating the `.docx` or `.pptx` in stackiq with a PHP office library. ADR-075 gives document generation one owner and bans a second engine in a leaf app. + +Rejected: a typed `IEventDispatcher` event that stackiq defines and filinq would have to listen for (the ADR-041 route the contract approvals use). The event class belongs to the app that owns the command; stackiq inventing one would be a phantom contract that nothing dispatches to. + +### D4. The Export menu + +The view page gets an Export menu with Download SVG, Create Word document and Create PowerPoint slide. Download SVG fetches the image route. The two document actions read the initial state `view_document_export` (provided through `IInitialState`, as `amef_register` is at `lib/AppInfo/Application.php:908`); when it is false they are disabled with the text "Word and PowerPoint need the document app filinq", so the absence is visible (ADR-075 Decision 4). After a document is made, a toast names the file and offers Open in Files. + +## Declarative versus imperative + +The change adds no lifecycle, aggregation, notification, relation or widget behaviour. It is two read-only renderers and one outbound call, all imperative by nature. + +## Risks + +- **Missing contract.** Until filinq publishes its document contract, only the SVG download works. The spec keeps the document scenarios behind the gateway's availability, and the Playwright spec covers the disabled state. +- **Large views.** A GEMMA view with a few hundred nodes makes an SVG of a few hundred kilobytes. The route streams it and sets no cache header for drawn views, which can change. +- **Fidelity.** The SVG shows boxes, names, types and arrows, not Archi's icons. The caption says the picture is drawn by stackiq, and the ArchiMate export stays the exact exchange format. diff --git a/openspec/changes/architecture-views-to-office-documents/proposal.md b/openspec/changes/architecture-views-to-office-documents/proposal.md new file mode 100644 index 00000000..4dd9b620 --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +depends_on: + - architecture-views-editor +--- + +# Put an architecture view into a Word or PowerPoint document + +## Summary + +An application owner who looks at a view in stackiq can download it as an SVG image, or ask for a Word document or a PowerPoint slide that holds the view with its name, description, legend and date. Stackiq draws the image. The document is made by filinq, the fleet's document app, and lands in the user's Files, where the office suite opens it. When filinq is not there, the two document actions say so and the image download still works. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-views-office`, "Put architecture views into Word or PowerPoint documents straight from the tool." It comes from the Helmond architecture repository tender, https://www.tenderned.nl/aankondigingen/overzicht/398728; the matrix note reads "Helmond REQ19 asks for architecture views embedded in office documents." + +No competitor rates yes. Three rate partial: +- GEMMA Softwarecatalogus: "Download de kaart met de knop [download SVG] ... De kaart volledig schaalbaar" (https://www.softwarecatalogus.nl/Hoe%20print%20ik%20een%20kaart%3F). +- SAP LeanIX: "Using the HTML Embed Code, you can embed and have live data from the SAP LeanIX inside a tool such as Confluence and PowerPoint" (https://help.sap.com/docs/leanix/ea/using-reports), with diagrams exported as PDF, SVG and PNG (https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams). +- BlueDolphin: "To use the image of a view, for example, in a document, you can download the view as a file in PNG, SVG, PDF" (https://help.bluedolphin.io/en/articles/11967514-download-a-view). + +The lane decided build on tender demand and a core area. + +## What stackiq has today + +- The only view exports are ArchiMate exchange files: `POST /api/archimate/export` and `GET /api/archimate/export/organization/{organizationUuid}` (`appinfo/routes.php:97-98`). No image, Word or PowerPoint output exists in `lib/` or `src/`. +- No page draws a view today; `architecture-views-editor` adds the view page on `CnGraphCanvas`. `CnGraphCanvas` in `@conduction/nextcloud-vue` 2.57.1 has no image export, and the library has no image export dependency. +- The stored view holds everything a picture needs: `xml.viewNodes` with `x`, `y`, `width`, `height`, `parent`, `name` and `type`, and `xml.viewRelationships` with source, target, type and bendpoints (`lib/Service/ArchiMateImportService.php:2886` to :3058 and :3364). +- `ViewService::getView` reads one view without RBAC (`lib/Service/ViewService.php:307`, the read at :328); `architecture-views-editor` limits that path to imported views. +- Stackiq holds no filinq integration: `grep -rn -i "docudesk\|filinq" lib src` finds only a comment in `lib/Repair/MigrateSchemaApplicationId.php:26`. + +## What this change builds + +- `lib/Service/ViewImageService.php`, which draws a view's stored geometry as a standalone SVG, and `GET /api/views/{viewId}/image.svg`, which reads the view with RBAC on. +- `lib/Service/ViewDocumentGateway.php` and `POST /api/views/{viewId}/document`, which hand the SVG and the view's text to filinq for a Word or PowerPoint file in the user's Files. +- An Export menu on the view page with Download SVG, Create Word document and Create PowerPoint slide, with the last two disabled and explained when filinq cannot take the request. + +## Out of scope + +- Making the Word or PowerPoint file. That is filinq's (ADR-075, ADR-087): its template rendering, its office format codec and its conversions. This change needs filinq's published document contract (ADR-075 Decision 1) and does not define it. +- Inserting a view into a document that is already open in the office suite. ADR-087 Decision 4 allows that only as a suite-specific extra behind a probe. +- PNG and PDF downloads. Word and PowerPoint read SVG, and filinq can convert when a template needs a bitmap. +- Live embedding that updates when the view changes, as LeanIX offers. + +## Risks + +- filinq's document contract does not exist yet at the shas read: ADR-075 is Proposed, OpenRegister 4fee776 has no capability registry (`grep -rn "pdf-export" openregister-ro/lib` finds nothing), and `@conduction/nextcloud-vue` 2.57.1 has no `CnIntegrationGate`. The SVG download ships on its own; the two document actions stay disabled with a notice until the contract lands. This is the sibling half the change assumes. +- filinq's documented backends are Mpdf and PhpWord (ADR-075 Context). A PowerPoint file needs a presentation writer or a conversion through `IConversionManager` (ADR-087 Decision 1), which filinq has to confirm. diff --git a/openspec/changes/architecture-views-to-office-documents/specs/architecture-view-office-export/spec.md b/openspec/changes/architecture-views-to-office-documents/specs/architecture-view-office-export/spec.md new file mode 100644 index 00000000..fdec9be8 --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/specs/architecture-view-office-export/spec.md @@ -0,0 +1,77 @@ +# architecture-view-office-export specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-views-to-office-documents + +## Purpose + +An application owner takes an architecture view out of stackiq into a document: as an SVG image they can place anywhere, or as a Word document or PowerPoint slide made for them. Stackiq draws the image from the stored view. The document is made by filinq, the fleet's document app (app id docudesk), through its published contract (ADR-075, ADR-087); stackiq never generates office files itself. + +## ADDED Requirements + +### Requirement: REQ-AVO-001 Stackiq SHALL draw a view as a standalone SVG from its stored geometry + +`GET /api/views/{viewId}/image.svg` SHALL return an SVG image of the view: every node at its stored position and size with its name and ArchiMate type, children inside their parents, every connection along its bendpoints with the arrowhead of its relation type, and a caption with the view name and date. It SHALL read the view with OpenRegister RBAC and multitenancy on and SHALL answer 404 for a view the caller may not read. Every text taken from the view SHALL be escaped, and the SVG SHALL contain no script, no external reference and no `foreignObject`. + +#### Scenario: An application owner downloads a view as SVG +@e2e tests/e2e/workflows/architecture-view-export.spec.ts + +- **GIVEN** the seeded drawn view Zaakgericht werken, huidige situatie +- **WHEN** an application owner opens it and chooses Export, then Download SVG +- **THEN** the browser SHALL receive an SVG file named after the view +- **AND** the file SHALL hold the text Zaaksysteem, Documentbeheer and Zaakregistratie + +#### Scenario: A view name cannot inject markup +@e2e exclude A rendering rule; tests/Unit/Service/ViewImageServiceTest.php renders a view named with a script tag and asserts the output escapes it and contains no script, external href or foreignObject. + +- **GIVEN** a drawn view whose name holds `