diff --git a/.procedure-code-baseline.json b/.procedure-code-baseline.json
new file mode 100644
index 000000000..57e568c36
--- /dev/null
+++ b/.procedure-code-baseline.json
@@ -0,0 +1 @@
+{"count": 0}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87505881a..74c048e88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@
sheets becomes or updates a module, its vendor, a usage for the municipality
and a contact person for its owner; a repeat import matches on APPID, so it
updates instead of duplicating. See `docs/features/cmdb-import.md`.
+- CMDB import: on an OpenRegister with the migration-pack editor
+ (openregister#4602), the import runs the mapping packs kept in
+ OpenRegister's migration pack store, where an administrator edits them.
+ The `SeedCmdbImportPacks` repair step stores them on install and upgrade;
+ a pack nobody edited follows newer releases, an edited one is kept.
+ Viewing the mapping writes nothing. An older OpenRegister keeps the packs
+ that ship with stackiq.
+- CMDB import, repeat imports: applications that left the CMDB sheets are
+ archived when the sheet "Gearchiveerde Applicaties" lists them and moved to
+ OpenRegister's trash when no sheet does; one that returns is unarchived or
+ restored, never duplicated. The new option `missingRecords` (`archive`, the
+ default, or `keep`) is a choice in the CMDB import section, and the report
+ counts archived, unarchived, deleted and restored applications. The module
+ (0.3.9) and usage (1.5.7) schemas opt into OpenRegister's archive state, and
+ the Applications and Applications in use pages get an "Archived" quick
+ filter. (cmdb-import-archive-reconciliation)
### Changed
- EOL feed: the register and schema slugs the feature reads from are corrected.
diff --git a/appinfo/info.xml b/appinfo/info.xml
index aa70380b6..aa719115b 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -50,7 +50,7 @@ Vrij en open source onder de EUPL-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. Voor een Service Level Agreement (SLA), neem contact op via sales@conduction.nl.
]]>
- 0.2.4-unstable.20261001120000
+ 0.2.6-unstable.20261012120000EUPL-1.2ConductionStackiq
@@ -122,6 +122,15 @@ Vrij en open source onder de EUPL-licentie.
-->
OCA\Stackiq\Repair\MigrateAppConfigKeysOCA\Stackiq\Repair\MigrateUserPreferences
+
+ OCA\Stackiq\Repair\RepointDashboardWidgetIdOCA\Stackiq\Repair\RenameDutchCatalogValues
+
+ OCA\Stackiq\Repair\SeedCmdbImportPacksOCA\Stackiq\Repair\RepointEolSyncConfigOCA\Stackiq\Repair\InitializeSettings
+
+ OCA\Stackiq\Repair\SeedCmdbImportPacks
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 9fc761db5..5558d9de5 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -109,6 +109,9 @@
// @spec openspec/changes/cmdb-export-import/tasks.md#task-8
['name' => 'cmdbImport#import', 'url' => '/api/cmdb-import', 'verb' => 'POST'],
['name' => 'cmdbImport#cancel', 'url' => '/api/cmdb-import/{operationId}/cancel', 'verb' => 'POST'],
+ // The mapping the CMDB import uses, read-only — same posture as the import (Nextcloud admins only, CSRF).
+ // @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ ['name' => 'settings#getCmdbImportMapping', 'url' => '/api/settings/cmdb-import/mapping', 'verb' => 'GET'],
// User Groups management routes
['name' => 'settings#getGenericUserGroups', 'url' => '/api/settings/user-groups/generic', 'verb' => 'GET'],
@@ -168,6 +171,8 @@
// User Profile endpoint
['name' => 'contactpersonen#getMe', 'url' => '/api/me', 'verb' => 'GET'],
+ // Display names of contact persons from the caller's own address books (owner columns).
+ ['name' => 'contactNames#index', 'url' => '/api/contact-names', 'verb' => 'GET'],
// Contactpersonen Management endpoints
['name' => 'contactpersonen#getContactpersonen', 'url' => '/api/contactpersonen/organisation/{organisationId}', 'verb' => 'GET'],
diff --git a/composer.json b/composer.json
index c1bcee448..82718f974 100644
--- a/composer.json
+++ b/composer.json
@@ -83,7 +83,7 @@
"conduction/hydra-gates": "^1.8.2",
"cyclonedx/cyclonedx-php-composer": "^6.2",
"edgedesign/phpqa": "^1.27",
- "guzzlehttp/guzzle": "^7.8",
+ "guzzlehttp/guzzle": "^8.2",
"nextcloud/ocp": "^35.0",
"phpcsstandards/phpcsextra": "^1.4",
"phpmd/phpmd": "^2.15",
diff --git a/composer.lock b/composer.lock
index 6e692867d..469b12f75 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a391ad10f944770d775e3219238670b6",
+ "content-hash": "bcff2a5dd5dacf3fcc7a22fef731e2ab",
"packages": [
{
"name": "adbario/php-dot-notation",
@@ -2792,16 +2792,16 @@
},
{
"name": "conduction/hydra-gates",
- "version": "v1.16.1",
+ "version": "v1.18.0",
"source": {
"type": "git",
"url": "https://github.com/ConductionNL/.github.git",
- "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358"
+ "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ConductionNL/.github/zipball/bfb34cc6caa9762f6aa3da66f9a2b573f8442358",
- "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358",
+ "url": "https://api.github.com/repos/ConductionNL/.github/zipball/477f930e84f1b3e709d2fb645d7c303d8d39a994",
+ "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994",
"shasum": ""
},
"require": {
@@ -2840,9 +2840,9 @@
"support": {
"docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md",
"issues": "https://github.com/ConductionNL/.github/issues",
- "source": "https://github.com/ConductionNL/.github/tree/v1.16.1"
+ "source": "https://github.com/ConductionNL/.github/tree/v1.18.0"
},
- "time": "2026-09-07T08:01:54+00:00"
+ "time": "2026-09-10T10:15:53+00:00"
},
{
"name": "consolidation/annotated-command",
@@ -3915,26 +3915,27 @@
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.15.2",
+ "version": "8.2.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "744101956d78b7c1384d0cbf379db13e859167bf"
+ "reference": "93939470950a9b11e2e84204166ef5e048c55fe4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf",
- "reference": "744101956d78b7c1384d0cbf379db13e859167bf",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4",
+ "reference": "93939470950a9b11e2e84204166ef5e048c55fe4",
"shasum": ""
},
"require": {
"ext-json": "*",
- "guzzlehttp/promises": "^2.5.1",
- "guzzlehttp/psr7": "^2.13",
- "php": "^7.2.5 || ^8.0",
+ "guzzlehttp/promises": "^3.0.2",
+ "guzzlehttp/psr7": "^3.1",
+ "php": "^7.4 || ^8.0",
"psr/http-client": "^1.0",
- "symfony/deprecation-contracts": "^2.5 || ^3.0",
- "symfony/polyfill-php80": "^1.25"
+ "psr/http-factory": "^1.0",
+ "symfony/polyfill-php80": "^1.25",
+ "symfony/polyfill-php82": "^1.27"
},
"provide": {
"psr/http-client-implementation": "1.0"
@@ -3942,10 +3943,10 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
- "guzzle/client-integration-tests": "3.0.3",
- "guzzlehttp/test-server": "^0.7",
+ "guzzle/client-integration-tests": "4.0.1",
+ "guzzlehttp/test-server": "^1.0.1",
"php-http/message-factory": "^1.1",
- "phpunit/phpunit": "^8.5.52 || ^9.6.34",
+ "phpunit/phpunit": "^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@@ -3961,9 +3962,6 @@
}
},
"autoload": {
- "files": [
- "src/functions_include.php"
- ],
"psr-4": {
"GuzzleHttp\\": "src/"
}
@@ -4023,7 +4021,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.15.2"
+ "source": "https://github.com/guzzle/guzzle/tree/8.2.0"
},
"funding": [
{
@@ -4039,29 +4037,28 @@
"type": "tidelift"
}
],
- "time": "2026-07-26T23:23:20+00:00"
+ "time": "2026-09-06T13:55:09+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.5.1",
+ "version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29"
+ "reference": "42118e66a53c492effaf92bc357e931985d5c6f9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29",
- "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9",
+ "reference": "42118e66a53c492effaf92bc357e931985d5c6f9",
"shasum": ""
},
"require": {
- "php": "^7.2.5 || ^8.0",
- "symfony/deprecation-contracts": "^2.5 || ^3.0"
+ "php": "^7.4 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.52 || ^9.6.34"
+ "phpunit/phpunit": "^9.6.34"
},
"type": "library",
"extra": {
@@ -4107,7 +4104,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.5.1"
+ "source": "https://github.com/guzzle/promises/tree/3.0.2"
},
"funding": [
{
@@ -4123,39 +4120,39 @@
"type": "tidelift"
}
],
- "time": "2026-07-08T15:48:39+00:00"
+ "time": "2026-08-24T10:00:26+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.13.0",
+ "version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4"
+ "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4",
- "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74",
+ "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74",
"shasum": ""
},
"require": {
- "php": "^7.2.5 || ^8.0",
- "psr/http-factory": "^1.0",
- "psr/http-message": "^1.1 || ^2.0",
- "ralouphie/getallheaders": "^3.0",
- "symfony/deprecation-contracts": "^2.5 || ^3.0",
- "symfony/polyfill-php80": "^1.25"
+ "php": "^7.4 || ^8.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^2.0",
+ "symfony/polyfill-php80": "^1.25",
+ "symfony/polyfill-php82": "^1.27"
},
"provide": {
- "psr/http-factory-implementation": "1.0",
- "psr/http-message-implementation": "1.0"
+ "psr/http-factory-implementation": "1.1",
+ "psr/http-message-implementation": "2.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
- "phpunit/phpunit": "^8.5.52 || ^9.6.34"
+ "php-http/psr7-integration-tests": "^1.5.1",
+ "phpunit/phpunit": "^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -4226,7 +4223,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.13.0"
+ "source": "https://github.com/guzzle/psr7/tree/3.1.0"
},
"funding": [
{
@@ -4242,7 +4239,7 @@
"type": "tidelift"
}
],
- "time": "2026-07-16T22:23:49+00:00"
+ "time": "2026-08-24T11:02:13+00:00"
},
{
"name": "kubawerlos/php-cs-fixer-custom-fixers",
@@ -6535,50 +6532,6 @@
},
"time": "2023-04-04T09:54:51+00:00"
},
- {
- "name": "ralouphie/getallheaders",
- "version": "3.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/ralouphie/getallheaders.git",
- "reference": "120b605dfeb996808c31b6477290a714d356e822"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
- "reference": "120b605dfeb996808c31b6477290a714d356e822",
- "shasum": ""
- },
- "require": {
- "php": ">=5.6"
- },
- "require-dev": {
- "php-coveralls/php-coveralls": "^2.1",
- "phpunit/phpunit": "^5 || ^6.5"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/getallheaders.php"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Ralph Khattar",
- "email": "ralph.khattar@gmail.com"
- }
- ],
- "description": "A polyfill for getallheaders.",
- "support": {
- "issues": "https://github.com/ralouphie/getallheaders/issues",
- "source": "https://github.com/ralouphie/getallheaders/tree/develop"
- },
- "time": "2019-03-08T08:55:37+00:00"
- },
{
"name": "roave/security-advisories",
"version": "dev-latest",
@@ -9294,16 +9247,16 @@
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.37.0",
+ "version": "v1.43.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
+ "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
- "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
+ "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"shasum": ""
},
"require": {
@@ -9354,7 +9307,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
},
"funding": [
{
@@ -9374,7 +9327,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T16:19:22+00:00"
+ "time": "2026-09-25T15:50:05+00:00"
},
{
"name": "symfony/polyfill-php81",
@@ -9456,6 +9409,86 @@
],
"time": "2026-05-26T12:45:58+00:00"
},
+ {
+ "name": "symfony/polyfill-php82",
+ "version": "v1.43.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php82.git",
+ "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc",
+ "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php82\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-25T15:50:05+00:00"
+ },
{
"name": "symfony/polyfill-php84",
"version": "v1.43.0",
@@ -9935,16 +9968,16 @@
},
{
"name": "symfony/yaml",
- "version": "v6.4.40",
+ "version": "v6.4.48",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "68dcd1f1602dac9d9221e25729683e0ce8733f3b"
+ "reference": "91288fddb497a598b564343ea663ea272370a7dc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/68dcd1f1602dac9d9221e25729683e0ce8733f3b",
- "reference": "68dcd1f1602dac9d9221e25729683e0ce8733f3b",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/91288fddb497a598b564343ea663ea272370a7dc",
+ "reference": "91288fddb497a598b564343ea663ea272370a7dc",
"shasum": ""
},
"require": {
@@ -9987,7 +10020,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v6.4.40"
+ "source": "https://github.com/symfony/yaml/tree/v6.4.48"
},
"funding": [
{
@@ -10007,7 +10040,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-19T20:33:22+00:00"
+ "time": "2026-10-12T09:18:28+00:00"
},
{
"name": "theseer/tokenizer",
diff --git a/docs/features/README.md b/docs/features/README.md
index 7fa964f0a..c57ff8486 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -158,7 +158,7 @@ The ArchiMate integration maps GEMMA Softwarecatalogus objects to ArchiMate appl
## CMDB Import
-Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row of the two CMDB sheets ("Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB") becomes or updates a module, its vendor as a Supplier organisation, a usage that links it to the municipality and records whether maintenance is arranged, and a contact person for its owner (identity in Nextcloud Contacts, never public). A repeat import matches on APPID per municipality, so it updates instead of duplicating, and leaves applications missing from the newer export as they are. The column mapping is declarative JSON executed by OpenRegister's mapping engine.
+Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row of the two CMDB sheets ("Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB") becomes or updates a module, its vendor as a Supplier organisation, a usage that links it to the municipality and records whether maintenance is arranged, and a contact person for its owner (identity in Nextcloud Contacts, never public). A repeat import matches on APPID per municipality, so it updates instead of duplicating. By default it archives the applications that moved to the sheet "Gearchiveerde Applicaties" and moves those on no sheet any more to OpenRegister's trash, and brings either back when it returns; the admin can choose to keep them instead. The Applications and Applications in use pages list the archive under the quick filter "Archived". The column mapping is declarative JSON executed by OpenRegister's mapping engine.
See [CMDB import](cmdb-import.md) for the steps, the expected file structure, the error codes and how to adjust the mapping.
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index b9d505abc..ae0e12c14 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -17,9 +17,11 @@ updates:
in Nextcloud Contacts. Owners are never readable by the public.
All of it is stored as OpenRegister objects in the stackiq register. Import a
-newer export later and the same applications are updated, not duplicated.
+newer export later and the same applications are updated, not duplicated;
+applications that left the export are archived or moved to the trash (see
+[Repeat imports](#repeat-imports)).
-Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import)).
+Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import); archiving and deleting on a repeat import in [`openspec/changes/cmdb-import-archive-reconciliation/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/cmdb-import-archive-reconciliation)).
## Who can import
@@ -77,9 +79,16 @@ page in stackiq.
and the summary counts them as *created unpublished*. Applications that
were imported before keep their publication as it is, whichever you
choose.
-6. Press **Import**. A progress bar shows how many rows have been processed.
- **Cancel import** stops the import before the next row; rows that were
- already processed stay imported. The section says whether the server
+6. **Applications missing from the export.** **Archive or delete them** is
+ the default: an application of this municipality that is on neither CMDB
+ sheet any more is archived or moved to the trash after the last row (see
+ [Repeat imports](#repeat-imports)). Choose **Keep them as they are** to
+ leave such applications untouched.
+7. Press **Import**. A progress bar shows how many rows have been processed.
+ **Cancel import** stops the import before the next row, or, while the
+ applications are compared with the export, before the next application;
+ what was already processed stays: the rows imported, and the applications
+ archived or deleted so far. The section says whether the server
accepted the cancel; one pressed before the server has started on the
rows cannot take effect yet, and the section says so.
@@ -92,11 +101,16 @@ When the import finishes, the section shows:
- the **summary**: rows read, created, updated, unchanged, skipped, failed
and warnings, and, when publishing was off, how many applications were
- created unpublished;
+ created unpublished; after a repeat import also how many applications
+ were archived, unarchived, deleted (moved to the trash) or restored, when
+ there are any. The sentence above it, which a screen reader announces,
+ names those counts too, for example *Applications: 1 archived, 1 deleted.*;
- **warnings for the whole file**, for example an optional column that is
missing;
- the **rows** table: sheet, row number, APPID, application, outcome,
- and the reasons and warnings for that row. Filter it with **Show rows with
+ and the reasons and warnings for that row. An application archived or
+ deleted because it left the export has no row number; its sheet is
+ `Gearchiveerde Applicaties` when that sheet listed it. Filter it with **Show rows with
outcome**, and sort it by clicking a column header. It shows 100 rows at a
time; **Show 100 more rows** adds the next ones. The application name links
to the module in stackiq.
@@ -106,8 +120,15 @@ the same organisation.
## The file
-The import reads the two CMDB sheets of the export and ignores all others,
-including the `Invoer` sheets they are derived from:
+The import reads the two CMDB sheets of the export, and of the sheet
+`Gearchiveerde Applicaties` only the `APPID` column (see
+[Repeat imports](#repeat-imports)). It ignores all other sheets, including
+the `Invoer` sheets they are derived from. The archive sheet is read in every
+import, also with **Keep them as they are**, and it is bounded like the two
+CMDB sheets (see [Limits](#limits)): an archive sheet over the limits refuses
+the import even when you keep missing applications. Delete the sheet from a
+copy of the export to import it without one; the import then archives and
+never deletes.
| Sheet | What it holds | Recorded on the usage |
|---|---|---|
@@ -120,8 +141,8 @@ surrounding spaces and a trailing `:` or `⚡` do not matter, and the order of
the columns does not matter. A column that one sheet has and the other has
not (such as `Nickname`, only on `Beheerde Applicaties CMDB`) is optional on
the sheet that lacks it. Empty rows, including formatted rows below the data,
-are ignored and not counted. A sheet may hold at most 10,000 rows with data
-by default (see [Limits](#limits)).
+are ignored and not counted. A sheet, the archive sheet included, may hold at
+most 10,000 rows with data by default (see [Limits](#limits)).
Two columns are **required** on every CMDB sheet that is present: `APPID`
and `Applicatie Naam`. Every other column is optional; when one is missing,
@@ -150,6 +171,10 @@ date stored there, including one set by hand.
### Columns and where they go
+This is the mapping as it ships. On an OpenRegister with the migration-pack
+editor an administrator can change it there (see
+[Viewing and editing the mapping](#viewing-and-editing-the-mapping)).
+
| Column | Goes to | Rule |
|---|---|---|
| APPID | module external number, and the match key | required, see [Repeat imports](#repeat-imports) |
@@ -167,6 +192,7 @@ date stored there, including one set by hand.
| End-of-Life Functioneel | usage phase-out date | Excel date, stored as is |
| (the sheet), Cluster, Applicatie Eigenaar (Afdeling) | usage internal annotation | `Beheer geregeld: ja` or `nee`, the cluster and the department, joined with ` / `; written only when the usage is new or the note is empty |
| Applicatie Eigenaar (Persoon), Applicatie Eigenaar (Functie) | usage business owner (contact person) | see [Owners](#owners) |
+| Applicatie Eigenaar (E-mail), Applicatie Eigenaar (Personeelsnummer) | how the business owner is recognised | optional, placeholder names; see [Owners](#owners) |
Columns not in this table are not read at all. That includes Hostingpartij
and Leverancier (not mapped yet), the BIV and value columns (Beschikbaarheid,
@@ -213,9 +239,9 @@ colliding.
- **Known APPID, nothing changed**: nothing is saved; the row is reported
as *unchanged*. Importing the same export twice creates nothing the second
time.
-- **APPID missing from a newer export**: the application, its usage and
- its contact persons are left as they are. They are not changed, depublished
- or deleted.
+- **APPID missing from a newer export**: see
+ [Applications that left the export](#applications-that-left-the-export)
+ below.
- Each application keeps exactly one usage for the municipality.
- **Known APPID, but the application belongs to another organisation**: an
application found by its match key is only updated when the municipality
@@ -243,6 +269,61 @@ OpenRegister refuses to save it, that row is reported as *failed* with the
step that failed, and the other rows are imported. Importing again completes
the failed row.
+### Applications that left the export
+
+With **Archive or delete them** (the default; `missingRecords=archive` in
+the API), the import compares the municipality's applications with the
+export after the last row. It looks only at the municipality's own usages
+and the applications its import created, never at an application only
+another organisation uses or one added by hand. For an application whose
+APPID is on neither CMDB sheet:
+
+| The APPID is | The application and its usage |
+|---|---|
+| on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. |
+| on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. |
+| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for), or the workbook has only one of the two CMDB sheets (the applications of the other may be among those missing) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found* (for the archive sheet or the missing CMDB sheet), *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. |
+
+An archived or deleted application disappears from the stackiq lists, from
+OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
+filter **Archived** on the **Applications** and **Applications in use** pages
+lists the archive. On the **Applications** page the **Archived** tab lists
+the whole archive: it ignores the GEMMA filters (the page says so in a note above the list while that tab is active), because
+their counts and matches describe the working set, which leaves archived
+applications out. The search in the **Search** sidebar does apply to that tab. An OpenCatalogi caller finds archived applications by
+adding `_archived=true` to its request. Contact persons are never archived
+or deleted: an owner is shared with the municipality's other applications.
+An application that is already in the state the export asks for is left
+alone and not reported again.
+
+When an archived or deleted application **returns** in a later export, it is
+taken out of the archive (*unarchived*) or out of the trash (*restored*),
+and then updated as usual. No second application is created. With **Update
+existing records** off, it is skipped as `exists` and stays archived or in
+the trash. When an application was deleted by hand and a later import
+created it again, the working one is updated and the deleted one stays in
+the trash.
+
+The comparison runs only when the import went through all rows: an import
+cancelled or failed before the comparison archives or deletes nothing.
+**Cancel import** during the comparison stops it before the next
+application; what was archived or deleted until then stays so, and the
+result lists it. During the comparison the progress line reads
+*Checking the applications of this municipality against the export* without
+a count, and the bar stays full: the comparison covers every application of
+the municipality, not only the ones missing from the export, so its length
+is not known in rows. Each
+application is handled on its own; one that cannot be archived is reported
+as *failed* with the step (`archive`, `restore` or `delete`) and the others
+continue. Archiving needs module schema 0.3.9 and usage schema 1.5.7 or
+later, which declare that their objects can be archived; on an older register configuration each application fails
+with the step `archive`, and **Force Update** at the top of the stackiq admin
+settings fixes it.
+
+With **Keep them as they are** (`missingRecords=keep`), the application, its
+usage and its contact persons are left as they are: not changed, archived,
+depublished or deleted.
+
## Owners
The owner becomes a **contact person of the municipality**, never a
@@ -259,11 +340,49 @@ The identity is kept in **Nextcloud Contacts**, in a dedicated address book,
creates that address book the first time it needs it. The import looks for
an existing contact only in that address book, never in the administrator's
own address books: a personal contact who happens to have the owner's name
-is not linked to the application. The CMDB sheets have no e-mail address, so
-a contact is found by an exact match on the name in **Stackiq CMDB owners**,
-and created there when there is none. The
-stackiq contact person object only holds the link to that contact, the role
-and the municipality. The same owner on several rows is one contact person.
+is not linked to the application. Within **Stackiq CMDB owners** the import
+recognises an owner, in this order:
+
+1. by the **employee number**, when the row has one. Two people with the same
+ name stay two owners, also when they share an e-mail address, and an owner
+ whose name changes keeps their contact. The number is not stored on the
+ contact; the contact's id is a keyed hash of it, the municipality and the
+ server's secret, so the number cannot be read back from it. A value
+ without a digit other than 0, such as `-`, `0` or `n.v.t.`, is not an
+ employee number; leading zeros and a decimal `.0` are ignored.
+2. by the **e-mail address**, when the row has one and no employee number.
+3. by an exact match on the **name** otherwise.
+
+A row without an employee number never takes a contact that was created
+for an employee number, even with the same e-mail address or name: that
+contact belongs to that employee. When the server has no `secret` in
+`config.php`, employee numbers are not used (the log says so once per
+import) and owners are matched by e-mail address or name. Changing that
+`secret` gives every owner with an employee number a new contact on the
+next import; the old contacts stay.
+
+When nothing matches, the contact is created there, with the e-mail address
+when the row has one. The stackiq contact person object only holds the link
+to that contact, the role and the municipality. The same owner on several
+rows is one contact person.
+
+The e-mail address and employee number come from the optional columns
+`Applicatie Eigenaar (E-mail)` and `Applicatie Eigenaar (Personeelsnummer)`.
+Those names are placeholders: the export does not have these columns yet.
+When your export names them differently, change the source column of the
+business-owner pack in OpenRegister (**Edit in OpenRegister** in the
+**CMDB import** section); no stackiq update is needed. An export without the
+columns imports as before, without a warning. That holds for the
+placeholder names only: once you rename the columns, an export that has
+them on only one of the two CMDB sheets warns that the column is missing
+on the other. Once you add the employee number, an owner who was found by
+name or e-mail address until then gets a new contact; the old contact
+stays in **Stackiq CMDB owners**. The same happens when an owner first
+imported by name alone arrives with an e-mail address: the name-only
+contact is not found by that address, so a second contact is created next
+to it. An instance without a `secret` in `config.php` cannot use the
+employee number: owners are then matched on e-mail address or name, so
+two namesakes become one contact (the log warns once).
When the Contacts app is disabled, applications and usages are still
imported; the owners are skipped and each affected row carries a warning.
@@ -281,20 +400,21 @@ and the section shows the reason and the error code.
| Error code | What it means | What to do |
|---|---|---|
| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). |
-| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read, or split the export. |
+| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. |
| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. |
| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. |
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
-| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other. |
-| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import them one after the other. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
+| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
+| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads (the two CMDB sheets and the archive sheet, in every import) or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. |
| `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. |
| `UPLOAD_FAILED` | The file reached the server but could not be stored there. | Try again; the Nextcloud log has the details. |
-| `MISSING_RECORDS_UNSUPPORTED` | The request asked to mark or remove records missing from the export. Only keeping them is supported. | Not reachable from the section; reported for API callers. |
-| `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine is missing, or one of the mapping files is invalid. | Update OpenRegister. If you changed a mapping file, check it against the Nextcloud log. |
+| `MISSING_RECORDS_UNSUPPORTED` | The request asked for something other than `keep` or `archive` for records missing from the export, for example `mark` or `remove`. The details list the accepted values. | Not reachable from the section; reported for API callers. |
+| `ARCHIVE_UNAVAILABLE` | **Archive or delete them** was chosen, but the OpenRegister on this server cannot archive or restore objects (it is missing or too old). Nothing was read or written. | Choose **Keep them as they are** and import again, or update OpenRegister. |
+| `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine or pack store is missing, or a mapping pack is invalid or breaks a rule of the import. The message names the pack when one is at fault. | Update or enable OpenRegister. If you changed a pack, correct it in OpenRegister; see [Viewing and editing the mapping](#viewing-and-editing-the-mapping). |
| `READER_UNAVAILABLE` | The Excel reader that ships with OpenRegister cannot be loaded. | Make sure OpenRegister is installed and enabled. |
| `NOT_CONFIGURED` | The stackiq register or its schemas cannot be found. | Run **Auto Configure** at the top of the stackiq admin settings. |
| `SCHEMA_OUTDATED` | A stackiq schema lacks a property the import recognises records by, for example `externalKey` on the module schema, or the module schema is older than version 0.3.8: it has no `applicationType`, or `externalKey` lacks the rule that only an administrator may change it. Importing anyway would create every application again, or leave the import key open to every editor. The message names the schema. | Press **Force Update** at the top of the stackiq admin settings to import the register configuration again. |
@@ -316,50 +436,138 @@ page.
## Limits
-Five limits are read from `lib/Settings/cmdb-import/topdesk-profile.json` on
+Six limits are read from `lib/Settings/cmdb-import/topdesk-profile.json` on
every import:
| Setting | Default | What it limits |
|---|---|---|
| `maxFileBytes` | `10485760` (10 MB) | the size of the uploaded file |
-| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet |
+| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet, and on the archive sheet |
| `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed |
-| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet |
+| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet; the archive sheet is read, so it is bounded like a CMDB sheet |
| `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed |
-| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per CMDB sheet before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
+| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per sheet the import reads (the two CMDB sheets and the archive sheet, also when the workbook has no archive sheet) before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
The section's help text shows the defaults; when the server refuses a file,
the message shows the limit the server applied. A larger file also has to
pass PHP's `upload_max_filesize` and `post_max_size` and the web server's
-request size limit. Like the mapping files, the profile is part of the app:
-a change made on the server is overwritten by the next app update.
-
-## Adjusting the mapping
-
-The mapping from columns to fields is not in code. It is a set of JSON files
-in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine:
-
-| File | What it maps |
+request size limit. The profile is part of the app: a change made on the
+server is overwritten by the next app update.
+
+## Viewing and editing the mapping
+
+**What you need.** Editing the mapping needs an OpenRegister with the
+migration-pack editor, whose pack writes are admin-only and need a CSRF
+token (added in [openregister#4602](https://github.com/ConductionNL/openregister/pull/4602);
+the first OpenRegister release that contains it). On an older OpenRegister
+nothing is stored in OpenRegister and there is nothing to edit: the import
+runs the packs that ship with stackiq, and the import result says so.
+Changing those shipped files on the server does not last, because the next
+app update replaces them. Once the packs have been stored in OpenRegister,
+the import does not fall back to the shipped copies: when OpenRegister's
+pack store is gone, it refuses with `MAPPING_UNAVAILABLE` until OpenRegister
+is back.
+
+The mapping from export columns to stackiq fields is not in code. It is a
+set of mapping packs that OpenRegister's mapping engine runs, one per kind of
+record the import writes. With the editor, four of them are kept in
+OpenRegister, where you can view and change them:
+
+| Pack (in OpenRegister) | What it maps |
|---|---|
-| `topdesk-profile.json` | the sheets, the constant each sheet adds to its rows (`Beheer`) and the columns it is known to lack, the match column, the required, date and id columns, the placeholder values that mean empty, the limits, and which pack is used for which target |
-| `topdesk-module.json` | a row to the module (hosting model and BBN lookups) |
-| `topdesk-manufacturer.json` | "Vendor" to the Supplier organisation |
-| `topdesk-municipality.json` | a typed municipality name to a new organisation |
-| `topdesk-usage.json` | a row to the usage (status and TIME lookups, dates, annotation) |
-| `topdesk-business-owner.json` | the owner columns |
-
-Each pack has a list of `fieldMappings`, one per column: `source` (the column
-name in the export), `target` (the field), optionally `required`, and a
-`transform` such as `trim`, `date` or a `lookup` with a `map` of export values
-to stored values. For example, to accept a new "Applicatiesoort" value, add
-it to the `map` of the hosting-model lookup in `topdesk-module.json`:
-
-```json
-"Cloud": ["SaaS"]
-```
-
-To accept a new "Applicatie Status" value, add it to the `map` of the status
-lookup in `topdesk-usage.json`. The packs are checked by OpenRegister when an import
-starts; an invalid pack stops the import with `MAPPING_UNAVAILABLE` before
-any row is read. A mapping file changed on the server is overwritten by the
-next app update, so propose lasting changes to the app itself.
+| `stackiq-topdesk-module` | a row to the application (hosting model and BBN lookups, dates) |
+| `stackiq-topdesk-manufacturer` | "Vendor" to the Supplier organisation |
+| `stackiq-topdesk-usage` | a row to the usage (status and TIME lookups, phase-out date, internal note) |
+| `stackiq-topdesk-business-owner` | the owner columns |
+
+When OpenRegister has the editor, stackiq puts these four packs into
+OpenRegister when it is installed or updated, and an import stores any that
+is missing. From then on the import runs what is saved in OpenRegister. The
+mapping endpoint (`GET /index.php/apps/stackiq/api/settings/cmdb-import/mapping`,
+Nextcloud admins only) answers, per pack, where the import takes it from
+(OpenRegister, or the copy that ships with stackiq), its version and the
+link to its editor in OpenRegister. Reading it changes nothing: it does not
+store or update a pack.
+
+**In the settings.** Open **Administration settings → Stackiq → CMDB import**
+and press **Mapping** under the import form. One table lists the sheets the
+import reads and its match, name, required and date columns; a second table
+lists the four packs, each with where the import takes it from, its version
+and, for a pack kept in OpenRegister, an **Edit in OpenRegister** link. The
+municipality pack is not listed: it maps the municipality you choose for the
+import, not a column. Opening the page changes nothing.
+
+Each pack has a list of field mappings, one per column: the column name in
+the export, the field it goes to, whether it is required, and a
+transformation such as trim, a date, or a lookup table of export values to
+stored values. For example, to accept a new "Applicatie Status" value, add it
+to the lookup table of the status mapping in `stackiq-topdesk-usage`. To
+accept a new "Applicatiesoort" value as a hosting model, add it to the lookup
+table of the hosting-model mapping in `stackiq-topdesk-module`, for example
+`Cloud` → `SaaS`. When a pack maps a column with a date transformation, the
+import reads that column as an Excel date.
+
+**What the import refuses.** The import writes past OpenRegister's access
+rules, so it checks every pack before it reads a row. It refuses a pack, with
+`MAPPING_UNAVAILABLE` and the name and id of the pack, when:
+
+- OpenRegister finds the pack invalid;
+- the application name, the APPID (to the source number), the vendor name or
+ the owner name is no longer mapped, or no longer marked required;
+- a mapping or a default writes a field the pack may not write. A pack may
+ write any plain field of the record it writes (the application, the
+ organisation or the usage), as the register defines it; the owner pack
+ writes the name and the role only. Refused are the fields the import sets
+ itself (the import key, the provider, the consumer, the application of a
+ usage, the business owner, the type of an organisation: a Supplier stays a
+ Supplier), every link to another record, files, the publication dates, the
+ fields the register hides from forms or calculates (the service desk
+ exchange's keys and stamps, the suggested TIME classification) and the
+ installed version the service desk owns, and the fields only an
+ administrator or the catalogue sets, such as an organisation's status and
+ who registered it. The status and registration
+ defaults of the vendor and municipality packs stay as shipped.
+
+The mapping endpoint answers the same reason, with the link to the pack's
+editor in `details.editUrl`, and the **Mapping** block shows it with an
+**Edit in OpenRegister** link, so you can correct the pack there. After saving
+it, reload the settings page: **Mapping** then shows the new version, or the
+reason the pack is still refused.
+
+**What the Mapping block shows.** Below the table of packs, each pack has a
+collapsible block "Columns and fields": which column of the export goes to
+which field, whether the mapping is required and which transform it uses, and
+the rules for what the import writes (the values it sets, the fields written
+only on a new record, the fields it never writes on an existing record). When
+an unedited pack in OpenRegister is older than the shipped one, the row says
+"Update pending": the next import replaces it with the shipped version.
+
+**What a pack does not change.** Which sheets are read, the APPID and
+"Applicatie Naam" columns, the values that mean empty (`NB`), the constant
+each sheet adds (`Beheer geregeld: ja` or `nee`) and the limits are part of
+the app (`lib/Settings/cmdb-import/topdesk-profile.json`) and cannot be
+changed; the **Mapping** block shows the sheets and the match, name, required
+and date columns, not the empty values, the constants or the limits. The
+municipality is mapped from the name you choose for the import, not from the
+export; that pack is not stored in OpenRegister and the block does not list it.
+
+**Reading more columns.** The import reads only the columns the mapping
+names. When you map a new column in a pack, the import reads that column from
+then on.
+
+**One mapping per Nextcloud.** The packs in OpenRegister apply to every
+import on this Nextcloud, whatever the municipality.
+
+**App updates.** A pack you have not changed follows the app: when an update
+ships a newer version, the stored pack is replaced by it (the mapping
+endpoint already shows the new pack, with `updatePending` true, before the
+next import runs). A pack you changed
+is never overwritten, also not when you save it while an import is updating
+it. When an update ships a newer version of a pack you changed, the mapping
+endpoint reports it (`newerShipped`) and the **Mapping** block says so; compare the two and copy what you need
+into your pack in OpenRegister.
+
+**Back to the shipped pack.** Delete the pack in OpenRegister. The next
+import, or the next app update, puts the shipped pack back. Do not rename a
+pack: the import finds it by its id (such as `stackiq-topdesk-usage`), and
+refuses to run while a pack it stored has another id.
diff --git a/l10n/en.js b/l10n/en.js
index 70c3bfcef..f398bcc42 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -508,8 +508,6 @@ OC.L10N.register(
"Organisation or new status is missing": "Organisation or new status is missing",
"An error occurred while changing the status": "An error occurred while changing the status",
"Search": "Search",
- "Search applications and services…": "Search applications and services…",
- "Clear search": "Clear search",
"Saved views": "Saved views",
"Save current filters as view": "Save current filters as view",
"GEMMA filters": "GEMMA filters",
@@ -974,7 +972,6 @@ OC.L10N.register(
"Error code: {code}": "Error code: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.",
"Failed": "Failed",
"Import": "Import",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses",
@@ -982,7 +979,7 @@ OC.L10N.register(
"Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
"Import progress": "Import progress",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -991,15 +988,14 @@ OC.L10N.register(
"No rows with this outcome": "No rows with this outcome",
"Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.",
"Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.",
- "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.",
+ "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.": "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.",
"Outcome": "Outcome",
"Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.",
- "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.",
"Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.",
"Reasons and warnings": "Reasons and warnings",
"Records missing from the export can only be kept.": "Records missing from the export can only be kept.",
"Reload the page and try again.": "Reload the page and try again.",
- "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.",
"Rows": "Rows",
@@ -1017,7 +1013,7 @@ OC.L10N.register(
"The import failed unexpectedly.": "The import failed unexpectedly.",
"The import mapping cannot run.": "The import mapping cannot run.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.",
- "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications processed before it stopped are kept.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.",
"The server could not be reached.": "The server could not be reached.",
"The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".",
@@ -1038,7 +1034,23 @@ OC.L10N.register(
"The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.",
"Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.",
"No running CMDB import has this id.": "No running CMDB import has this id.",
- "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.",
+ "Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
+ "Unarchived": "Unarchived",
+ "Restored": "Restored",
+ "Archived": "Archived",
+ "Deleted": "Deleted",
+ "Applications missing from the export": "Applications missing from the export",
+ "Archive or delete them": "Archive or delete them",
+ "Keep them as they are": "Keep them as they are",
+ "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.",
+ "Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.",
@@ -1046,7 +1058,10 @@ OC.L10N.register(
"The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.",
"The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.",
"The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.",
"Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optional column \"%s\" not found",
@@ -1086,15 +1101,13 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
@@ -1125,12 +1138,84 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
- "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads."
+ "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
+ "In development since": "In development since",
+ "In use since": "In use since",
+ "Application (module)": "Application (module)",
+ "Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
+ "Business owner (contact person)": "Business owner (contact person)",
+ "Loading the mapping…": "Loading the mapping…",
+ "The mapping could not be loaded.": "The mapping could not be loaded.",
+ "Sheets read": "Sheets read",
+ "Match column": "Match column",
+ "Name column": "Name column",
+ "Required columns": "Required columns",
+ "Date columns": "Date columns",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Mapping": "Mapping",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Shipped file",
+ "Version {version}": "Version {version}",
+ "Version {version} (shipped: {shipped})": "Version {version} (shipped: {shipped})",
+ "Edited": "Edited",
+ "Edit in OpenRegister": "Edit in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Edit in OpenRegister: {target} (opens in a new tab)",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
+ "The sheet {sheets} is read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Expected the sheet {sheets}. Sheet names must match exactly.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Expected one of the sheets {sheets}. Sheet names must match exactly.",
+ "Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of \"{archive}\"), and import that copy.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.",
+ "A pack deleted in OpenRegister is restored from the shipped file at the next import.": "A pack deleted in OpenRegister is restored from the shipped file at the next import.",
+ "Choose a municipality, or type a new name and press Enter.": "Choose a municipality, or type a new name and press Enter.",
+ "Kind of record": "Kind of record",
+ "New applications become visible to everyone, also without logging in.": "New applications become visible to everyone, also without logging in.",
+ "Overwrites applications imported before with the values from the export. The (i) button above lists the fields.": "Overwrites applications imported before with the values from the export. The (i) button above lists the fields.",
+ "Stored in": "Stored in",
+ "The export": "The export",
+ "Which columns of the export the next import reads, and where each column goes.": "Which columns of the export the next import reads, and where each column goes.",
+ "Packs": "Packs",
+ "Update pending: shipped {version} replaces stored {storedVersion} at the next import.": "Update pending: shipped {version} replaces stored {storedVersion} at the next import.",
+ "Columns and fields: {target}": "Columns and fields: {target}",
+ "Which column of the export goes to which field": "Which column of the export goes to which field",
+ "Column of the export": "Column of the export",
+ "Field": "Field",
+ "Transform": "Transform",
+ "Yes": "Yes",
+ "No": "No",
+ "What the import writes": "What the import writes",
+ "Set on every record": "Set on every record",
+ "Set on a new record only": "Set on a new record only",
+ "Written on a new record, or on an existing one only when empty": "Written on a new record, or on an existing one only when empty",
+ "Never written on an existing record": "Never written on an existing record",
+ "The mapping could not be loaded. Open Mapping for the reason.": "The mapping could not be loaded. Open Mapping for the reason.",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters.": "This tab lists the whole archive. It ignores the GEMMA filters.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.",
+ "Not signed in": "Not signed in",
+ "At most %s contact persons per request": "At most %s contact persons per request",
+ "Loading the name": "Loading the name",
+ "The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.",
+ "This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.",
+ "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read.",
+ "Archive them": "Archive them",
+ "Applications no longer in the export are archived. The (i) button above explains when.": "Applications no longer in the export are archived. The (i) button above explains when.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.",
+ "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index c1996692c..3ca802f32 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -507,8 +507,6 @@
"Organisation or new status is missing": "Organisation or new status is missing",
"An error occurred while changing the status": "An error occurred while changing the status",
"Search": "Search",
- "Search applications and services…": "Search applications and services…",
- "Clear search": "Clear search",
"Saved views": "Saved views",
"Save current filters as view": "Save current filters as view",
"GEMMA filters": "GEMMA filters",
@@ -973,7 +971,6 @@
"Error code: {code}": "Error code: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.",
"Failed": "Failed",
"Import": "Import",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses",
@@ -981,7 +978,7 @@
"Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
"Import progress": "Import progress",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -990,15 +987,14 @@
"No rows with this outcome": "No rows with this outcome",
"Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.",
"Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.",
- "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.",
+ "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.": "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.",
"Outcome": "Outcome",
"Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.",
- "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.",
"Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.",
"Reasons and warnings": "Reasons and warnings",
"Records missing from the export can only be kept.": "Records missing from the export can only be kept.",
"Reload the page and try again.": "Reload the page and try again.",
- "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.",
"Rows": "Rows",
@@ -1016,7 +1012,7 @@
"The import failed unexpectedly.": "The import failed unexpectedly.",
"The import mapping cannot run.": "The import mapping cannot run.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.",
- "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications processed before it stopped are kept.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.",
"The server could not be reached.": "The server could not be reached.",
"The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".",
@@ -1037,7 +1033,23 @@
"The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.",
"Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.",
"No running CMDB import has this id.": "No running CMDB import has this id.",
- "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.",
+ "Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
+ "Unarchived": "Unarchived",
+ "Restored": "Restored",
+ "Archived": "Archived",
+ "Deleted": "Deleted",
+ "Applications missing from the export": "Applications missing from the export",
+ "Archive or delete them": "Archive or delete them",
+ "Keep them as they are": "Keep them as they are",
+ "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.",
+ "Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.",
@@ -1045,7 +1057,10 @@
"The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.",
"The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.",
"The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.",
"Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optional column \"%s\" not found",
@@ -1085,15 +1100,13 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
@@ -1124,11 +1137,83 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
- "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads."
+ "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
+ "In development since": "In development since",
+ "In use since": "In use since",
+ "Application (module)": "Application (module)",
+ "Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
+ "Business owner (contact person)": "Business owner (contact person)",
+ "Loading the mapping…": "Loading the mapping…",
+ "The mapping could not be loaded.": "The mapping could not be loaded.",
+ "Sheets read": "Sheets read",
+ "Match column": "Match column",
+ "Name column": "Name column",
+ "Required columns": "Required columns",
+ "Date columns": "Date columns",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Mapping": "Mapping",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Shipped file",
+ "Version {version}": "Version {version}",
+ "Version {version} (shipped: {shipped})": "Version {version} (shipped: {shipped})",
+ "Edited": "Edited",
+ "Edit in OpenRegister": "Edit in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Edit in OpenRegister: {target} (opens in a new tab)",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
+ "The sheet {sheets} is read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Expected the sheet {sheets}. Sheet names must match exactly.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Expected one of the sheets {sheets}. Sheet names must match exactly.",
+ "Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of \"{archive}\"), and import that copy.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.",
+ "A pack deleted in OpenRegister is restored from the shipped file at the next import.": "A pack deleted in OpenRegister is restored from the shipped file at the next import.",
+ "Choose a municipality, or type a new name and press Enter.": "Choose a municipality, or type a new name and press Enter.",
+ "Kind of record": "Kind of record",
+ "New applications become visible to everyone, also without logging in.": "New applications become visible to everyone, also without logging in.",
+ "Overwrites applications imported before with the values from the export. The (i) button above lists the fields.": "Overwrites applications imported before with the values from the export. The (i) button above lists the fields.",
+ "Stored in": "Stored in",
+ "The export": "The export",
+ "Which columns of the export the next import reads, and where each column goes.": "Which columns of the export the next import reads, and where each column goes.",
+ "Packs": "Packs",
+ "Update pending: shipped {version} replaces stored {storedVersion} at the next import.": "Update pending: shipped {version} replaces stored {storedVersion} at the next import.",
+ "Columns and fields: {target}": "Columns and fields: {target}",
+ "Which column of the export goes to which field": "Which column of the export goes to which field",
+ "Column of the export": "Column of the export",
+ "Field": "Field",
+ "Transform": "Transform",
+ "Yes": "Yes",
+ "No": "No",
+ "What the import writes": "What the import writes",
+ "Set on every record": "Set on every record",
+ "Set on a new record only": "Set on a new record only",
+ "Written on a new record, or on an existing one only when empty": "Written on a new record, or on an existing one only when empty",
+ "Never written on an existing record": "Never written on an existing record",
+ "The mapping could not be loaded. Open Mapping for the reason.": "The mapping could not be loaded. Open Mapping for the reason.",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters.": "This tab lists the whole archive. It ignores the GEMMA filters.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.",
+ "Not signed in": "Not signed in",
+ "At most %s contact persons per request": "At most %s contact persons per request",
+ "Loading the name": "Loading the name",
+ "The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.",
+ "This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.",
+ "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read.",
+ "Archive them": "Archive them",
+ "Applications no longer in the export are archived. The (i) button above explains when.": "Applications no longer in the export are archived. The (i) button above explains when.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.",
+ "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index b2c3a6257..6044f4669 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -545,8 +545,6 @@ OC.L10N.register(
"Group members": "Groepsleden",
"Merge organisations": "Organisaties samenvoegen",
"Search": "Zoeken",
- "Search applications and services…": "Zoek applicaties en diensten…",
- "Clear search": "Zoekopdracht wissen",
"Saved views": "Opgeslagen weergaven",
"Save current filters as view": "Huidige filters opslaan als weergave",
"GEMMA filters": "GEMMA-filters",
@@ -1042,7 +1040,6 @@ OC.L10N.register(
"Error code: {code}": "Foutcode: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.",
"Failed": "Mislukt",
"Import": "Importeren",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt",
@@ -1050,7 +1047,7 @@ OC.L10N.register(
"Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
"Import progress": "Voortgang van de import",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1059,15 +1056,14 @@ OC.L10N.register(
"No rows with this outcome": "Geen rijen met dit resultaat",
"Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.",
"Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.",
- "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.",
+ "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt, of een mappingpakket is ongeldig of breekt een regel van de import. De reden hieronder noemt het pakket: herstel het in OpenRegister, of bekijk het Nextcloud-logboek.",
"Outcome": "Resultaat",
"Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.",
- "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.",
"Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.",
"Reasons and warnings": "Redenen en waarschuwingen",
"Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.",
"Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.",
- "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.",
"Rows": "Rijen",
@@ -1085,7 +1081,7 @@ OC.L10N.register(
"The import failed unexpectedly.": "De import is onverwacht mislukt.",
"The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.",
- "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties die vóór het stoppen zijn verwerkt, blijven behouden.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.",
"The server could not be reached.": "De server is niet bereikbaar.",
"The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".",
@@ -1106,7 +1102,23 @@ OC.L10N.register(
"The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.",
"Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.",
"No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.",
- "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.",
+ "Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in %2$s rijen (%3$s), dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Unarchived": "Uit archief gehaald",
+ "Restored": "Hersteld",
+ "Archived": "Gearchiveerd",
+ "Deleted": "Verwijderd",
+ "Applications missing from the export": "Applicaties die in de export ontbreken",
+ "Archive or delete them": "Archiveren of verwijderen",
+ "Keep them as they are": "Laat ze zoals ze zijn",
+ "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd. Ze verdwijnt uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Kies Laat ze zoals ze zijn om zulke applicaties ongemoeid te laten.",
+ "Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.",
@@ -1114,7 +1126,10 @@ OC.L10N.register(
"The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.",
"The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "De mapping van de import kan niet worden uitgevoerd: het migratiepakket %s in OpenRegister is ongeldig of kan niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "De opslag voor migratiepakketten van OpenRegister is niet beschikbaar, dus deze import gebruikte de mapping die met stackiq wordt meegeleverd.",
"The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.",
"Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden",
@@ -1154,15 +1169,13 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
@@ -1193,12 +1206,84 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
- "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest."
+ "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
+ "In development since": "In ontwikkeling sinds",
+ "In use since": "In gebruik sinds",
+ "Application (module)": "Applicatie (module)",
+ "Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
+ "Business owner (contact person)": "Business owner (contactpersoon)",
+ "Loading the mapping…": "Mapping laden…",
+ "The mapping could not be loaded.": "De mapping kon niet worden geladen.",
+ "Sheets read": "Gelezen tabbladen",
+ "Match column": "Koppelkolom",
+ "Name column": "Naamkolom",
+ "Required columns": "Verplichte kolommen",
+ "Date columns": "Datumkolommen",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Mapping": "Mapping",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "De packopslag van OpenRegister is niet beschikbaar, dus de import gebruikt de mapping die met stackiq wordt meegeleverd. Werk OpenRegister bij of schakel het in om de mapping daar te bekijken en te bewerken.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Meegeleverd bestand",
+ "Version {version}": "Versie {version}",
+ "Version {version} (shipped: {shipped})": "Versie {version} (meegeleverd: {shipped})",
+ "Edited": "Bewerkt",
+ "Edit in OpenRegister": "Bewerken in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Bewerken in OpenRegister: {target} (opent in een nieuw tabblad)",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq levert een nieuwere versie van dit pack mee ({version}). Die is niet toegepast, omdat het pack in OpenRegister is bewerkt. Verwijder het pack daar om vanaf de volgende import de meegeleverde versie te gebruiken; de wijzigingen gaan dan verloren.",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "The sheet {sheets} is read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Verwacht werd het tabblad {sheets}. De naam van het tabblad moet precies overeenkomen.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Verwacht werd een van de tabbladen {sheets}. De namen van de tabbladen moeten precies overeenkomen.",
+ "Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest {sheets}, en de kolom APPID van \"{archive}\"), en importeer die kopie.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen {sheets}), en importeer die kopie.",
+ "A pack deleted in OpenRegister is restored from the shipped file at the next import.": "Een pack dat in OpenRegister is verwijderd, wordt bij de volgende import hersteld uit het meegeleverde bestand.",
+ "Choose a municipality, or type a new name and press Enter.": "Kies een gemeente, of typ een nieuwe naam en druk op Enter.",
+ "Kind of record": "Soort gegeven",
+ "New applications become visible to everyone, also without logging in.": "Nieuwe applicaties worden voor iedereen zichtbaar, ook zonder in te loggen.",
+ "Overwrites applications imported before with the values from the export. The (i) button above lists the fields.": "Overschrijft eerder geïmporteerde applicaties met de waarden uit de export. De (i)-knop bovenaan noemt de velden.",
+ "Stored in": "Opgeslagen in",
+ "The export": "De export",
+ "Which columns of the export the next import reads, and where each column goes.": "Welke kolommen van de export de volgende import leest, en waar elke kolom heen gaat.",
+ "Packs": "Packs",
+ "Update pending: shipped {version} replaces stored {storedVersion} at the next import.": "Update in afwachting: de meegeleverde versie {version} vervangt de opgeslagen versie {storedVersion} bij de volgende import.",
+ "Columns and fields: {target}": "Kolommen en velden: {target}",
+ "Which column of the export goes to which field": "Welke kolom van de export naar welk veld gaat",
+ "Column of the export": "Kolom van de export",
+ "Field": "Veld",
+ "Transform": "Omzetting",
+ "Yes": "Ja",
+ "No": "Nee",
+ "What the import writes": "Wat de import schrijft",
+ "Set on every record": "Op elk record gezet",
+ "Set on a new record only": "Alleen op een nieuw record gezet",
+ "Written on a new record, or on an existing one only when empty": "Op een nieuw record geschreven, of op een bestaand record alleen als de waarde leeg is",
+ "Never written on an existing record": "Nooit geschreven op een bestaand record",
+ "The mapping could not be loaded. Open Mapping for the reason.": "De mapping kon niet worden geladen. Open Mapping voor de reden.",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters.": "Dit tabblad toont het hele archief. De GEMMA-filters worden genegeerd.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer.",
+ "Not signed in": "Niet aangemeld",
+ "At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek",
+ "Loading the name": "De naam wordt geladen",
+ "The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.",
+ "This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.",
+ "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen.",
+ "Archive them": "Archiveren",
+ "Applications no longer in the export are archived. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd. De (i)-knop bovenaan legt uit wanneer.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn.",
+ "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd als het tabblad \"{archive}\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, of mist de werkmap een van de CMDB-tabbladen, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index e226a52ca..d1ecd4eec 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -544,8 +544,6 @@
"Group members": "Groepsleden",
"Merge organisations": "Organisaties samenvoegen",
"Search": "Zoeken",
- "Search applications and services…": "Zoek applicaties en diensten…",
- "Clear search": "Zoekopdracht wissen",
"Saved views": "Opgeslagen weergaven",
"Save current filters as view": "Huidige filters opslaan als weergave",
"GEMMA filters": "GEMMA-filters",
@@ -1041,7 +1039,6 @@
"Error code: {code}": "Foutcode: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.",
"Failed": "Mislukt",
"Import": "Importeren",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt",
@@ -1049,7 +1046,7 @@
"Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
"Import progress": "Voortgang van de import",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1058,15 +1055,14 @@
"No rows with this outcome": "Geen rijen met dit resultaat",
"Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.",
"Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.",
- "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.",
+ "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt, of een mappingpakket is ongeldig of breekt een regel van de import. De reden hieronder noemt het pakket: herstel het in OpenRegister, of bekijk het Nextcloud-logboek.",
"Outcome": "Resultaat",
"Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.",
- "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.",
"Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.",
"Reasons and warnings": "Redenen en waarschuwingen",
"Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.",
"Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.",
- "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.",
"Rows": "Rijen",
@@ -1084,7 +1080,7 @@
"The import failed unexpectedly.": "De import is onverwacht mislukt.",
"The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.",
- "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties die vóór het stoppen zijn verwerkt, blijven behouden.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.",
"The server could not be reached.": "De server is niet bereikbaar.",
"The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".",
@@ -1105,7 +1101,23 @@
"The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.",
"Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.",
"No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.",
- "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.",
+ "Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in %2$s rijen (%3$s), dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Unarchived": "Uit archief gehaald",
+ "Restored": "Hersteld",
+ "Archived": "Gearchiveerd",
+ "Deleted": "Verwijderd",
+ "Applications missing from the export": "Applicaties die in de export ontbreken",
+ "Archive or delete them": "Archiveren of verwijderen",
+ "Keep them as they are": "Laat ze zoals ze zijn",
+ "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd. Ze verdwijnt uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Kies Laat ze zoals ze zijn om zulke applicaties ongemoeid te laten.",
+ "Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.",
@@ -1113,7 +1125,10 @@
"The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.",
"The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "De mapping van de import kan niet worden uitgevoerd: het migratiepakket %s in OpenRegister is ongeldig of kan niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "De opslag voor migratiepakketten van OpenRegister is niet beschikbaar, dus deze import gebruikte de mapping die met stackiq wordt meegeleverd.",
"The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.",
"Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden",
@@ -1153,15 +1168,13 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
@@ -1192,11 +1205,83 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
- "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest."
+ "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
+ "In development since": "In ontwikkeling sinds",
+ "In use since": "In gebruik sinds",
+ "Application (module)": "Applicatie (module)",
+ "Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
+ "Business owner (contact person)": "Business owner (contactpersoon)",
+ "Loading the mapping…": "Mapping laden…",
+ "The mapping could not be loaded.": "De mapping kon niet worden geladen.",
+ "Sheets read": "Gelezen tabbladen",
+ "Match column": "Koppelkolom",
+ "Name column": "Naamkolom",
+ "Required columns": "Verplichte kolommen",
+ "Date columns": "Datumkolommen",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Mapping": "Mapping",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "De packopslag van OpenRegister is niet beschikbaar, dus de import gebruikt de mapping die met stackiq wordt meegeleverd. Werk OpenRegister bij of schakel het in om de mapping daar te bekijken en te bewerken.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Meegeleverd bestand",
+ "Version {version}": "Versie {version}",
+ "Version {version} (shipped: {shipped})": "Versie {version} (meegeleverd: {shipped})",
+ "Edited": "Bewerkt",
+ "Edit in OpenRegister": "Bewerken in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Bewerken in OpenRegister: {target} (opent in een nieuw tabblad)",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq levert een nieuwere versie van dit pack mee ({version}). Die is niet toegepast, omdat het pack in OpenRegister is bewerkt. Verwijder het pack daar om vanaf de volgende import de meegeleverde versie te gebruiken; de wijzigingen gaan dan verloren.",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "The sheet {sheets} is read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Verwacht werd het tabblad {sheets}. De naam van het tabblad moet precies overeenkomen.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Verwacht werd een van de tabbladen {sheets}. De namen van de tabbladen moeten precies overeenkomen.",
+ "Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest {sheets}, en de kolom APPID van \"{archive}\"), en importeer die kopie.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen {sheets}), en importeer die kopie.",
+ "A pack deleted in OpenRegister is restored from the shipped file at the next import.": "Een pack dat in OpenRegister is verwijderd, wordt bij de volgende import hersteld uit het meegeleverde bestand.",
+ "Choose a municipality, or type a new name and press Enter.": "Kies een gemeente, of typ een nieuwe naam en druk op Enter.",
+ "Kind of record": "Soort gegeven",
+ "New applications become visible to everyone, also without logging in.": "Nieuwe applicaties worden voor iedereen zichtbaar, ook zonder in te loggen.",
+ "Overwrites applications imported before with the values from the export. The (i) button above lists the fields.": "Overschrijft eerder geïmporteerde applicaties met de waarden uit de export. De (i)-knop bovenaan noemt de velden.",
+ "Stored in": "Opgeslagen in",
+ "The export": "De export",
+ "Which columns of the export the next import reads, and where each column goes.": "Welke kolommen van de export de volgende import leest, en waar elke kolom heen gaat.",
+ "Packs": "Packs",
+ "Update pending: shipped {version} replaces stored {storedVersion} at the next import.": "Update in afwachting: de meegeleverde versie {version} vervangt de opgeslagen versie {storedVersion} bij de volgende import.",
+ "Columns and fields: {target}": "Kolommen en velden: {target}",
+ "Which column of the export goes to which field": "Welke kolom van de export naar welk veld gaat",
+ "Column of the export": "Kolom van de export",
+ "Field": "Veld",
+ "Transform": "Omzetting",
+ "Yes": "Ja",
+ "No": "Nee",
+ "What the import writes": "Wat de import schrijft",
+ "Set on every record": "Op elk record gezet",
+ "Set on a new record only": "Alleen op een nieuw record gezet",
+ "Written on a new record, or on an existing one only when empty": "Op een nieuw record geschreven, of op een bestaand record alleen als de waarde leeg is",
+ "Never written on an existing record": "Nooit geschreven op een bestaand record",
+ "The mapping could not be loaded. Open Mapping for the reason.": "De mapping kon niet worden geladen. Open Mapping voor de reden.",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters.": "Dit tabblad toont het hele archief. De GEMMA-filters worden genegeerd.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer.",
+ "Not signed in": "Niet aangemeld",
+ "At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek",
+ "Loading the name": "De naam wordt geladen",
+ "The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.",
+ "This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.",
+ "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen.",
+ "Archive them": "Archiveren",
+ "Applications no longer in the export are archived. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd. De (i)-knop bovenaan legt uit wanneer.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn.",
+ "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd als het tabblad \"{archive}\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, of mist de werkmap een van de CMDB-tabbladen, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten."
}
}
diff --git a/lib/BackgroundJob/ModuleVersionPublicationJob.php b/lib/BackgroundJob/ModuleVersionPublicationJob.php
index 6c5928563..7c8536883 100644
--- a/lib/BackgroundJob/ModuleVersionPublicationJob.php
+++ b/lib/BackgroundJob/ModuleVersionPublicationJob.php
@@ -19,7 +19,7 @@
* @version GIT:
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
declare(strict_types=1);
@@ -42,7 +42,7 @@
/**
* One copy of one module's publication onto its versions.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
class ModuleVersionPublicationJob extends QueuedJob {
@@ -88,7 +88,7 @@ public function __construct(
*
* @return void
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
protected function run($argument): void {
$moduleUuid = '';
diff --git a/lib/Controller/CmdbImportController.php b/lib/Controller/CmdbImportController.php
index 7d1c00a8f..8eaf1a3c2 100644
--- a/lib/Controller/CmdbImportController.php
+++ b/lib/Controller/CmdbImportController.php
@@ -86,7 +86,8 @@ public function __construct(
* Import a TOPdesk CMDB export for one municipality.
*
* Multipart fields: `cmdbFile`, `municipalityUuid` or `municipalityName`,
- * `updateExisting` (default true), `publish` (default true), `missingRecords` (only `keep`) and
+ * `updateExisting` (default true), `publish` (default true), `missingRecords` (`keep` or `archive`,
+ * default `archive`) and
* `operationId` (pattern `cmdb-` plus 8 to 64 letters, digits or hyphens).
*
* @return JSONResponse The report (200), or an error envelope with the contract code.
@@ -94,6 +95,7 @@ public function __construct(
* @auth admin-only the import writes with RBAC and multitenancy off, across tenants, so only a full Nextcloud admin may run it.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
public function import(): JSONResponse {
try {
@@ -247,15 +249,21 @@ protected function iniBytes(string $name): ?int {
* @return array{path: string, options: array}|JSONResponse The import input, or the first error.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
private function readOptions(string $path, string $fileName): array|JSONResponse {
- $missingRecords = $this->stringParam(name: 'missingRecords', default: 'keep');
+ $missingRecords = $this->stringParam(name: 'missingRecords', default: CmdbExportImportService::DEFAULT_MISSING_RECORDS);
if ($missingRecords === null) {
return $this->invalidField(field: 'missingRecords');
}
- if ($this->importService->supportsMissingRecords(mode: $missingRecords) === false) {
- return $this->error(code: 'MISSING_RECORDS_UNSUPPORTED', status: Http::STATUS_UNPROCESSABLE_ENTITY, details: ['accepted' => ['keep']]);
+ $accepted = $this->importService->missingRecordsModes();
+ if (in_array($missingRecords, $accepted, true) === false) {
+ return $this->error(
+ code: 'MISSING_RECORDS_UNSUPPORTED',
+ status: Http::STATUS_UNPROCESSABLE_ENTITY,
+ details: ['accepted' => $accepted]
+ );
}
// An unrecognised value is refused rather than read as true: true is the mode that overwrites.
@@ -293,6 +301,7 @@ private function readOptions(string $path, string $fileName): array|JSONResponse
'municipalityName' => $municipalityName,
'updateExisting' => $updateExisting,
'publish' => $publish,
+ 'missingRecords' => $missingRecords,
'operationId' => $this->request->getParam('operationId'),
'fileName' => basename(str_replace('\\', '/', $fileName)),
],
@@ -384,6 +393,8 @@ private function error(string $code, int $status, array $details = []): JSONResp
* @return string
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per contract error code.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
private function message(string $code, array $details): string {
$megabytes = (string)round((int)($details['maxBytes'] ?? $this->importService->maxFileBytes()) / 1048576, 1);
@@ -394,14 +405,14 @@ private function message(string $code, array $details): string {
'NO_FILE_UPLOADED' => $this->l10n->t('No file was uploaded.'),
'NOT_XLSX' => $this->l10n->t('The file is not an Excel workbook (.xlsx).'),
'FILE_TOO_LARGE' => $this->l10n->t('The file is larger than the maximum of %s MB.', [$megabytes]),
- 'MISSING_RECORDS_UNSUPPORTED' => $this->l10n->t('Only keeping records that are missing from the export is supported.'),
+ 'MISSING_RECORDS_UNSUPPORTED' => $this->l10n->t('Applications missing from the export can only be archived or kept.'),
'FIELD_INVALID' => $this->fieldMessage(field: (string)($details['field'] ?? ''), accepted: $accepted),
'MUNICIPALITY_REQUIRED' => $this->l10n->t('Choose a municipality or enter the name of a new one.'),
'MUNICIPALITY_INVALID' => $this->l10n->t('The chosen organisation is not a municipality.'),
'NO_SOURCE_SHEET' => $this->l10n->t('The workbook has neither of the sheets %s.', [$expected]),
'MISSING_COLUMN' => $this->l10n->t('Sheet "%1$s" has no column "%2$s".', [(string)($details['sheet'] ?? ''), (string)($details['column'] ?? '')]),
'TOO_MANY_ROWS' => $this->l10n->t('Sheet "%1$s" has more than %2$s rows.', [(string)($details['sheet'] ?? ''), (string)($details['limit'] ?? '')]),
- 'MAPPING_UNAVAILABLE' => $this->l10n->t('The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.'),
+ 'MAPPING_UNAVAILABLE' => $this->mappingMessage(details: $details),
'READER_UNAVAILABLE' => $this->l10n->t('The Excel reader is not available: OpenRegister is missing or incomplete.'),
'NOT_CONFIGURED' => $this->l10n->t('Stackiq is not configured: the register or its schemas cannot be found.'),
'OPERATION_NOT_FOUND' => $this->l10n->t('No running CMDB import has this id.'),
@@ -410,10 +421,38 @@ private function message(string $code, array $details): string {
'SCHEMA_OUTDATED' => $this->l10n->t('The stackiq register is out of date; import its configuration again.'),
'IMPORT_IN_PROGRESS' => $this->l10n->t('Another CMDB import is running; try again when it has finished.'),
'MUNICIPALITY_AMBIGUOUS' => $this->l10n->t('Several municipalities have this name; choose one from the list.'),
+ 'ARCHIVE_UNAVAILABLE' => $this->l10n->t(
+ 'Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.'
+ ),
default => $this->l10n->t('The import failed. The details are in the Nextcloud log.'),
};
}//end message()
+ /**
+ * The message of MAPPING_UNAVAILABLE, naming the OpenRegister migration pack when one is at fault.
+ *
+ * @param array $details The details: `pack` and `packId` for a pack stored in OpenRegister.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function mappingMessage(array $details): string {
+ $pack = ($details['pack'] ?? null);
+ if (is_string($pack) === false || $pack === '') {
+ return $this->l10n->t('The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.');
+ }
+
+ if (is_int($details['packId'] ?? null) === true) {
+ $pack .= ' (id ' . $details['packId'] . ')';
+ }
+
+ return $this->l10n->t(
+ 'The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.',
+ [$pack]
+ );
+ }//end mappingMessage()
+
/**
* The message of FIELD_INVALID, naming the accepted values when the field has a fixed set.
*
diff --git a/lib/Controller/ContactNamesController.php b/lib/Controller/ContactNamesController.php
new file mode 100644
index 000000000..ed91f9d13
--- /dev/null
+++ b/lib/Controller/ContactNamesController.php
@@ -0,0 +1,256 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Controller;
+
+use OCA\OpenRegister\Contract\ObjectServiceInterface;
+use OCA\Stackiq\Service\SettingsService;
+use OCA\Stackiq\Service\StackiqContactSyncService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\UserRateLimit;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * The display names of contact persons, for a signed-in user.
+ *
+ * A contact person in OpenRegister carries only a `contactsUid`; the name
+ * lives in Nextcloud Contacts (the CMDB import puts its owners in the
+ * importing admin's "Stackiq CMDB owners" address book). This endpoint reads
+ * the contact persons with the caller's own OpenRegister rights, then looks
+ * each `contactsUid` up, by exact UID, in the caller's own and shared address
+ * books only; the system principal's books (the local system address book
+ * and the books synced from trusted servers) never count, so a contactsUid
+ * set to a local or remote user id names nobody. A name is therefore returned only to someone who can
+ * already read both the contact person and the contact. Nothing is stored,
+ * and there is no public route.
+ *
+ * @category Controller
+ * @package OCA\Stackiq\Controller
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+class ContactNamesController extends Controller {
+
+ /**
+ * The most contact persons one request may name; a list page shows at most this many rows.
+ */
+ public const MAX_IDS = 100;
+
+ /**
+ * Constructor for ContactNamesController.
+ *
+ * @param string $appName The app name.
+ * @param IRequest $request The request object.
+ * @param IUserSession $userSession The user session.
+ * @param IL10N $l10n Translations for the error messages.
+ * @param SettingsService $settingsService Gives the register and contact person schema.
+ * @param StackiqContactSyncService $contactSync Reads Nextcloud Contacts as the signed-in user.
+ * @param ObjectServiceInterface $objectService OpenRegister object access, with the caller's rights.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private readonly IUserSession $userSession,
+ private readonly IL10N $l10n,
+ private readonly SettingsService $settingsService,
+ private readonly StackiqContactSyncService $contactSync,
+ private readonly ObjectServiceInterface $objectService,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(appName: $appName, request: $request);
+ }//end __construct()
+
+ /**
+ * The display names of the given contact persons.
+ *
+ * API Endpoint: GET /api/contact-names?ids[]=
+ *
+ * Answers `{"names": {"": ""}}`. The per-object
+ * check is OpenRegister's: the contact persons are read with the caller's
+ * RBAC and organisation scope, so one the caller may not read is left out,
+ * as is one whose contact is not in the caller's own or shared address
+ * books. At most 120 requests a minute per user (one per list page).
+ *
+ * @return JSONResponse 200 with the names, 400 for more than MAX_IDS ids, 401 when not signed in.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[UserRateLimit(limit: 120, period: 60)]
+ public function index(): JSONResponse {
+ if ($this->userSession->getUser() === null) {
+ return new JSONResponse(
+ ['message' => $this->l10n->t('Not signed in'), 'error' => 'not-authenticated'],
+ Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $ids = $this->requestedIds();
+ if (count($ids) > self::MAX_IDS) {
+ return new JSONResponse(
+ ['message' => $this->l10n->t('At most %s contact persons per request', [self::MAX_IDS]), 'error' => 'too-many-ids'],
+ Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($ids === [] || $this->contactSync->isAvailable() === false) {
+ return new JSONResponse(['names' => (object)[]], Http::STATUS_OK);
+ }
+
+ $names = [];
+ foreach ($this->contactsUidsOf(ids: $ids) as $uuid => $contactsUid) {
+ $contact = $this->contactSync->findOwnContactByUid(uid: $contactsUid);
+ $name = self::firstValue(value: ($contact['FN'] ?? ''));
+ if ($name !== '') {
+ $names[$uuid] = $name;
+ }
+ }
+
+ return new JSONResponse(['names' => (object)$names], Http::STATUS_OK);
+ }//end index()
+
+ /**
+ * The distinct, well-formed uuids in the `ids` query parameter, lower case.
+ *
+ * @return array
+ */
+ private function requestedIds(): array {
+ $raw = $this->request->getParam('ids', []);
+ if (is_string($raw) === true) {
+ $raw = [$raw];
+ }
+
+ if (is_array($raw) === false) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($raw as $id) {
+ if (is_string($id) === true && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) === 1) {
+ $ids[strtolower($id)] = true;
+ }
+ }
+
+ return array_keys($ids);
+ }//end requestedIds()
+
+ /**
+ * The `contactsUid` of each requested contact person the caller may read.
+ *
+ * Fails closed: without a configured register and contact person schema,
+ * or when OpenRegister cannot be read, no contact person is resolved.
+ *
+ * @param array $ids Contact person uuids, lower case.
+ *
+ * @return array uuid => contactsUid.
+ */
+ private function contactsUidsOf(array $ids): array {
+ $config = $this->settingsService->getVoorzieningenConfig();
+ $register = ($config['register'] ?? null);
+ $schema = ($config[SettingsService::LEGACY_SCHEMA_KEY['contactPerson']] ?? null);
+ if (empty($register) === true || empty($schema) === true) {
+ $this->logger->warning('[ContactNames] The register or the contact person schema is not configured; no names are given');
+ return [];
+ }
+
+ try {
+ // RBAC and multitenancy stay at their defaults: the caller's own rights.
+ // The ids go in the query as `_ids`: OpenRegister's single
+ // register+schema path reads that key and ignores the `ids` argument.
+ $result = $this->objectService->searchObjectsPaginated(
+ query: [
+ '@self' => ['register' => (int)$register, 'schema' => (int)$schema],
+ '_ids' => $ids,
+ '_limit' => self::MAX_IDS,
+ ]
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning('[ContactNames] The contact persons could not be read', ['exception' => $e]);
+ return [];
+ }
+
+ $uids = [];
+ foreach (($result['results'] ?? []) as $entry) {
+ $data = self::objectData(entry: $entry);
+ $uuid = strtolower((string)($data['id'] ?? ($data['@self']['id'] ?? '')));
+ $contactsUid = trim((string)($data['contactsUid'] ?? ''));
+ // The ids filter is OpenRegister's; a record it did not ask for is never named.
+ if ($contactsUid !== '' && in_array($uuid, $ids, true) === true) {
+ $uids[$uuid] = $contactsUid;
+ }
+ }
+
+ return $uids;
+ }//end contactsUidsOf()
+
+ /**
+ * The data of one search result, an entity or an array.
+ *
+ * @param mixed $entry The search result.
+ *
+ * @return array
+ */
+ private static function objectData(mixed $entry): array {
+ if (is_array($entry) === true) {
+ return $entry;
+ }
+
+ if (is_object($entry) === true && method_exists($entry, 'jsonSerialize') === true) {
+ $data = $entry->jsonSerialize();
+ if (is_array($data) === true) {
+ return $data;
+ }
+ }
+
+ return [];
+ }//end objectData()
+
+ /**
+ * The first value of a vCard property, which the contacts manager may give as a list.
+ *
+ * @param mixed $value The property value.
+ *
+ * @return string
+ */
+ private static function firstValue(mixed $value): string {
+ if (is_array($value) === true) {
+ $value = (reset($value) ?? '');
+ }
+
+ if (is_scalar($value) === false) {
+ return '';
+ }
+
+ return trim((string)$value);
+ }//end firstValue()
+}//end class
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 2e5fd4b2e..f4faa4bf7 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -26,8 +26,10 @@
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\OpenRegister\Service\ConfigurationService;
+use OCA\Stackiq\Exception\CmdbImportException;
use OCA\Stackiq\Service\ArchiMateImportService;
use OCA\Stackiq\Service\ArchiMateService;
+use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
use OCA\Stackiq\Service\ConnectionReportService;
use OCA\Stackiq\Service\EolSyncService;
use OCA\Stackiq\Service\OrganizationSyncService;
@@ -42,7 +44,9 @@
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroupManager;
+use OCP\IL10N;
use OCP\IRequest;
+use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@@ -64,6 +68,20 @@
*/
class SettingsController extends Controller {
+ /**
+ * The longest loader reason the CMDB mapping endpoint answers, in characters.
+ *
+ * @var int
+ */
+ private const REASON_MAX_LENGTH = 500;
+
+ /**
+ * OpenRegister's single-page catch-all route, which serves its migration-pack editor.
+ *
+ * @var string
+ */
+ private const OPENREGISTER_PAGE_ROUTE = 'openregister.dashboard.catchAll';
+
/**
* The OpenRegister object service.
*
@@ -88,10 +106,14 @@ class SettingsController extends Controller {
* @param EolSyncService $eolSyncService The EOL feed sync orchestration service.
* @param LoggerInterface $logger The logger instance.
* @param ConnectionReportService|null $connectionReports Asks integriq to look again after an email settings save.
+ * @param CmdbImportProfile|null $cmdbImportProfile The CMDB import profile and packs; null builds the shipped one.
+ * @param IL10N|null $l10n Translations of the CMDB mapping messages.
+ * @param IURLGenerator|null $urlGenerator Builds the link to a CMDB mapping pack's editor in OpenRegister.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*
* @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
public function __construct(
$appName,
@@ -108,6 +130,9 @@ public function __construct(
private readonly EolSyncService $eolSyncService,
private readonly LoggerInterface $logger,
private readonly ?ConnectionReportService $connectionReports = null,
+ private readonly ?CmdbImportProfile $cmdbImportProfile = null,
+ private readonly ?IL10N $l10n = null,
+ private readonly ?IURLGenerator $urlGenerator = null,
) {
parent::__construct(appName: $appName, request: $request);
@@ -3883,4 +3908,131 @@ public function getEolSyncStatus(): JSONResponse {
return $this->buildConfigReadErrorResponse(operationLabel: 'get EOL sync status', exception: $e);
}
}//end getEolSyncStatus()
+
+ /**
+ * The column mapping the CMDB import uses, read-only, with where each pack is edited.
+ *
+ * Loads the import profile and its packs through the loader the import
+ * uses (CmdbImportProfile), so the answer is what the next import runs:
+ * each sheet pack from OpenRegister's migration-pack store when it holds
+ * it, otherwise the shipped file. Success is the flat
+ * `{profile, packs, openRegisterPacks}`; every pack carries its source and
+ * versions and an `editUrl`, the absolute link to its editor in
+ * OpenRegister, or null when it is not stored there. A failure uses the
+ * import's error envelope, so the section shows it like an import error,
+ * with the loader's reason in `details.reason` because the admin who
+ * edited a pack needs to know which pack and why; a pack stored in
+ * OpenRegister is also named in `details.pack` and `details.packId`, with
+ * `details.editUrl` linking to its editor so the admin can correct it. That reason is the
+ * loader's message cut to its first line and REASON_MAX_LENGTH characters:
+ * the file or the OpenRegister pack and its id, the broken rule and the
+ * configuration value it refused, never a workbook cell or a person.
+ *
+ * @return JSONResponse `{profile, packs, openRegisterPacks}` (200), 503 MAPPING_UNAVAILABLE, or 500 IMPORT_FAILED.
+ *
+ * @auth admin-only the import's configuration; the import writes with RBAC and multitenancy off, so its view keeps that posture.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ public function getCmdbImportMapping(): JSONResponse {
+ $profile = ($this->cmdbImportProfile ?? new CmdbImportProfile(container: $this->container));
+
+ try {
+ $overview = $profile->mappingOverview();
+ } catch (CmdbImportException $e) {
+ $this->logger->info(
+ 'SettingsController: CMDB import mapping unavailable',
+ ['error' => $e->getErrorCode(), 'reason' => $e->getMessage()]
+ );
+ $details = array_merge($e->getDetails(), ['reason' => self::boundedReason(message: $e->getMessage())]);
+ if (is_int($details['packId'] ?? null) === true) {
+ $details['editUrl'] = $this->packEditUrl(packId: $details['packId']);
+ }
+
+ return new JSONResponse(
+ [
+ 'success' => false,
+ 'error' => $e->getErrorCode(),
+ 'message' => (
+ $this->l10n?->t('The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.')
+ ?? 'The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.'
+ ),
+ 'details' => (object)$details,
+ ],
+ $e->getHttpStatus()
+ );
+ } catch (\Throwable $e) {
+ $this->logger->error('SettingsController: CMDB import mapping failed', ['exception' => $e]);
+ return new JSONResponse(
+ [
+ 'success' => false,
+ 'error' => 'IMPORT_FAILED',
+ 'message' => (
+ $this->l10n?->t('The import mapping could not be read. The details are in the Nextcloud log.')
+ ?? 'The import mapping could not be read. The details are in the Nextcloud log.'
+ ),
+ 'details' => (object)[],
+ ],
+ Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ foreach ($overview['packs'] as $index => $pack) {
+ $overview['packs'][$index]['editUrl'] = $this->packEditUrl(packId: ($pack['packId'] ?? null));
+ }
+
+ return new JSONResponse($overview, Http::STATUS_OK);
+ }//end getCmdbImportMapping()
+
+ /**
+ * The absolute link to a migration pack's editor in OpenRegister.
+ *
+ * Built from OpenRegister's catch-all page route, so an instance that
+ * needs `/index.php` in its URLs gets it.
+ *
+ * @param int|null $packId The OpenRegister row id, or null for a pack that is not stored there.
+ *
+ * @return string|null Null when the pack is not stored in OpenRegister or the link cannot be built.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ private function packEditUrl(?int $packId): ?string {
+ if ($packId === null || $this->urlGenerator === null) {
+ return null;
+ }
+
+ $path = 'migration-packs/' . $packId;
+ try {
+ $url = $this->urlGenerator->linkToRouteAbsolute(self::OPENREGISTER_PAGE_ROUTE, ['path' => $path]);
+ } catch (\Throwable $e) {
+ $this->logger->info('SettingsController: no link to the OpenRegister pack editor', ['exception' => get_class($e)]);
+ return null;
+ }
+
+ // Nextcloud's router answers an unknown route with an empty path, which would link to the instance's front page.
+ if (str_ends_with($url, '/' . $path) === false) {
+ return null;
+ }
+
+ return $url;
+ }//end packEditUrl()
+
+ /**
+ * A loader message as the mapping endpoint answers it: its first line, at most REASON_MAX_LENGTH characters.
+ *
+ * @param string $message The CmdbImportException message.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ private static function boundedReason(string $message): string {
+ $firstLine = strtok($message, "\r\n");
+ if ($firstLine === false) {
+ return '';
+ }
+
+ return mb_substr($firstLine, 0, self::REASON_MAX_LENGTH);
+ }//end boundedReason()
}//end class
diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php
index 8686c4501..5a9290477 100644
--- a/lib/Controller/SetupController.php
+++ b/lib/Controller/SetupController.php
@@ -106,7 +106,7 @@ public function __construct(
*
* @return JSONResponse The status document.
*
- * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
+ * @spec openspec/specs/first-time-setup/spec.md
*/
#[AuthorizedAdminSetting(StackiqAdmin::class)]
public function status(): JSONResponse {
@@ -142,7 +142,7 @@ public function status(): JSONResponse {
*
* @return JSONResponse `{ success, config }`.
*
- * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
+ * @spec openspec/specs/first-time-setup/spec.md
*/
#[AuthorizedAdminSetting(StackiqAdmin::class)]
public function saveConfig(): JSONResponse {
@@ -227,7 +227,7 @@ public function runAction(string $actionId): JSONResponse {
*
* @return JSONResponse `{ success, message }`.
*
- * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
+ * @spec openspec/specs/first-time-setup/spec.md
*/
private function loadDataset(string $actionId): JSONResponse {
$picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, '');
@@ -306,7 +306,7 @@ private function loadDataset(string $actionId): JSONResponse {
*
* @return JSONResponse|null The refusal, or null when the dataset is known.
*
- * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
+ * @spec openspec/specs/first-time-setup/spec.md
*/
private function refuseDataset(mixed $value): ?JSONResponse {
if (is_scalar($value) === false) {
diff --git a/lib/Dashboard/ConceptOrganisatiesWidget.php b/lib/Dashboard/ConceptOrganisatiesWidget.php
index 8da5c906b..3e40e7279 100644
--- a/lib/Dashboard/ConceptOrganisatiesWidget.php
+++ b/lib/Dashboard/ConceptOrganisatiesWidget.php
@@ -43,10 +43,13 @@ public function __construct(
public function getId(): string {
// FROZEN across the softwarecatalog -> stackiq rename. The Dashboard app
// stores each user's chosen widgets BY WIDGET ID, in its own `dashboard`
- // appid namespace in `oc_preferences` — data this app's repair steps
- // cannot reach. Renaming this id therefore does not error: the widget
- // simply stops matching the stored selection and silently vanishes from
- // every dashboard that had it.
+ // appid namespace in `oc_preferences`. Renaming this id therefore does
+ // not error: the widget simply stops matching the stored selection and
+ // silently vanishes from every dashboard that had it. Stable 0.1.148 to
+ // 0.2.0 shipped the renamed id anyway, so the repair step
+ // `RepointDashboardWidgetId` rewrites that id back to this one in every
+ // user's `dashboard`/`layout` row — the Dashboard rows are ordinary user
+ // preferences, which `IConfig` reaches like this app's own.
return 'softwarecatalog_concept_organisaties_widget';
}//end getId()
diff --git a/lib/EventListener/ModuleVersionPublicationListener.php b/lib/EventListener/ModuleVersionPublicationListener.php
index 1e143b513..56dc41bd0 100644
--- a/lib/EventListener/ModuleVersionPublicationListener.php
+++ b/lib/EventListener/ModuleVersionPublicationListener.php
@@ -10,7 +10,7 @@
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -32,7 +32,7 @@
/**
* Hands every created, updated or deleted object to the publication mirror.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*
* @template-implements IEventListener
*/
@@ -57,7 +57,7 @@ public function __construct(
*
* @return void
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function handle(Event $event): void {
try {
diff --git a/lib/Exception/CmdbImportException.php b/lib/Exception/CmdbImportException.php
index 31404c505..65127a9fe 100644
--- a/lib/Exception/CmdbImportException.php
+++ b/lib/Exception/CmdbImportException.php
@@ -50,6 +50,7 @@ class CmdbImportException extends RuntimeException {
public const SCHEMA_OUTDATED = 'SCHEMA_OUTDATED';
public const IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS';
public const MUNICIPALITY_AMBIGUOUS = 'MUNICIPALITY_AMBIGUOUS';
+ public const ARCHIVE_UNAVAILABLE = 'ARCHIVE_UNAVAILABLE';
/**
* HTTP status per error code.
@@ -70,6 +71,7 @@ class CmdbImportException extends RuntimeException {
self::SCHEMA_OUTDATED => 503,
self::IMPORT_IN_PROGRESS => 409,
self::MUNICIPALITY_AMBIGUOUS => 422,
+ self::ARCHIVE_UNAVAILABLE => 503,
];
/**
diff --git a/lib/Portal/PortalContributionProvider.php b/lib/Portal/PortalContributionProvider.php
index 8d9c7ce63..491ac086d 100644
--- a/lib/Portal/PortalContributionProvider.php
+++ b/lib/Portal/PortalContributionProvider.php
@@ -37,7 +37,7 @@
*
* @link https://conduction.nl
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
declare(strict_types=1);
@@ -62,7 +62,7 @@
* the counterparty organisation's contactpersoon so a portal read never leaks
* another organisation's data.
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
class PortalContributionProvider {
/**
@@ -91,7 +91,7 @@ class PortalContributionProvider {
*
* @return array The audience identifiers.
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
public function getAudiences(): array {
return ['vendor-org', 'participant-org'];
@@ -105,7 +105,7 @@ public function getAudiences(): array {
*
* @return string The primary audience identifier.
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
public function getAudience(): string {
return 'vendor-org';
@@ -125,7 +125,7 @@ public function getAudience(): string {
*
* @return array|null The manifest, or null when not contributing.
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
public function getContribution(array $subject): ?array {
$audience = ($subject['audience'] ?? '');
@@ -155,7 +155,7 @@ public function getContribution(array $subject): ?array {
*
* @return array The vendor-org manifest.
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
private function vendorContribution(): array {
return [
@@ -258,7 +258,7 @@ private function vendorContribution(): array {
*
* @return array The participant-org manifest.
*
- * @spec openspec/changes/portal-contribution/specs/portal-contribution/spec.md
+ * @spec openspec/specs/portal-contribution/spec.md
*/
private function participantContribution(): array {
return [
diff --git a/lib/Repair/BackfillModuleVersionPublication.php b/lib/Repair/BackfillModuleVersionPublication.php
index 861084554..1613d5c21 100644
--- a/lib/Repair/BackfillModuleVersionPublication.php
+++ b/lib/Repair/BackfillModuleVersionPublication.php
@@ -18,7 +18,7 @@
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -42,7 +42,7 @@
/**
* Backfills the mirrored publication on module versions.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
class BackfillModuleVersionPublication implements IRepairStep {
@@ -92,7 +92,7 @@ public function getName(): string {
*
* @return void
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function run(IOutput $output): void {
if ($this->appConfig->getValueBool(Application::APP_ID, self::DONE_CONFIG_KEY, false) === true) {
diff --git a/lib/Repair/MigrateAppConfigKeys.php b/lib/Repair/MigrateAppConfigKeys.php
index 83e8691eb..5f40d79e5 100644
--- a/lib/Repair/MigrateAppConfigKeys.php
+++ b/lib/Repair/MigrateAppConfigKeys.php
@@ -31,7 +31,7 @@
* @version GIT:
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -51,7 +51,7 @@
/**
* Copies app config from the legacy `stackiq` app id to `stackiq`.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
*/
class MigrateAppConfigKeys implements IRepairStep {
/**
@@ -92,7 +92,7 @@ public function __construct(
*
* @return string The repair step name.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
*/
public function getName(): string {
return 'Migrate app config from the stackiq app id to stackiq';
@@ -111,7 +111,7 @@ public function getName(): string {
*
* @return void
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
*/
public function run(IOutput $output): void {
try {
@@ -192,7 +192,7 @@ public function run(IOutput $output): void {
*
* @return bool True when a value was written.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-app-config-survives-the-rename
*/
protected function copyValue(string $key, mixed $value): bool {
if (is_bool($value) === true) {
diff --git a/lib/Repair/MigrateBackgroundJobClasses.php b/lib/Repair/MigrateBackgroundJobClasses.php
index 09e1fc104..f82cda775 100644
--- a/lib/Repair/MigrateBackgroundJobClasses.php
+++ b/lib/Repair/MigrateBackgroundJobClasses.php
@@ -29,7 +29,7 @@
* @version GIT:
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -49,7 +49,7 @@
/**
* Removes background job registrations left behind by the namespace rename.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
*/
class MigrateBackgroundJobClasses implements IRepairStep {
/**
@@ -84,7 +84,7 @@ public function __construct(
*
* @return string The repair step name.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
*/
public function getName(): string {
return 'Deregister background jobs orphaned by the OCA\\SoftwareCatalog namespace rename';
@@ -101,7 +101,7 @@ public function getName(): string {
*
* @return void
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-background-job-classes-survive-the-rename
*/
public function run(IOutput $output): void {
try {
diff --git a/lib/Repair/MigrateUserPreferences.php b/lib/Repair/MigrateUserPreferences.php
index 96d89639a..efabbac92 100644
--- a/lib/Repair/MigrateUserPreferences.php
+++ b/lib/Repair/MigrateUserPreferences.php
@@ -29,7 +29,7 @@
* @version GIT:
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -52,7 +52,7 @@
/**
* Copies per-user preferences from the legacy `stackiq` app id to `stackiq`.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
*/
class MigrateUserPreferences implements IRepairStep {
/**
@@ -93,7 +93,7 @@ public function __construct(
*
* @return string The repair step name.
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
*/
public function getName(): string {
return 'Migrate user preferences from the stackiq app id to stackiq';
@@ -111,7 +111,7 @@ public function getName(): string {
*
* @return void
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
*/
public function run(IOutput $output): void {
$this->copied = 0;
@@ -156,7 +156,7 @@ function (IUser $user): ?bool {
*
* @return void
*
- * @spec openspec/changes/rename-app-id-to-stackiq/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-stored-user-preferences-survive-the-rename
*/
protected function migrateUser(IUser $user): void {
$userId = $user->getUID();
diff --git a/lib/Repair/RepointDashboardWidgetId.php b/lib/Repair/RepointDashboardWidgetId.php
new file mode 100644
index 000000000..718862996
--- /dev/null
+++ b/lib/Repair/RepointDashboardWidgetId.php
@@ -0,0 +1,222 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Repair;
+
+use OCA\Stackiq\AppInfo\Application;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Rewrites `stackiq_concept_organisaties_widget` to the frozen widget id in every user's dashboard layout.
+ *
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen
+ */
+class RepointDashboardWidgetId implements IRepairStep {
+
+ /**
+ * The app id the Dashboard app stores its per-user layout under.
+ */
+ public const DASHBOARD_APP_ID = 'dashboard';
+
+ /**
+ * The preference key holding the comma-separated widget ids.
+ */
+ public const LAYOUT_KEY = 'layout';
+
+ /**
+ * The widget id stackiq 0.1.148 through 0.2.0 registered the widget under.
+ */
+ public const RENAMED_WIDGET_ID = 'stackiq_concept_organisaties_widget';
+
+ /**
+ * The frozen, pre-rename widget id `ConceptOrganisatiesWidget::getId()` returns.
+ */
+ public const FROZEN_WIDGET_ID = 'softwarecatalog_concept_organisaties_widget';
+
+ /**
+ * Number of users whose layout was rewritten during the current run.
+ *
+ * @var int
+ */
+ private int $users = 0;
+
+
+ /**
+ * Constructor.
+ *
+ * @param IConfig $config The user config service.
+ * @param IUserManager $userManager The user manager.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly IConfig $config,
+ private readonly IUserManager $userManager,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+
+ /**
+ * Returns the name of this repair step.
+ *
+ * @return string The repair step name.
+ *
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen
+ */
+ public function getName(): string {
+ return 'Re-point dashboards from the renamed concept organisations widget id to the frozen one';
+ }//end getName()
+
+
+ /**
+ * Rewrite the renamed widget id in every seen user's dashboard layout.
+ *
+ * Reads and writes both sit inside the try: a throw escaping a repair
+ * step aborts `occ upgrade`, and a dashboard row is never worth that.
+ *
+ * @param IOutput $output The output interface for progress reporting.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen
+ */
+ public function run(IOutput $output): void {
+ $this->users = 0;
+
+ try {
+ // The callback must return bool|null, not void: IUserManager treats a
+ // `false` return as "stop iterating", so null means "keep going".
+ $this->userManager->callForSeenUsers(
+ function (IUser $user): ?bool {
+ try {
+ $this->repointUser(user: $user);
+ } catch (Throwable $e) {
+ // One unreadable user must not stop the walk for the rest.
+ $this->logger->warning(
+ 'Stackiq: could not re-point the dashboard widget id for user ' . $user->getUID() . ': ' . $e->getMessage(),
+ [
+ 'app' => Application::APP_ID,
+ 'exception' => $e,
+ ]
+ );
+ }
+
+ return null;
+ }
+ );
+
+ $output->info(
+ sprintf(
+ 'Stackiq: re-pointed the concept organisations widget from "%s" to "%s" on %d dashboard(s)',
+ self::RENAMED_WIDGET_ID,
+ self::FROZEN_WIDGET_ID,
+ $this->users
+ )
+ );
+ } catch (Throwable $e) {
+ // Swallowed deliberately — see the docblock.
+ $this->logger->error(
+ 'Stackiq: failed to re-point the dashboard widget id: ' . $e->getMessage(),
+ [
+ 'app' => Application::APP_ID,
+ 'exception' => $e,
+ ]
+ );
+ $output->warning(
+ 'Stackiq: dashboard widget re-pointing failed; see the log. '
+ . 'Users who added the widget on stackiq 0.1.148 to 0.2.0 may have to add it again.'
+ );
+ }//end try
+ }//end run()
+
+
+ /**
+ * Rewrite one user's layout when it names the renamed widget id.
+ *
+ * @param IUser $user The user whose dashboard layout to check.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen
+ */
+ protected function repointUser(IUser $user): void {
+ $userId = $user->getUID();
+ $layout = $this->config->getUserValue($userId, self::DASHBOARD_APP_ID, self::LAYOUT_KEY, '');
+ if ($layout === '') {
+ return;
+ }
+
+ $ids = array_values(
+ array_filter(
+ array_map('trim', explode(',', $layout)),
+ static fn (string $id): bool => $id !== ''
+ )
+ );
+ if (in_array(self::RENAMED_WIDGET_ID, $ids, true) === false) {
+ return;
+ }
+
+ // Keep the widget in the position the user gave it; never list the
+ // frozen id twice when the user somehow holds both.
+ $rewritten = [];
+ foreach ($ids as $id) {
+ if ($id === self::RENAMED_WIDGET_ID) {
+ $id = self::FROZEN_WIDGET_ID;
+ }
+
+ if (in_array($id, $rewritten, true) === false) {
+ $rewritten[] = $id;
+ }
+ }
+
+ $this->config->setUserValue($userId, self::DASHBOARD_APP_ID, self::LAYOUT_KEY, implode(',', $rewritten));
+ $this->users++;
+ }//end repointUser()
+}//end class
diff --git a/lib/Repair/SeedCmdbImportPacks.php b/lib/Repair/SeedCmdbImportPacks.php
new file mode 100644
index 000000000..3d911e9b4
--- /dev/null
+++ b/lib/Repair/SeedCmdbImportPacks.php
@@ -0,0 +1,146 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Repair;
+
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore;
+use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Seeds and updates the CMDB import packs in OpenRegister's migration-pack store.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+class SeedCmdbImportPacks implements IRepairStep {
+ /**
+ * Constructor.
+ *
+ * @param CmdbImportProfile $profile Reads and validates the shipped packs.
+ * @param CmdbImportPackStore $store OpenRegister's migration-pack store.
+ * @param LoggerInterface $logger Logger for seed diagnostics.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function __construct(
+ private readonly CmdbImportProfile $profile,
+ private readonly CmdbImportPackStore $store,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * The name of this repair step.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function getName(): string {
+ return 'Seed the CMDB import mapping packs into OpenRegister';
+ }//end getName()
+
+ /**
+ * Seed the missing packs and update the unedited ones.
+ *
+ * @param IOutput $output Output interface for status messages.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function run(IOutput $output): void {
+ if ($this->store->isAvailable() === false) {
+ $output->info('OpenRegister migration pack store not available; CMDB import packs not seeded');
+ return;
+ }
+
+ try {
+ $packs = $this->profile->shippedEditablePacks();
+ } catch (Throwable $e) {
+ $this->logger->warning('[SeedCmdbImportPacks] shipped packs unreadable: ' . $e->getMessage());
+ $output->warning('CMDB import packs not seeded: ' . $e->getMessage());
+ return;
+ }
+
+ foreach ($packs as $shipped) {
+ $this->seed(output: $output, shipped: $shipped);
+ }
+ }//end run()
+
+ /**
+ * Seed or update one pack, and report what happened.
+ *
+ * @param IOutput $output Output interface for status messages.
+ * @param array $shipped The shipped pack.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ private function seed(IOutput $output, array $shipped): void {
+ $slug = (string)($shipped['id'] ?? '');
+ try {
+ $stored = $this->store->resolve(shipped: $shipped);
+ } catch (Throwable $e) {
+ $this->logger->warning('[SeedCmdbImportPacks] ' . $slug . ': ' . $e->getMessage());
+ $output->warning('CMDB import pack ' . $slug . ' not seeded: ' . $e->getMessage());
+ return;
+ }
+
+ $where = $slug . ' (id ' . $stored['id'] . ')';
+ if ($stored['outcome'] === CmdbImportPackStore::SEEDED) {
+ $output->info('Seeded CMDB import pack ' . $where);
+ return;
+ }
+
+ if ($stored['outcome'] === CmdbImportPackStore::UPDATED) {
+ $output->info('Updated unedited CMDB import pack ' . $where . ' to version ' . $stored['version']);
+ return;
+ }
+
+ if ($stored['newerShipped'] === true) {
+ $output->warning(
+ 'CMDB import pack ' . $where . ' was edited in OpenRegister and is kept; this release ships version '
+ . (string)($shipped['version'] ?? '') . ', which is not applied'
+ );
+ return;
+ }
+
+ $output->info('CMDB import pack ' . $where . ' already present');
+ }//end seed()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackMarker.php b/lib/Service/Cmdb/CmdbImportPackMarker.php
new file mode 100644
index 000000000..5d41765f9
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackMarker.php
@@ -0,0 +1,175 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+/**
+ * Adds, reads and compares the seed marker of a stored pack.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbImportPackMarker {
+ /**
+ * The definition key that records what stackiq seeded.
+ */
+ public const KEY = 'x-stackiq';
+
+ /**
+ * The hash stackiq records for a shipped pack: sha256 of its canonical JSON.
+ *
+ * @param array $pack The pack, without the marker.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function hash(array $pack): string {
+ $json = json_encode($this->canonical(value: $pack), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
+ return hash('sha256', (string)$json);
+ }//end hash()
+
+ /**
+ * The shipped pack as it is stored: with the marker.
+ *
+ * @param array $shipped The shipped pack.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function seed(array $shipped): array {
+ $definition = $this->strip(definition: $shipped);
+ $definition[self::KEY] = [
+ 'shippedVersion' => (string)($shipped['version'] ?? ''),
+ 'shippedHash' => $this->hash(pack: $definition),
+ ];
+
+ return $definition;
+ }//end seed()
+
+ /**
+ * A stored definition without the marker: the pack the engine runs.
+ *
+ * @param array $definition The stored definition.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function strip(array $definition): array {
+ unset($definition[self::KEY]);
+ return $definition;
+ }//end strip()
+
+ /**
+ * Whether a stored definition differs from what stackiq seeded.
+ *
+ * @param array $definition The stored definition.
+ *
+ * @return bool|null Null when it carries no marker, so nobody knows what was seeded.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isEdited(array $definition): ?bool {
+ $recorded = $this->recordedHash(definition: $definition);
+ if ($recorded === null) {
+ return null;
+ }
+
+ return $this->hash(pack: $this->strip(definition: $definition)) !== $recorded;
+ }//end isEdited()
+
+ /**
+ * Whether a stored pack nobody edited was seeded from another shipped pack than this one.
+ *
+ * @param array $definition The stored definition.
+ * @param array $shipped The shipped pack.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isOutdated(array $definition, array $shipped): bool {
+ return $this->isEdited(definition: $definition) === false
+ && $this->recordedHash(definition: $definition) !== $this->hash(pack: $shipped);
+ }//end isOutdated()
+
+ /**
+ * Whether an edited stored pack was seeded from a shipped pack that has changed since.
+ *
+ * @param array $definition The stored definition.
+ * @param array $shipped The shipped pack.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isNewerShipped(array $definition, array $shipped): bool {
+ return $this->isEdited(definition: $definition) === true
+ && $this->recordedHash(definition: $definition) !== $this->hash(pack: $shipped);
+ }//end isNewerShipped()
+
+ /**
+ * The hash the marker of a stored definition records.
+ *
+ * @param array $definition The stored definition.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function recordedHash(array $definition): ?string {
+ $hash = ($definition[self::KEY]['shippedHash'] ?? null);
+ if (is_string($hash) === false || $hash === '') {
+ return null;
+ }
+
+ return $hash;
+ }//end recordedHash()
+
+ /**
+ * A value with every object's keys sorted, recursively.
+ *
+ * @param mixed $value The value.
+ *
+ * @return mixed
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function canonical(mixed $value): mixed {
+ if (is_array($value) === false) {
+ return $value;
+ }
+
+ if (array_is_list($value) === false) {
+ ksort($value, SORT_STRING);
+ }
+
+ return array_map(fn ($item) => $this->canonical(value: $item), $value);
+ }//end canonical()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackRules.php b/lib/Service/Cmdb/CmdbImportPackRules.php
new file mode 100644
index 000000000..a8ca512f1
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackRules.php
@@ -0,0 +1,404 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCA\Stackiq\Exception\CmdbImportException;
+
+/**
+ * Lists the import rules a pack breaks.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbImportPackRules {
+ /**
+ * The fields a pack must map with `required: true`, per target.
+ *
+ * The import keys a module on its APPID and recognises a manufacturer,
+ * a municipality and an owner by name; without them it cannot match.
+ *
+ * @var array>
+ */
+ private const REQUIRED_TARGETS = [
+ 'module' => ['name', 'externalNumber'],
+ 'manufacturer' => ['name'],
+ 'municipality' => ['name'],
+ 'usage' => [],
+ 'businessOwner' => ['name'],
+ ];
+
+ /**
+ * The register schema a target writes into; a field a pack maps must be a plain property of it.
+ *
+ * @var array
+ */
+ private const TARGET_SCHEMAS = [
+ 'module' => 'module',
+ 'manufacturer' => 'organization',
+ 'municipality' => 'organization',
+ 'usage' => 'usage',
+ ];
+
+ /**
+ * The fields a pack may map or default for a target that writes into no schema.
+ *
+ * The business owner is resolved in Nextcloud Contacts by employee number,
+ * e-mail address or name, with the role on the contact person, so it has
+ * no schema to read its fields from.
+ *
+ * @var array>
+ */
+ private const FIELDS_WITHOUT_SCHEMA = [
+ 'businessOwner' => ['name', 'role', 'email', 'employeeNumber'],
+ ];
+
+ /**
+ * Fields no pack may write: object metadata, and the keys and relations the import sets itself.
+ *
+ * @var array
+ */
+ private const MANAGED_FIELDS = ['@self', 'id', 'provider', 'consumer', 'module', 'externalKey', 'businessOwner'];
+
+ /**
+ * Fields only the catalogue or an administrator sets: the publication dates and the admin fields.
+ *
+ * The pinned defaults below may still set some of them, as shipped.
+ *
+ * @var array
+ */
+ private const PROTECTED_FIELDS = [
+ 'publicationDate',
+ 'depublicationDate',
+ 'registeredBy',
+ 'registrationStatus',
+ 'mergedInto',
+ 'contactsUid',
+ 'pki',
+ 'xml',
+ ];
+
+ /**
+ * Further protected fields per target: an organisation's approval status, a module's type and the version the service desk records as installed.
+ *
+ * @var array>
+ */
+ private const PROTECTED_TARGET_FIELDS = [
+ 'module' => ['type'],
+ 'manufacturer' => ['status'],
+ 'municipality' => ['status'],
+ 'usage' => ['installedVersion'],
+ ];
+
+ /**
+ * Further match keys per target: the import finds an organisation by name and type.
+ *
+ * @var array>
+ */
+ private const MATCH_FIELDS = ['manufacturer' => ['type'], 'municipality' => ['type']];
+
+ /**
+ * Defaults a pack must keep as shipped, per target.
+ *
+ * The organisation type is a match key; the status and who registered it
+ * are admin fields that a pack may set only to these values.
+ *
+ * @var array>
+ */
+ private const PINNED_DEFAULTS = [
+ 'manufacturer' => ['type' => 'Supplier', 'status' => 'Active', 'registeredBy' => 'Supplier'],
+ 'municipality' => ['type' => 'Municipality', 'status' => 'Active'],
+ ];
+
+ /**
+ * The register's properties per schema with why no pack may write them, read once.
+ *
+ * @var CmdbRegisterSchemaFields|null
+ */
+ private ?CmdbRegisterSchemaFields $registerSchema = null;
+
+ /**
+ * Constructor.
+ *
+ * @param string|null $settingsDirectory Directory of the register file and its fragments; null is the shipped one.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private ?string $settingsDirectory = null,
+ ) {
+ if ($this->settingsDirectory === null) {
+ $this->settingsDirectory = __DIR__ . '/../../Settings';
+ }
+ }//end __construct()
+
+ /**
+ * The rules a pack breaks, as readable sentences; empty when it follows them all.
+ *
+ * @param string $target The profile target.
+ * @param array $pack The pack, validated by OpenRegister.
+ * @param array $profile The decoded import profile.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function violations(string $target, array $pack, array $profile): array {
+ $never = $profile['neverWritten'][$target] ?? [];
+ if (is_array($never) === false) {
+ $never = [];
+ }
+
+ $refused = [
+ 'managed' => array_merge(self::MANAGED_FIELDS, (self::MATCH_FIELDS[$target] ?? []), array_map('strval', $never)),
+ 'protected' => array_merge(self::PROTECTED_FIELDS, (self::PROTECTED_TARGET_FIELDS[$target] ?? [])),
+ 'relation' => $this->fieldsWithReason(target: $target, reason: 'relation'),
+ 'system' => $this->fieldsWithReason(target: $target, reason: 'system'),
+ ];
+ $allowed = array_values(array_diff($this->targetFields(target: $target), ...array_values($refused)));
+
+ return array_values(
+ array_unique(
+ array_merge(
+ $this->requiredViolations(target: $target, pack: $pack, keyColumn: (string)($profile['keyColumn'] ?? 'APPID')),
+ $this->mappingViolations(target: $target, pack: $pack, allowed: $allowed, refused: $refused),
+ $this->defaultViolations(target: $target, pack: $pack, allowed: $allowed, refused: $refused)
+ )
+ )
+ );
+ }//end violations()
+
+ /**
+ * The required mappings a pack lacks.
+ *
+ * @param string $target The target.
+ * @param array $pack The pack.
+ * @param string $keyColumn The match key column.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function requiredViolations(string $target, array $pack, string $keyColumn): array {
+ $violations = [];
+ foreach ((self::REQUIRED_TARGETS[$target] ?? []) as $field) {
+ if ($this->hasRequiredMapping(pack: $pack, field: $field, keyColumn: $keyColumn) === true) {
+ continue;
+ }
+
+ if ($field === 'externalNumber') {
+ $violations[] = 'the key column "' . $keyColumn . '" must be mapped to "externalNumber" as required';
+ continue;
+ }
+
+ $violations[] = '"' . $field . '" must be mapped as required';
+ }
+
+ return $violations;
+ }//end requiredViolations()
+
+ /**
+ * Whether a pack maps a field as required; `externalNumber` only from the key column.
+ *
+ * @param array $pack The pack.
+ * @param string $field The field.
+ * @param string $keyColumn The match key column.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function hasRequiredMapping(array $pack, string $field, string $keyColumn): bool {
+ foreach (($pack['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === false || ($mapping['target'] ?? null) !== $field || ($mapping['required'] ?? false) !== true) {
+ continue;
+ }
+
+ if ($field !== 'externalNumber' || ($mapping['source'] ?? null) === $keyColumn) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end hasRequiredMapping()
+
+ /**
+ * The mapped targets a pack may not write.
+ *
+ * @param string $target The target.
+ * @param array $pack The pack.
+ * @param array $allowed The fields it may write.
+ * @param array> $refused The refused fields, per reason.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function mappingViolations(string $target, array $pack, array $allowed, array $refused): array {
+ $violations = [];
+ foreach (($pack['fieldMappings'] ?? []) as $mapping) {
+ $field = (string)($mapping['target'] ?? '');
+ if (in_array($field, $allowed, true) === false) {
+ $violations[] = self::fieldViolation(field: $field, refused: $refused, target: $target);
+ }
+ }
+
+ return $violations;
+ }//end mappingViolations()
+
+ /**
+ * The defaults a pack may not set, and the pinned defaults it changed.
+ *
+ * @param string $target The target.
+ * @param array $pack The pack.
+ * @param array $allowed The fields it may write.
+ * @param array> $refused The refused fields, per reason.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function defaultViolations(string $target, array $pack, array $allowed, array $refused): array {
+ $defaults = $pack['defaults'] ?? [];
+ if (is_array($defaults) === false) {
+ $defaults = [];
+ }
+
+ $pinned = (self::PINNED_DEFAULTS[$target] ?? []);
+ $violations = [];
+ foreach (array_keys($defaults) as $field) {
+ $field = (string)$field;
+ if (array_key_exists($field, $pinned) === false && in_array($field, $allowed, true) === false) {
+ $violations[] = 'default ' . self::fieldViolation(field: $field, refused: $refused, target: $target);
+ }
+ }
+
+ foreach ($pinned as $field => $value) {
+ if (($defaults[$field] ?? null) !== $value) {
+ $violations[] = 'default "' . $field . '" must stay "' . $value . '"';
+ }
+ }
+
+ return $violations;
+ }//end defaultViolations()
+
+ /**
+ * Why a field may not be written.
+ *
+ * @param string $field The field.
+ * @param array> $refused The refused fields, per reason.
+ * @param string $target The target.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function fieldViolation(string $field, array $refused, string $target): string {
+ if (in_array($field, $refused['managed'], true) === true) {
+ return '"' . $field . '" is set by the import and cannot be mapped';
+ }
+
+ if (in_array($field, $refused['protected'], true) === true) {
+ return '"' . $field . '" is set outside the import and cannot be mapped';
+ }
+
+ if (in_array($field, $refused['system'], true) === true) {
+ return '"' . $field . '" is set outside the import and cannot be mapped';
+ }
+
+ if (in_array($field, $refused['relation'], true) === true) {
+ return '"' . $field . '" is a relation or a file and cannot be mapped';
+ }
+
+ return '"' . $field . '" is not a field the ' . $target . ' pack may map';
+ }//end fieldViolation()
+
+ /**
+ * The properties per schema of the register stackiq ships, fragments included, each with why no pack may write it.
+ *
+ * @return array>
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register file or one of its fragments cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function schemaFields(): array {
+ if ($this->registerSchema === null) {
+ $this->registerSchema = new CmdbRegisterSchemaFields(settingsDirectory: (string)$this->settingsDirectory);
+ }
+
+ return $this->registerSchema->fields();
+ }//end schemaFields()
+
+ /**
+ * The properties of a target's schema, or the fixed fields of a target without one.
+ *
+ * @param string $target The target.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function targetFields(string $target): array {
+ $schema = (self::TARGET_SCHEMAS[$target] ?? null);
+ if ($schema === null) {
+ return (self::FIELDS_WITHOUT_SCHEMA[$target] ?? []);
+ }
+
+ return array_map('strval', array_keys(($this->schemaFields()[$schema] ?? [])));
+ }//end targetFields()
+
+ /**
+ * The properties of a target's schema that no pack may write for one reason.
+ *
+ * @param string $target The target.
+ * @param string $reason `relation` or `system`.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function fieldsWithReason(string $target, string $reason): array {
+ $schema = (self::TARGET_SCHEMAS[$target] ?? null);
+ if ($schema === null) {
+ return [];
+ }
+
+ $fields = array_filter(($this->schemaFields()[$schema] ?? []), static fn (string $why): bool => $why === $reason);
+ return array_map('strval', array_keys($fields));
+ }//end fieldsWithReason()
+
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackStore.php b/lib/Service/Cmdb/CmdbImportPackStore.php
new file mode 100644
index 000000000..f046b13c1
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackStore.php
@@ -0,0 +1,586 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCA\Stackiq\AppInfo\Application;
+use OCA\Stackiq\Exception\CmdbImportException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IAppConfig;
+use Psr\Container\ContainerInterface;
+use Throwable;
+
+/**
+ * Seeds, finds and updates the CMDB import packs in OpenRegister.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbImportPackStore {
+ /**
+ * OpenRegister's migration-pack service (not a public contract).
+ */
+ public const SERVICE_CLASS = 'OCA\OpenRegister\Service\MigrationPackService';
+
+ /**
+ * App config key: the OpenRegister row id per pack slug, as JSON.
+ */
+ public const CONFIG_KEY = 'cmdb_import_pack_ids';
+
+ /**
+ * The store resolved the pack as it was.
+ */
+ public const FOUND = 'found';
+
+ /**
+ * The store created the pack from the shipped file.
+ */
+ public const SEEDED = 'seeded';
+
+ /**
+ * The store replaced an unedited pack with a newer shipped one.
+ */
+ public const UPDATED = 'updated';
+
+ /**
+ * A class only an OpenRegister with the hardened pack store has.
+ *
+ * It ships with the CSRF-protected, admin-only pack writes and the lock on
+ * a built-in pack's slug.
+ */
+ public const HARDENED_CLASS = 'OCA\OpenRegister\Exception\BuiltinPackSlugLockedException';
+
+ /**
+ * OpenRegister's controller for the pack routes; its write methods must be admin-only and CSRF-protected.
+ */
+ public const CONTROLLER_CLASS = 'OCA\OpenRegister\Controller\MigrationPacksController';
+
+ /**
+ * The exception OpenRegister throws when a pack was saved after the caller read it.
+ */
+ public const PACK_CHANGED_CLASS = 'OCA\OpenRegister\Exception\PackChangedException';
+
+
+ /**
+ * Decides whether OpenRegister's store may be used, and hands out its service.
+ *
+ * @var CmdbImportPackStoreProbe
+ */
+ private readonly CmdbImportPackStoreProbe $probe;
+
+ /**
+ * Reads and writes the seed marker.
+ *
+ * @var CmdbImportPackMarker
+ */
+ private readonly CmdbImportPackMarker $marker;
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves OpenRegister's service, and the app config when none is given.
+ * @param IAppConfig|null $appConfig Keeps the row ids; null resolves it from the container.
+ * @param string $hardenedClass The class that marks an OpenRegister with the hardened pack store.
+ * @param string $controllerClass The controller whose write methods must be guarded.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private ?IAppConfig $appConfig = null,
+ private readonly string $hardenedClass = self::HARDENED_CLASS,
+ private readonly string $controllerClass = self::CONTROLLER_CLASS,
+ ) {
+ $this->marker = new CmdbImportPackMarker();
+ $this->probe = new CmdbImportPackStoreProbe(
+ container: $this->container,
+ serviceClass: self::SERVICE_CLASS,
+ hardenedClass: $this->hardenedClass,
+ controllerClass: $this->controllerClass
+ );
+ }//end __construct()
+
+ /**
+ * Whether OpenRegister's migration-pack store can be used.
+ *
+ * Only an OpenRegister with the hardened pack store counts: it has
+ * HARDENED_CLASS, and no write method of its pack controller carries an
+ * attribute or annotation that skips the CSRF check or admits non-admins.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isAvailable(): bool {
+ return $this->service() !== null;
+ }//end isAvailable()
+
+ /**
+ * The OpenRegister row ids this store resolved before, per pack slug.
+ *
+ * Not empty means the packs were stored in OpenRegister once, so the
+ * import may no longer fall back to the shipped files.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function recordedPackIds(): array {
+ $config = $this->appConfig();
+ if ($config === null) {
+ return [];
+ }
+
+ $decoded = json_decode($config->getValueString(Application::APP_ID, self::CONFIG_KEY, ''), true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($decoded as $slug => $id) {
+ if (is_string($slug) === true && is_int($id) === true && $id > 0) {
+ $ids[$slug] = $id;
+ }
+ }
+
+ return $ids;
+ }//end recordedPackIds()
+
+ /**
+ * Refuse the shipped files once the packs were stored in OpenRegister.
+ *
+ * Called when the store is not available: before any pack was stored
+ * there the import may use the shipped files; afterwards that would
+ * silently drop an administrator's edit.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE naming the packs the store held.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function assertShippedFallbackAllowed(): void {
+ $named = [];
+ foreach ($this->recordedPackIds() as $slug => $id) {
+ $named[] = $slug . ' (id ' . $id . ')';
+ }
+
+ if ($named === []) {
+ return;
+ }
+
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack store is not available, but it holds the CMDB import packs '
+ . implode(', ', $named) . '; the import does not fall back to the shipped files',
+ details: ['packs' => $this->recordedPackIds()]
+ );
+ }//end assertShippedFallbackAllowed()
+
+ /**
+ * The stored pack for a shipped pack, read only: nothing is created, updated or recorded.
+ *
+ * For a view of the mapping. The import run and the seed step use resolve(),
+ * which writes. An unedited stored pack that the next import run replaces
+ * is answered as that run will see it: the shipped pack and its version,
+ * with `updatePending` true; nothing is written.
+ *
+ * @param array $shipped The shipped pack, already validated.
+ *
+ * @return array|null As resolve(), with outcome found; null when the store holds no such pack.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is missing or fails,
+ * or when the pack was renamed in OpenRegister.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function find(array $shipped): ?array {
+ $service = $this->requireService();
+ $slug = (string)($shipped['id'] ?? '');
+ $entity = $this->findBySlug(service: $service, slug: $slug);
+ if ($entity === null) {
+ $this->assertNotRenamed(service: $service, slug: $slug);
+ return null;
+ }
+
+ $answer = $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: self::FOUND);
+ if ($this->updateWouldRun(entity: $entity, shipped: $shipped) === true) {
+ $answer['pack'] = $shipped;
+ $answer['version'] = (string)($shipped['version'] ?? '');
+ $answer['updatePending'] = true;
+ }
+
+ return $answer;
+ }//end find()
+
+ /**
+ * Whether the next import run replaces this stored pack with the shipped one.
+ *
+ * Only an unedited, outdated pack whose last save is known: without an
+ * `updatedAt` the update cannot be guarded against an edit in between, so
+ * the stored pack is kept.
+ *
+ * @param object $entity The stored pack as read.
+ * @param array $shipped The shipped pack.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function updateWouldRun(object $entity, array $shipped): bool {
+ return $entity->getUpdatedAt() !== null
+ && $this->marker->isOutdated(definition: self::definitionOf(entity: $entity), shipped: $shipped) === true;
+ }//end updateWouldRun()
+
+ /**
+ * The stored pack for a shipped pack: found, seeded when missing, or updated when unedited and outdated.
+ *
+ * The answer holds the stored definition, the pack the engine runs (the
+ * definition without the marker), whether it was edited (null when it
+ * carries no marker) and whether an edit kept a newer shipped pack out.
+ *
+ * @param array $shipped The shipped pack, already validated.
+ *
+ * @return array id (int), slug, version, definition (stored, with the marker),
+ * pack (without it), edited (bool|null), newerShipped (bool) and outcome.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is missing or fails,
+ * or when the pack was renamed in OpenRegister. A create
+ * that fails because another request seeded the pack
+ * first is no failure: that pack is used.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function resolve(array $shipped): array {
+ $service = $this->requireService();
+ $slug = (string)($shipped['id'] ?? '');
+ $outcome = self::FOUND;
+ $entity = $this->findBySlug(service: $service, slug: $slug);
+ if ($entity === null) {
+ $this->assertNotRenamed(service: $service, slug: $slug);
+ try {
+ $entity = $this->write(
+ slug: $slug,
+ action: 'seeded',
+ call: fn (): object => $service->create(definition: $this->marker->seed(shipped: $shipped), ownerUid: null, builtin: true)
+ );
+ $outcome = self::SEEDED;
+ } catch (CmdbImportException $e) {
+ // Another request can seed the pack between the lookup and the create; the row it stored is the pack.
+ $entity = $this->findBySlug(service: $service, slug: $slug);
+ if ($entity === null) {
+ throw $e;
+ }
+ }
+ } elseif ($this->updateWouldRun(entity: $entity, shipped: $shipped) === true) {
+ [$entity, $outcome] = $this->updateUnedited(service: $service, entity: $entity, slug: $slug, shipped: $shipped);
+ }
+
+ $this->record(slug: $slug, id: (int)$entity->getId());
+ return $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: $outcome);
+ }//end resolve()
+
+ /**
+ * Replace an unedited stored pack with the newer shipped one, unless someone saved it since it was read.
+ *
+ * The update carries the read pack's `updatedAt`, so an administrator's
+ * edit saved in between is refused by OpenRegister instead of overwritten;
+ * that edit is then read back and kept. resolve() calls this
+ * only for a pack with an `updatedAt` (see updateWouldRun()).
+ *
+ * @param object $service OpenRegister's service.
+ * @param object $entity The stored pack as read.
+ * @param string $slug The pack slug.
+ * @param array $shipped The shipped pack.
+ *
+ * @return array{0: object, 1: string} The stored entity and the outcome (updated, or found when an edit came in between).
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the update fails otherwise.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function updateUnedited(object $service, object $entity, string $slug, array $shipped): array {
+ $id = (int)$entity->getId();
+ $readAt = $entity->getUpdatedAt()?->getTimestamp();
+ try {
+ $updated = $this->write(
+ slug: $slug,
+ action: 'updated',
+ call: fn (): object => $service->update(id: $id, definition: $this->marker->seed(shipped: $shipped), expectedUpdatedAt: $readAt)
+ );
+ return [$updated, self::UPDATED];
+ } catch (CmdbImportException $e) {
+ $current = null;
+ if (is_a($e->getPrevious(), self::PACK_CHANGED_CLASS) === true) {
+ $current = $this->findBySlug(service: $service, slug: $slug);
+ }
+
+ if ($current === null) {
+ throw $e;
+ }
+
+ return [$current, self::FOUND];
+ }
+ }//end updateUnedited()
+
+ /**
+ * What resolve() and find() answer for a stored pack.
+ *
+ * @param object $entity The MigrationPack entity.
+ * @param string $slug The pack slug.
+ * @param array $shipped The shipped pack.
+ * @param string $outcome found, seeded or updated.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function describe(object $entity, string $slug, array $shipped, string $outcome): array {
+ $definition = self::definitionOf(entity: $entity);
+ return [
+ 'id' => (int)$entity->getId(),
+ 'slug' => $slug,
+ 'version' => (string)($definition['version'] ?? ''),
+ 'storedVersion' => (string)($definition['version'] ?? ''),
+ 'definition' => $definition,
+ 'pack' => $this->marker->strip(definition: $definition),
+ 'edited' => $this->marker->isEdited(definition: $definition),
+ 'newerShipped' => $this->marker->isNewerShipped(definition: $definition, shipped: $shipped),
+ 'outcome' => $outcome,
+ 'updatePending' => false,
+ ];
+ }//end describe()
+
+ /**
+ * OpenRegister's service, or a refusal when the store is not available.
+ *
+ * @return object
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is not available.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function requireService(): object {
+ $service = $this->service();
+ if ($service === null) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister MigrationPackService is not available'
+ );
+ }
+
+ return $service;
+ }//end requireService()
+
+ /**
+ * The decoded definition of a stored pack.
+ *
+ * @param object $entity The MigrationPack entity.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function definitionOf(object $entity): array {
+ $definition = $entity->getDefinitionArray();
+ if (is_array($definition) === false) {
+ return [];
+ }
+
+ return $definition;
+ }//end definitionOf()
+
+ /**
+ * Find a stored pack by slug.
+ *
+ * @param object $service OpenRegister's service.
+ * @param string $slug The pack slug.
+ *
+ * @return object|null The entity, or null when no pack has the slug.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the lookup fails otherwise.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function findBySlug(object $service, string $slug): ?object {
+ try {
+ return $service->findByPackSlug(packSlug: $slug);
+ } catch (DoesNotExistException $e) {
+ return null;
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack ' . $slug . ' could not be read: ' . get_class($e),
+ details: ['pack' => $slug],
+ previous: $e
+ );
+ }
+ }//end findBySlug()
+
+ /**
+ * Refuse when the row recorded for a slug still exists under another slug.
+ *
+ * A renamed pack is one the import no longer reads, so seeding a second
+ * one would let the administrator edit a pack that never runs.
+ *
+ * @param object $service OpenRegister's service.
+ * @param string $slug The pack slug.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the pack was renamed or the lookup fails.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function assertNotRenamed(object $service, string $slug): void {
+ $id = ($this->recordedPackIds()[$slug] ?? null);
+ if ($id === null) {
+ return;
+ }
+
+ try {
+ $entity = $service->find(id: $id);
+ } catch (DoesNotExistException $e) {
+ // Deleted: seeding it again restores the shipped pack.
+ return;
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack ' . $slug . ' (id ' . $id . ') could not be read: ' . get_class($e),
+ details: ['pack' => $slug, 'packId' => $id],
+ previous: $e
+ );
+ }
+
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack id ' . $id . ' was renamed from ' . $slug . ' to '
+ . (string)$entity->getPackSlug() . '; the import reads it by the id ' . $slug . ' only, so rename it back',
+ details: ['pack' => $slug, 'packId' => $id]
+ );
+ }//end assertNotRenamed()
+
+ /**
+ * Create or update a stored pack, turning a failure into MAPPING_UNAVAILABLE.
+ *
+ * @param string $slug The pack slug.
+ * @param string $action What the call does, for the message.
+ * @param callable(): object $call The service call.
+ *
+ * @return object The entity.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the call fails.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function write(string $slug, string $action, callable $call): object {
+ try {
+ return $call();
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack ' . $slug . ' could not be ' . $action . ': ' . get_class($e),
+ details: ['pack' => $slug],
+ previous: $e
+ );
+ }
+ }//end write()
+
+ /**
+ * Remember the row id of a slug in the app config, when it changed.
+ *
+ * @param string $slug The pack slug.
+ * @param int $id The row id.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function record(string $slug, int $id): void {
+ $config = $this->appConfig();
+ if ($config === null) {
+ return;
+ }
+
+ $ids = $this->recordedPackIds();
+ if (($ids[$slug] ?? null) === $id) {
+ return;
+ }
+
+ $ids[$slug] = $id;
+ ksort($ids);
+ $config->setValueString(Application::APP_ID, self::CONFIG_KEY, (string)json_encode($ids));
+ }//end record()
+
+ /**
+ * OpenRegister's service; null on an OpenRegister without the hardened pack store.
+ *
+ * @return object|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function service(): ?object {
+ return $this->probe->service();
+ }//end service()
+
+ /**
+ * The app config, resolved from the container when it was not given.
+ *
+ * @return IAppConfig|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function appConfig(): ?IAppConfig {
+ if ($this->appConfig !== null) {
+ return $this->appConfig;
+ }
+
+ try {
+ if ($this->container->has(IAppConfig::class) === true) {
+ $config = $this->container->get(IAppConfig::class);
+ if ($config instanceof IAppConfig) {
+ $this->appConfig = $config;
+ }
+ }
+ } catch (Throwable $e) {
+ // No app config in this container: nothing is recorded.
+ $this->appConfig = null;
+ }
+
+ return $this->appConfig;
+ }//end appConfig()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackStoreProbe.php b/lib/Service/Cmdb/CmdbImportPackStoreProbe.php
new file mode 100644
index 000000000..4c41b8b66
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackStoreProbe.php
@@ -0,0 +1,205 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use Psr\Container\ContainerInterface;
+use ReflectionAttribute;
+use ReflectionClass;
+use ReflectionMethod;
+use Throwable;
+
+/**
+ * Resolves OpenRegister's pack service when the store may be used.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+final class CmdbImportPackStoreProbe {
+ /**
+ * The methods the store calls on OpenRegister's service.
+ *
+ * @var array
+ */
+ private const SERVICE_METHODS = ['findByPackSlug', 'find', 'create', 'update'];
+
+ /**
+ * The controller methods that write packs.
+ *
+ * @var array
+ */
+ private const WRITE_METHODS = ['create', 'update', 'destroy', 'import', 'preview'];
+
+ /**
+ * The attributes and annotations that would open a write route to non-admins or skip its CSRF check.
+ *
+ * @var array
+ */
+ private const OPENING_MARKERS = ['NoCSRFRequired', 'NoAdminRequired', 'PublicPage', 'AuthorizedAdminSetting'];
+
+ /**
+ * The service, once resolved; false when the store may not be used.
+ *
+ * @var object|false|null
+ */
+ private object|false|null $service = null;
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves OpenRegister's service.
+ * @param string $serviceClass OpenRegister's pack service.
+ * @param string $hardenedClass The class that marks an OpenRegister with the hardened pack store.
+ * @param string $controllerClass The controller whose write methods must be guarded.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private readonly string $serviceClass,
+ private readonly string $hardenedClass,
+ private readonly string $controllerClass,
+ ) {
+ }//end __construct()
+
+ /**
+ * OpenRegister's service, resolved once; null when the store may not be used.
+ *
+ * @return object|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function service(): ?object {
+ if ($this->service === null) {
+ $this->service = false;
+ try {
+ $this->service = $this->resolve();
+ } catch (Throwable $e) {
+ // OpenRegister is missing or its service cannot be built: the store is not available.
+ $this->service = false;
+ }
+ }
+
+ if ($this->service === false) {
+ return null;
+ }
+
+ return $this->service;
+ }//end service()
+
+ /**
+ * The service when every condition holds, false otherwise.
+ *
+ * @return object|false
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function resolve(): object|false {
+ if (class_exists($this->hardenedClass) === false
+ || self::writesAreGuarded(controllerClass: $this->controllerClass) === false
+ || $this->container->has($this->serviceClass) === false
+ ) {
+ return false;
+ }
+
+ $service = $this->container->get($this->serviceClass);
+ if (is_object($service) === false || self::hasMethods(service: $service) === false) {
+ return false;
+ }
+
+ return $service;
+ }//end resolve()
+
+ /**
+ * Whether every pack write method of the controller is admin-only and CSRF-protected.
+ *
+ * Nextcloud opens a route with an attribute or a docblock annotation, so
+ * a method that carries none of the opening markers keeps the default:
+ * admins only, with a CSRF token.
+ *
+ * @param string $controllerClass The controller class.
+ *
+ * @return bool False when the class or a write method is missing, or a write method is opened.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function writesAreGuarded(string $controllerClass): bool {
+ if (class_exists($controllerClass) === false) {
+ return false;
+ }
+
+ $class = new ReflectionClass($controllerClass);
+ foreach (self::WRITE_METHODS as $name) {
+ if ($class->hasMethod($name) === false || self::isOpened(method: $class->getMethod($name)) === true) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end writesAreGuarded()
+
+ /**
+ * Whether a controller method carries an attribute or annotation that opens its route.
+ *
+ * @param ReflectionMethod $method The method.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function isOpened(ReflectionMethod $method): bool {
+ $attributes = array_map(
+ static fn (ReflectionAttribute $attribute): string => substr((string)strrchr('\\' . $attribute->getName(), '\\'), 1),
+ $method->getAttributes()
+ );
+ $doc = (string)$method->getDocComment();
+ foreach (self::OPENING_MARKERS as $marker) {
+ if (in_array($marker, $attributes, true) === true || str_contains($doc, '@' . $marker) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isOpened()
+
+ /**
+ * Whether a resolved service has every method the store calls.
+ *
+ * @param object $service The service.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function hasMethods(object $service): bool {
+ foreach (self::SERVICE_METHODS as $method) {
+ if (method_exists($service, $method) === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end hasMethods()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index 1e8805c90..ce36506ba 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -11,10 +11,18 @@
* mean empty, which constants a sheet adds to its rows, and which columns
* may be read at all (the allowlist, design D3).
*
+ * The four sheet packs come from OpenRegister's migration-pack store when it
+ * is available (CmdbImportPackStore), so an administrator's edit there is
+ * what the import runs; the shipped files are the seed. The municipality
+ * pack maps the request options and always comes from its file. A pack from
+ * either source must pass the validator and the import's own rules
+ * (CmdbImportPackRules).
+ *
* `PackDefinitionValidator` is not part of OpenRegister's `Contract`
- * namespace, so it is resolved defensively. When it is missing, or a shipped
- * pack is invalid, loading throws `MAPPING_UNAVAILABLE` (503) before any
- * row is read (REQ-CMDB-005).
+ * namespace, so it is resolved defensively. When it is missing, a pack is
+ * invalid or breaks a rule, or the store that holds the packs is gone,
+ * loading throws `MAPPING_UNAVAILABLE` (503) before any row is read
+ * (REQ-CMDB-005, REQ-CMDB-021).
*
* @category Service
* @package OCA\Stackiq\Service\Cmdb
@@ -24,6 +32,7 @@
* @link https://github.com/ConductionNL/stackiq
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -47,6 +56,8 @@
* @SuppressWarnings(PHPMD.TooManyMethods) The same accessors, plus the loader's small private helpers.
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The accessors each guard against a
* malformed profile value; the sum passes the threshold, no single method is complex.
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength) The same accessors with their docblocks, and the
+ * loader that picks each pack's source; the store, the marker and the import rules are classes of their own.
*/
class CmdbImportProfile {
/**
@@ -86,6 +97,16 @@ class CmdbImportProfile {
*/
public const DEFAULT_MAX_REFERENCED_STRING_BYTES = 67108864;
+ /**
+ * The targets whose pack an administrator edits in OpenRegister.
+ *
+ * The municipality pack maps the request options, not a sheet, so it
+ * stays a shipped file.
+ *
+ * @var array
+ */
+ public const EDITABLE_TARGETS = ['module', 'manufacturer', 'usage', 'businessOwner'];
+
/**
* Sources of the municipality pack that come from the request, not from a sheet.
*
@@ -93,6 +114,11 @@ class CmdbImportProfile {
*/
private const OPTION_SOURCES = ['municipalityName'];
+ /**
+ * The separator of a concat mapping when the pack names none (OpenRegister's default).
+ */
+ private const DEFAULT_CONCAT_SEPARATOR = ' ';
+
/**
* The decoded profile, once loaded.
*
@@ -107,64 +133,205 @@ class CmdbImportProfile {
*/
private array $packs = [];
+ /**
+ * Where each loaded pack came from, per target.
+ *
+ * @var array> Per target: source, packId, storedVersion, shippedVersion, edited, newerShipped, updatePending.
+ */
+ private array $sources = [];
+
+ /**
+ * Whether OpenRegister's pack store was available when the packs were loaded.
+ *
+ * @var bool
+ */
+ private bool $storeAvailable = false;
+
+ /**
+ * The import rules every pack must follow.
+ *
+ * @var CmdbImportPackRules
+ */
+ private readonly CmdbImportPackRules $rules;
+
/**
* Constructor.
*
* @param ContainerInterface $container Resolves OpenRegister's validator.
* @param string|null $directory Directory of the profile and packs; null is the shipped one.
* @param string $profileFile File name of the profile inside the directory.
+ * @param CmdbImportPackStore|null $packStore OpenRegister's pack store; null builds one on the container.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function __construct(
private readonly ContainerInterface $container,
private ?string $directory = null,
private readonly string $profileFile = 'topdesk-profile.json',
+ private ?CmdbImportPackStore $packStore = null,
) {
if ($this->directory === null) {
$this->directory = __DIR__ . '/../../Settings/cmdb-import';
}
+
+ $this->rules = new CmdbImportPackRules();
}//end __construct()
/**
* Load the profile and validate every pack it names.
*
+ * Each sheet pack is taken from OpenRegister's pack store when the store
+ * is available, seeded from its shipped file when it is missing there, and
+ * otherwise from the shipped file. Once the packs were stored in
+ * OpenRegister, a store that is gone fails closed: the import never falls
+ * back to the files and silently drops an administrator's edit.
+ *
+ * The import run and the seed step load this way; viewing the mapping
+ * uses loadForView(), which writes nothing.
+ *
* @return void
*
- * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing,
- * or the profile or a pack is unreadable or invalid.
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, the profile
+ * or a pack is unreadable, invalid or breaks an import rule, or
+ * the store that holds the packs is not available.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function load(): void {
+ $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): array => $store->resolve(shipped: $shipped));
+ }//end load()
+
+ /**
+ * Load the profile and validate every pack it names, reading the store only.
+ *
+ * A pack the store does not hold yet is taken from its shipped file, and
+ * nothing is seeded, updated or recorded: viewing the mapping changes
+ * neither OpenRegister nor stackiq's app config. The next import run
+ * seeds and updates as load() does.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE as load().
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function loadForView(): void {
+ $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->find(shipped: $shipped));
+ }//end loadForView()
+
+ /**
+ * Load the profile and the packs, taking each editable pack from the store as the callback answers it.
+ *
+ * @param callable $storedPack (store, shipped pack) => the stored pack, or null to use the shipped file.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE as load().
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function loadPacks(callable $storedPack): void {
$validator = $this->resolveValidator();
$profile = $this->decodeFile(fileName: $this->profileFile);
+ $store = $this->packStore();
+ $storeAvailable = $store->isAvailable();
+ if ($storeAvailable === false) {
+ $store->assertShippedFallbackAllowed();
+ }
$packs = [];
+ $sources = [];
foreach (self::TARGETS as $target) {
- $fileName = $profile['packs'][$target] ?? null;
- if (is_string($fileName) === false || $fileName === '' || basename($fileName) !== $fileName) {
- throw new CmdbImportException(
- errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
- message: 'CMDB import profile names no pack for target ' . $target
- );
+ $fileName = (string)($profile['packs'][$target] ?? '');
+ $shipped = $this->readShippedPack(profile: $profile, target: $target, validator: $validator);
+ $pack = $shipped;
+ $label = 'CMDB mapping pack ' . $fileName;
+ $details = [];
+ $source = [
+ 'source' => 'file',
+ 'packId' => null,
+ 'storedVersion' => null,
+ 'shippedVersion' => (string)($shipped['version'] ?? ''),
+ 'edited' => null,
+ 'newerShipped' => false,
+ 'updatePending' => false,
+ ];
+
+ $stored = null;
+ if ($storeAvailable === true && in_array($target, self::EDITABLE_TARGETS, true) === true) {
+ $stored = $storedPack($store, $shipped);
}
- $pack = $this->decodeFile(fileName: $fileName);
- $errors = $validator->validate($pack);
- if (is_array($errors) === true && $errors !== []) {
- throw new CmdbImportException(
- errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
- message: 'CMDB mapping pack ' . $fileName . ' is invalid: ' . implode('; ', array_map('strval', $errors))
+ if ($stored !== null) {
+ $pack = $stored['pack'];
+ $label = 'OpenRegister migration pack ' . $stored['slug'] . ' (id ' . $stored['id'] . ')';
+ $details = ['pack' => $stored['slug'], 'packId' => $stored['id']];
+ $this->assertValidPack(validator: $validator, pack: $pack, label: $label, details: $details);
+ $source = array_merge(
+ $source,
+ [
+ 'source' => 'openregister',
+ 'packId' => $stored['id'],
+ 'storedVersion' => $stored['storedVersion'],
+ 'edited' => $stored['edited'],
+ 'newerShipped' => $stored['newerShipped'],
+ 'updatePending' => $stored['updatePending'],
+ ]
);
}
+ $this->assertImportRules(target: $target, pack: $pack, profile: $profile, label: $label, details: $details);
$packs[$target] = $pack;
- }
+ $sources[$target] = $source;
+ }//end foreach
$this->profile = $profile;
$this->packs = $packs;
- }//end load()
+ $this->sources = $sources;
+ $this->storeAvailable = $storeAvailable;
+ }//end loadPacks()
+
+ /**
+ * The shipped packs an administrator edits in OpenRegister, validated, per target.
+ *
+ * The seed step stores these; it reads them without touching the store.
+ *
+ * @return array>
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing or a file is unreadable or invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function shippedEditablePacks(): array {
+ $validator = $this->resolveValidator();
+ $profile = $this->decodeFile(fileName: $this->profileFile);
+
+ $packs = [];
+ foreach (self::EDITABLE_TARGETS as $target) {
+ $packs[$target] = $this->readShippedPack(profile: $profile, target: $target, validator: $validator);
+ }
+
+ return $packs;
+ }//end shippedEditablePacks()
+
+ /**
+ * Whether the import uses the shipped files because OpenRegister's pack store is not available.
+ *
+ * Only true while the packs were never stored there; afterwards a missing
+ * store fails the load instead.
+ *
+ * @return bool
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when loading fails.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function usesShippedFallback(): bool {
+ $this->profile();
+ return $this->storeAvailable === false;
+ }//end usesShippedFallback()
/**
* The upload limit in bytes, readable without validating the packs.
@@ -332,6 +499,42 @@ public function sheetNames(): array {
return array_column($this->sheets(), 'name');
}//end sheetNames()
+ /**
+ * The name of the archive sheet, or null when the profile names none.
+ *
+ * The archive sheet lists the applications the municipality archived; the
+ * import reads only its key column (`archiveSheet.name` in the profile).
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function archiveSheetName(): ?string {
+ $name = $this->profile()['archiveSheet']['name'] ?? null;
+ if (is_string($name) === false || $name === '' || in_array($name, $this->sheetNames(), true) === true) {
+ return null;
+ }
+
+ return $name;
+ }//end archiveSheetName()
+
+ /**
+ * Every sheet the reader may parse: the source sheets, then the archive sheet.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function readSheetNames(): array {
+ $names = $this->sheetNames();
+ $archive = $this->archiveSheetName();
+ if ($archive !== null) {
+ $names[] = $archive;
+ }
+
+ return $names;
+ }//end readSheetNames()
+
/**
* The rank of a sheet when an APPID is on more than one: lower wins.
*
@@ -469,14 +672,40 @@ public function requiredColumns(): array {
}//end requiredColumns()
/**
- * Columns that hold Excel serial dates.
+ * Columns that hold Excel serial dates: the profile's, plus every column a pack maps with a date transform.
+ *
+ * A pack edited in OpenRegister can map a new column to a date; that
+ * column is converted too, so the date transform gets `Y-m-d` and not an
+ * Excel serial number.
*
* @return array
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function dateColumns(): array {
- return $this->stringList(key: 'dateColumns');
+ $columns = $this->stringList(key: 'dateColumns');
+ foreach (self::TARGETS as $target) {
+ if ($target === 'municipality') {
+ continue;
+ }
+
+ foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === true && ($mapping['transform']['type'] ?? null) === 'date') {
+ $columns[] = (string)($mapping['source'] ?? '');
+ }
+ }
+ }
+
+ $constants = $this->constantColumns();
+ return array_values(
+ array_unique(
+ array_filter(
+ $columns,
+ fn (string $column): bool => $column !== '' && in_array($column, $constants, true) === false
+ )
+ )
+ );
}//end dateColumns()
/**
@@ -574,19 +803,65 @@ public function neverWrittenOnUpdate(string $target): array {
}//end neverWrittenOnUpdate()
/**
- * The accepted values of the missingRecords option.
+ * The separator of the concat mapping that writes a field, or null when no concat mapping writes it.
+ *
+ * The import drops the empty parts of a concatenated note; it splits on
+ * the separator the pack uses, which an administrator can change. A later
+ * plain mapping of the same field does not cancel it: the engine skips a
+ * mapping whose column is empty, so the concatenated value can still be
+ * the one kept, and the cleanup does no harm to a plain value. With several
+ * concat mappings, the last one's separator counts.
+ *
+ * @param string $target The profile target.
+ * @param string $field The target field.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function concatSeparator(string $target, string $field): ?string {
+ $separator = null;
+ foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === false || ($mapping['target'] ?? null) !== $field) {
+ continue;
+ }
+
+ if (($mapping['transform']['type'] ?? null) !== 'concat') {
+ continue;
+ }
+
+ $separator = ($mapping['transform']['separator'] ?? self::DEFAULT_CONCAT_SEPARATOR);
+ if (is_string($separator) === false || $separator === '') {
+ $separator = self::DEFAULT_CONCAT_SEPARATOR;
+ }
+ }
+
+ return $separator;
+ }//end concatSeparator()
+
+ /**
+ * The accepted values of the missingRecords option, readable without validating the packs.
+ *
+ * The controller checks the option before the import runs, so, like
+ * maxFileBytes(), this must not depend on OpenRegister being available.
*
* @return array
*
- * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
public function missingRecordsModes(): array {
- $modes = $this->stringList(key: 'missingRecords');
- if ($modes === []) {
+ try {
+ $profile = $this->profile ?? $this->decodeFile(fileName: $this->profileFile);
+ } catch (CmdbImportException $e) {
+ return ['keep'];
+ }
+
+ $modes = $profile['missingRecords'] ?? [];
+ if (is_array($modes) === false || $modes === []) {
return ['keep'];
}
- return $modes;
+ return array_values(array_map('strval', $modes));
}//end missingRecordsModes()
/**
@@ -626,7 +901,115 @@ public function referencedColumns(): array {
}//end referencedColumns()
/**
- * The loaded profile.
+ * The profile and the packs as the admin settings show them: what the next import runs.
+ *
+ * Loads and validates everything first, so a broken pack throws here as
+ * it does when an import starts. The transform of each field mapping is
+ * passed through as the pack stores it, so no key the engine reads is
+ * hidden from the admin; `required` is normalised to a boolean. Each pack
+ * also carries the write rules the import applies on top of the mapping:
+ * `defaults` (pack values set on every write), `createOnlyDefaults`
+ * (values set on create only, not mapped columns), `createOnlyFields` (mapped
+ * fields written on create or, on update, only when the stored value is
+ * empty; a key of `createOnlyDefaults` only when it is also mapped) and `neverWritten` (fields the
+ * import never writes on an existing object). It also says where it came
+ * from (`source`, `packId`, the stored and the shipped version, whether it
+ * was edited and whether a newer shipped pack was left out because of
+ * that) and whether it is edited in OpenRegister (`editable`).
+ *
+ * @return array{profile: array, packs: array>, openRegisterPacks: bool}
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing,
+ * or the profile or a pack is unreadable or invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ public function mappingOverview(): array {
+ $profile = $this->profile();
+
+ $packs = [];
+ foreach (self::TARGETS as $target) {
+ $pack = $this->pack(target: $target);
+ $mappings = [];
+ foreach (($pack['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === false) {
+ continue;
+ }
+
+ $transform = $mapping['transform'] ?? null;
+ if (is_array($transform) === false) {
+ $transform = null;
+ }
+
+ $mappings[] = [
+ 'source' => (string)($mapping['source'] ?? ''),
+ 'target' => (string)($mapping['target'] ?? ''),
+ 'required' => (bool)($mapping['required'] ?? false),
+ 'transform' => $transform,
+ ];
+ }
+
+ // A create-only default that is not a mapped field (the module type) is a
+ // value the import sets on create; merge() never reads it on update, so it
+ // is listed under createOnlyDefaults only. A mapped field keeps its place
+ // in createOnlyFields, since merge() guards it on update.
+ $createOnlyDefaults = $this->createOnlyDefaults(target: $target);
+ $unmappedDefaults = array_diff(array_map('strval', array_keys($createOnlyDefaults)), array_column($mappings, 'target'));
+ $createOnlyFields = array_values(array_diff($this->createOnlyFields(target: $target), $unmappedDefaults));
+
+ $defaults = $pack['defaults'] ?? [];
+ if (is_array($defaults) === false) {
+ $defaults = [];
+ }
+
+ $packs[] = array_merge(
+ [
+ 'target' => $target,
+ 'file' => (string)($profile['packs'][$target] ?? ''),
+ 'id' => (string)($pack['id'] ?? ''),
+ 'name' => (string)($pack['name'] ?? ''),
+ 'version' => (string)($pack['version'] ?? ''),
+ 'description' => (string)($pack['description'] ?? ''),
+ 'fieldMappings' => $mappings,
+ 'defaults' => $defaults,
+ 'createOnlyDefaults' => $createOnlyDefaults,
+ 'createOnlyFields' => $createOnlyFields,
+ 'neverWritten' => $this->neverWrittenOnUpdate(target: $target),
+ ],
+ $this->sources[$target],
+ ['editable' => in_array($target, self::EDITABLE_TARGETS, true)]
+ );
+ }//end foreach
+
+ return [
+ 'profile' => [
+ 'id' => (string)($profile['id'] ?? ''),
+ 'name' => (string)($profile['name'] ?? ''),
+ 'version' => (string)($profile['version'] ?? ''),
+ 'profileFile' => $this->profileFile,
+ 'sheets' => $this->sheets(),
+ 'archiveSheet' => $this->archiveSheetName(),
+ 'sheetPrecedence' => $this->stringList(key: 'sheetPrecedence'),
+ 'keyColumn' => $this->keyColumn(),
+ 'nameColumn' => $this->nameColumn(),
+ 'requiredColumns' => $this->requiredColumns(),
+ 'dateColumns' => $this->dateColumns(),
+ 'idColumns' => $this->idColumns(),
+ 'emptyValues' => $this->emptyValues(),
+ 'externalKeyPrefix' => $this->externalKeyPrefix(),
+ 'missingRecords' => $this->missingRecordsModes(),
+ ],
+ 'packs' => $packs,
+ 'openRegisterPacks' => $this->storeAvailable,
+ ];
+ }//end mappingOverview()
+
+ /**
+ * The loaded profile; loaded read only when nothing loaded it yet.
+ *
+ * The import run calls load() itself before it reads a row, so a lazy
+ * load is a read: the limits, the sheet names or the mapping view.
*
* @return array
*
@@ -634,7 +1017,7 @@ public function referencedColumns(): array {
*/
private function profile(): array {
if ($this->profile === null) {
- $this->load();
+ $this->loadForView();
}
return ($this->profile ?? []);
@@ -656,6 +1039,107 @@ private function stringList(string $key): array {
return array_values(array_map('strval', $value));
}//end stringList()
+ /**
+ * OpenRegister's pack store, built on the container when none was injected.
+ *
+ * @return CmdbImportPackStore
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function packStore(): CmdbImportPackStore {
+ if ($this->packStore === null) {
+ $this->packStore = new CmdbImportPackStore(container: $this->container);
+ }
+
+ return $this->packStore;
+ }//end packStore()
+
+ /**
+ * Read and validate the shipped pack of a target.
+ *
+ * @param array $profile The decoded profile.
+ * @param string $target The target.
+ * @param object $validator OpenRegister's validator.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the profile names no pack or the file is unreadable or invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function readShippedPack(array $profile, string $target, object $validator): array {
+ $fileName = $profile['packs'][$target] ?? null;
+ if (is_string($fileName) === false || $fileName === '' || basename($fileName) !== $fileName) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'CMDB import profile names no pack for target ' . $target
+ );
+ }
+
+ $pack = $this->decodeFile(fileName: $fileName);
+ $this->assertValidPack(validator: $validator, pack: $pack, label: 'CMDB mapping pack ' . $fileName);
+
+ return $pack;
+ }//end readShippedPack()
+
+ /**
+ * Refuse a pack OpenRegister's validator refuses.
+ *
+ * @param object $validator OpenRegister's validator.
+ * @param array $pack The pack.
+ * @param string $label The pack as the message names it: its file, or its OpenRegister slug and id.
+ * @param array $details The exception details: the OpenRegister pack and its id, for a stored pack.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the pack is invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function assertValidPack(object $validator, array $pack, string $label, array $details = []): void {
+ $errors = $validator->validate($pack);
+ if (is_array($errors) === true && $errors !== []) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: $label . ' is invalid: ' . implode('; ', array_map('strval', $errors)),
+ details: $details
+ );
+ }
+ }//end assertValidPack()
+
+ /**
+ * Refuse a pack that breaks a rule of the import, which the validator does not know.
+ *
+ * The import writes with OpenRegister's RBAC and multitenancy off, so a
+ * pack may map any plain property of its target schema, never the
+ * object's metadata, the keys the import sets itself, a relation, a file,
+ * a field the register hides from forms or calculates, the publication
+ * dates or an admin field, and must keep the mappings the
+ * import matches records on (REQ-CMDB-021, CmdbImportPackRules).
+ *
+ * @param string $target The target.
+ * @param array $pack The validated pack.
+ * @param array $profile The decoded profile.
+ * @param string $label The pack as the message names it.
+ * @param array $details The exception details: the OpenRegister pack and its id, for a stored pack.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the pack breaks a rule, or the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function assertImportRules(string $target, array $pack, array $profile, string $label, array $details): void {
+ $violations = $this->rules->violations(target: $target, pack: $pack, profile: $profile);
+ if ($violations !== []) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: $label . ' breaks a rule of the CMDB import: ' . implode('; ', $violations),
+ details: $details
+ );
+ }
+ }//end assertImportRules()
+
/**
* Resolve OpenRegister's pack validator.
*
@@ -679,8 +1163,8 @@ private function resolveValidator(): object {
}
if (class_exists($class) === true) {
- $validator = new $class();
- if (method_exists($validator, 'validate') === true) {
+ $validator = (new OpenRegisterInstance(container: $this->container))->build(class: $class);
+ if ($validator !== null && method_exists($validator, 'validate') === true) {
return $validator;
}
}
diff --git a/lib/Service/Cmdb/CmdbImportReport.php b/lib/Service/Cmdb/CmdbImportReport.php
index 730365fde..2615c8b07 100644
--- a/lib/Service/Cmdb/CmdbImportReport.php
+++ b/lib/Service/Cmdb/CmdbImportReport.php
@@ -36,6 +36,10 @@ class CmdbImportReport {
public const UNCHANGED = 'unchanged';
public const SKIPPED = 'skipped';
public const FAILED = 'failed';
+ public const ARCHIVED = 'archived';
+ public const UNARCHIVED = 'unarchived';
+ public const DELETED = 'deleted';
+ public const RESTORED = 'restored';
/**
* The row entries, in processing order.
@@ -90,7 +94,7 @@ public function __construct(
* Add one row outcome.
*
* @param string $sheet The sheet name.
- * @param int $row The 1-based sheet row number.
+ * @param int $row The 1-based sheet row number, or 0 for an application the reconciliation archived or deleted.
* @param string $appId The APPID ('' when missing).
* @param string $name The application name ('' when missing).
* @param string $outcome One of the outcome constants.
@@ -180,14 +184,25 @@ public function markCancelled(): void {
}//end markCancelled()
/**
- * The number of rows processed so far.
+ * Whether the run stopped on a cancel.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function isCancelled(): bool {
+ return $this->cancelled;
+ }//end isCancelled()
+
+ /**
+ * The number of sheet rows processed so far; reconciliation entries (row 0) are not sheet rows.
*
* @return int
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
*/
public function processed(): int {
- return count($this->rows);
+ return count(array_filter($this->rows, static fn (array $row): bool => $row['row'] > 0));
}//end processed()
/**
@@ -195,21 +210,28 @@ public function processed(): int {
*
* `unpublished` counts the modules created without a publication date;
* a row whose module was created but whose usage then failed counts too.
+ * `processed` counts sheet rows only; every outcome, also of a
+ * reconciliation entry, is counted under its own key.
*
* @return array{rowsRead: int, processed: int, created: int, updated: int, unchanged: int, skipped: int, failed: int,
- * warnings: int, unpublished: int}
+ * archived: int, unarchived: int, deleted: int, restored: int, warnings: int, unpublished: int}
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
public function summary(): array {
$summary = [
'rowsRead' => $this->rowsRead,
- 'processed' => count($this->rows),
+ 'processed' => $this->processed(),
self::CREATED => 0,
self::UPDATED => 0,
self::UNCHANGED => 0,
self::SKIPPED => 0,
self::FAILED => 0,
+ self::ARCHIVED => 0,
+ self::UNARCHIVED => 0,
+ self::DELETED => 0,
+ self::RESTORED => 0,
'warnings' => 0,
'unpublished' => $this->unpublished,
];
diff --git a/lib/Service/Cmdb/CmdbOwnerIdentity.php b/lib/Service/Cmdb/CmdbOwnerIdentity.php
new file mode 100644
index 000000000..b37710533
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbOwnerIdentity.php
@@ -0,0 +1,174 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCP\IConfig;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Derives the contact UID and the per-run cache key of an owner.
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+class CmdbOwnerIdentity {
+ /**
+ * The prefix of a contact UID derived from an employee number.
+ */
+ public const UID_PREFIX = 'stackiq-cmdb-owner-';
+
+ /**
+ * Whether the missing instance secret was logged already.
+ *
+ * @var boolean
+ */
+ private bool $missingSecretLogged = false;
+
+ /**
+ * Constructor.
+ *
+ * @param IConfig $config Reads this instance's `secret`, from which the UID's HMAC key derives.
+ * @param LoggerInterface $logger Says, once, that employee numbers are not used without a secret; never handed a number.
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ public function __construct(
+ private readonly IConfig $config,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * The contact UID of an owner with this employee number, or null without a usable one.
+ *
+ * An HMAC-SHA256 with a key derived from this instance's `secret`
+ * (config.php), so the employee number cannot be recovered from the UID
+ * by hashing every possible number, and scoped to the municipality, so
+ * the same number at two municipalities is two owners. A number Excel
+ * stored as a decimal ("1234.0"), with leading zeros ("01234") or with
+ * surrounding spaces, non-breaking ones included, is the same number as
+ * "1234". A value without a digit other than 0 ("-", "n.v.t.", "0") is
+ * no employee number: it gives null, so the owner is matched on the
+ * e-mail address or the name instead of sharing one UID with every
+ * other owner whose cell holds the same placeholder.
+ *
+ * Fail-mode (ADR-102): degrades. Without a `secret` the employee number is
+ * not used (null, logged once), never hashed with an empty key, and the
+ * owner is matched on the e-mail address or the name like before the
+ * number existed, so two namesakes become one contact. A changed secret
+ * gives every owner a new UID, so a new contact on the next import.
+ *
+ * @param string $employeeNumber The employee number from the owner pack.
+ * @param string $municipalityUuid The municipality.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ public function contactUid(string $employeeNumber, string $municipalityUuid): ?string {
+ $number = self::normaliseNumber(employeeNumber: $employeeNumber);
+ if ($number === null) {
+ return null;
+ }
+
+ $secret = $this->config->getSystemValueString('secret', '');
+ if ($secret === '') {
+ if ($this->missingSecretLogged === false) {
+ $this->missingSecretLogged = true;
+ $this->logger->warning('CmdbOwnerIdentity: this instance has no secret in config.php; owners are matched without their employee number');
+ }
+
+ return null;
+ }
+
+ $key = hash_hmac('sha256', self::UID_PREFIX, $secret);
+ return self::UID_PREFIX . hash_hmac('sha256', $municipalityUuid . '|' . $number, $key);
+ }//end contactUid()
+
+ /**
+ * A random UID (UUID v4) for the contact of an owner without an employee number.
+ *
+ * The import gives every new owner contact its UID, so the contacts
+ * bridge never matches one on its own (on an e-mail address it would
+ * also take a contact identified by an employee number).
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ public function newContactUid(): string {
+ $bytes = random_bytes(16);
+ $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
+ $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);
+
+ return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
+ }//end newContactUid()
+
+ /**
+ * The employee number in its compared form, or null when the value is no employee number.
+ *
+ * @param string $employeeNumber The value from the owner pack.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ private static function normaliseNumber(string $employeeNumber): ?string {
+ $number = mb_strtolower((string)preg_replace('/^[\s\x{00A0}]+|[\s\x{00A0}]+$/u', '', $employeeNumber));
+ $number = (string)preg_replace('/^(\d+)\.0+$/', '$1', $number);
+ if (preg_match('/^\d+$/', $number) === 1) {
+ $number = ltrim($number, '0');
+ }
+
+ if (preg_match('/[1-9]/', $number) !== 1) {
+ return null;
+ }
+
+ return $number;
+ }//end normaliseNumber()
+
+ /**
+ * The key under which one import run remembers the owner's contact.
+ *
+ * @param string|null $contactUid The UID from the employee number, or null.
+ * @param string $email The e-mail address, or ''.
+ * @param string $displayName The display name.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ public function cacheKey(?string $contactUid, string $email, string $displayName): string {
+ if ($contactUid !== null) {
+ return 'uid:' . $contactUid;
+ }
+
+ if ($email !== '') {
+ return 'email:' . mb_strtolower($email);
+ }
+
+ return 'name:' . mb_strtolower($displayName);
+ }//end cacheKey()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbRegisterSchemaFields.php b/lib/Service/Cmdb/CmdbRegisterSchemaFields.php
new file mode 100644
index 000000000..5394bbf72
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbRegisterSchemaFields.php
@@ -0,0 +1,251 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCA\Stackiq\Exception\CmdbImportException;
+
+/**
+ * Reads the register's properties per schema and why no pack may write them.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbRegisterSchemaFields {
+
+ /**
+ * Per schema, once read: each property name and why no pack may write it.
+ *
+ * @var array>|null
+ */
+ private ?array $fields = null;
+
+ /**
+ * Constructor.
+ *
+ * @param string $settingsDirectory Directory of the register file and its fragments.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private string $settingsDirectory,
+ ) {
+ }//end __construct()
+
+ /**
+ * The properties per schema of the register stackiq ships, fragments included, each with why no pack may write it.
+ *
+ * A property is refused when any of its definitions makes it a relation
+ * or a file (`relation`), or hides it from forms or calculates it (`system`);
+ * a relation wins.
+ *
+ * @return array>
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register file or one of its fragments cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function fields(): array {
+ if ($this->fields !== null) {
+ return $this->fields;
+ }
+
+ $decodedFiles = $this->registerDocuments();
+
+ $fields = [];
+ foreach ($decodedFiles as $decoded) {
+ foreach (($decoded['components']['schemas'] ?? []) as $schema => $definition) {
+ $calculations = (array)($definition['configuration']['x-openregister-calculations'] ?? []);
+ foreach ((array)($definition['properties'] ?? []) as $property => $propertyDefinition) {
+ $reason = self::reasonOf(definition: $propertyDefinition, calculated: array_key_exists((string)$property, $calculations));
+ $before = ($fields[(string)$schema][(string)$property] ?? '');
+ if ($before !== 'relation' && $reason !== '') {
+ $before = $reason;
+ }
+
+ $fields[(string)$schema][(string)$property] = $before;
+ }
+ }
+ }
+
+ $this->fields = $fields;
+ return $fields;
+ }//end fields()
+
+ /**
+ * The register file and every fragment, decoded; fails closed when one cannot be read.
+ *
+ * A fragment carries the same protection as the register (a hidden or
+ * calculated field), so a skipped fragment would let a pack write it.
+ *
+ * @return array>
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register file or one of its fragments cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function registerDocuments(): array {
+ $register = $this->decodeJson(file: $this->settingsDirectory . '/softwarecatalogus_register.json');
+ if ($register === null) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'The stackiq register definition cannot be read, so the CMDB import packs cannot be checked'
+ );
+ }
+
+ $fragments = glob($this->settingsDirectory . '/register.d/*.json');
+ if ($fragments === false) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'The stackiq register fragments cannot be listed, so the CMDB import packs cannot be checked'
+ );
+ }
+
+ $decodedFiles = [$register];
+ foreach ($fragments as $file) {
+ $fragment = $this->decodeJson(file: $file);
+ if ($fragment === null) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'The stackiq register fragment ' . basename($file) . ' cannot be read, so the CMDB import packs cannot be checked'
+ );
+ }
+
+ $decodedFiles[] = $fragment;
+ }
+
+ return $decodedFiles;
+ }//end registerDocuments()
+
+ /**
+ * Why no pack may write a property: `relation`, `system`, or '' for a plain, writable one.
+ *
+ * @param mixed $definition The property definition.
+ * @param bool $calculated Whether the schema calculates the property.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function reasonOf(mixed $definition, bool $calculated): string {
+ if (self::isRelationOrFile(definition: $definition) === true) {
+ return 'relation';
+ }
+
+ if (self::isSystemOwned(definition: $definition, calculated: $calculated) === true) {
+ return 'system';
+ }
+
+ return '';
+ }//end reasonOf()
+
+ /**
+ * Whether a property definition, or that of its items, points at another object or holds a file.
+ *
+ * A reference, an inverse or write-back relation, a uuid, an object
+ * configuration that relates or names a schema, or a file; a nested
+ * object with no schema is a plain value.
+ *
+ * @param mixed $definition The property definition.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function isRelationOrFile(mixed $definition): bool {
+ if (is_array($definition) === false) {
+ return false;
+ }
+
+ $configuration = $definition['objectConfiguration'] ?? [];
+ if (is_array($configuration) === false) {
+ $configuration = [];
+ }
+
+ $markers = array_filter(
+ [
+ ($definition['$ref'] ?? null),
+ ($definition['inversedBy'] ?? null),
+ ($definition['writeBack'] ?? null),
+ ($configuration['schema'] ?? null),
+ ($definition['format'] ?? null) === 'uuid',
+ ($configuration['handling'] ?? null) === 'related-object',
+ ($definition['type'] ?? null) === 'file',
+ ]
+ );
+ if ($markers !== []) {
+ return true;
+ }
+
+ return self::isRelationOrFile(definition: ($definition['items'] ?? null));
+ }//end isRelationOrFile()
+
+ /**
+ * Whether a property definition is hidden from forms or calculated.
+ *
+ * Such a field is written by another subsystem (the service desk
+ * exchange stamps, the calculated suggestions), not edited by people.
+ *
+ * @param mixed $definition The property definition.
+ * @param bool $calculated Whether the schema calculates the property.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function isSystemOwned(mixed $definition, bool $calculated): bool {
+ if ($calculated === true) {
+ return true;
+ }
+
+ return is_array($definition) === true && ($definition['hideOnForm'] ?? false) === true;
+ }//end isSystemOwned()
+
+ /**
+ * A JSON file decoded to an array, or null when it cannot be read.
+ *
+ * @param string $file The file.
+ *
+ * @return array|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function decodeJson(string $file): ?array {
+ $content = false;
+ if (is_readable($file) === true) {
+ $content = file_get_contents($file);
+ }
+
+ if (is_string($content) === false) {
+ return null;
+ }
+
+ $decoded = json_decode($content, true);
+ if (is_array($decoded) === false) {
+ return null;
+ }
+
+ return $decoded;
+ }//end decodeJson()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php
index 3ff3e9184..674179925 100644
--- a/lib/Service/Cmdb/CmdbWorkbookReader.php
+++ b/lib/Service/Cmdb/CmdbWorkbookReader.php
@@ -42,6 +42,9 @@
* beyond twice the row limit is `TOO_MANY_ROWS`. A read filter then materialises only the
* header row and the resolved columns of the rows up to that bound
* (CmdbReadFilter), which bounds the cell objects, not the parse.
+ * 7. The archive sheet ("Gearchiveerde Applicaties"), when the workbook has
+ * it, counts as a read sheet for every bound above, and only its key
+ * column is resolved and read (openspec/changes/cmdb-import-archive-reconciliation).
*
* @category Service
* @package OCA\Stackiq\Service\Cmdb
@@ -151,17 +154,39 @@ public function isAvailable(): bool {
* The sheets are then read twice through a read filter: once for the
* header row, once for the resolved columns of the data rows, so no other
* cell is ever materialised.
+ * The archive sheet, when the workbook has it, is read under the same
+ * bounds as a source sheet, for its key column only. A row whose key is a
+ * formula without a cached value is read as empty, as on a source sheet,
+ * and its row number is listed in `archive.uncachedRows`: the sheet then
+ * lists an application the reader could not see, so a caller must not
+ * treat the sheet as complete.
+ * The `appIds` are the raw key-column values as read, in sheet order:
+ * not trimmed, not normalised and not de-duplicated, and an empty cell
+ * is an empty entry. Matching them to the stored applications, and any
+ * normalisation that needs, is the consumer's job.
+ * The referenced-string budget (`maxReferencedStringBytes`) is divided
+ * over every sheet in the profile's `readSheetNames()`, so the archive
+ * sheet always counts as one of them, also in a workbook that has no
+ * archive sheet: each sheet's share is limit / sheet count (limit / 3 for
+ * the Topdesk profile). This is deliberate: the bound does not depend on
+ * what the workbook contains.
*
* @param string $path The xlsx file, already checked by assertXlsx().
* @param CmdbImportProfile $profile The import profile.
*
* @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings`
- * (list of {sheet, message}) and `date1904` (bool).
+ * (list of {sheet, message}), `date1904` (bool), `archive`
+ * ({sheet, present, keyColumnMissing, appIds, uncachedRows}) and `uncachedKeyRows`
+ * (source sheet => numbers of the rows dropped as empty because their key is a
+ * formula without a cached value; a row with other content is in `rows`, its
+ * `uncached` lists the key) and `absentSheets` (the profile's source sheets the
+ * workbook does not have, in profile order).
*
* @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
* MISSING_COLUMN or TOO_MANY_ROWS.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
public function read(string $path, CmdbImportProfile $profile): array {
$oversized = $this->assertUncompressedSize(path: $path, limit: $profile->maxUncompressedBytes(), partLimit: $profile->maxPartBytes());
@@ -177,7 +202,7 @@ public function read(string $path, CmdbImportProfile $profile): array {
$blank = (new CmdbPartReferences())->assertPartSizes(
path: $path,
oversized: $oversized,
- sourceSheets: $profile->sheetNames(),
+ sourceSheets: $profile->readSheetNames(),
limit: $profile->maxPartBytes(),
scanner: $scanner
);
@@ -303,10 +328,102 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
$bounds->assertReferencedStringBytes(
path: $path,
limit: $profile->maxReferencedStringBytes(),
- sheetCount: count($profile->sheetNames()),
+ sheetCount: count($profile->readSheetNames()),
scanner: $scanner
);
+ ['present' => $present, 'absent' => $absent, 'available' => $available] = $this->sourceSheets(path: $path, profile: $profile);
+
+ $archiveSheet = $profile->archiveSheetName();
+ $loaded = $present;
+ if ($archiveSheet !== null && in_array($archiveSheet, $available, true) === true) {
+ $loaded[] = $archiveSheet;
+ }
+
+ $limit = $profile->maxRowsPerSheet();
+ $lastRow = self::lastReadableRow(limit: $limit);
+ $this->assertRowSpan(path: $path, sheetNames: $loaded, lastRow: $lastRow, limit: $limit);
+
+ $headers = $this->load(path: $path, sheetNames: $loaded, filter: new CmdbReadFilter(lastRow: 1));
+ try {
+ $resolved = $this->resolveSheets(spreadsheet: $headers, sheetNames: $present, profile: $profile);
+ $archiveColumns = [];
+ if ($loaded !== $present) {
+ // Only the key column of the archive sheet is read; the allowlist does not grow.
+ $archiveColumns = $this->resolveColumns(worksheet: $headers->getSheetByName($archiveSheet), referenced: [$profile->keyColumn()]);
+ }
+ } finally {
+ $headers->disconnectWorksheets();
+ }
+
+ // A sheet without the key column is not read; the import reports it as missing its key column.
+ $archive = [
+ 'sheet' => (string)$archiveSheet,
+ 'present' => false,
+ 'keyColumnMissing' => ($loaded !== $present && $archiveColumns === []),
+ 'appIds' => [],
+ 'uncachedRows' => [],
+ ];
+ $letters = array_map(static fn (array $columns): array => array_keys($columns), $resolved['columns']);
+ $loaded = $present;
+ if ($archiveColumns !== []) {
+ $letters[$archiveSheet] = array_keys($archiveColumns);
+ $loaded[] = $archiveSheet;
+ }
+
+ $spreadsheet = $this->load(path: $path, sheetNames: $loaded, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters));
+ try {
+ $uncachedKeyRows = [];
+ $rows = $this->readSourceRows(
+ spreadsheet: $spreadsheet,
+ sheetNames: $present,
+ columnsPerSheet: $resolved['columns'],
+ limit: $limit,
+ keyColumn: $profile->keyColumn(),
+ uncachedKeyRows: $uncachedKeyRows
+ );
+ if ($archiveColumns !== []) {
+ $unseen = [];
+ $archiveRows = $this->readRows(
+ worksheet: $spreadsheet->getSheetByName($archiveSheet),
+ columns: $archiveColumns,
+ sheetName: (string)$archiveSheet,
+ limit: $limit,
+ unseen: $unseen
+ );
+ $archive['present'] = true;
+ $archive['uncachedRows'] = $unseen;
+ $archive['appIds'] = array_column(array_column($archiveRows, 'cells'), $profile->keyColumn());
+ }
+
+ $date1904 = self::usesDate1904(spreadsheet: $spreadsheet);
+ } finally {
+ $spreadsheet->disconnectWorksheets();
+ }
+
+ return [
+ 'rows' => $rows,
+ 'importWarnings' => $resolved['warnings'],
+ 'date1904' => $date1904,
+ 'archive' => $archive,
+ 'uncachedKeyRows' => $uncachedKeyRows,
+ 'absentSheets' => $absent,
+ ];
+ }//end readBounded()
+
+ /**
+ * The profile's source sheets that the workbook has, the ones it lacks, and every sheet it has.
+ *
+ * A workbook with only some of the source sheets is read; the import must not take it as the whole CMDB.
+ *
+ * @param string $path The xlsx file.
+ * @param CmdbImportProfile $profile The import profile.
+ *
+ * @return array{present: array, absent: array, available: array}
+ *
+ * @throws CmdbImportException NOT_XLSX when the sheet list cannot be read, NO_SOURCE_SHEET when no source sheet is there.
+ */
+ private function sourceSheets(string $path, CmdbImportProfile $profile): array {
$reader = $this->newReader(sheetNames: null);
try {
$available = $reader->listWorksheetNames($path);
@@ -328,41 +445,68 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
);
}
- $limit = $profile->maxRowsPerSheet();
- $lastRow = self::lastReadableRow(limit: $limit);
- $this->assertRowSpan(path: $path, sheetNames: $present, lastRow: $lastRow, limit: $limit);
+ return ['present' => $present, 'absent' => array_values(array_diff($expected, $available)), 'available' => $available];
+ }//end sourceSheets()
- $headers = $this->load(path: $path, sheetNames: $present, filter: new CmdbReadFilter(lastRow: 1));
- try {
- $resolved = $this->resolveSheets(spreadsheet: $headers, sheetNames: $present, profile: $profile);
- } finally {
- $headers->disconnectWorksheets();
+ /**
+ * The data rows of the source sheets, in sheet order.
+ *
+ * @param object $spreadsheet The spreadsheet, loaded through the data-pass read filter.
+ * @param array $sheetNames The source sheets the workbook has.
+ * @param array> $columnsPerSheet Sheet name => column letter => column name.
+ * @param int $limit The row limit per sheet.
+ * @param string $keyColumn The key column's name.
+ * @param array> $uncachedKeyRows Filled, per sheet, with the number of each row that was
+ * dropped as empty only because its key is a formula
+ * without a cached value.
+ *
+ * @return array> See read().
+ *
+ * @throws CmdbImportException TOO_MANY_ROWS.
+ */
+ private function readSourceRows(
+ object $spreadsheet,
+ array $sheetNames,
+ array $columnsPerSheet,
+ int $limit,
+ string $keyColumn,
+ array &$uncachedKeyRows,
+ ): array {
+ $rows = [];
+ foreach ($sheetNames as $sheetName) {
+ $unseen = [];
+ $sheetRows = $this->readRows(
+ worksheet: $spreadsheet->getSheetByName($sheetName),
+ columns: $columnsPerSheet[$sheetName],
+ sheetName: $sheetName,
+ limit: $limit,
+ unseen: $unseen,
+ keyColumn: $keyColumn
+ );
+ if ($unseen !== []) {
+ $uncachedKeyRows[$sheetName] = array_values((array)$unseen);
+ }
+
+ array_push($rows, ...$sheetRows);
}
- $letters = array_map(static fn (array $columns): array => array_keys($columns), $resolved['columns']);
- $spreadsheet = $this->load(path: $path, sheetNames: $present, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters));
- try {
- $rows = [];
- foreach ($present as $sheetName) {
- $sheetRows = $this->readRows(
- worksheet: $spreadsheet->getSheetByName($sheetName),
- columns: $resolved['columns'][$sheetName],
- sheetName: $sheetName,
- limit: $limit
- );
- array_push($rows, ...$sheetRows);
- }
+ return $rows;
+ }//end readSourceRows()
- $date1904 = false;
- if (method_exists($spreadsheet, 'getExcelCalendar') === true) {
- $date1904 = ((int)$spreadsheet->getExcelCalendar() === 1904);
- }
- } finally {
- $spreadsheet->disconnectWorksheets();
+ /**
+ * Whether the workbook uses the 1904 date system.
+ *
+ * @param object $spreadsheet The spreadsheet.
+ *
+ * @return bool
+ */
+ private static function usesDate1904(object $spreadsheet): bool {
+ if (method_exists($spreadsheet, 'getExcelCalendar') === false) {
+ return false;
}
- return ['rows' => $rows, 'importWarnings' => $resolved['warnings'], 'date1904' => $date1904];
- }//end readBounded()
+ return ((int)$spreadsheet->getExcelCalendar() === 1904);
+ }//end usesDate1904()
/**
* The last row number the data pass reads.
@@ -625,12 +769,22 @@ private function resolveColumns(object $worksheet, array $referenced): array {
* @param array $columns Column letter => column name.
* @param string $sheetName The sheet name.
* @param int $limit The maximum number of non-empty rows.
+ * @param array|null $unseen Filled with the number of each row that read as empty only because
+ * a formula in it has no cached value; null to not collect them.
+ * @param string|null $keyColumn When set, only a row whose uncached columns include this one is collected.
*
* @return array, uncached: array}>
*
* @throws CmdbImportException TOO_MANY_ROWS.
*/
- private function readRows(object $worksheet, array $columns, string $sheetName, int $limit): array {
+ private function readRows(
+ object $worksheet,
+ array $columns,
+ string $sheetName,
+ int $limit,
+ ?array &$unseen = null,
+ ?string $keyColumn = null,
+ ): array {
$rows = [];
$lastRow = (int)$worksheet->getHighestDataRow();
for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) {
@@ -640,6 +794,10 @@ private function readRows(object $worksheet, array $columns, string $sheetName,
rowNumber: $rowNumber
);
if ($empty === true) {
+ if ($uncached !== [] && $unseen !== null && ($keyColumn === null || in_array($keyColumn, $uncached, true) === true)) {
+ $unseen[] = $rowNumber;
+ }
+
continue;
}
diff --git a/lib/Service/Cmdb/OpenRegisterInstance.php b/lib/Service/Cmdb/OpenRegisterInstance.php
new file mode 100644
index 000000000..400075b2f
--- /dev/null
+++ b/lib/Service/Cmdb/OpenRegisterInstance.php
@@ -0,0 +1,94 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCP\IL10N;
+use OCP\L10N\IFactory;
+use Psr\Container\ContainerInterface;
+use ReflectionClass;
+use Throwable;
+
+/**
+ * Builds an OpenRegister class with the constructor arguments it declares.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+class OpenRegisterInstance {
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container The container that resolves the translation factory.
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ ) {
+ }//end __construct()
+
+ /**
+ * Construct the class: without arguments, or with OpenRegister's translations when its constructor takes an IL10N.
+ *
+ * @param string $class The OpenRegister class.
+ *
+ * @return object|null The instance, or null when it cannot be built.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function build(string $class): ?object {
+ try {
+ $constructor = (new ReflectionClass($class))->getConstructor();
+ $arguments = [];
+ foreach (($constructor?->getParameters() ?? []) as $parameter) {
+ if ($parameter->isOptional() === true) {
+ break;
+ }
+
+ $type = $parameter->getType();
+ if ($type instanceof \ReflectionNamedType && $type->getName() === IL10N::class) {
+ $arguments[] = $this->l10n();
+ continue;
+ }
+
+ return null;
+ }
+
+ return new $class(...$arguments);
+ } catch (Throwable $e) {
+ return null;
+ }
+ }//end build()
+
+ /**
+ * OpenRegister's translations.
+ *
+ * @return IL10N
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ private function l10n(): IL10N {
+ $factory = $this->container->get(IFactory::class);
+
+ return $factory->get('openregister');
+ }//end l10n()
+}//end class
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 1efc049d7..f7e8bf8d4 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -30,7 +30,15 @@
* - `publicationDate` is set to the import's start on create, unless the
* admin chose not to publish (`publish` false), and never written on
* update; neither is `depublicationDate`.
- * - Records missing from a newer export are left untouched.
+ * - Records missing from a newer export are left untouched with
+ * `missingRecords` `keep`. With `archive` (the default) the municipality's
+ * usages and their modules are reconciled after the last row: archived
+ * when the APPID is on the archive sheet, soft-deleted when it is on no
+ * sheet, archived only when the workbook has no archive sheet, the sheet
+ * has no APPID column or an APPID in it has no cached value
+ * (openspec/changes/cmdb-import-archive-reconciliation, design D5). A
+ * module or usage matched among archived or soft-deleted objects is
+ * unarchived or restored before it is updated, never duplicated.
* - Owners become contact persons, never Nextcloud user accounts, and no
* report entry or log line carries an owner name or e-mail address.
* - One import runs per register at a time: every match is find-then-create,
@@ -55,10 +63,13 @@
use DateTimeImmutable;
use DateTimeZone;
+use JsonSerializable;
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\Stackiq\Exception\CmdbImportException;
use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
use OCA\Stackiq\Service\Cmdb\CmdbImportReport;
+use OCA\Stackiq\Service\Cmdb\CmdbOwnerIdentity;
+use OCA\Stackiq\Service\Cmdb\OpenRegisterInstance;
use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser;
use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -68,6 +79,7 @@
use OCP\Lock\LockedException;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
+use RuntimeException;
use Throwable;
/**
@@ -75,6 +87,12 @@
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
*
+ * @phpstan-type ArchiveSheet array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array}
+ *
+ * @phpstan-type ScopeExtra array{schemaKey: string, uuid: string, state: array{archived: bool, deleted: bool}}
+ * @phpstan-type ScopeStates array
+ * @phpstan-type ScopeEntry array{key: string, usage: object, module: object, states: ScopeStates, extras?: array}
+ *
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One import run resolves four linked
* object kinds per row (supplier, module, owners, usage), each with its own match rule,
* create-only fields and run cache. Splitting them over several classes would hand the
@@ -111,6 +129,58 @@ class CmdbExportImportService {
*/
public const SCHEMA_MAPPER_CLASS = 'OCA\OpenRegister\Db\SchemaMapper';
+ /**
+ * OpenRegister's archive handler, behind POST and DELETE .../archive (not a public contract).
+ */
+ public const ARCHIVE_HANDLER_CLASS = 'OCA\OpenRegister\Service\Object\ArchiveHandler';
+
+ /**
+ * OpenRegister's object mapper, whose restoreObject() empties the trash of one object (not a public contract).
+ */
+ public const OBJECT_MAPPER_CLASS = 'OCA\OpenRegister\Db\MagicMapper';
+
+ /**
+ * What happens to records missing from the export when the request does not say.
+ */
+ public const DEFAULT_MISSING_RECORDS = 'archive';
+
+ /**
+ * The start of every archive reason the import writes, so the audit trail says who archived and why.
+ */
+ private const REASON_PREFIX = 'cmdb-import: ';
+
+ /**
+ * The search lens that lists the archive alone (OpenRegister's `_archived=true`).
+ *
+ * The state of a stored object is read from the lens that found it, never
+ * from the archive marker of a search result: OpenRegister's list search
+ * does not hydrate that marker (design D9).
+ *
+ * @var array
+ */
+ private const ARCHIVED_LENS = ['_archived' => 'true'];
+
+ /**
+ * The state of an object found without a lens.
+ *
+ * @var array{archived: bool, deleted: bool}
+ */
+ private const STATE_WORKING = ['archived' => false, 'deleted' => false];
+
+ /**
+ * The state of an object found with ARCHIVED_LENS.
+ *
+ * @var array{archived: bool, deleted: bool}
+ */
+ private const STATE_ARCHIVED = ['archived' => true, 'deleted' => false];
+
+ /**
+ * The state of an object found in the trash; whether it was archived too is read when it is restored.
+ *
+ * @var array{archived: bool, deleted: bool}
+ */
+ private const STATE_DELETED = ['archived' => false, 'deleted' => true];
+
/**
* The properties every match search and the module key rely on, per schema.
*
@@ -178,14 +248,16 @@ class CmdbExportImportService {
private const UNMATCHED_STATUSES = ['merged', 'Inactive'];
/**
- * Page size for loading the organisations a name may match.
+ * How many row numbers an archive-sheet warning names before it cuts the list.
+ *
+ * @var int
*/
- private const PAGE_SIZE = 500;
+ private const WARNED_ROWS = 10;
/**
- * Separator of the concat mapping for the internal note.
+ * Page size for loading the organisations a name may match.
*/
- private const NOTE_SEPARATOR = ' / ';
+ private const PAGE_SIZE = 500;
/**
* The mapping engine of the current run.
@@ -222,6 +294,37 @@ class CmdbExportImportService {
*/
private array $winningSheets = [];
+ /**
+ * The CMDB-sheet rows of this run whose key column the reader could not see (a formula without a cached value).
+ *
+ * @var array> Sheet name => row numbers.
+ */
+ private array $unreadableKeyRows = [];
+
+ /**
+ * The profile's source sheets this run's workbook does not have.
+ *
+ * Applications an earlier import took from such a sheet are missing from
+ * `winningSheets`, so a run with one is not authoritative for a deletion.
+ *
+ * @var array
+ */
+ private array $absentSheets = [];
+
+ /**
+ * OpenRegister's archive handler and object mapper, once resolved.
+ *
+ * @var array{handler: object, mapper: object}|null
+ */
+ private ?array $archiveServices = null;
+
+ /**
+ * The register's soft-deleted modules and usages, read once per run: schema key => uuid => entity.
+ *
+ * @var array{module: array, usage: array}|null
+ */
+ private ?array $trash = null;
+
/**
* Contact UIDs by e-mail or display name, per run.
*
@@ -250,6 +353,7 @@ class CmdbExportImportService {
* @param LoggerInterface $logger Logger; never handed person data.
* @param ILockingProvider $lockingProvider Serialises imports per register.
* @param IUserSession $userSession Names the admin who ran an import in its audit log lines.
+ * @param CmdbOwnerIdentity $ownerIdentity Derives an owner's contact UID and cache key.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList) Each collaborator is one concern of the
* import (the file, the packs, OpenRegister, Contacts, progress, the lock); grouping them
@@ -269,6 +373,7 @@ public function __construct(
private readonly LoggerInterface $logger,
private readonly ILockingProvider $lockingProvider,
private readonly IUserSession $userSession,
+ private readonly CmdbOwnerIdentity $ownerIdentity,
) {
}//end __construct()
@@ -300,17 +405,16 @@ public function assertXlsx(string $path, string $fileName): void {
}//end assertXlsx()
/**
- * Whether a `missingRecords` value is supported (only `keep`).
- *
- * @param string $mode The requested value.
+ * The accepted `missingRecords` values: the profile's modes (`keep`, `archive`); any other is refused.
*
- * @return bool
+ * @return array
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
- public function supportsMissingRecords(string $mode): bool {
- return $mode === 'keep';
- }//end supportsMissingRecords()
+ public function missingRecordsModes(): array {
+ return $this->profile->missingRecordsModes();
+ }//end missingRecordsModes()
/**
* Ask a running import to stop between rows.
@@ -349,19 +453,21 @@ public function requestCancel(string $operationId): bool {
*
* @param string $path The xlsx file, already checked by assertXlsx().
* @param array $options municipalityUuid, municipalityName, updateExisting, publish,
- * operationId, and fileName (the upload's name, for the audit log line).
+ * missingRecords (keep or archive, default archive), operationId,
+ * and fileName (the upload's name, for the audit log line).
*
* @return array The report (contract.md).
*
* @throws CmdbImportException MAPPING_UNAVAILABLE, NOT_CONFIGURED, SCHEMA_OUTDATED, IMPORT_IN_PROGRESS,
* WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
* MISSING_COLUMN, TOO_MANY_ROWS, MUNICIPALITY_REQUIRED,
- * MUNICIPALITY_INVALID or MUNICIPALITY_AMBIGUOUS.
+ * MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS or ARCHIVE_UNAVAILABLE.
* @throws \Exception An unexpected OpenRegister error outside a row, such as
* creating the municipality; rows catch their own.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
public function import(string $path, array $options): array {
$this->resetRun();
@@ -371,6 +477,10 @@ public function import(string $path, array $options): array {
$this->profile->load();
$this->engine = $this->resolveEngine();
$this->coordinates = $this->resolveCoordinates();
+ if (self::missingRecordsFrom(options: $options) === 'archive') {
+ // Refused before the lock and the file: an import that cannot reconcile must not half-run.
+ $this->archiveServices();
+ }
$lock = $this->acquireImportLock(register: $this->coordinates['register']);
try {
@@ -442,29 +552,23 @@ private function acquireImportLock(int $register): string {
* @return array The report.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
private function runImport(string $path, array $options, string $startedAt): array {
$workbook = $this->reader->read(path: $path, profile: $this->profile);
$municipality = $this->resolveMunicipality(options: $options);
$operationId = $this->operationIdFrom(options: $options);
+ $missingRecords = self::missingRecordsFrom(options: $options);
$rows = array_values($workbook['rows']);
$report = new CmdbImportReport(operationId: $operationId, rowsRead: count($rows));
$report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']);
$report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings']));
- if ($municipality['created'] === true) {
- $report->addImportWarnings(
- warnings: [
- [
- 'sheet' => '',
- 'message' => $this->l10n->t(
- 'No municipality named "%s" was found, so it was created. Check the name if you meant an existing one.',
- [$municipality['name']]
- ),
- ],
- ]
- );
- }
+ $archive = $this->archiveSheet(workbook: $workbook);
+ $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows, droppedRows: (array)($workbook['uncachedKeyRows'] ?? []));
+ $this->absentSheets = array_values(array_map('strval', (array)($workbook['absentSheets'] ?? [])));
+ $this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords);
$this->progressTracker->startOperation(
operationType: self::OPERATION_TYPE,
@@ -488,6 +592,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
'municipalityCreated' => $municipality['created'],
'updateExisting' => $updateExisting,
'publish' => $publish,
+ 'missingRecords' => $missingRecords,
];
$this->logger->info('CmdbExportImportService: import started', array_merge($audit, ['rows' => count($rows)]));
try {
@@ -507,6 +612,16 @@ private function runImport(string $path, array $options, string $startedAt): arr
$this->progressTracker->updateProgress(processedItems: ($index + 1));
}
+ if ($missingRecords === 'archive' && $report->isCancelled() === false) {
+ $this->reconcile(
+ municipalityUuid: $municipality['uuid'],
+ archive: $archive,
+ operationId: $operationId,
+ processedItems: count($rows),
+ report: $report
+ );
+ }
+
$result = $report->toArray();
$this->finishOperation(report: $report->toStoredArray(maxRows: self::STORED_REPORT_ROWS));
} catch (Throwable $e) {
@@ -528,6 +643,104 @@ private function runImport(string $path, array $options, string $startedAt): arr
return $result;
}//end runImport()
+ /**
+ * Add the run's own warnings.
+ *
+ * A mapping that fell back to the packs shipped with stackiq, an archive sheet that is missing,
+ * has no key column or has unreadable keys, a CMDB sheet that is missing or has unreadable keys,
+ * and a created municipality.
+ *
+ * @param CmdbImportReport $report The report.
+ * @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $missingRecords The import's `missingRecords` mode.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/archive/2026-10-05-cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function addRunWarnings(CmdbImportReport $report, array $municipality, array $archive, string $missingRecords): void {
+ if ($this->profile->usesShippedFallback() === true) {
+ $report->addImportWarnings(
+ warnings: [
+ [
+ 'sheet' => '',
+ 'message' => $this->l10n->t('OpenRegister\'s migration pack store is not available, so this import used the mapping that ships with stackiq.'),
+ ],
+ ]
+ );
+ }
+
+ if ($missingRecords === 'archive' && ($archive['present'] === false || $archive['uncachedRows'] !== [])) {
+ $report->addImportWarnings(warnings: [['sheet' => $archive['sheet'], 'message' => $this->archiveSheetWarning(archive: $archive)]]);
+ }
+
+ if ($missingRecords === 'archive') {
+ foreach ($this->absentSheets as $sheet) {
+ $absent = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => []];
+ $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $absent)]]);
+ }
+
+ foreach ($this->unreadableKeyRows as $sheet => $rowNumbers) {
+ $cmdbSheet = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => $rowNumbers];
+ $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $cmdbSheet)]]);
+ }
+ }
+
+ if ($municipality['created'] === true) {
+ $report->addImportWarnings(
+ warnings: [
+ [
+ 'sheet' => '',
+ 'message' => $this->l10n->t(
+ 'No municipality named "%s" was found, so it was created. Check the name if you meant an existing one.',
+ [$municipality['name']]
+ ),
+ ],
+ ]
+ );
+ }
+ }//end addRunWarnings()
+
+ /**
+ * The CMDB-sheet rows whose key column is a formula without a cached value, per sheet.
+ *
+ * Such a row reads as empty and is skipped, so its application may be
+ * missing from `winningSheets` although it is on a CMDB sheet. A run with
+ * one is not authoritative for a deletion, as with an archive sheet that
+ * has an unreadable key.
+ *
+ * @param array}> $rows The reader rows.
+ * @param array> $droppedRows The rows the reader dropped as empty because their key
+ * has no cached value (they are not in `$rows`), per sheet.
+ *
+ * @return array> Sheet name => row numbers.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function unreadableKeyRows(array $rows, array $droppedRows): array {
+ $key = $this->profile->keyColumn();
+ $found = [];
+ foreach ($droppedRows as $sheet => $rowNumbers) {
+ $found[(string)$sheet] = array_map('intval', array_values((array)$rowNumbers));
+ }
+
+ foreach ($rows as $row) {
+ if (in_array($key, ($row['uncached'] ?? []), true) === true) {
+ $found[$row['sheet']][] = $row['row'];
+ }
+ }
+
+ foreach ($found as $sheet => $rowNumbers) {
+ sort($rowNumbers);
+ $found[$sheet] = $rowNumbers;
+ }
+
+ return $found;
+ }//end unreadableKeyRows()
+
/**
* Process one row in its own error boundary and add its outcome.
*
@@ -748,11 +961,20 @@ private function translateImportWarnings(array $warnings): array {
* @param string $module The module outcome.
* @param string $usage The usage outcome.
*
- * @return string created when the module was created, updated when anything was saved, else unchanged.
+ * @return string restored when either was restored from the trash, else unarchived when either was
+ * unarchived, else created when the module was created, updated when anything was saved,
+ * else unchanged.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private static function rowOutcome(string $module, string $usage): string {
+ foreach ([CmdbImportReport::RESTORED, CmdbImportReport::UNARCHIVED] as $revived) {
+ if ($module === $revived || $usage === $revived) {
+ return $revived;
+ }
+ }
+
if ($module === CmdbImportReport::CREATED) {
return CmdbImportReport::CREATED;
}
@@ -781,7 +1003,7 @@ private function finishOperation(array $report): void {
}
$this->progressTracker->completeOperation(finalStatistics: ['report' => $report]);
- // A cancel that came in after the last row's check has nothing left to stop.
+ // A cancel that came in after the last check has nothing left to stop.
$this->progressTracker->clearCancelRequested(operationId: (string)$report['operationId']);
}//end finishOperation()
@@ -1028,19 +1250,19 @@ private function resolveManufacturer(array $values, int $rowNumber): ?string {
* @param string $matchKey The APPID's match key.
* @param array{updateExisting: bool} $options Whether a match is updated.
*
- * @return array{existing: object|null, uuid: string|null, skipReason: string|null} The stored module, the uuid
- * to report for a skipped row,
- * and why the row is skipped.
+ * @return array{existing: array{entity: object, state: array{archived: bool, deleted: bool}}|null, uuid: string|null, skipReason: string|null}
+ * The stored module with its state, the uuid to report for a skipped row, and why the row is skipped.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function matchModule(string $externalKey, string $municipalityUuid, string $matchKey, array $options): array {
- $existing = $this->findOne(schemaKey: 'module', filters: ['externalKey' => $externalKey]);
+ $existing = $this->findInAnyState(schemaKey: 'module', filters: ['externalKey' => $externalKey]);
if ($existing === null) {
return ['existing' => null, 'uuid' => null, 'skipReason' => null];
}
- $uuid = (string)$existing->getUuid();
+ $uuid = (string)$existing['entity']->getUuid();
if ($this->moduleBelongsTo(moduleUuid: $uuid, municipalityUuid: $municipalityUuid) === false) {
$this->logger->warning(
'CmdbExportImportService: import key belongs to a module another organisation uses; row not imported',
@@ -1062,7 +1284,8 @@ private function matchModule(string $externalKey, string $municipalityUuid, stri
*
* @param array $data The mapped module fields.
* @param string $externalKey The module's import key.
- * @param object|null $existing The module matchModule() found, or null to create one.
+ * @param array{entity: object, state: array{archived: bool, deleted: bool}}|null $existing The module matchModule()
+ * found, or null to create one.
* @param string|null $providerUuid The supplier, when there is one.
* @param string|null $publicationDate ISO start time of the import for a module that is published
* when created, or null to create it unpublished.
@@ -1075,7 +1298,7 @@ private function matchModule(string $externalKey, string $municipalityUuid, stri
private function importModule(
array $data,
string $externalKey,
- ?object $existing,
+ ?array $existing,
?string $providerUuid,
?string $publicationDate,
CmdbImportReport $report,
@@ -1102,6 +1325,8 @@ private function importModule(
* together with the usages: the module must already have a usage whose
* consumer is this municipality, or, before the first import, no usage
* at all. A module only another organisation uses is never taken over.
+ * This municipality's usage counts in every state: an archived or
+ * soft-deleted usage still says the module is the municipality's.
*
* @param string $moduleUuid The module found by its import key.
* @param string $municipalityUuid The consumer of this import.
@@ -1109,9 +1334,10 @@ private function importModule(
* @return bool
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function moduleBelongsTo(string $moduleUuid, string $municipalityUuid): bool {
- if ($this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]) !== null) {
+ if ($this->findInAnyState(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]) !== null) {
return true;
}
@@ -1123,19 +1349,22 @@ private function moduleBelongsTo(string $moduleUuid, string $municipalityUuid):
*
* @param array $data The mapped module fields.
* @param string $externalKey The match key.
- * @param object|null $existing The stored module, or null to create one.
+ * @param array{entity: object, state: array{archived: bool, deleted: bool}}|null $existing The stored module and its
+ * state, or null to create one.
* @param string|null $providerUuid The supplier, when there is one.
* @param string|null $publicationDate ISO start time of the import for a module that is published
* when created, or null to create it unpublished.
*
- * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged.
+ * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged, or restored or
+ * unarchived when the stored module was revived first.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function upsertModule(
array $data,
string $externalKey,
- ?object $existing,
+ ?array $existing,
?string $providerUuid,
?string $publicationDate,
): array {
@@ -1154,14 +1383,15 @@ private function upsertModule(
return ['uuid' => $this->save(schemaKey: 'module', data: $create, uuid: null), 'outcome' => CmdbImportReport::CREATED];
}
- $uuid = (string)$existing->getUuid();
- $merged = $this->merge(target: 'module', stored: $existing->getObject(), mapped: $data);
+ $uuid = (string)$existing['entity']->getUuid();
+ $revived = $this->revive(found: $existing);
+ $merged = $this->merge(target: 'module', stored: $revived['entity']->getObject(), mapped: $data);
if ($merged === null) {
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UNCHANGED)];
}
$this->save(schemaKey: 'module', data: $merged, uuid: $uuid);
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UPDATED)];
}//end upsertModule()
/**
@@ -1173,18 +1403,22 @@ private function upsertModule(
* @param string|null $providerUuid The supplier, when there is one.
* @param array{businessOwner: string|null} $owners The owner contact person.
*
- * @return array{uuid: string, outcome: string}
+ * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged, or restored or
+ * unarchived when the stored usage was revived first.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array {
- if (isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) {
+ $separator = $this->profile->concatSeparator(target: 'usage', field: 'interneAnnotation');
+ if ($separator !== null && isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) {
// The concat mapping leaves an empty part for every empty column; drop those.
$parts = array_filter(
- array_map('trim', explode(self::NOTE_SEPARATOR, $data['interneAnnotation'])),
+ array_map('trim', explode($separator, $data['interneAnnotation'])),
fn (string $part): bool => $part !== ''
);
- $data['interneAnnotation'] = implode(self::NOTE_SEPARATOR, $parts);
+ $data['interneAnnotation'] = implode($separator, $parts);
}
$data['consumer'] = $municipalityUuid;
@@ -1199,19 +1433,20 @@ private function upsertUsage(array $data, string $municipalityUuid, string $modu
}
}
- $existing = $this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]);
+ $existing = $this->findInAnyState(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]);
if ($existing === null) {
return ['uuid' => $this->save(schemaKey: 'usage', data: $data, uuid: null), 'outcome' => CmdbImportReport::CREATED];
}
- $uuid = (string)$existing->getUuid();
- $merged = $this->merge(target: 'usage', stored: $existing->getObject(), mapped: $data);
+ $uuid = (string)$existing['entity']->getUuid();
+ $revived = $this->revive(found: $existing);
+ $merged = $this->merge(target: 'usage', stored: $revived['entity']->getObject(), mapped: $data);
if ($merged === null) {
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UNCHANGED)];
}
$this->save(schemaKey: 'usage', data: $merged, uuid: $uuid);
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UPDATED)];
}//end upsertUsage()
/**
@@ -1300,7 +1535,7 @@ private function resolveOwners(array $values, int $rowNumber, string $municipali
foreach ($identities as $target => $identity) {
$column = $this->ownerColumn(target: $target);
try {
- $contactsUid = $this->resolveContactUid(identity: $identity);
+ $contactsUid = $this->resolveContactUid(identity: $identity, municipalityUuid: $municipalityUuid);
if ($contactsUid === null) {
$warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved in Nextcloud Contacts', [$column]);
continue;
@@ -1328,36 +1563,43 @@ private function resolveOwners(array $values, int $rowNumber, string $municipali
*
* Contacts are matched, and created, only in the importing admin's
* dedicated "Stackiq CMDB owners" address book, never in the admin's
- * other address books. With an e-mail address, StackiqContactSyncService
- * matches on it there or creates the contact. Without one, only a contact
- * there whose display name is exactly the owner's name (case-insensitive)
- * is reused, so an owner known by name alone is not created again on
- * every import.
- *
- * @param array $identity name, email and role from the owner pack.
+ * other address books. With an employee number, the contact is the one
+ * whose UID derives from that number and the municipality, so two
+ * namesakes stay two contacts and a renamed owner keeps the same one;
+ * the number itself is never stored, and an e-mail address on the row
+ * never matches another contact (a shared mailbox would merge two
+ * owners again): the contact is created with that UID. Without a
+ * number, a contact there with the row's e-mail address is reused, and
+ * without either, a contact whose display name is exactly the owner's
+ * name (case-insensitive), so an owner known by name alone is not
+ * created again on every import; neither ever reuses a contact
+ * identified by an employee number, which belongs to that employee.
+ * A new contact gets its UID from here, so StackiqContactSyncService
+ * does no matching of its own.
+ *
+ * @param array $identity name, email, employeeNumber and role from the owner pack.
+ * @param string $municipalityUuid The municipality whose employee numbers these are.
*
* @return string|null The contact UID, or null.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
*/
- private function resolveContactUid(array $identity): ?string {
+ private function resolveContactUid(array $identity, string $municipalityUuid): ?string {
$parts = self::splitPersonName(name: (string)$identity['name']);
$displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']);
$email = trim((string)($identity['email'] ?? ''));
+ $numberUid = $this->ownerIdentity->contactUid(
+ employeeNumber: (string)($identity['employeeNumber'] ?? ''),
+ municipalityUuid: $municipalityUuid
+ );
- $cacheKey = 'name:' . mb_strtolower($displayName);
- if ($email !== '') {
- $cacheKey = 'email:' . mb_strtolower($email);
- }
-
+ $cacheKey = $this->ownerIdentity->cacheKey(contactUid: $numberUid, email: $email, displayName: $displayName);
if (array_key_exists($cacheKey, $this->contactUids) === true) {
return $this->contactUids[$cacheKey];
}
- $uid = null;
- if ($email === '') {
- $uid = $this->contactByDisplayName(displayName: $displayName);
- }
+ $uid = $this->existingOwnerContact(numberUid: $numberUid, email: $email, displayName: $displayName);
if ($uid === null) {
$record = ['voornaam' => $parts['voornaam'], 'achternaam' => $parts['achternaam']];
@@ -1374,7 +1616,8 @@ private function resolveContactUid(array $identity): ?string {
objectType: 'contactPerson',
record: $record,
addressBookUri: self::OWNER_ADDRESS_BOOK_URI,
- displayName: $this->l10n->t('Stackiq CMDB owners')
+ displayName: $this->l10n->t('Stackiq CMDB owners'),
+ fixedUid: ($numberUid ?? $this->ownerIdentity->newContactUid())
);
if ($uid === '') {
$uid = null;
@@ -1386,29 +1629,88 @@ private function resolveContactUid(array $identity): ?string {
}//end resolveContactUid()
/**
- * The contact in the owners' address book whose display name is exactly this one, case-insensitive.
+ * The contact an owner already has: by the UID from the employee number, else by e-mail address, else by exact name.
+ *
+ * @param string|null $numberUid The UID from the employee number, or null.
+ * @param string $email The e-mail address, or ''.
+ * @param string $displayName The display name.
+ *
+ * @return string|null The contact UID, or null.
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ private function existingOwnerContact(?string $numberUid, string $email, string $displayName): ?string {
+ if ($numberUid !== null) {
+ return $this->contactByUid(uid: $numberUid);
+ }
+
+ if ($email !== '') {
+ return $this->contactByProperty(property: 'EMAIL', key: 'emails', value: $email);
+ }
+
+ return $this->contactByProperty(property: 'FN', key: 'name', value: $displayName);
+ }//end existingOwnerContact()
+
+ /**
+ * The contact in the owners' address book with exactly this UID.
+ *
+ * Nextcloud indexes the UID property of every card (CardDavBackend
+ * INDEXED_PROPERTIES) and AddressBookImpl::search() matches it with a
+ * LIKE on that index, so a search on ['UID'] finds the card. Should the
+ * search miss it anyway, StackiqContactSyncService::syncToNamedAddressBook()
+ * reuses a card found by getCard('.vcf') in the same address book. A
+ * card with this UID at a different URI is not found that way: createCard
+ * then throws UidConflict, which is caught and returns null.
+ *
+ * @param string $uid The contact UID.
+ *
+ * @return string|null The contact UID, or null.
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ */
+ private function contactByUid(string $uid): ?string {
+ $contacts = $this->contactSync->searchNamedAddressBook(query: $uid, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: ['UID']);
+ foreach ($contacts as $contact) {
+ if ((string)($contact['uid'] ?? '') === $uid) {
+ return $uid;
+ }
+ }
+
+ return null;
+ }//end contactByUid()
+
+ /**
+ * The contact in the owners' address book whose e-mail address or display name is exactly this one, case-insensitive.
*
* Only the dedicated "Stackiq CMDB owners" address book is searched: a
* namesake in another address book of the admin, such as a personal
- * contact, is never linked to an imported owner.
+ * contact, is never linked to an imported owner. A contact identified by
+ * an employee number is never returned: it is that employee's, and an
+ * owner without a number who shares a name or mailbox is someone else
+ * as far as the import can tell.
*
- * @param string $displayName The display name.
+ * @param string $property The vCard property to search: EMAIL or FN.
+ * @param string $key The key of that value in a search result: emails (every address) or name.
+ * @param string $value The e-mail address or display name.
*
* @return string|null The contact UID, or null.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
*/
- private function contactByDisplayName(string $displayName): ?string {
- $needle = mb_strtolower($displayName);
- $contacts = $this->contactSync->searchNamedAddressBook(query: $displayName, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: ['FN']);
+ private function contactByProperty(string $property, string $key, string $value): ?string {
+ $needle = mb_strtolower(trim($value));
+ $contacts = $this->contactSync->searchNamedAddressBook(query: $value, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: [$property]);
foreach ($contacts as $contact) {
- if (mb_strtolower(trim((string)($contact['name'] ?? ''))) === $needle) {
- return (string)$contact['uid'];
+ $uid = (string)($contact['uid'] ?? '');
+ $values = array_map(fn (mixed $candidate): string => mb_strtolower(trim((string)$candidate)), (array)($contact[$key] ?? []));
+ if (str_starts_with($uid, CmdbOwnerIdentity::UID_PREFIX) === false && in_array($needle, $values, true) === true) {
+ return $uid;
}
}
return null;
- }//end contactByDisplayName()
+ }//end contactByProperty()
/**
* Find or create the contact person of a contact for the municipality.
@@ -1633,7 +1935,7 @@ private function organisationsOfType(string $type): array {
}//end organisationsOfType()
/**
- * The one object matching every filter, or null.
+ * The working object matching every filter, or null.
*
* @param string $schemaKey The coordinates key of the schema.
* @param array $filters Field => exact value.
@@ -1643,10 +1945,67 @@ private function organisationsOfType(string $type): array {
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
*/
private function findOne(string $schemaKey, array $filters): ?object {
+ return $this->search(schemaKey: $schemaKey, filters: $filters, lens: []);
+ }//end findOne()
+
+ /**
+ * The object matching every filter in any state, with that state, or null.
+ *
+ * A working object wins over an archived one, and an archived one over one
+ * in the trash: an object deleted by hand may share its import key with the
+ * one a later import created, and bringing the deleted one back would
+ * duplicate it. The state is the lens that found the object (design D9):
+ * a search without a lens finds only working objects, the archive lens only
+ * archived ones, and the trash is read from OpenRegister's trash listing,
+ * because its list search cannot return a soft-deleted object.
+ *
+ * @param string $schemaKey module or usage.
+ * @param array $filters Field => exact value.
+ *
+ * @return array{entity: object, state: array{archived: bool, deleted: bool}}|null
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function findInAnyState(string $schemaKey, array $filters): ?array {
+ $entity = $this->search(schemaKey: $schemaKey, filters: $filters, lens: []);
+ if ($entity !== null) {
+ return ['entity' => $entity, 'state' => self::STATE_WORKING];
+ }
+
+ $entity = $this->search(schemaKey: $schemaKey, filters: $filters, lens: self::ARCHIVED_LENS);
+ if ($entity !== null) {
+ return ['entity' => $entity, 'state' => self::STATE_ARCHIVED];
+ }
+
+ foreach (($this->trash()[$schemaKey] ?? []) as $entity) {
+ if (self::matchesFilters(entity: $entity, filters: $filters) === true) {
+ return ['entity' => $entity, 'state' => self::STATE_DELETED];
+ }
+ }
+
+ return null;
+ }//end findInAnyState()
+
+ /**
+ * The first object a search with one lens returns that matches every filter, or null.
+ *
+ * The filters are checked again on the result: a filter OpenRegister cannot
+ * apply must not turn into a match.
+ *
+ * @param string $schemaKey The coordinates key of the schema.
+ * @param array $filters Field => exact value.
+ * @param array $lens No lens (working objects) or ARCHIVED_LENS.
+ *
+ * @return object|null The entity (ObjectEntityInterface).
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function search(string $schemaKey, array $filters, array $lens): ?object {
$coordinates = $this->coordinates();
$results = $coordinates['objectService']->searchObjects(
query: array_merge(
['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates[$schemaKey]], '_limit' => 10],
+ $lens,
$filters
),
_rbac: false,
@@ -1657,22 +2016,34 @@ private function findOne(string $schemaKey, array $filters): ?object {
}
foreach ($results as $entity) {
- $data = $entity->getObject();
- $matches = true;
- foreach ($filters as $field => $value) {
- if (self::relationUuid(value: ($data[$field] ?? null)) !== $value) {
- $matches = false;
- break;
- }
- }
-
- if ($matches === true) {
+ if (self::matchesFilters(entity: $entity, filters: $filters) === true) {
return $entity;
}
}
return null;
- }//end findOne()
+ }//end search()
+
+ /**
+ * Whether an object's data has every filter's value.
+ *
+ * @param object $entity The entity (ObjectEntityInterface).
+ * @param array $filters Field => exact value.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/archive/2026-10-05-cmdb-export-import/tasks.md#task-5
+ */
+ private static function matchesFilters(object $entity, array $filters): bool {
+ $data = $entity->getObject();
+ foreach ($filters as $field => $value) {
+ if (self::relationUuid(value: ($data[$field] ?? null)) !== $value) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end matchesFilters()
/**
* Save an object through OpenRegister and return its uuid.
@@ -1699,6 +2070,832 @@ private function save(string $schemaKey, array $data, ?string $uuid): string {
return (string)$entity->getUuid();
}//end save()
+ /**
+ * The `missingRecords` mode of an import: the option, or DEFAULT_MISSING_RECORDS.
+ *
+ * @param array $options The import options.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
+ */
+ private static function missingRecordsFrom(array $options): string {
+ $mode = ($options['missingRecords'] ?? null);
+ if (is_string($mode) === false || $mode === '') {
+ return self::DEFAULT_MISSING_RECORDS;
+ }
+
+ return $mode;
+ }//end missingRecordsFrom()
+
+ /**
+ * The archive sheet as the reader found it, with its APPIDs as match keys.
+ *
+ * @param array $workbook What the reader returned.
+ *
+ * @return ArchiveSheet
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function archiveSheet(array $workbook): array {
+ $archive = ($workbook['archive'] ?? []);
+ $key = $this->profile->keyColumn();
+ $keys = [];
+ foreach (($archive['appIds'] ?? []) as $appId) {
+ $values = $this->normaliser->normalise(cells: [$key => $appId], dateColumns: [], idColumns: $this->profile->idColumns());
+ $matchKey = self::matchKey(appId: ($values[$key] ?? ''));
+ if ($matchKey !== '') {
+ $keys[$matchKey] = true;
+ }
+ }
+
+ return [
+ 'sheet' => (string)($archive['sheet'] ?? ($this->profile->archiveSheetName() ?? '')),
+ 'present' => (($archive['present'] ?? false) === true),
+ 'keyColumnMissing' => (($archive['keyColumnMissing'] ?? false) === true),
+ 'uncachedRows' => array_values(array_map('intval', (array)($archive['uncachedRows'] ?? []))),
+ 'keys' => $keys,
+ ];
+ }//end archiveSheet()
+
+ /**
+ * The report warning of an archiving import whose archive sheet is absent or has no key column, or has unreadable keys.
+ *
+ * Also the warning of a CMDB sheet with unreadable keys: it is handed the sheet's name and rows in the archive sheet's shape.
+ *
+ * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet, or a CMDB sheet.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function archiveSheetWarning(array $archive): string {
+ if ($archive['uncachedRows'] !== []) {
+ // A long list is cut: the warning counts the rows and names where to look, not every one.
+ $count = count($archive['uncachedRows']);
+ $rows = implode(', ', array_slice($archive['uncachedRows'], 0, self::WARNED_ROWS));
+ if ($count > self::WARNED_ROWS) {
+ $rows .= ', …';
+ }
+
+ if ($count === 1) {
+ return $this->l10n->t(
+ 'Sheet "%1$s" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $rows]
+ );
+ }
+
+ return $this->l10n->t(
+ 'Sheet "%1$s" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $count, $rows]
+ );
+ }
+
+ if ($archive['keyColumnMissing'] === true) {
+ return $this->l10n->t(
+ 'Sheet "%1$s" has no column "%2$s", so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $this->profile->keyColumn()]
+ );
+ }
+
+ return $this->l10n->t('Sheet "%s" was not found, so applications missing from the export are archived, not deleted.', [$archive['sheet']]);
+ }//end archiveSheetWarning()
+
+ /**
+ * Resolve OpenRegister's archive handler and object mapper, failing closed.
+ *
+ * @return array{handler: object, mapper: object} The handler, with `archive()` and `unarchive()`, and the
+ * mapper, with `restoreObject()` and `findDeletedAcrossAllMagicTables()`.
+ *
+ * @throws CmdbImportException ARCHIVE_UNAVAILABLE.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function archiveServices(): array {
+ if ($this->archiveServices !== null) {
+ return $this->archiveServices;
+ }
+
+ $previous = null;
+ try {
+ $handler = $this->container->get(static::ARCHIVE_HANDLER_CLASS);
+ $mapper = $this->container->get(static::OBJECT_MAPPER_CLASS);
+ } catch (Throwable $e) {
+ $this->logger->warning('CmdbExportImportService: OpenRegister archive services could not be resolved', ['exception' => get_class($e)]);
+ $previous = $e;
+ $handler = null;
+ $mapper = null;
+ }
+
+ if (is_object($handler) === false || method_exists($handler, 'archive') === false || method_exists($handler, 'unarchive') === false
+ || is_object($mapper) === false || method_exists($mapper, 'restoreObject') === false
+ || method_exists($mapper, 'findDeletedAcrossAllMagicTables') === false
+ ) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::ARCHIVE_UNAVAILABLE,
+ message: 'OpenRegister ArchiveHandler or MagicMapper is not available',
+ previous: $previous
+ );
+ }
+
+ $this->archiveServices = ['handler' => $handler, 'mapper' => $mapper];
+ return $this->archiveServices;
+ }//end archiveServices()
+
+ /**
+ * Whether an object as OpenRegister's mapper reads it by uuid is archived and whether it is in the trash.
+ *
+ * Read from its `@self` metadata. Only for an object the mapper read
+ * itself (the one restoreObject() answers): a list search result carries
+ * no archive marker (design D9).
+ *
+ * @param object $entity The entity (ObjectEntityInterface).
+ *
+ * @return array{archived: bool, deleted: bool}
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private static function objectState(object $entity): array {
+ $self = [];
+ if ($entity instanceof JsonSerializable) {
+ $serialised = $entity->jsonSerialize();
+ if (is_array($serialised) === true && is_array($serialised['@self'] ?? null) === true) {
+ $self = $serialised['@self'];
+ }
+ }
+
+ return [
+ 'archived' => (self::isEmptyValue(value: ($self['archived'] ?? null)) === false),
+ 'deleted' => (self::isEmptyValue(value: ($self['deleted'] ?? null)) === false),
+ ];
+ }//end objectState()
+
+ /**
+ * Bring a matched module or usage back before it is written: out of the trash, then out of the archive.
+ *
+ * OpenRegister refuses a data write to an archived object, and the
+ * archive handler cannot reach one in the trash, hence the order. The
+ * trash listing does not say whether an object was archived before it was
+ * deleted; the restored object, read by the mapper, does. The update then
+ * starts from that object: the trash listing decodes every string that
+ * parses as JSON, so its copy of the data must not be written back.
+ *
+ * @param array{entity: object, state: array{archived: bool, deleted: bool}} $found The matched entity and its state.
+ *
+ * @return array{outcome: string|null, entity: object} restored, unarchived, or null when it was neither, and the
+ * entity whose data the update starts from.
+ *
+ * @throws CmdbImportException ARCHIVE_UNAVAILABLE when the archive services are missing.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function revive(array $found): array {
+ $entity = $found['entity'];
+ $archived = $found['state']['archived'];
+ if ($found['state']['deleted'] === false && $archived === false) {
+ return ['outcome' => null, 'entity' => $entity];
+ }
+
+ $uuid = (string)$entity->getUuid();
+ $outcome = null;
+ if ($found['state']['deleted'] === true) {
+ $restored = $this->restore(uuid: $uuid);
+ $outcome = CmdbImportReport::RESTORED;
+ if (is_object($restored) === true && method_exists($restored, 'getObject') === true) {
+ $entity = $restored;
+ $archived = self::objectState(entity: $restored)['archived'];
+ }
+ }
+
+ if ($archived === true) {
+ $this->archiveServices()['handler']->unarchive(identifier: $uuid, reason: self::REASON_PREFIX . 'back in the export');
+ $outcome = ($outcome ?? CmdbImportReport::UNARCHIVED);
+ }
+
+ return ['outcome' => $outcome, 'entity' => $entity];
+ }//end revive()
+
+ /**
+ * Take one object out of the trash, and out of this run's copy of the trash.
+ *
+ * @param string $uuid The object.
+ *
+ * @return mixed What MagicMapper::restoreObject() answers: the object as it reads it.
+ *
+ * @throws CmdbImportException ARCHIVE_UNAVAILABLE when the archive services are missing.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function restore(string $uuid): mixed {
+ $restored = $this->archiveServices()['mapper']->restoreObject(uuid: $uuid);
+ if ($this->trash !== null) {
+ unset($this->trash['module'][$uuid], $this->trash['usage'][$uuid]);
+ }
+
+ return $restored;
+ }//end restore()
+
+ /**
+ * The register's soft-deleted modules and usages, read once per run.
+ *
+ * OpenRegister's list search cannot return a soft-deleted object, whatever
+ * `_includeDeleted` says, so the trash is read from the trash listing that
+ * the Deleted page uses (design D9). That listing scans every magic table
+ * of the instance, so it is read once, lazily, and only this register's
+ * modules and usages are kept. Its objects carry no archive or trash
+ * marker; everything in it is deleted. Without the archive services (a
+ * `keep` import on an OpenRegister without them) the trash cannot be read:
+ * nothing in it is matched, and the log says so. A listing that throws is
+ * not cached, so the next row reads it again.
+ *
+ * It is read for `keep` as well: matching is not tied to the mode (REQ-CMDB-016),
+ * and an application a person soft-deleted by hand must be restored, not
+ * created a second time. The listing cannot be narrowed, so the cost is one
+ * scan per run, paid on the first row no working or archived object matches.
+ *
+ * @return array{module: array, usage: array} Schema key => uuid => entity.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function trash(): array {
+ if ($this->trash !== null) {
+ return $this->trash;
+ }
+
+ try {
+ $services = $this->archiveServices();
+ } catch (CmdbImportException $e) {
+ $this->logger->warning('CmdbExportImportService: OpenRegister trash is not readable; soft-deleted modules and usages are not matched');
+ $this->trash = ['module' => [], 'usage' => []];
+ return $this->trash;
+ }
+
+ // Cached only once the listing was read: a listing that fails fails its row, and the
+ // next row reads it again instead of matching against an empty trash and duplicating.
+ $coordinates = $this->coordinates();
+ $deleted = $services['mapper']->findDeletedAcrossAllMagicTables(limit: null, offset: null, _rbac: false, _multitenancy: false);
+ $this->trash = self::registerTrash(deleted: $deleted, coordinates: $coordinates);
+
+ return $this->trash;
+ }//end trash()
+
+ /**
+ * The modules and usages of this register among the instance's soft-deleted objects.
+ *
+ * @param mixed $deleted What MagicMapper::findDeletedAcrossAllMagicTables() answered.
+ * @param array $coordinates The register and schema ids, from coordinates().
+ *
+ * @return array{module: array, usage: array} Schema key => uuid => entity.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private static function registerTrash(mixed $deleted, array $coordinates): array {
+ $trash = ['module' => [], 'usage' => []];
+ if (is_array($deleted) === false) {
+ return $trash;
+ }
+
+ foreach ($deleted as $entity) {
+ if (is_object($entity) === false || (string)$entity->getRegister() !== (string)$coordinates['register'] || $entity->getUuid() === null) {
+ continue;
+ }
+
+ foreach (['module', 'usage'] as $schemaKey) {
+ if ((string)$entity->getSchema() === (string)$coordinates[$schemaKey]) {
+ $trash[$schemaKey][(string)$entity->getUuid()] = $entity;
+ }
+ }
+ }
+
+ return $trash;
+ }//end registerTrash()
+
+ /**
+ * Archive or soft-delete every application of the municipality whose APPID left the CMDB sheets.
+ *
+ * Runs after the last row of an import that was not cancelled, under the
+ * same register lock. The whole scope is loaded before anything is
+ * written, so the paging is not shifted by the writes. Every application
+ * has its own error boundary. A cancel is honoured before the scope is
+ * loaded and before each application: what was archived or deleted so far
+ * stays, and the report says the run was cancelled.
+ *
+ * @param string $municipalityUuid The consumer of this import.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $operationId The operation id, for the archive reason.
+ * @param int $processedItems The rows already counted as processed.
+ * @param CmdbImportReport $report The report.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function reconcile(string $municipalityUuid, array $archive, string $operationId, int $processedItems, CmdbImportReport $report): void {
+ // A cancel that came in during the last row stops the import before anything is archived or deleted.
+ if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) {
+ $report->markCancelled();
+ return;
+ }
+
+ $scope = $this->reconciliationScope(municipalityUuid: $municipalityUuid);
+ $this->progressTracker->setPhase(phase: 'reconciling', data: ['total_items' => ($processedItems + count($scope))]);
+
+ foreach ($scope as $index => $application) {
+ if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) {
+ $report->markCancelled();
+ return;
+ }
+
+ // Every APPID on a CMDB sheet was handled by its row, whatever the row's outcome.
+ if (isset($this->winningSheets[$application['key']]) === false) {
+ $this->reconcileApplication(application: $application, archive: $archive, operationId: $operationId, report: $report);
+ }
+
+ $this->progressTracker->updateProgress(processedItems: ($processedItems + $index + 1));
+ }
+ }//end reconcile()
+
+ /**
+ * The municipality's usages, in every state, each with its module when that module carries this municipality's import key.
+ *
+ * A usage of a hand-made module, or of a module another organisation
+ * imported, is left out: only what this municipality's import created is
+ * reconciled. Working and archived usages are paged with one lens each,
+ * soft-deleted ones come from the trash (design D9); each object keeps the
+ * state its lens or the trash gave it. When an import key occurs more than
+ * once, only the entry the import would match is kept (onePerKey()).
+ *
+ * @param string $municipalityUuid The consumer of this import.
+ *
+ * @return array}>
+ * Per application, the APPID's match key, the usage, the module and the state of both by schema key.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function reconciliationScope(string $municipalityUuid): array {
+ $usages = $this->usagesInEveryState(municipalityUuid: $municipalityUuid);
+
+ $owned = [];
+ foreach ($usages as $usage) {
+ $data = $usage['entity']->getObject();
+ $moduleUuid = self::relationUuid(value: ($data['module'] ?? null));
+ // The filter is checked again: a filter OpenRegister cannot apply must not widen the scope.
+ if (self::relationUuid(value: ($data['consumer'] ?? null)) === $municipalityUuid && $moduleUuid !== null && $usage['entity']->getUuid() !== null) {
+ $owned[] = $usage + ['module' => $moduleUuid];
+ }
+ }
+
+ $prefix = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':';
+ $modules = $this->modulesByUuid(uuids: array_values(array_unique(array_column($owned, 'module'))));
+ $scope = [];
+ foreach ($owned as $usage) {
+ $module = ($modules[$usage['module']] ?? null);
+ if ($module === null) {
+ continue;
+ }
+
+ $externalKey = (string)($module['entity']->getObject()['externalKey'] ?? '');
+ if (str_starts_with($externalKey, $prefix) === true && strlen($externalKey) > strlen($prefix)) {
+ $scope[] = [
+ 'key' => substr($externalKey, strlen($prefix)),
+ 'usage' => $usage['entity'],
+ 'module' => $module['entity'],
+ 'states' => ['usage' => $usage['state'], 'module' => $module['state']],
+ ];
+ }
+ }
+
+ return self::onePerKey(scope: $scope);
+ }//end reconciliationScope()
+
+ /**
+ * One application per import key: the entry the import would match, with the other usages of the key as extras.
+ *
+ * The import takes a working module before an archived one, and an archived
+ * one before one in the trash (design D4), so a module deleted by hand next
+ * to the one a later import created is never revived. The reconciliation
+ * follows the same order: the entry with the best module state is the
+ * application, the best usage state breaks a tie, and the first one listed
+ * breaks the rest. The other entries of the key are not dropped: their
+ * usages, and their modules when those are not in the trash, are carried
+ * as `extras` and brought to the same state, so a stray usage of a stale
+ * duplicate does not stay visible. Nothing in the trash is restored for an
+ * extra, and nothing the application itself holds is listed twice.
+ *
+ * @param array $scope The entries.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function onePerKey(array $scope): array {
+ $preferred = [];
+ $ranks = [];
+ $entries = [];
+ foreach ($scope as $application) {
+ $rank = ((self::stateRank(state: $application['states']['module']) * 3) + self::stateRank(state: $application['states']['usage']));
+ $key = $application['key'];
+ $entries[$key][] = $application;
+ if (isset($ranks[$key]) === false || $rank < $ranks[$key]) {
+ $ranks[$key] = $rank;
+ $preferred[$key] = $application;
+ }
+ }
+
+ foreach ($preferred as $key => $application) {
+ $preferred[$key]['extras'] = self::extrasOf(application: $application, entries: $entries[$key]);
+ }
+
+ return array_values($preferred);
+ }//end onePerKey()
+
+ /**
+ * The usages and modules of the other entries of a key that the reconciliation brings to the same state.
+ *
+ * A usage or module appears once, and not when the application holds it
+ * itself. One in the trash is left out: it is not visible, and restoring
+ * a stale duplicate is what the preference rule exists to prevent.
+ *
+ * @param ScopeEntry $application The preferred entry of the key.
+ * @param array $entries Every entry of the key.
+ *
+ * @return array Usages first, then modules.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function extrasOf(array $application, array $entries): array {
+ $seen = [(string)$application['usage']->getUuid() => true, (string)$application['module']->getUuid() => true];
+ $found = ['usage' => [], 'module' => []];
+ foreach ($entries as $entry) {
+ foreach (['usage', 'module'] as $schemaKey) {
+ $uuid = (string)$entry[$schemaKey]->getUuid();
+ if (isset($seen[$uuid]) === false && $entry['states'][$schemaKey]['deleted'] === false) {
+ $seen[$uuid] = true;
+ $found[$schemaKey][] = ['schemaKey' => $schemaKey, 'uuid' => $uuid, 'state' => $entry['states'][$schemaKey]];
+ }
+ }
+ }
+
+ return array_merge($found['usage'], $found['module']);
+ }//end extrasOf()
+
+ /**
+ * How far down the preference order a state is: working 0, archived 1, deleted 2.
+ *
+ * @param array{archived: bool, deleted: bool} $state The state.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function stateRank(array $state): int {
+ if ($state['deleted'] === true) {
+ return 2;
+ }
+
+ if ($state['archived'] === true) {
+ return 1;
+ }
+
+ return 0;
+ }//end stateRank()
+
+ /**
+ * The usages that name this municipality as consumer, in every state, each with that state.
+ *
+ * Working and archived usages are paged with one lens each; soft-deleted
+ * ones come from the trash, which is not filtered by consumer, so the
+ * caller checks the consumer of every usage.
+ *
+ * @param string $municipalityUuid The consumer of this import.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function usagesInEveryState(string $municipalityUuid): array {
+ $coordinates = $this->coordinates();
+ $usages = [];
+ foreach ([[[], self::STATE_WORKING], [self::ARCHIVED_LENS, self::STATE_ARCHIVED]] as [$lens, $state]) {
+ $offset = 0;
+ do {
+ $page = $coordinates['objectService']->searchObjects(
+ query: array_merge(
+ [
+ '@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['usage']],
+ 'consumer' => $municipalityUuid,
+ '_limit' => self::PAGE_SIZE,
+ '_offset' => $offset,
+ ],
+ $lens
+ ),
+ _rbac: false,
+ _multitenancy: false
+ );
+ if (is_array($page) === false) {
+ $page = [];
+ }
+
+ foreach ($page as $usage) {
+ $usages[] = ['entity' => $usage, 'state' => $state];
+ }
+
+ $offset += self::PAGE_SIZE;
+ $pageSize = count($page);
+ } while ($pageSize === self::PAGE_SIZE);
+ }//end foreach
+
+ foreach ($this->trash()['usage'] as $usage) {
+ $usages[] = ['entity' => $usage, 'state' => self::STATE_DELETED];
+ }
+
+ return $usages;
+ }//end usagesInEveryState()
+
+ /**
+ * The modules with these uuids, in every state, each with its state.
+ *
+ * Per chunk of PAGE_SIZE uuids one search without a lens and one with
+ * the archive lens; the rest is looked up in the trash.
+ *
+ * @param array $uuids The module uuids.
+ *
+ * @return array Uuid => entity and state.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function modulesByUuid(array $uuids): array {
+ $coordinates = $this->coordinates();
+ $modules = [];
+ foreach (array_chunk($uuids, self::PAGE_SIZE) as $chunk) {
+ // The chunks are disjoint, so a uuid found in an earlier chunk is never overwritten.
+ $modules += $this->modulesInChunk(chunk: $chunk, coordinates: $coordinates);
+ }
+
+ foreach ($uuids as $uuid) {
+ $trashed = ($this->trash()['module'][$uuid] ?? null);
+ if (isset($modules[$uuid]) === false && $trashed !== null) {
+ $modules[$uuid] = ['entity' => $trashed, 'state' => self::STATE_DELETED];
+ }
+ }
+
+ return $modules;
+ }//end modulesByUuid()
+
+ /**
+ * The working and archived modules of one chunk of uuids; a module both lenses return keeps the first state.
+ *
+ * @param array $chunk At most PAGE_SIZE module uuids.
+ * @param array $coordinates The object service and register and schema ids, from coordinates().
+ *
+ * @return array Uuid => entity and state.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function modulesInChunk(array $chunk, array $coordinates): array {
+ $modules = [];
+ foreach ([[[], self::STATE_WORKING], [self::ARCHIVED_LENS, self::STATE_ARCHIVED]] as [$lens, $state]) {
+ $results = $coordinates['objectService']->searchObjects(
+ query: array_merge(
+ ['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['module']], '_ids' => $chunk, '_limit' => count($chunk)],
+ $lens
+ ),
+ _rbac: false,
+ _multitenancy: false
+ );
+ if (is_array($results) === false) {
+ continue;
+ }
+
+ foreach ($results as $entity) {
+ $uuid = (string)$entity->getUuid();
+ if (in_array($uuid, $chunk, true) === true && isset($modules[$uuid]) === false) {
+ $modules[$uuid] = ['entity' => $entity, 'state' => $state];
+ }
+ }
+ }
+
+ return $modules;
+ }//end modulesInChunk()
+
+ /**
+ * Archive or soft-delete one application that left the CMDB sheets, usage first, in its own error boundary.
+ *
+ * The writes go in this order: the application's own usage, the other
+ * usages of its key, its module, the other modules. A failure stops the
+ * application there and reports one `failed` row (the step and the
+ * exception class, no list of what was already written). What it leaves:
+ * a failing own usage leaves everything as it was; a failing extra usage
+ * leaves the own usage in the new state, that extra and the module as
+ * they were (so the extra stays visible and the module stays working);
+ * a failing module leaves every usage in the new state and the module
+ * as it was. The next import reconciles the application again.
+ * An application already in the state the export asks for is not reported.
+ *
+ * @param ScopeEntry $application The application.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $operationId The operation id, for the archive reason.
+ * @param CmdbImportReport $report The report.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function reconcileApplication(array $application, array $archive, string $operationId, CmdbImportReport $report): void {
+ $moduleData = $application['module']->getObject();
+ [
+ 'target' => $target,
+ 'reason' => $reason,
+ 'sheet' => $sheet,
+ 'onArchiveSheet' => $onArchiveSheet,
+ ] = $this->reconcileTarget(key: $application['key'], archive: $archive, operationId: $operationId);
+ $entry = [
+ 'sheet' => $sheet,
+ 'row' => 0,
+ 'appId' => (string)($moduleData['externalNumber'] ?? $application['key']),
+ 'name' => (string)($moduleData['name'] ?? ''),
+ ];
+
+ $uuids = ['module' => (string)$application['module']->getUuid(), 'usage' => (string)$application['usage']->getUuid()];
+ $step = 'archive';
+ $changed = false;
+ try {
+ foreach (self::itemsOf(application: $application, restoreDeleted: $onArchiveSheet) as $item) {
+ foreach (self::transitions(state: $item['state'], target: $target, restoreDeleted: $item['restore']) as $step) {
+ $this->transition(step: $step, schemaKey: $item['schemaKey'], uuid: $item['uuid'], reason: $reason);
+ $changed = true;
+ }
+ }
+ } catch (Throwable $e) {
+ // The module's stored values are taken out of the log line, as a row's cells are.
+ $values = array_map('strval', array_filter($moduleData, static fn (mixed $value): bool => is_scalar($value)));
+ $this->failRow(report: $report, entry: $entry, step: $step, e: $e, warnings: [], uuids: [$uuids['module'], $uuids['usage']], values: $values);
+ return;
+ }
+
+ if ($changed === true) {
+ $this->addRow(report: $report, entry: $entry, outcome: $target, moduleUuid: $uuids['module'], usageUuid: $uuids['usage']);
+ }
+ }//end reconcileApplication()
+
+ /**
+ * Where an application that left the CMDB sheets goes, and the archive reason it gets.
+ *
+ * Listed on the archive sheet: archived, with that sheet as the reason.
+ * Otherwise soft-deleted, but only when the workbook is authoritative for
+ * a deletion: it has the archive sheet and every source sheet, and no sheet
+ * has a row whose key could not be read. In every other case it is archived.
+ *
+ * @param string $key The application key.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $operationId The operation id, for the archive reason.
+ *
+ * @return array{target: string, reason: string, sheet: string, onArchiveSheet: bool}
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function reconcileTarget(string $key, array $archive, string $operationId): array {
+ if ($archive['present'] === true && isset($archive['keys'][$key]) === true) {
+ return [
+ 'target' => CmdbImportReport::ARCHIVED,
+ 'reason' => self::REASON_PREFIX . 'on sheet "' . $archive['sheet'] . '" (' . $operationId . ')',
+ 'sheet' => $archive['sheet'],
+ 'onArchiveSheet' => true,
+ ];
+ }
+
+ $target = CmdbImportReport::ARCHIVED;
+ if ($archive['present'] === true && $archive['uncachedRows'] === [] && $this->unreadableKeyRows === [] && $this->absentSheets === []) {
+ // A sheet with an unreadable key (the archive sheet or a CMDB sheet), or a source sheet the workbook
+ // lacks, may list this application, so the workbook is not authoritative for a deletion.
+ $target = CmdbImportReport::DELETED;
+ }
+
+ return [
+ 'target' => $target,
+ 'reason' => self::REASON_PREFIX . 'not in the export (' . $operationId . ')',
+ 'sheet' => '',
+ 'onArchiveSheet' => false,
+ ];
+ }//end reconcileTarget()
+
+ /**
+ * What one application brings to the state the export asks for: its usage and the other usages of its key, then its module and the other modules.
+ *
+ * Own usage, extra usages, own module, extra modules. Usages come before
+ * modules, so a failure leaves usages in the new state and a module at
+ * worst unchanged; it does not leave a module changed under a usage that
+ * still shows. An extra usage that fails does hold back the module.
+ *
+ * Only the application's own objects are restored from the trash, and only
+ * when `restoreDeleted` says so; the extras never are.
+ *
+ * @param ScopeEntry $application The application.
+ * @param bool $restoreDeleted Whether the application's own objects in the trash are restored to be archived.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function itemsOf(array $application, bool $restoreDeleted): array {
+ $items = [];
+ foreach (['usage', 'module'] as $schemaKey) {
+ // Every usage is written before any module, whoever holds it.
+ $items[] = [
+ 'schemaKey' => $schemaKey,
+ 'uuid' => (string)$application[$schemaKey]->getUuid(),
+ 'state' => $application['states'][$schemaKey],
+ 'restore' => $restoreDeleted,
+ ];
+ foreach (($application['extras'] ?? []) as $extra) {
+ if ($extra['schemaKey'] === $schemaKey) {
+ $items[] = $extra + ['restore' => false];
+ }
+ }
+ }
+
+ return $items;
+ }//end itemsOf()
+
+ /**
+ * The steps that bring one module or usage to the state the export asks for (design D5).
+ *
+ * Deleted: soft-delete what is not in the trash yet. Archived: archive
+ * what is working; an object in the trash is restored first only when the
+ * archive sheet lists it, so a workbook without the sheet never takes
+ * anything out of the trash.
+ *
+ * @param array{archived: bool, deleted: bool} $state The object's state.
+ * @param string $target archived or deleted.
+ * @param bool $restoreDeleted Whether an object in the trash is restored to be archived.
+ *
+ * @return array delete, restore and archive, in order; empty when nothing changes.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function transitions(array $state, string $target, bool $restoreDeleted): array {
+ if ($target === CmdbImportReport::DELETED) {
+ if ($state['deleted'] === true) {
+ return [];
+ }
+
+ return ['delete'];
+ }
+
+ if ($state['deleted'] === true) {
+ if ($restoreDeleted === true) {
+ return ['restore', 'archive'];
+ }
+
+ return [];
+ }
+
+ if ($state['archived'] === true) {
+ return [];
+ }
+
+ return ['archive'];
+ }//end transitions()
+
+ /**
+ * Run one step through OpenRegister: soft delete, restore from the trash, or archive.
+ *
+ * @param string $step delete, restore or archive.
+ * @param string $schemaKey The coordinates key of the schema.
+ * @param string $uuid The object.
+ * @param string $reason The archive reason.
+ *
+ * @return void
+ *
+ * @throws RuntimeException When OpenRegister does not delete the object.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function transition(string $step, string $schemaKey, string $uuid, string $reason): void {
+ if ($step === 'delete') {
+ $coordinates = $this->coordinates();
+ $deleted = $coordinates['objectService']->deleteObject(
+ uuid: $uuid,
+ register: $coordinates['register'],
+ schema: $coordinates[$schemaKey],
+ _rbac: false,
+ _multitenancy: false
+ );
+ if ($deleted !== true) {
+ throw new RuntimeException('OpenRegister did not delete the object');
+ }
+
+ return;
+ }
+
+ if ($step === 'restore') {
+ $this->restore(uuid: $uuid);
+ return;
+ }
+
+ $this->archiveServices()['handler']->archive(identifier: $uuid, reason: $reason);
+ }//end transition()
+
/**
* Resolve OpenRegister's mapping engine.
*
@@ -1723,8 +2920,8 @@ private function resolveEngine(): object {
}
if (class_exists($class) === true) {
- $engine = new $class();
- if (method_exists($engine, 'mapRow') === true) {
+ $engine = (new OpenRegisterInstance(container: $this->container))->build(class: $class);
+ if ($engine !== null && method_exists($engine, 'mapRow') === true) {
return $engine;
}
}
@@ -1913,8 +3110,12 @@ private function resetRun(): void {
$this->suppliers = null;
$this->seenKeys = [];
$this->winningSheets = [];
+ $this->unreadableKeyRows = [];
+ $this->absentSheets = [];
$this->contactUids = [];
$this->contactPersons = [];
+ $this->archiveServices = null;
+ $this->trash = null;
}//end resetRun()
/**
diff --git a/lib/Service/ModuleVersionPublicationService.php b/lib/Service/ModuleVersionPublicationService.php
index af09b6f8b..f1c8a922b 100644
--- a/lib/Service/ModuleVersionPublicationService.php
+++ b/lib/Service/ModuleVersionPublicationService.php
@@ -24,7 +24,7 @@
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @link https://github.com/ConductionNL/stackiq
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -45,7 +45,7 @@
/**
* Copies a module's publication onto its versions.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
class ModuleVersionPublicationService {
@@ -79,7 +79,7 @@ public function __construct(
*
* @return array{modulePublicationDate: string|null, moduleRegisteredBy: string|null}
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public static function mirrorOf(array $module): array {
return [
@@ -114,7 +114,7 @@ private static function text(mixed $value): ?string {
*
* @return integer The number of versions written in this request: 0 for a module, whose versions the job writes.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function objectSaved(ObjectEntityInterface $object, ?ObjectEntityInterface $previous = null): int {
$schema = (string) $object->getSchema();
@@ -143,7 +143,7 @@ public function objectSaved(ObjectEntityInterface $object, ?ObjectEntityInterfac
*
* @return integer The number of versions written in this request: always 0, the job writes them.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function objectDeleted(ObjectEntityInterface $object): int {
if ((string) $object->getSchema() !== (string) $this->settingsService->getSchemaIdForObjectType('module')) {
@@ -176,7 +176,7 @@ private function queueVersions(string $moduleUuid, bool $deleted): void {
*
* @return array{written: int, failed: int} The versions written, and the versions or searches that failed.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function clearVersions(string $moduleUuid): array {
return $this->copyOntoVersions(moduleUuid: $moduleUuid, mirror: self::mirrorOf(module: []));
@@ -189,7 +189,7 @@ public function clearVersions(string $moduleUuid): array {
*
* @return array{written: int, failed: int} The versions written, and the versions or searches that failed.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function backfillModule(ObjectEntityInterface $module): array {
return $this->copyOntoVersions(moduleUuid: (string) $module->getUuid(), mirror: self::mirrorOf(module: (array) $module->getObject()));
@@ -323,7 +323,7 @@ private function logFailure(string $message, array $context, bool $depublishes):
*
* @return integer 1 when the version was written, else 0.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
*/
public function versionSaved(ObjectEntityInterface $version): int {
$objects = $this->objectService();
diff --git a/lib/Service/ProgressTracker.php b/lib/Service/ProgressTracker.php
index b0c9ce229..5a9706ada 100644
--- a/lib/Service/ProgressTracker.php
+++ b/lib/Service/ProgressTracker.php
@@ -62,6 +62,11 @@ class ProgressTracker {
'processing_relationships' => ['weight' => 15, 'description' => 'Processing relationships'],
'processing_organizations' => ['weight' => 10, 'description' => 'Processing organizations'],
'processing_views' => ['weight' => 10, 'description' => 'Processing views'],
+ // The CMDB import's reconciliation of records missing from the source
+ // (openspec/changes/cmdb-import-archive-reconciliation). Weight 0, so the
+ // weighted percentage of the ArchiMate import's phases is unchanged; the
+ // CMDB section computes its own percentage from the item counts.
+ 'reconciling' => ['weight' => 0, 'description' => 'Reconciling records missing from the source'],
'finalizing' => ['weight' => 5, 'description' => 'Finalizing import'],
'completed' => ['weight' => 0, 'description' => 'Completed'],
];
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
index b8fa30d18..528f65ebe 100644
--- a/lib/Service/SettingsService.php
+++ b/lib/Service/SettingsService.php
@@ -7363,7 +7363,7 @@ public function setEolSyncStatus(array $status): void {
*
* @return array The merged register with the highest version per schema.
*
- * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-003-a-fragment-never-lowers-a-schema-version
+ * @spec openspec/specs/publication-field-rules/spec.md#requirement-req-pfr-003-a-fragment-never-lowers-a-schema-version
*/
private static function keepHighestSchemaVersions(array $before, array $after): array {
foreach (($after['components']['schemas'] ?? []) as $key => $schema) {
diff --git a/lib/Service/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php
index 2c09cd6fb..0428654c9 100644
--- a/lib/Service/StackiqContactSyncService.php
+++ b/lib/Service/StackiqContactSyncService.php
@@ -179,11 +179,11 @@ public function syncToContacts(string $objectType, array $record): ?string {
/**
* Resolve the contact of a record, creating it in a dedicated address book of the signed-in user.
*
- * A contact the record already links (`contactsUid`) is kept. Otherwise
- * a contact with the record's e-mail address is reused only when it is
- * in the address book with this URI: a contact in another address book
- * of the user is never matched, so the user's own contacts are never
- * linked to imported records. A new contact goes into that address book,
+ * A contact the record already links (`contactsUid`) is kept. Otherwise,
+ * without a fixed UID, a contact with the record's e-mail address is
+ * reused only when it is in the address book with this URI: a contact
+ * in another address book of the user is never matched, so the user's
+ * own contacts are never linked to imported records. A new contact goes into that address book,
* which is created with the display name when the user has none, and
* never into the user's own first writable address book.
*
@@ -191,12 +191,23 @@ public function syncToContacts(string $objectType, array $record): ?string {
* @param array $record The relationship record.
* @param string $addressBookUri The address book's URI, stable across languages.
* @param string $displayName The display name for a new address book.
+ * @param string|null $fixedUid The UID for a new contact, so the caller can find it again by UID; a random one when null.
+ * With a fixed UID the record's e-mail address matches no existing contact: the
+ * caller identifies the contact by that UID, and a shared e-mail address must not
+ * merge two of them.
*
* @return ?string The contacts UID, or null when it could not be resolved or created.
*
* @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
*/
- public function syncToNamedAddressBook(string $objectType, array $record, string $addressBookUri, string $displayName): ?string {
+ public function syncToNamedAddressBook(
+ string $objectType,
+ array $record,
+ string $addressBookUri,
+ string $displayName,
+ ?string $fixedUid = null,
+ ): ?string {
if ($this->isAvailable() === false) {
return null;
}
@@ -206,7 +217,7 @@ public function syncToNamedAddressBook(string $objectType, array $record, string
return $existingUid;
}
- $matched = $this->namedAddressBookContactByEmail(record: $record, addressBookUri: $addressBookUri);
+ $matched = $this->namedAddressBookContactByEmail(record: $record, addressBookUri: $addressBookUri, fixedUid: $fixedUid);
if ($matched !== null) {
return $matched;
}
@@ -231,7 +242,7 @@ public function syncToNamedAddressBook(string $objectType, array $record, string
uri: $addressBookUri,
displayName: $displayName
);
- $contactUid = self::newUid();
+ $contactUid = ($fixedUid ?? self::newUid());
$backend->createCard($addressBookId, $contactUid . '.vcf', self::serialiseVCard(uid: $contactUid, properties: $properties));
} catch (Throwable $e) {
$this->logger->warning(
@@ -251,10 +262,12 @@ public function syncToNamedAddressBook(string $objectType, array $record, string
* @param string $addressBookUri The address book's URI.
* @param array $properties The vCard properties to search, such as FN or EMAIL.
*
- * @return array> The matching contacts, as searchContacts() returns them;
+ * @return array> The matching contacts, as searchContacts() returns them,
+ * plus `emails`: every e-mail address of the contact;
* none when the user has no such address book.
*
* @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
*/
public function searchNamedAddressBook(string $query, string $addressBookUri, array $properties): array {
$contacts = [];
@@ -263,6 +276,10 @@ public function searchNamedAddressBook(string $query, string $addressBookUri, ar
'uid' => (string)$result['UID'],
'name' => $this->firstValue(value: ($result['FN'] ?? '')),
'email' => $this->firstValue(value: ($result['EMAIL'] ?? '')),
+ 'emails' => array_map(
+ fn (mixed $email): string => $this->firstValue(value: [$email]),
+ array_values((array)($result['EMAIL'] ?? []))
+ ),
'addressBookKey' => (string)($result['addressbook-key'] ?? ''),
];
}
@@ -273,12 +290,37 @@ public function searchNamedAddressBook(string $query, string $addressBookUri, ar
/**
* The contact in the named address book with the record's e-mail address, or null.
*
+ * With a fixed UID the e-mail address matches nothing: the caller
+ * identifies the contact by that UID, and a shared e-mail address must
+ * not merge two of them. Only a card that already has that UID (by URI)
+ * is returned.
+ *
* @param array $record The relationship record.
* @param string $addressBookUri The address book's URI.
+ * @param string|null $fixedUid The UID the caller gives a new contact, or null.
*
* @return string|null The contact UID.
+ *
+ * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
*/
- private function namedAddressBookContactByEmail(array $record, string $addressBookUri): ?string {
+ private function namedAddressBookContactByEmail(array $record, string $addressBookUri, ?string $fixedUid): ?string {
+ if ($fixedUid !== null) {
+ // A card that already has the fixed UID is the contact: Nextcloud
+ // refuses a second one (UidConflict), and the contacts search may not
+ // show it yet. Looked up by URI, not by search.
+ $key = $this->existingNamedAddressBookKey(addressBookUri: $addressBookUri);
+ $backend = null;
+ if ($key !== null) {
+ $backend = $this->container?->get(static::CARDDAV_BACKEND_CLASS);
+ }
+
+ if (is_object($backend) === true && is_array($backend->getCard((int)$key, $fixedUid . '.vcf')) === true) {
+ return $fixedUid;
+ }
+
+ return null;
+ }
+
$email = trim((string)($record['e-mailadres'] ?? $record['email'] ?? ''));
foreach ($this->namedAddressBookResults(query: $email, addressBookUri: $addressBookUri, properties: ['EMAIL']) as $result) {
if ($this->valueMatches(value: ($result['EMAIL'] ?? ''), needle: $email) === true) {
@@ -470,6 +512,81 @@ public function findContactByUid(string $uid): ?array {
return null;
}//end findContactByUid()
+ /**
+ * Find a contact by its exact UID in the signed-in user's own and shared address books only.
+ *
+ * A result counts only when it comes from an address book the CardDAV
+ * backend lists for the user's principal (own and shared); every
+ * address book of the system principal is left out, the local system
+ * address book (one card per local user) and the books synced from
+ * trusted servers alike. The UID is matched exactly, not as a substring.
+ * This is the lookup for a name shown to a user: a contactsUid is free
+ * text on a contactPerson, so a lookup that read the system books would
+ * name any local or federated user for whoever may write a contactPerson.
+ * Without a CardDAV backend or a signed-in user nothing is found.
+ *
+ * @param string $uid The contact UID.
+ *
+ * @return ?array The contact, or null when not found there.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+ public function findOwnContactByUid(string $uid): ?array {
+ if ($uid === '' || $this->isAvailable() === false) {
+ return null;
+ }
+
+ $keys = $this->userAddressBookKeys();
+ if ($keys === []) {
+ return null;
+ }
+
+ // With enumeration and fullmatch both off, the contacts manager skips the
+ // local system address book; strict_search turns the substring match off.
+ $options = ['limit' => 5, 'strict_search' => true, 'enumeration' => false, 'fullmatch' => false];
+ foreach ($this->contactsManager->search($uid, ['UID'], $options) as $result) {
+ $key = (string)($result['addressbook-key'] ?? '');
+ if (($result['UID'] ?? '') === $uid && in_array($key, $keys, true) === true) {
+ return $result;
+ }
+ }
+
+ return null;
+ }//end findOwnContactByUid()
+
+ /**
+ * The keys of the signed-in user's own and shared address books.
+ *
+ * @return array CardDAV address book ids as strings; empty without a backend or a user.
+ */
+ private function userAddressBookKeys(): array {
+ try {
+ $backend = $this->container?->get(static::CARDDAV_BACKEND_CLASS);
+ $uid = $this->userSession?->getUser()?->getUID();
+ if (is_object($backend) === false || $uid === null) {
+ return [];
+ }
+
+ $books = $backend->getAddressBooksForUser('principals/users/' . $uid);
+ } catch (Throwable $e) {
+ $this->logger->warning('[StackiqContactSync] The address books of the user could not be read', ['exception' => get_class($e)]);
+ return [];
+ }
+
+ if (is_array($books) === false) {
+ return [];
+ }
+
+ $keys = [];
+ foreach ($books as $book) {
+ if (is_array($book) === true && isset($book['id']) === true) {
+ $keys[] = (string)$book['id'];
+ }
+ }
+
+ return $keys;
+ }//end userAddressBookKeys()
+
/**
* Find a Nextcloud contact matching a relationship record's identity, by
* e-mail first and — for organisations — by CBS/KvK code as a fallback.
diff --git a/lib/Settings/cmdb-import/topdesk-business-owner.json b/lib/Settings/cmdb-import/topdesk-business-owner.json
index f987d51ee..a2b09d728 100644
--- a/lib/Settings/cmdb-import/topdesk-business-owner.json
+++ b/lib/Settings/cmdb-import/topdesk-business-owner.json
@@ -1,12 +1,14 @@
{
"id": "stackiq-topdesk-business-owner",
"name": "TOPdesk CMDB export to business owner identity",
- "description": "The application owner (Applicatie Eigenaar (Persoon)); the value may be a function instead of a name and is used as the display name either way. The import resolves it in Nextcloud Contacts by exact display name and links a contactPerson of the municipality as usage.businessOwner, with the owner's function as its role. No other person column is read.",
+ "description": "The application owner (Applicatie Eigenaar (Persoon)); the value may be a function instead of a name and is used as the display name either way. The import resolves it in Nextcloud Contacts and links a contactPerson of the municipality as usage.businessOwner, with the owner's function as its role. It matches on the employee number when the row has one, else on the e-mail address, else on the exact display name. The e-mail and employee number columns are optional; rename their source here when the export names them differently. No other person column is read.",
"sourceFormat": "excel",
- "version": "2.0.0",
+ "version": "1.1.0",
"fieldMappings": [
{ "source": "Applicatie Eigenaar (Persoon)", "target": "name", "required": true, "transform": { "type": "trim" } },
- { "source": "Applicatie Eigenaar (Functie)", "target": "role", "transform": { "type": "trim" } }
+ { "source": "Applicatie Eigenaar (Functie)", "target": "role", "transform": { "type": "trim" } },
+ { "source": "Applicatie Eigenaar (E-mail)", "target": "email", "transform": { "type": "trim" } },
+ { "source": "Applicatie Eigenaar (Personeelsnummer)", "target": "employeeNumber", "transform": { "type": "trim" } }
],
"idStrategy": { "type": "generate" }
}
diff --git a/lib/Settings/cmdb-import/topdesk-manufacturer.json b/lib/Settings/cmdb-import/topdesk-manufacturer.json
index dec8b10c9..ae3cb262e 100644
--- a/lib/Settings/cmdb-import/topdesk-manufacturer.json
+++ b/lib/Settings/cmdb-import/topdesk-manufacturer.json
@@ -3,7 +3,7 @@
"name": "TOPdesk CMDB export to stackiq supplier organisation",
"description": "The Vendor column (the maker of the software) becomes one organisation of type Supplier per distinct name, registered as a Supplier so the public catalogue can show it as the module's provider. An empty Vendor means the row has no provider. Leverancier and Hostingpartij are not read.",
"sourceFormat": "excel",
- "version": "2.1.0",
+ "version": "1.0.0",
"fieldMappings": [
{ "source": "Vendor", "target": "name", "required": true, "transform": { "type": "trim" } }
],
diff --git a/lib/Settings/cmdb-import/topdesk-module.json b/lib/Settings/cmdb-import/topdesk-module.json
index 3a7ca7496..a1f0368c2 100644
--- a/lib/Settings/cmdb-import/topdesk-module.json
+++ b/lib/Settings/cmdb-import/topdesk-module.json
@@ -3,7 +3,7 @@
"name": "TOPdesk CMDB export to stackiq module",
"description": "One application row of a CMDB sheet becomes a stackiq module. A mapping marked required that fails skips the row; any other failing mapping drops that field with a warning. Nickname and Roepnaam both feed shortDescription; Roepnaam wins when both are filled. Applicatiesoort is kept as is in applicationType; only its SaaS/PaaS/IaaS/on-premises values also set the hosting model, every other kind leaves that empty without a warning.",
"sourceFormat": "excel",
- "version": "2.0.0",
+ "version": "1.0.0",
"fieldMappings": [
{ "source": "Applicatie Naam", "target": "name", "required": true, "transform": { "type": "trim" } },
{ "source": "APPID", "target": "externalNumber", "required": true, "transform": { "type": "trim" } },
diff --git a/lib/Settings/cmdb-import/topdesk-profile.json b/lib/Settings/cmdb-import/topdesk-profile.json
index 79dc43253..df2323931 100644
--- a/lib/Settings/cmdb-import/topdesk-profile.json
+++ b/lib/Settings/cmdb-import/topdesk-profile.json
@@ -1,8 +1,8 @@
{
"id": "topdesk-cmdb",
"name": "TOPdesk CMDB export",
- "version": "2.0.0",
- "description": "How stackiq reads a TOPdesk CMDB export (xlsx): the two CMDB sheets, the key and required columns, date and id columns, values that mean empty, the pack per target and the limits. Columns that neither this profile nor a pack names are never read.",
+ "version": "1.0.0",
+ "description": "How stackiq reads a TOPdesk CMDB export (xlsx): the two CMDB sheets, the archive sheet (APPID column only), the key and required columns, date and id columns, values that mean empty, the pack per target, the accepted missingRecords modes and the limits. Columns that neither this profile nor a pack names are never read.",
"maxFileBytes": 10485760,
"maxRowsPerSheet": 10000,
"maxUncompressedBytes": 52428800,
@@ -13,14 +13,16 @@
{
"name": "Onbeh Applicaties CMDB",
"constants": { "Beheer": "Beheer geregeld: nee" },
- "absentColumns": ["Nickname"]
+ "absentColumns": ["Nickname", "Applicatie Eigenaar (E-mail)", "Applicatie Eigenaar (Personeelsnummer)"]
},
{
"name": "Beheerde Applicaties CMDB",
- "constants": { "Beheer": "Beheer geregeld: ja" }
+ "constants": { "Beheer": "Beheer geregeld: ja" },
+ "absentColumns": ["Applicatie Eigenaar (E-mail)", "Applicatie Eigenaar (Personeelsnummer)"]
}
],
"sheetPrecedence": ["Beheerde Applicaties CMDB", "Onbeh Applicaties CMDB"],
+ "archiveSheet": { "name": "Gearchiveerde Applicaties" },
"keyColumn": "APPID",
"nameColumn": "Applicatie Naam",
"requiredColumns": ["APPID", "Applicatie Naam"],
@@ -44,5 +46,5 @@
"neverWritten": {
"module": ["publicationDate", "depublicationDate"]
},
- "missingRecords": ["keep"]
+ "missingRecords": ["keep", "archive"]
}
diff --git a/lib/Settings/cmdb-import/topdesk-usage.json b/lib/Settings/cmdb-import/topdesk-usage.json
index 0038df197..3edcf7476 100644
--- a/lib/Settings/cmdb-import/topdesk-usage.json
+++ b/lib/Settings/cmdb-import/topdesk-usage.json
@@ -3,7 +3,7 @@
"name": "TOPdesk CMDB export to stackiq usage",
"description": "The usage that links the application to the municipality. The internal note records whether maintenance is arranged (the sheet the row came from), the cluster and the owner's department. An unknown status or TIME value drops the field with a warning.",
"sourceFormat": "excel",
- "version": "2.0.0",
+ "version": "1.0.0",
"fieldMappings": [
{
"source": "Applicatie Status",
diff --git a/lib/Settings/register.d/topdesk-cmdb-import.json b/lib/Settings/register.d/topdesk-cmdb-import.json
index 5fb300099..977c5db24 100644
--- a/lib/Settings/register.d/topdesk-cmdb-import.json
+++ b/lib/Settings/register.d/topdesk-cmdb-import.json
@@ -2,7 +2,10 @@
"components": {
"schemas": {
"module": {
- "version": "0.3.8",
+ "version": "0.3.9",
+ "configuration": {
+ "x-openregister-archive": { "enabled": true }
+ },
"properties": {
"externalId": {
"type": "string",
@@ -72,8 +75,9 @@
}
},
"usage": {
- "version": "1.5.6",
+ "version": "1.5.7",
"configuration": {
+ "x-openregister-archive": { "enabled": true },
"x-openregister-lifecycle": {
"transitions": {
"importAcquisition": {
diff --git a/openapi.json b/openapi.json
index 7ee628b87..f456fc209 100644
--- a/openapi.json
+++ b/openapi.json
@@ -62,10 +62,11 @@
"missingRecords": {
"type": "string",
"enum": [
- "keep"
+ "keep",
+ "archive"
],
- "default": "keep",
- "description": "Only keep is accepted; mark and remove are reserved"
+ "default": "archive",
+ "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, an APPID in it has no cached value, or an APPID on a CMDB sheet has no cached value (that application may be the one the reader could not see); an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED"
},
"operationId": {
"type": "string",
@@ -128,7 +129,7 @@
}
},
"422": {
- "description": "MISSING_RECORDS_UNSUPPORTED, MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS (more than one live Municipality has the typed name; details.matches lists their uuids), NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS",
+ "description": "MISSING_RECORDS_UNSUPPORTED (details.accepted lists keep and archive), MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS (more than one live Municipality has the typed name; details.matches lists their uuids), NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS",
"content": {
"application/json": {
"schema": {
@@ -148,7 +149,7 @@
}
},
"503": {
- "description": "MAPPING_UNAVAILABLE, READER_UNAVAILABLE, NOT_CONFIGURED, or SCHEMA_OUTDATED (a stackiq schema lacks a property the import matches on, or the module schema predates 0.3.8; details.schema names it and details.missing lists the properties, and externalKey.authorization.update for a missing admin-only write rule)",
+ "description": "MAPPING_UNAVAILABLE, READER_UNAVAILABLE, NOT_CONFIGURED, or SCHEMA_OUTDATED (a stackiq schema lacks a property the import matches on, or the module schema predates 0.3.8; details.schema names it and details.missing lists the properties, and externalKey.authorization.update for a missing admin-only write rule), or ARCHIVE_UNAVAILABLE (missingRecords=archive, but OpenRegister's archive handler or object mapper is missing; nothing is read or written)",
"content": {
"application/json": {
"schema": {
@@ -499,6 +500,10 @@
"unchanged",
"skipped",
"failed",
+ "archived",
+ "unarchived",
+ "deleted",
+ "restored",
"warnings",
"unpublished"
],
@@ -524,6 +529,22 @@
"failed": {
"type": "integer"
},
+ "archived": {
+ "type": "integer",
+ "description": "Applications whose module and usage this import archived because they left the CMDB sheets"
+ },
+ "unarchived": {
+ "type": "integer",
+ "description": "Rows whose archived module or usage this import unarchived before updating it"
+ },
+ "deleted": {
+ "type": "integer",
+ "description": "Applications whose module and usage this import soft-deleted because no sheet lists them any more"
+ },
+ "restored": {
+ "type": "integer",
+ "description": "Rows whose soft-deleted module or usage this import restored from the trash before updating it"
+ },
"warnings": {
"type": "integer"
},
@@ -571,7 +592,8 @@
"type": "string"
},
"row": {
- "type": "integer"
+ "type": "integer",
+ "description": "The sheet row, or 0 for an application the reconciliation archived or deleted"
},
"appId": {
"type": "string"
@@ -586,7 +608,11 @@
"updated",
"unchanged",
"skipped",
- "failed"
+ "failed",
+ "archived",
+ "unarchived",
+ "deleted",
+ "restored"
]
},
"reasons": {
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/.openspec.yaml b/openspec/changes/cmdb-import-archive-reconciliation/.openspec.yaml
new file mode 100644
index 000000000..5c0f6674b
--- /dev/null
+++ b/openspec/changes/cmdb-import-archive-reconciliation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: conduction
+created: 2026-10-09
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
new file mode 100644
index 000000000..dfb88b9a0
--- /dev/null
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -0,0 +1,212 @@
+# Design: cmdb-import-archive-reconciliation
+
+## Context
+
+The CMDB import (archived change `2026-10-05-cmdb-export-import`) matches rows on `module.externalKey` = `topdesk::` and leaves records that are missing from a newer export untouched (REQ-CMDB-012). The municipality's export carries, next to the two CMDB sheets, a sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging), derived from the raw "Invoer gearchiveerde appl" sheet. The municipality wants an application that moved there to be archived, and one that is on no sheet any more to be soft-deleted (2026-10-01).
+
+What OpenRegister offers (origin/beta `ff4dad5c`, identical to development for `lib/`):
+
+- **Soft delete.** `ObjectServiceInterface::deleteObject(uuid, register, schema, ...)` writes `@self.deleted` (`deletedBy`, `deletedAt`, `purgeDate` 30 days on). Lists exclude deleted objects unless `_includeDeleted=true`, but the list search cannot return one even then (D9). `MagicMapper::findDeletedAcrossAllMagicTables()` lists the trash; `MagicMapper::restoreObject(uuid)` clears the marker. `DeletedController` uses both.
+- **Archive.** `ObjectEntity::archive()` / `unarchive()` write and clear `@self.archived` (`by`, `at`, `reason`). `Service\Object\ArchiveHandler::archive()` / `unarchive()` is the service path behind `POST` / `DELETE /api/objects/{register}/{schema}/{id}/archive`: it resolves the object, refuses a schema without `x-openregister-archive: {enabled: true}` (`ArchiveNotOfferedException`), checks `update` on the object for the session user, persists the marker and writes an audit entry. Lists exclude archived objects unless `_archived=true` (only the archive) or `_archived=any`. A read by identifier is unaffected. `SaveObject` refuses a data write to an archived object (`ObjectStateWriteException`).
+- **Search lenses** combine in SQL: `_ids`, `_includeDeleted` and `_archived` apply together in `MagicSearchHandler`. Its row conversion, however, drops a soft-deleted row and hydrates no archive marker (D9).
+
+OpenCatalogi (beta `df25ff75`) passes list parameters through to OpenRegister and forces `_includeDeleted=false`; it has no archive filter of its own, so `_archived=true` reaches OpenRegister unchanged. Portaliq (beta `8287e390`) lists usages with neither lens. Both hide archived and deleted records as they are (decided 2026-10-09).
+
+## Goals / Non-Goals
+
+**Goals**
+
+- A re-import archives what moved to the archive sheet and soft-deletes what left the export entirely, for the imported municipality only.
+- A returning application is revived, never duplicated.
+- The admin sees and chooses the behaviour, and the report accounts for every archived, unarchived, deleted and restored application.
+- Archived applications and usages can be listed in stackiq on request and stay out of OpenCatalogi and Portaliq without a change there.
+
+**Non-Goals**
+
+- Hard deletion, the trash's retention, contact persons, suppliers, modules another organisation imported (a module carrying this municipality's import key is this municipality's record, also when another organisation uses it; D5), changes to OpenCatalogi or Portaliq, an archive widget on the detail pages.
+
+## Architecture Overview
+
+```
+CmdbImportController::import() missingRecords: keep | archive (default archive); remove → 422
+ ▼
+CmdbExportImportService::import()
+ ├─ resolve ArchiveHandler + MagicMapper (archive mode only; missing → 503 ARCHIVE_UNAVAILABLE)
+ ├─ lock the register
+ ├─ CmdbWorkbookReader::read() CMDB sheets as before + the archive sheet's APPID column
+ ├─ rows, each in its own boundary match module and usage: working, then _archived=true, then the trash (D9)
+ │ archived match → unarchive, deleted match → restore (and unarchive), then the usual update
+ └─ reconcile() (archive mode, not cancelled) phase `reconciling`, same lock
+ page usages (consumer = municipality): working, then _archived=true; deleted ones from the trash
+ load their modules in batches (_ids: working, then _archived=true; the rest from the trash)
+ per application: key on CMDB sheets → nothing
+ key on archive sheet → archive module + usage (restore first when deleted)
+ key nowhere → soft-delete module + usage, or archive when the sheet is absent or incomplete (D3)
+ each in its own boundary → report rows archived | deleted | failed
+ ▼
+OpenRegister: module, usage (archived / deleted markers); contactPerson, organization untouched
+ ├─▶ OpenCatalogi: archived modules only with _archived=true
+ └─▶ Portaliq: archived and deleted usages disappear from "Software we use"
+```
+
+## Decisions
+
+### D1. "Archived" is OpenRegister's archive state on the module and on the usage
+
+Decided by the product owner on 2026-10-09. No new field, no new `usage.status` value, no `depublicationDate`.
+
+- **Alternative: `usage.status` `Archived`.** Rejected: OpenCatalogi drops `status === 'archived'` only on `/api/search`, so `/api/{catalogSlug}` and the federation route would need an OpenCatalogi change and a new beta.
+- **Alternative: `module.depublicationDate`.** Rejected: the module read rule does not look at it, and the import never writes it (D6 of the first change).
+
+Both schemas opt in through the register fragment: `module` 0.3.8 → 0.3.9 and `usage` 1.5.6 → 1.5.7 get `configuration.x-openregister-archive: {"enabled": true}`. Fragments are merged by key union and the loader keeps the highest `version` per schema, so the bump deploys with the existing register import. The `SCHEMA_OUTDATED` check is not widened: an installation whose schemas predate the annotation gets `ArchiveNotOfferedException` per object, which the reconciliation reports as a failed row (D7), and the docs point to Force Update.
+
+### D2. The option: `keep | archive`, default `archive`
+
+`CmdbImportProfile::missingRecordsModes()` reads the accepted values from the profile (`["keep", "archive"]`) without validating the packs, like `maxFileBytes()`, because the controller checks the option before the import runs. The default lives in `CmdbExportImportService::DEFAULT_MISSING_RECORDS`. `remove` and `mark` stay reserved: 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` naming both accepted values. The section sends the admin's choice as `missingRecords` and defaults to `archive`.
+
+With `updateExisting=false`, a matched row is skipped as `exists` before anything is revived, as before: the admin asked to leave existing records alone.
+
+### D3. Reading the archive sheet
+
+The profile names the sheet (`archiveSheet.name`: "Gearchiveerde Applicaties"). The reader lists the workbook's sheets, and when the archive sheet is present it is loaded with the CMDB sheets under the same bounds: it counts as a read sheet for `CmdbPartReferences` (an oversized archive sheet is refused, not blanked) and for the referenced-string budget, its row span is bounded like a source sheet, and `TOO_MANY_ROWS` names it. Only its APPID column is resolved and read; the allowlist does not grow. The reader returns `archive: {present, keyColumnMissing, appIds, uncachedRows}` next to `rows`.
+
+- Sheet absent, or present without an APPID column: `present` false (with `keyColumnMissing`), and the service adds a report warning. The reconciliation then archives every missing application and soft-deletes nothing: a workbook saved without the sheet must not empty the trash-bound set by accident.
+- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash. The same holds for a CMDB sheet: a row whose APPID has no cached value is skipped, so that application may be on a CMDB sheet and still look absent. The service notes such rows (`unreadableKeyRows`, per sheet) and, with any of them, archives instead of soft-deleting and warns naming the CMDB sheet and the rows, as for the archive sheet.
+- The archive sheet's other columns are not read; `Applicatie Status` there is always `Verwijderd` in the municipality's export and adds nothing.
+
+### D4. Matching widens to archived and deleted objects, and revives what it matches
+
+`matchModule()` looks the module up by `externalKey` in every state, as D9 reads it; `moduleBelongsTo()` looks up this municipality's usage the same way (a module whose usage is archived still belongs to the municipality), and keeps the working lens for "does any other organisation use it". `upsertUsage()` finds the usage in every state. Whatever is matched is revived before it is written: a soft-deleted object is restored (`MagicMapper::restoreObject()`), and unarchived as well when the restored object says it was archived; an archived one is unarchived (`ArchiveHandler::unarchive()`), because `SaveObject` refuses a write to an archived object. The update starts from the object `restoreObject()` answers, not from the trash listing's copy. The row outcome is `restored` when anything was restored, else `unarchived` when anything was unarchived, else the usual `created | updated | unchanged`. When more than one object matches, `findInAnyState()` takes a working one before an archived one, and an archived one before one in the trash: before this change a module deleted by hand was invisible to the match, so the next import created a second one with the same `externalKey`, and reviving the deleted one would make two live modules. Organisations and contact persons keep the working lens: a deleted supplier is never matched.
+
+### D5. The reconciliation step
+
+Runs inside `runImport()` after the row loop, only when the loop finished (not cancelled) and `missingRecords` is `archive`, under the register lock the import already holds. It checks for a cancel once before it loads the scope (a cancel that came in during the last row) and again before each application; a cancel stops it there, keeps what was already archived or deleted, and marks the report cancelled. An exception outside an object's boundary fails the run like any other (`IMPORT_FAILED`, operation `failed`), so a half-done reconciliation is visible.
+
+Scope: the usages whose `consumer` is the municipality, in every state as D9 reads it: paged with `PAGE_SIZE` without a lens and with `_archived=true`, plus the deleted ones from the trash. Their modules are loaded in chunks of `PAGE_SIZE` with `_ids`, without a lens and with `_archived=true`, and the rest from the trash. Every usage and module keeps the state D9 gave it; the table below decides on that state. Only a module whose `externalKey` starts with `topdesk::` is an imported application; the key after the last `:` is the APPID's match key. A usage without such a module (a hand-made usage, or a module another organisation imported) is left alone.
+
+Per application, with `processed` = every APPID match key on the CMDB sheets (whatever the row's outcome: a row that failed or was skipped is still in the export) and `archived` = the match keys on the archive sheet:
+
+| key is | module/usage state | action | outcome |
+|---|---|---|---|
+| on a CMDB sheet | any | nothing (the row handled it) | — |
+| on the archive sheet | working | archive usage, then module | `archived` |
+| on the archive sheet | archived | nothing | — |
+| on the archive sheet | deleted | restore, then archive | `archived` |
+| nowhere, sheet present | working or archived | soft-delete usage, then module | `deleted` |
+| nowhere, sheet present | deleted | nothing | — |
+| nowhere, sheet absent or incomplete (no key column, `uncachedRows` not empty, a CMDB-sheet row with an unreadable APPID, or a CMDB sheet absent from the workbook: `absentSheets` not empty) | working | archive usage, then module | `archived` |
+| nowhere, sheet absent or incomplete | archived or deleted | nothing | — |
+
+The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
+
+The writes go in this order: the application's own usage, the other usages of its key, its module, the other modules. A failure stops that application and gives one `failed` report row (step and exception class only). A failing own usage leaves everything as it was; a failing extra usage leaves the own usage in the new state and that extra and the module as they were (the extra stays visible, the module stays working); a failing module leaves every usage in the new state and the module as it was. Never is a module changed while a usage of the key still shows. The next import reconciles the application again. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
+
+Each application runs in its own boundary: a failure is a `failed` row naming the step (`archive`, `restore`, `delete`) and the exception class, as row failures do, and the other applications continue. Contact persons are never touched.
+
+Progress: the operation gets the phase `reconciling` (added to `ProgressTracker::PHASES` with weight 0, so the ArchiMate import's weighted percentage is unchanged), `total_items` grows by the number of usages in scope, and `processed_items` advances per application; the section's own percentage comes from those counts.
+
+### D6. The report
+
+`CmdbImportReport` gains the outcomes `archived`, `unarchived`, `deleted` and `restored`; `summary()` counts them like the others. Reconciliation rows carry the archive sheet's name (for `archived` from the sheet) or an empty sheet, row 0, the APPID (`externalNumber`), the module name and both uuids: no person data. `toStoredArray()` ranks them with the rest. The section shows the four counts as summary tiles when they are non-zero, and the outcome filter offers them.
+
+### D7. Failure modes
+
+- `ArchiveHandler` or `MagicMapper` missing, or without a method the import calls (`archive`, `unarchive`, `restoreObject`, `findDeletedAcrossAllMagicTables`): 503 `ARCHIVE_UNAVAILABLE` before the lock is taken, only in archive mode. `keep` needs neither: without them the trash cannot be read, so a soft-deleted module or usage is not matched (the log says so) and a row whose match must be unarchived fails at step `module` or `usage`.
+- A schema without `x-openregister-archive`: `ArchiveNotOfferedException` from OpenRegister per object → a failed row with the exception class; the docs say to run Force Update.
+- A failure while reviving a matched row: the row fails at step `module` or `usage` like any other row failure.
+
+### D8. Stackiq UI
+
+> This section records the decision. The UI code it describes (the quick filters, the `_ids` override and the `src/icons.js` entry) is not part of the reader slice in this PR; it lands in the UI follow-up PR (#1287).
+
+The Applications (`Modules`, a `FacetedCatalogIndexView` that forwards `quickFilters` to `CnIndexPage`) and Applications in use (`Gebruik`) pages get the quick filter `{"label": "Archived", "filter": {"_archived": "true"}}`. `useSelfFetchList` spreads the active tab's filter into the fetch parameters as they are, so `_archived=true` reaches OpenRegister and the list shows the archive alone. The facet narrowing's `_ids` would compose with it (AND) and hide archived applications the facets do not know; the Archived tab of the Applications page therefore overrides `_ids` with an empty value, so it lists the whole archive and ignores the facets and the search. The icon `ArchiveOutline` is registered in `src/icons.js`. `@conduction/nextcloud-vue` 2.65 renders no `@self.archived` on a detail page or in the sidebar metadata, so the detail pages show no archive state yet; that is a library follow-up, not a stackiq widget.
+
+### D9. How the import reads the state of a stored object
+
+Found on a Nextcloud 35 rig with OpenRegister 2.1.37-beta (origin/beta `ff4dad5c`): the second import of the original export left APPID 1234 archived (`unchanged`) and created a second module for APPID 2. `ObjectService::searchObjects()` (via `QueryHandler::searchObjects()` → `MagicMapper::searchObjects()` → `MagicSearchHandler::searchObjects()`) applies the lenses in SQL (`buildFilteredQuery()` → `applyBasicFilters()`, `MagicSearchHandler.php` 2135–2153), but converts each row with `MagicSearchHandler::convertRowToObjectEntity()` (3780), which:
+
+- reads no `_archived` column, so every result keeps `ObjectEntity::$archived = []` (`ObjectEntity.php` 334) and looks working;
+- turns the `_deleted` marker into `new DateTime(...)` (3896–3898), but `DeleteObject` writes it as a JSON object (`DeleteObject.php` 361), so the conversion throws, the row becomes null (4035) and `executeSearchQuery()` skips it (3714): `_includeDeleted=true` returns no soft-deleted object.
+
+So the import never reads the state from a search result:
+
+- **Working**: found by a search without a lens. **Archived**: found by a search with `_archived=true` (the archive alone, not deleted). An object is looked up working first, then archived.
+- **Deleted**: from `MagicMapper::findDeletedAcrossAllMagicTables(_rbac: false, _multitenancy: false)`, the listing behind the Deleted page, which reads the rows itself (`MagicMapper.php` 7001). It scans every magic table of the instance and pages only after merging them, so the import reads it once per run, lazily, and keeps this register's modules and usages. Its objects carry data but no markers (`MagicMapper::rowToObjectEntity()`, 9131), and that data has every string that parses as JSON decoded ("2" becomes 2), so it is never written back.
+- **Deleted and archived**: the trash does not say. `MagicMapper::restoreObject()` clears `_deleted` (10078) and answers the object as the mapper reads it by uuid, through `MagicStatisticsHandler::convertRowToObjectEntity()`, which does decode `_archived` (`MagicStatisticsHandler.php` 631–652). A returning row unarchives it when that object is archived, and updates from it. The reconciliation does not need to know: an object in the trash is either left there or restored and archived, and `ArchiveHandler::archive()` leaves an archived object as it is (`ArchiveHandler.php` 111).
+
+`ArchiveHandler::unarchive()` clears the marker to SQL NULL, so the object is back in the working lists, and does nothing for an object that is not archived (161). A lookup costs one search for a working match and two for an archived one or none, plus the one trash read per run. When OpenRegister hydrates both markers on search results, this can go back to one search with both lenses; the lens-based reading stays correct either way.
+
+## API Design
+
+`POST /api/cmdb-import`: `missingRecords` accepts `keep` and `archive` (default `archive`). The report's `summary` gains `archived`, `unarchived`, `deleted` and `restored`; `rows[].outcome` may be any of the four; reconciliation rows have `row: 0`. New error code 503 `ARCHIVE_UNAVAILABLE`. `MISSING_RECORDS_UNSUPPORTED` keeps its status and gains both accepted values in `details.accepted`. `openapi.json` is updated accordingly.
+
+## Database Changes
+
+None. The archive and delete markers are OpenRegister's metadata columns.
+
+## Mixed-spec rationale (ADR-032)
+
+The change is `kind: code`. Its only schema delta is the annotation `x-openregister-archive: {enabled: true}` on `module` and `usage` in the existing fragment `topdesk-cmdb-import.json`, with a version bump of both. The annotation is glue for this code: OpenRegister refuses to archive an object of a schema without it, and nothing else in stackiq reads it. It follows the fragment convention (ADR-037) and deploys with the existing register import.
+
+## Declarative-vs-imperative decision (ADR-031)
+
+- **Declarative:** which schemas may be archived (`x-openregister-archive` on `module` and `usage`), which sheet is the archive sheet and which option values are accepted (`topdesk-profile.json`), and which list shows the archive (the manifest quick filter `_archived=true`).
+- **Imperative, because it is the external-integration exception the first change already claimed:** comparing an uploaded workbook with the register and deciding per application between nothing, archive, unarchive, soft-delete and restore. No `x-openregister-*` block can express "archive when the source lists it as archived and delete when the source no longer lists it": that rule depends on a file the register never sees. The state transitions themselves are OpenRegister's (`ArchiveHandler`, `deleteObject()`, `restoreObject()`); stackiq only decides when to call them.
+- **Rule stated once, enforced in code:** the table in D5.
+
+## Nextcloud Integration
+
+- Controller: `CmdbImportController::import()` (unchanged auth: Nextcloud admins only, CSRF).
+- Services: `CmdbExportImportService` (lenses, revival, reconciliation), `Cmdb\CmdbWorkbookReader` and `Cmdb\CmdbImportProfile` (archive sheet), `Cmdb\CmdbImportReport` (outcomes), `ProgressTracker` (phase).
+- OpenRegister: `ObjectServiceInterface::searchObjects()` with `_archived` and `_ids`, `::deleteObject()` (contract); `Service\Object\ArchiveHandler::archive()` / `unarchive()` and `Db\MagicMapper::restoreObject()` / `findDeletedAcrossAllMagicTables()` (container, guarded, like the mapping engine). The handler checks `update` for the session user with RBAC on; the import is admin-only, so the check passes, and the audit entry names the admin.
+
+## Security Considerations
+
+- Still admin-only, CSRF-protected, with the file bounds of the first change; the archive sheet is read under the same bounds, APPID column only.
+- Scope is the imported municipality's usages; a supplier's or another municipality's module is never archived or deleted through this municipality's import.
+- Nothing is hard-deleted; every transition is an OpenRegister audit entry with a reason.
+- The report and the logs carry APPIDs, module names and uuids, never owner data; contact persons are untouched.
+
+## NL Design System
+
+The section adds one radio group with a help text, built from `NcCheckboxRadioSwitch` like the existing switches, labelled and translated. The quick filter is the library's own chip.
+
+## File Structure
+
+```
+lib/Service/CmdbExportImportService.php lenses, revival, reconcile(), state helpers
+lib/Service/Cmdb/CmdbWorkbookReader.php the archive sheet
+lib/Service/Cmdb/CmdbImportProfile.php archiveSheetName(), readSheetNames(), missingRecordsModes()
+lib/Service/Cmdb/CmdbImportReport.php archived | unarchived | deleted | restored
+lib/Controller/CmdbImportController.php default archive, accepted values, ARCHIVE_UNAVAILABLE
+lib/Exception/CmdbImportException.php ARCHIVE_UNAVAILABLE
+lib/Service/ProgressTracker.php phase reconciling
+lib/Settings/cmdb-import/topdesk-profile.json archiveSheet, missingRecords
+lib/Settings/register.d/topdesk-cmdb-import.json x-openregister-archive, module 0.3.9, usage 1.5.7
+src/utils/cmdbImport.js, src/views/settings/sections/CmdbImport.vue
+src/manifest.json, src/manifest.d/usages.json, src/icons.js
+tests/Unit/..., tests/fixtures/cmdb/topdesk-archived-applications.xlsx, tests/e2e/spec-coverage/cmdb-import.spec.ts
+docs/features/cmdb-import.md, l10n/en.json, l10n/nl.json
+```
+
+## Seed Data
+
+No seed objects change. The fragment's three seed modules stay as they are; the `module` and `usage` schemas gain only the archive annotation.
+
+## Risks / Trade-offs
+
+- [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted.
+- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value, or a workbook without one of the CMDB sheets] → archive only, never delete, plus a warning (D3).
+- [A failure part way through an application] → usages are written before modules, so a module is never changed under a usage that still shows; but a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says `failed`. The next import retries (D5).
+- [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens).
+- [The trash listing scans every magic table of the instance] → read once per import, not per row (D9).
+- [OpenRegister internals change shape] → guarded resolution, 503 before the file is read (D7).
+
+## Migration Plan
+
+No data migration. The fragment deploys with the existing register import (Force Update on an installation that already has the register). Rollback is a revert of the PR; archived or deleted objects keep their state and can be unarchived or restored by hand.
+
+## Open Questions
+
+- Should the library render `@self.archived` on detail pages, so an admin sees why an application is not in the list? (library follow-up)
+- Should a module that no usage points at any more be archived as well? Out of scope here.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
new file mode 100644
index 000000000..0531293d2
--- /dev/null
+++ b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
@@ -0,0 +1,95 @@
+---
+kind: code
+depends_on: []
+---
+
+# Proposal: cmdb-import-archive-reconciliation
+
+## Summary
+
+A repeated CMDB import now reconciles the applications a municipality uses with the newer export. An application whose APPID is no longer on the two CMDB sheets but is listed on the export's sheet "Gearchiveerde Applicaties" is archived: its `module` and the municipality's `usage` get OpenRegister's archive state, so they drop out of every list and search until an admin asks for the archive. An application whose APPID is on no sheet at all is soft-deleted through OpenRegister's trash, from which it can be restored. An application that returns to the export is unarchived, or restored from the trash, and then updated as usual, so no duplicate is ever created. The import option `missingRecords` accepts `keep` and `archive`, and `archive` is the default; the settings section shows the choice. Stackiq's Applications and Applications in use pages get an "Archived" quick filter. The report gets the outcomes `archived`, `unarchived`, `deleted` and `restored`.
+
+## Motivation
+
+The first version of the import (openspec/changes/archive/2026-10-05-cmdb-export-import) left records that disappeared from a newer export untouched. The municipality delivers a new export every month and asked on 2026-10-01 (Jira WOO-587, parent WOO-586, epic WOO-281): an application that left the CMDB sheets but is on the archive sheet must be marked as archived and be findable in OpenCatalogi only with an explicit filter; an application that is on no sheet any more must be soft-deleted with the existing functionality. Without this, the catalogue keeps showing applications the municipality retired, and Portaliq keeps listing them under "Software we use".
+
+OpenRegister already has both states. Its archive state (`@self.archived`, `POST /api/objects/{register}/{schema}/{id}/archive`) keeps an object whole but out of every list unless `_archived=true` or `_archived=any` is asked; its soft delete (`@self.deleted`) puts an object in the trash for 30 days, from which it can be restored. OpenCatalogi passes the list parameters through to OpenRegister, and Portaliq lists usages without either lens, so both apps hide archived and deleted records with no change of their own (decided 2026-10-09).
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `cmdb-export-import`: `missingRecords` becomes `keep | archive` with `archive` as the default (REQ-CMDB-012 modified); the reconciliation of missing records against the archive sheet (REQ-CMDB-015), the return of an archived or deleted application (REQ-CMDB-016) and the archive lens in stackiq (REQ-CMDB-017) are added; the settings section shows the choice (REQ-CMDB-014 modified). The `module` and `usage` schemas opt into OpenRegister's archive state through the register fragment (see design.md, Mixed-spec rationale).
+
+## Affected Projects
+
+- [ ] Project: `stackiq`: import service, workbook reader and profile, controller option, register fragment, the "CMDB import" settings section, the manifest quick filters, tests, fixtures, administrator docs and translations.
+
+## Scope
+
+### In Scope
+
+- The sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging) is read for its APPID column only, when it is present.
+- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value, on the archive sheet or on a CMDB sheet), or when the workbook lacks one of the CMDB sheets: archived only, with a warning in the report.
+- A returning APPID: an archived module or usage is unarchived, a soft-deleted one is restored from the trash, and the row is then updated as before.
+- `missingRecords`: `keep` leaves everything as it is (the behaviour so far), `archive` reconciles and is the default; `remove` stays reserved and is refused.
+- The settings section offers the choice with help text; the report shows the new outcomes in its summary and rows.
+- `x-openregister-archive` on the `module` and `usage` schemas through the register fragment, with a version bump.
+- An "Archived" quick filter on the Applications and Applications in use pages.
+- PHPUnit, Jest, a Playwright e2e for the re-import with the archive sheet, a fixture variant, docs and Dutch and English strings.
+
+### Out of Scope
+
+- Hard deletion of objects; the trash's own retention (30 days) stays OpenRegister's.
+- Changes to the municipality's TOPdesk export, and the raw "Invoer gearchiveerde appl" sheet (the derived "Gearchiveerde Applicaties" sheet is the source).
+- Contact persons: the owner of an archived or deleted application stays, because it is shared with the municipality's other applications.
+- Modules only other organisations use, and organisations (suppliers) that no application names any more.
+- An archive widget on the detail pages: `@conduction/nextcloud-vue` 2.65 does not render `@self.archived`; a follow-up once the library does.
+- Changes to OpenCatalogi or Portaliq.
+
+## Approach
+
+`CmdbWorkbookReader` loads the archive sheet next to the two CMDB sheets when the workbook has it, under the same size and row bounds, and hands the import service the APPIDs it holds. `CmdbExportImportService` widens the match of a module (by import key) and of a usage (by consumer and module) to archived and soft-deleted objects, and revives what it matches before the usual update. After the last row, when the import was not cancelled and `missingRecords` is `archive`, a reconciliation step pages through the municipality's usages with no lens and with OpenRegister's `_archived=true` lens, reads the trash once through `MagicMapper::findDeletedAcrossAllMagicTables()` (a search with `_includeDeleted=true` returns no deleted rows on the current OpenRegister beta; design.md D9), loads their modules in batches, and archives, unarchives, soft-deletes or restores module and usage through OpenRegister's `ArchiveHandler`, `ObjectServiceInterface::deleteObject()` and `MagicMapper::restoreObject()`, each object in its own error boundary. The step runs under the import's register lock, reports its progress in a phase of its own, and adds its outcomes to the report. Details are in design.md.
+
+## New Dependencies
+
+None. `ArchiveHandler` and `MagicMapper` come from OpenRegister, which stackiq already requires; both are resolved through the container behind a guard, like the mapping engine.
+
+## Impact
+
+- **Backend**: `lib/Service/CmdbExportImportService.php` (match lenses, revival, reconciliation), `lib/Service/Cmdb/CmdbWorkbookReader.php` and `CmdbImportProfile.php` (archive sheet), `CmdbImportReport.php` (outcomes), `lib/Controller/CmdbImportController.php` (option default and accepted values), `lib/Service/ProgressTracker.php` (phase `reconciling`), `lib/Exception/CmdbImportException.php` (`ARCHIVE_UNAVAILABLE`).
+- **Configuration**: `lib/Settings/cmdb-import/topdesk-profile.json` (archive sheet, accepted modes).
+- **Schema**: `lib/Settings/register.d/topdesk-cmdb-import.json` declares `x-openregister-archive` on `module` (0.3.9) and `usage` (1.5.7).
+- **Frontend**: `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `src/manifest.json`, `src/manifest.d/usages.json`, `src/icons.js`.
+- **Data**: a re-import with the default option archives or soft-deletes modules and usages the export no longer lists. Nothing is hard-deleted; a soft-deleted object can be restored from OpenRegister's trash for 30 days.
+
+## Cross-Project Dependencies
+
+- **openregister** (consumed, not changed): `ObjectServiceInterface::deleteObject()` (contract), `Service\Object\ArchiveHandler` and `Db\MagicMapper::restoreObject()` and `findDeletedAcrossAllMagicTables()` (not public contracts, guarded), the `_archived` list lens, and the schema annotation `x-openregister-archive`.
+- **opencatalogi** and **portaliq** (consumers, not changed): both list through OpenRegister's default lens, so archived and deleted applications disappear from them without a change; an OpenCatalogi caller finds archived applications with `_archived=true` on the existing public routes.
+
+## Risks
+
+### Risk 1: A re-import with the new default archives what an admin did not expect
+**Severity:** Medium — **Mitigation:** only the municipality's own usages and the modules they point at are in scope, never a supplier's or another municipality's module; nothing is hard-deleted; the choice is visible in the section with help text, and the report lists every archived and deleted application. A cancelled or failed import never reconciles.
+
+### Risk 2: A workbook without a usable archive sheet soft-deletes everything that moved there
+**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see on it or on a CMDB sheet (a formula without a cached value), or with a CMDB sheet absent from the workbook, missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows).
+
+### Risk 3: The archive services are OpenRegister internals
+**Severity:** Low — **Mitigation:** resolved through the container behind a guard; when missing, an import with `missingRecords=archive` is refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read, and `keep` still works.
+
+### Risk 4: The schemas on an installation predate the annotation
+**Severity:** Low — **Mitigation:** OpenRegister refuses to archive an object of a schema without `x-openregister-archive`; the import reports that object as failed and the docs point to Force Update, which deploys the fragment.
+
+## Rollback Strategy
+
+Revert the PR. Objects archived or soft-deleted by an import keep their state; an admin unarchives them through OpenRegister's object actions or restores them from the trash. The schema annotation may stay deployed without harm.
+
+## Open Questions
+
+- Should a module that no usage points at any more (every municipality archived it) be archived as well? Out of scope here; a supplier's module stays.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
new file mode 100644
index 000000000..7e941afb2
--- /dev/null
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -0,0 +1,260 @@
+# cmdb-export-import Specification
+
+**Status**: in-progress
+**Scope**: stackiq
+**OpenSpec changes**:
+- [cmdb-export-import](../../../archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_
+- [cmdb-import-archive-reconciliation](../../)
+
+## Purpose
+
+A repeated CMDB import reconciles the applications a municipality uses with the newer export. An application whose APPID left the two CMDB sheets is archived in OpenRegister's archive state when the export's sheet "Gearchiveerde Applicaties" lists it, and soft-deleted into OpenRegister's trash when no sheet lists it any more; one that returns is unarchived or restored and updated as usual, never duplicated. Archived and deleted applications disappear from OpenCatalogi and Portaliq without a change there, and stackiq lists the archive on request (Jira WOO-587).
+
+OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects()` with no lens and with the `_archived=true` and `_ids` lenses, `deleteObject()`), `Service\Object\ArchiveHandler` (`archive()`, `unarchive()`) and `Db\MagicMapper` (`findDeletedAcrossAllMagicTables()` for the trash, `restoreObject()`) (both resolved through the container behind a guard; why the trash is read that way: design.md D9), and the schema annotation `x-openregister-archive`.
+
+## ADDED Requirements
+
+### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015)
+
+With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. The same holds when a row of a CMDB sheet holds an APPID formula without a cached value (the row is skipped as empty, so its application may look absent): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name that CMDB sheet and the rows. The same holds when the workbook lacks one of the profile's CMDB sheets while it has another (the reader lists the absent ones in `absentSheets`; a workbook with none of them is refused with `NO_SOURCE_SHEET`): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name the missing sheet. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
+
+#### Scenario: An application that moved to the archive sheet is archived
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** the modules with APPID `1234` and `2` were imported for "Gemeente Voorbeeldstad" with their usages
+- **WHEN** an export is imported in which `1234` is only on "Gearchiveerde Applicaties" and `2` is on no sheet
+- **THEN** the module and the usage of `1234` SHALL carry OpenRegister's archive state and the report row SHALL be `archived`
+- **AND** the module and the usage of `2` SHALL be in OpenRegister's trash and the report row SHALL be `deleted`
+- **AND** the Applications page SHALL list `1234` under the quick filter "Archived" and neither under "All"
+- **AND** the owner contact person SHALL be unchanged
+
+#### Scenario: Without the archive sheet a missing application is archived, never deleted
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testWithoutTheArchiveSheetMissingApplicationsAreArchivedNotDeleted imports a workbook without the sheet and asserts the archive state, no delete, and the warning naming the sheet.
+
+- **GIVEN** the module with APPID `7` and its usage were imported
+- **WHEN** a workbook without "Gearchiveerde Applicaties" that no longer lists `7` is imported
+- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning that the sheet "Gearchiveerde Applicaties" was not found
+
+#### Scenario: A stale duplicate under the same import key is left in the trash
+@e2e exclude Data condition from earlier imports; tests/Unit/Service/CmdbExportImportServiceTest.php testAStaleDuplicateIsNeverRestoredByTheReconciliation stores a working and a trashed module and usage for APPID `7`, lists `7` on the archive sheet, and asserts the working pair is archived, no `restore` happens and the trashed pair stays deleted; testEveryOtherUsageOfAKeyFollowsTheApplication asserts the stray and second usages follow.
+
+- **GIVEN** a working module with APPID `7` and its usage, and a module with the same import key in the trash with its own usage
+- **WHEN** an export without `7` on the CMDB sheets, with `7` on "Gearchiveerde Applicaties", is imported with `archive`
+- **THEN** the working module and usage SHALL be archived
+- **AND** the module and the usage in the trash SHALL stay there, and nothing SHALL be restored
+- **AND** a working usage that points at the module in the trash, or a second usage of the working module, SHALL be archived as well
+
+#### Scenario: An archive row whose APPID has no cached value never turns an archive into a deletion
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedArchiveRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the sheet and the row; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAnUncachedArchiveKeyIsListedNotDropped asserts the reader lists the row.
+
+- **GIVEN** the module with APPID `7` and its usage were imported, and the workbook's archive sheet lists `7` in a formula that carries no cached value
+- **WHEN** the workbook is imported with `archive` and `7` is on no CMDB sheet
+- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning naming the sheet "Gearchiveerde Applicaties" and the row
+
+#### Scenario: A CMDB row whose APPID has no cached value never turns an archive into a deletion
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedCmdbRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the CMDB sheet and the row.
+
+- **GIVEN** the module with APPID `8` and its usage were imported, `8` is on no readable row, and a row of a CMDB sheet holds its APPID in a formula that carries no cached value
+- **WHEN** the workbook is imported with `archive` and a readable archive sheet that does not list `8`
+- **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning naming the CMDB sheet and the row
+
+#### Scenario: A workbook that lacks one of the CMDB sheets never turns an archive into a deletion
+@e2e exclude Covered by the service and reader tests; tests/Unit/Service/CmdbExportImportServiceTest.php testAMissingSourceSheetArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the missing sheet; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAWorkbookWithSomeSourceSheetsNamesTheAbsentOnes asserts the reader lists the absent sheet.
+
+- **GIVEN** the module with APPID `8` and its usage were imported from the sheet "Beheerde Applicaties CMDB"
+- **WHEN** a workbook with "Onbeh Applicaties CMDB" and a readable archive sheet, but without "Beheerde Applicaties CMDB", is imported with `archive`
+- **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning naming "Beheerde Applicaties CMDB"
+
+#### Scenario: Keeping missing records, a cancelled import and another municipality leave everything as it is
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept, testACancelledImportDoesNotReconcile and testAnotherMunicipalitysUsagesAreNeverReconciled assert no archive, no delete and no reconciliation row.
+
+- **GIVEN** the modules with APPID `1` and `7` and their usages for "Gemeente Voorbeeldstad", and a usage of `7` for "Gemeente Anders"
+- **WHEN** an export with only `1` is imported with `missingRecords` `keep`, or with `archive` but cancelled after row 1
+- **THEN** no module or usage SHALL be archived or deleted
+- **AND** when it is imported with `archive` and completes, only the usage of "Gemeente Voorbeeldstad" for `7` and the module SHALL be archived; the usage of "Gemeente Anders" SHALL be untouched
+
+#### Scenario: A cancel after the last row or during the reconciliation stops it
+@e2e exclude Timing-dependent; tests/Unit/Service/CmdbExportImportServiceTest.php testACancelDuringTheLastRowArchivesNothing and testACancelDuringTheReconciliationStopsBeforeTheNextApplication request the cancel at those two moments and assert the transitions and the cancelled report.
+
+- **GIVEN** the modules with APPID `1`, `7` and `8` and their usages for "Gemeente Voorbeeldstad"
+- **WHEN** an export with only `1`, and `7` and `8` on the archive sheet, is imported with `archive` and a cancel arrives while row `1` is processed
+- **THEN** no module or usage SHALL be archived or deleted, and the report SHALL be cancelled
+- **AND** when the cancel arrives instead while `7` is archived, `7` SHALL stay archived, `8` SHALL be left as it is, and the report SHALL be cancelled
+
+#### Scenario: One application that cannot be archived does not stop the others
+@e2e exclude Fault injection; tests/Unit/Service/CmdbExportImportServiceTest.php testAFailingArchiveIsAFailedRowAndTheOthersAreReconciled makes the archive of one application throw and asserts a failed row naming the step, the other application archived, and the log line without a name.
+
+- **GIVEN** the modules with APPID `7` and `8` left the export and are on the archive sheet
+- **WHEN** archiving `7` throws
+- **THEN** the report SHALL list `7` as `failed` with the step `archive` and the exception class
+- **AND** `8` SHALL be archived
+
+#### Scenario: Without the archive services an archiving import is refused before reading
+@e2e exclude Environment condition; tests/Unit/Service/CmdbExportImportServiceTest.php testMissingArchiveServicesRefuseAnArchivingImport asserts 503 ARCHIVE_UNAVAILABLE with no read and no save, and that keep still imports; tests/Unit/Controller/CmdbImportControllerTest.php asserts the status and the translated message.
+
+- **GIVEN** an OpenRegister without `ArchiveHandler`
+- **WHEN** an export is imported with `missingRecords` `archive`
+- **THEN** the endpoint SHALL answer 503 with error `ARCHIVE_UNAVAILABLE` and nothing SHALL be read or written
+- **AND** the same export with `keep` SHALL be imported
+
+### Requirement: An application that returns to the export SHALL be unarchived or restored, never duplicated (REQ-CMDB-016)
+
+The import SHALL match a module by its import key, and a usage by consumer and module, among archived and soft-deleted objects as well as working ones. Before it updates a matched module or usage that is archived, it SHALL unarchive it through OpenRegister's archive state; before it updates one that is soft-deleted, it SHALL restore it from the trash. It SHALL then update the row as REQ-CMDB-006 and REQ-CMDB-009 describe, and SHALL NOT create a second module or usage. The row's outcome SHALL be `restored` when a module or usage was restored, else `unarchived` when one was unarchived, else the usual outcome; the summary SHALL count both. With `updateExisting` false the row SHALL be skipped as `exists` and nothing SHALL be revived. When more than one module or usage matches, the import SHALL take a working one before an archived one, and an archived one before one in the trash, so a module deleted by hand next to the one a later import created SHALL NOT be restored. Organisations and contact persons SHALL keep matching among working objects only.
+
+#### Scenario: An archived application returns to the export
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** the module and the usage of APPID `1234` are archived and those of APPID `2` are in the trash
+- **WHEN** an export that lists both on the CMDB sheets is imported
+- **THEN** the report SHALL show `1234` as `unarchived` and `2` as `restored`
+- **AND** there SHALL still be one module and one usage for each
+- **AND** both SHALL be listed again without a lens
+
+#### Scenario: A returning application is not revived when existing records are not updated
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testUpdateExistingFalseLeavesAnArchivedMatchArchived asserts the row is skipped as `exists`, the archive state stays and no object is created.
+
+- **GIVEN** the module and the usage of APPID `7` are archived
+- **WHEN** a row with APPID `7` is imported with `updateExisting` false
+- **THEN** the row SHALL be skipped with reason `exists`
+- **AND** both SHALL stay archived, and no module or usage SHALL be created
+
+#### Scenario: A working match wins over a deleted one with the same import key
+@e2e exclude Data condition from earlier imports; tests/Unit/Service/CmdbExportImportServiceTest.php testAWorkingMatchWinsOverADeletedOneWithTheSameKey stores a deleted module and usage ahead of the working ones and asserts the working ones are updated and nothing is restored.
+
+- **GIVEN** a module with APPID `7` in the trash and a working module with the same import key, each with a usage of "Gemeente Voorbeeldstad"
+- **WHEN** a row with APPID `7` is imported
+- **THEN** the working module and its usage SHALL be updated
+- **AND** the module and the usage in the trash SHALL stay there, and nothing SHALL be restored
+
+### Requirement: Archived applications and usages SHALL be listable in stackiq and SHALL stay out of OpenCatalogi and Portaliq by default (REQ-CMDB-017)
+
+The `module` and `usage` schemas SHALL declare `x-openregister-archive` with `enabled` true through the register fragment `topdesk-cmdb-import.json`, as `module` 0.3.9 and `usage` 1.5.7, so OpenRegister offers the archive state on both. Stackiq's Applications and Applications in use pages SHALL offer a quick filter "Archived" that lists the archive alone (`_archived=true`). No change SHALL be made to OpenCatalogi or Portaliq: both list through OpenRegister's default lens, so an archived or deleted application is not in OpenCatalogi's search results or in Portaliq's "Software we use", and an OpenCatalogi caller finds archived applications with `_archived=true` on the existing routes.
+
+#### Scenario: The merged register offers the archive state on both schemas
+@e2e exclude Configuration; tests/Unit/Settings/TopdeskCmdbFragmentTest.php testTheMergedSchemasOfferTheArchiveState asserts the versions and the annotation after merging every fragment.
+
+- **GIVEN** every register fragment merged in filename order
+- **THEN** `module` SHALL be version 0.3.9 and `usage` 1.5.7
+- **AND** both SHALL declare `configuration.x-openregister-archive.enabled` true, and the usage lifecycle of 1.5.6 SHALL still be declared
+
+#### Scenario: The Applications page lists the archive on request
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** the module of APPID `1234` is archived
+- **WHEN** an admin opens the Applications page
+- **THEN** the module SHALL NOT be listed
+- **AND** under the quick filter "Archived" it SHALL be listed
+
+## MODIFIED Requirements
+
+### Requirement: Records missing from a newer export SHALL be archived by default or kept on request (REQ-CMDB-012)
+
+The import SHALL accept `missingRecords` with the values `keep` and `archive`; `archive` is the default. With `keep` it SHALL NOT change, archive, depublish or delete a module, usage, organisation or contact person because its APPID is absent from the upload. With `archive` it SHALL reconcile as REQ-CMDB-015 describes. Any other value, including the reserved `mark` and `remove`, SHALL be refused with 422 `MISSING_RECORDS_UNSUPPORTED`, naming both accepted values in `details.accepted`. The accepted values SHALL be declared in the import profile.
+
+(Previously: `keep` was the only accepted value and the default; records missing from a newer export were always left untouched.)
+
+#### Scenario: An application dropped from the export stays when records are kept
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept imports two rows, then one with `missingRecords` `keep`, and asserts the other module and usage are unchanged.
+
+- **GIVEN** the modules with APPID `1` and `7` were imported for "Gemeente Voorbeeldstad"
+- **WHEN** a newer export that only contains APPID `1` is imported with `missingRecords` `keep`
+- **THEN** the module with APPID `7` and its usage SHALL be unchanged
+
+#### Scenario: Without the option a missing application is reconciled
+@e2e exclude Validation; tests/Unit/Controller/CmdbImportControllerTest.php testMissingRecordsDefaultsToArchive asserts the service receives `archive` when the field is absent and the sent value otherwise.
+
+- **GIVEN** a valid export
+- **WHEN** a Nextcloud admin posts it without `missingRecords`
+- **THEN** the import SHALL run with `archive`
+
+#### Scenario: A reserved value is refused
+@e2e exclude Validation; tests/Unit/Controller/CmdbImportControllerTest.php testAReservedMissingRecordsValueIsRefused.
+
+- **GIVEN** a valid export
+- **WHEN** a Nextcloud admin posts it with `missingRecords=remove`
+- **THEN** the endpoint SHALL answer 422 with error `MISSING_RECORDS_UNSUPPORTED` and `details.accepted` `["keep", "archive"]`
+- **AND** no object SHALL be written
+
+### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
+
+Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including those four; a reconciliation row SHALL show no row number. The section SHALL also show the mapping the import uses (REQ-CMDB-020), as tables: the export's sheets and columns, and per sheet pack its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; the municipality pack, which maps the import options and no column, SHALL NOT be listed. When the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. The section SHALL offer no editing of the mapping itself. The sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, and so SHALL the archive sheet its texts name, with no archive sheet when the profile names none; the shipped names are the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
+
+(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server. This block also carries the missing-applications choice, the four counts and the reconciliation rows of cmdb-import-archive-reconciliation, which modifies REQ-CMDB-014 too (before it, the request always sent `keep`); both changes carry this same block, so archiving them in either order leaves the whole requirement.)
+
+#### Scenario: The admin runs an import from the settings page
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** a Nextcloud admin on stackiq's admin settings page
+- **WHEN** they choose "Gemeente Voorbeeldstad", choose the anonymised export and press "Import"
+- **THEN** a progress bar SHALL appear while the import runs
+- **AND** afterwards the summary and the report table SHALL be shown
+- **AND** filtering the table on `created` SHALL show the two imported rows
+
+#### Scenario: The admin chooses to keep missing applications
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** the section
+- **WHEN** the admin selects "Keep them as they are" and starts the import
+- **THEN** the request SHALL carry `missingRecords=keep`
+- **AND** without a choice it SHALL carry `missingRecords=archive`
+
+#### Scenario: The sheet names in the help come from the server
+@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+
+- **GIVEN** a Nextcloud admin on stackiq's admin settings page
+- **WHEN** the mapping endpoint has answered
+- **THEN** the help text under the file control SHALL name the sheets the endpoint returned
+- **AND** a profile with one, two or three sheets SHALL give a help text and an info text that name exactly those sheets, with no sheet the profile does not name and no description tied to a sheet's position
+- **AND** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error
+
+#### Scenario: The archive sheet in the help comes from the server
+@e2e exclude Not driven in e2e yet: the texts are derived in the page from the endpoint's answer, and src/views/settings/sections/CmdbImport.spec.js emits mappings with the archive sheet "Archief" and with none and asserts the info text, the missing-applications paragraph and the too-large hint; the route intercept in tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts could drive it as well.
+
+- **GIVEN** the mapping endpoint answers `profile.archiveSheet` `Archief`
+- **WHEN** the section renders its information panel and a too-large error
+- **THEN** the info text, the paragraph on applications missing from the export and the hint SHALL name the sheet `Archief`
+- **AND** when it answers `null`, none of them SHALL name an archive sheet, and the paragraph SHALL say that the import only archives
+
+#### Scenario: The section lists the mapping and links each pack to its editor
+@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+
+- **GIVEN** a Nextcloud admin on stackiq's admin settings page
+- **WHEN** the mapping block of the "CMDB import" section has loaded
+- **THEN** a table of the export SHALL list the sheets read and the match, name, required and date columns, and a table of the packs SHALL show each of the four sheet packs with its source and version and a link "Edit in OpenRegister" to its `editUrl`
+- **AND** the municipality pack, which maps the import options and no column, SHALL NOT be listed
+- **AND** when the endpoint answers 503 for a stored pack, the error SHALL show a link "Edit in OpenRegister" to its `details.editUrl`
+- **AND** the block SHALL hold no input field
+
+#### Scenario: An edited pack whose newer shipped version was not applied says so
+@e2e exclude Needs a stackiq release that ships a newer pack than the one installed; tests/vitest/cmdbImportMapping.spec.js mounts the block with an edited usage pack whose `newerShipped` is true and asserts the hint, and its absence for the unedited module pack.
+
+- **GIVEN** the mapping endpoint answers an edited usage pack with `newerShipped` `true` and `shippedVersion` `1.1.0`
+- **WHEN** the mapping block renders
+- **THEN** the usage row SHALL say that stackiq ships version `1.1.0`, that it was not applied because the pack was edited, and how to use the shipped version
+- **AND** a row whose `newerShipped` is `false` SHALL show no such hint
+
+## Non-Functional Requirements
+
+- **Performance:** the reconciliation SHALL page the municipality's usages with the import's page size and load their modules in one search per page, so an export of 1,100 rows adds a handful of searches, and SHALL make no write for an application that is already in the state the export asks for.
+- **Security:** unchanged from the first change; the archive sheet is read under the same bounds, APPID column only; the scope is the imported municipality's usages; nothing is hard-deleted; every state change is an OpenRegister audit entry.
+- **Privacy:** reconciliation rows and log lines carry APPIDs, module names and uuids only; contact persons are never touched.
+- **Accessibility:** Target WCAG 2.2 AA. The new choice is a labelled radio group (SC 1.3.1, 3.3.2; gates `form-label-association`, `nc-input-labels`) with help text; the summary tiles and the outcome filter follow the existing pattern. New in 2.2: 2.4.11, 2.5.8 and 3.3.7 apply as before; 2.5.7, 3.2.6 and 3.3.8 do not apply (no dragging, no help mechanism, no authentication).
+- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the choice, the outcomes, the warning and the error message.
+
+## Acceptance Criteria
+
+- [ ] A re-import with the archive sheet archives the application that moved there and soft-deletes the one on no sheet; the report shows `archived` and `deleted`.
+- [ ] Importing the original export again unarchives and restores them; no duplicate is created.
+- [ ] With "Keep them as they are" nothing is archived or deleted.
+- [ ] The Applications and Applications in use pages list archived records only under "Archived".
+- [ ] OpenCatalogi's search and Portaliq's "Software we use" no longer show an archived application; `_archived=true` on OpenCatalogi's route finds it.
+
+## Notes
+
+- The decision table per application is in the change's [design.md](../../design.md), D5.
+- The library does not render `@self.archived` on detail pages yet; a follow-up for `@conduction/nextcloud-vue`.
+- REQ-CMDB-014 is modified by this change and by cmdb-import-mapping-view; both carry the same block, which holds what each adds, so archiving them in either order leaves the whole requirement. A change to that block goes into both.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
new file mode 100644
index 000000000..be258b8b3
--- /dev/null
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -0,0 +1,94 @@
+# Tasks: cmdb-import-archive-reconciliation
+
+Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md` (`SPEC` below). Design: `design.md` (the decision table in D5 is authoritative for what happens per application).
+
+## Implementation Tasks
+
+Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented in #1286 (the reader's tasks 1-2 come from the base PR). Task 6 (the UI) is implemented in #1287; task 7 has its fixture, docs and e2e spec written there, and the Playwright run is the one thing not done yet.
+
+### Task 1: Schema opt-in and the import profile
+- **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
+- **files**: `lib/Settings/register.d/topdesk-cmdb-import.json`, `lib/Settings/cmdb-import/topdesk-profile.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Settings/TopdeskCmdbFragmentTest.php`, `tests/Unit/Settings/PublicationFieldRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`
+- **acceptance_criteria**:
+ - GIVEN every register fragment merged in filename order THEN `module` is 0.3.9 and `usage` is 1.5.7, both with `configuration.x-openregister-archive.enabled` true, and the usage lifecycle of 1.5.6 is still there
+ - GIVEN the profile THEN `archiveSheetName()` is "Gearchiveerde Applicaties", `readSheetNames()` lists the two CMDB sheets plus the archive sheet, and `missingRecordsModes()` is `["keep", "archive"]` without OpenRegister
+- [x] Implement
+- [x] Test
+
+### Task 2: The reader reads the archive sheet's APPIDs
+- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
+- **files**: `lib/Service/Cmdb/CmdbWorkbookReader.php`, `tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php`
+- **acceptance_criteria**:
+ - GIVEN the anonymised fixture THEN `read()` returns `archive.present` true and `archive.appIds` `["1198"]`, and the rows of the CMDB sheets are unchanged
+ - GIVEN a workbook without the sheet, or with the sheet but without an APPID column THEN `archive.present` is false (and `keyColumnMissing` says which), and the CMDB rows are still read
+ - GIVEN an archive sheet whose APPID cell is a formula without a cached value THEN `archive.uncachedRows` lists its row and `appIds` does not, so the service can refuse to soft-delete (task 4)
+ - GIVEN an archive sheet beyond the row limit THEN `TOO_MANY_ROWS` names it; only its APPID column is materialised
+- [x] Implement
+- [x] Test
+
+### Task 3: Matching revives archived and deleted applications
+- **spec_ref**: `SPEC#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016`
+- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/Cmdb/CmdbImportReport.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Service/Cmdb/CmdbImportReportTest.php`
+- **acceptance_criteria**:
+ - GIVEN an archived module and usage for an APPID WHEN a row with that APPID is imported THEN both are unarchived before the update, no second module or usage exists, and the row's outcome is `unarchived`
+ - GIVEN a soft-deleted module and usage WHEN the row returns THEN both are restored from the trash and the outcome is `restored`
+ - GIVEN only the usage archived or soft-deleted THEN the usage alone is unarchived or restored, with that outcome, and no second usage exists
+ - GIVEN a module and usage soft-deleted while archived THEN both are restored and unarchived, the outcome is `restored`, and the update starts from the restored object (design D9)
+ - GIVEN OpenRegister's search results (no archive marker; no soft-deleted row, `_includeDeleted` or not) THEN the state comes from the lens that found the object and from the trash listing, read once per import (design D9)
+ - GIVEN `updateExisting` false THEN the row is skipped as `exists` and nothing is revived
+ - GIVEN a deleted and a working module (and usage) sharing one import key THEN the working one is updated and nothing is restored
+- [x] Implement
+- [x] Test
+
+### Task 4: Reconciliation after the rows
+- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
+- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/ProgressTracker.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`
+- **acceptance_criteria**:
+ - GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched
+ - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value, or a CMDB-sheet row whose APPID has no cached value, or a workbook without one of the CMDB sheets, THEN they are archived instead and the report warns
+ - GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written
+ - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted; GIVEN a further working usage of the key (of the trashed module, or a second one of the working module) THEN it is archived or deleted with the application
+ - GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted
+ - GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
+ - GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading
+ - GIVEN the step runs THEN the operation is in phase `reconciling` and its progress advances per application
+- [x] Implement
+- [x] Test
+
+### Task 5: Controller option
+- **spec_ref**: `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
+- **files**: `lib/Controller/CmdbImportController.php`, `tests/Unit/Controller/CmdbImportControllerTest.php`, `openapi.json`, `postman/stackiq-tests.json`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN no `missingRecords` THEN the service receives `archive`; GIVEN `keep` or `archive` THEN it is passed through; GIVEN `remove` or `mark` THEN 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` `["keep", "archive"]` and no import
+ - GIVEN `ARCHIVE_UNAVAILABLE` from the service THEN 503 with its own translated message
+- [x] Implement
+- [x] Test
+
+### Task 6: Settings section, report words and the archive quick filter
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014`, `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`
+- **files**: `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `src/utils/cmdbImport.spec.js`, `src/views/settings/sections/CmdbImport.spec.js`, `tests/vitest/cmdbArchiveQuickFilter.spec.js`, `src/manifest.json`, `src/manifest.d/usages.json`, `src/icons.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN the section THEN a labelled choice "Applications missing from the export" offers Archive (default) and Keep with help text, and `buildImportForm()` sends the choice as `missingRecords`
+ - GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number
+ - GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true`
+ - GIVEN the Applications page with the "Archived" tab active THEN a note under the toolbar says the tab ignores the GEMMA filters, and it is gone on any other tab
+ - Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current
+- [x] Implement
+- [x] Test
+
+### Task 7: Fixture, docs and e2e
+- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
+- **files**: `tests/fixtures/cmdb/build-fixtures.py`, `tests/fixtures/cmdb/topdesk-archived-applications.xlsx`, `tests/fixtures/cmdb/README.md`, `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php`, `tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/e2e/spec-coverage/cmdb-import.spec.ts`, `docs/features/cmdb-import.md`, `docs/features/README.md`, `CHANGELOG.md`
+- **acceptance_criteria**:
+ - GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it
+ - GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's
+ - GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED`
+- [x] Implement
+- [ ] Test
+ - Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree.
+
+## Quality checklist
+
+- PHPUnit for every new rule in the service, the reader and the controller; the fixture hygiene test covers the new variant
+- Jest for the section's request and the report words; Playwright for the re-import with the archive sheet
+- `composer test:unit`, `composer phpcs`, `composer lint`, `npm run test:unit`, `npm run lint`, `npm run format`, `npm run test:l10n`, `npm run check:l10n-js`, `npm run check:manifest`, `npm run check:schema-l10n` and `npm run check:vue-demi` pass
diff --git a/openspec/changes/cmdb-import-mapping-view/.openspec.yaml b/openspec/changes/cmdb-import-mapping-view/.openspec.yaml
new file mode 100644
index 000000000..5c0f6674b
--- /dev/null
+++ b/openspec/changes/cmdb-import-mapping-view/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: conduction
+created: 2026-10-09
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
new file mode 100644
index 000000000..f5cab267f
--- /dev/null
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -0,0 +1,167 @@
+# Design: cmdb-import-mapping-view
+
+## Context
+
+The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk export through `lib/Settings/cmdb-import/topdesk-profile.json` and five migration packs, loaded and validated by `CmdbImportProfile` and executed by OpenRegister's `MappingEngine`. The packs are OpenRegister migration-pack documents (`fieldMappings` with `trim`, `date`, `lookup`, `concat` transforms, `defaults`, `idStrategy`). OpenRegister already stores such documents in `openregister_migration_packs` through `MigrationPackService`, with CRUD at `/api/migration-packs` for admins. On 2026-10-12 it was decided that the mapping lives in OpenRegister: the administrator edits the packs there, and the import runs what is stored.
+
+## Goals / Non-Goals
+
+**Goals**
+
+- The import runs the pack the administrator saved in OpenRegister, unchanged.
+- An edit is never lost: not overwritten by an update, never silently replaced by the shipped file.
+- An edit cannot make the import write what it must not write.
+- The mapping endpoint tells where each pack comes from and links to its editor; the settings section that shows it is part of #1289.
+
+**Non-Goals**
+
+- The editor screens (OpenRegister's change).
+- Editing the municipality pack or the import profile.
+- A mapping per municipality.
+
+## Architecture Overview
+
+```
+Install / upgrade Import or GET /api/settings/cmdb-import/mapping
+ SeedCmdbImportPacks (repair) CmdbImportProfile::load()
+ │ │ for module, manufacturer, usage, businessOwner
+ ▼ ▼
+ CmdbImportPackStore::resolve(shipped) ◄────────┘ municipality: shipped file only
+ │ findByPackSlug → absent: create(builtin, + x-stackiq)
+ │ unedited + outdated: update(shipped + x-stackiq)
+ │ edited: keep (newerShipped)
+ ▼
+ OpenRegister MigrationPackService ── openregister_migration_packs
+ │
+ CmdbImportPackRules (required mappings, writable fields) + PackDefinitionValidator
+ │
+ MappingEngine::mapRow() (import) SettingsController: + editUrl (IURLGenerator)
+```
+
+## Decisions
+
+### D1. The endpoint lives in `SettingsController` and reuses `CmdbImportProfile`
+
+The route sits with the other `/api/settings/*` reads, and `SettingsController` gets `CmdbImportProfile`, `IL10N` and `IURLGenerator` as optional, trailing constructor arguments, so the existing tests that build the controller positionally keep working and Nextcloud's container injects them. The method loads through `CmdbImportProfile::load()`, the loader the import uses, so the view can never show a mapping the import would refuse.
+
+### D2. Admin-only, CSRF, the import's error envelope
+
+The method carries no auth attribute (Nextcloud's default: admins with a CSRF token), with `@auth admin-only` and its reason, as `CmdbImportController` does. Success is the flat `{profile, packs, openRegisterPacks}` (ADR-050). Failure is the import's envelope `{success: false, error, message, details}`; `details.reason` carries the loader's message cut to its first line and 500 characters, and a pack stored in OpenRegister is also named in `details.pack` and `details.packId`. The import's own 503 names the pack in its `message` from the same details; the full reason goes to the Nextcloud log. The reason names the file or the pack, the broken rule and the configuration value it refused, never a workbook cell or a person.
+
+### D3. The transform and the write rules are shown as stored
+
+`fieldMappings[].transform` is passed through as the pack stores it. The write rules the import applies besides the mapping are answered per pack (`defaults`, `createOnlyDefaults`, `createOnlyFields`, `neverWritten`), built from the accessors the import uses. A create-only default that is not a mapped field, such as the module `type`, is answered in `createOnlyDefaults` only: `merge()` never reads it on update, so `createOnlyFields` leaves it out. A mapped field with a create default stays in `createOnlyFields`.
+
+### D4. The section takes the sheet names from the endpoint, with the constant as fallback
+
+Unchanged from the first version: the help text computes the sheet names from the endpoint's answer, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The help text, the info text and the missing-sheet and too-large hints name every sheet the profile lists, whatever their number: they are built from the list (`quotedSheetList`), not from its first two entries, and say nothing that depends on a sheet's position. The archive sheet those texts name comes from the endpoint's `profile.archiveSheet` the same way, with `PROFILE_DEFAULTS.archiveSheet` as the fallback; when the profile names none, the texts leave it out and the information panel says the import only archives.
+
+### D5. The packs live in OpenRegister's migration-pack store, found by slug
+
+The four sheet packs are stored as OpenRegister migration packs and found by their slug (`stackiq-topdesk-module`, …). That is the format the packs already have and the engine the import already calls, so what the administrator edits is exactly what runs, with OpenRegister's validator on save (422) and on import (503).
+
+- **Alternative: OpenRegister's Twig mapping entity (`/api/mappings`).** Rejected: lookups, `required` and dates would become Twig templates, validation would no longer be structural, and errors would lose their row and column; the import engine would have to be rewritten.
+- **Alternative: packs as register objects, like Integriq's mappings.** Rejected: a third mapping store next to the one OpenRegister already has, with the validator duplicated in a listener.
+
+The municipality pack maps the import options (`municipalityName`), not a sheet, so it stays a shipped file and is answered with `editable: false`. The import profile stays a shipped file: its limits guard memory and zip bombs, and its sheets, key column and write rules are stackiq semantics.
+
+OpenRegister's pack store has no organisation column: an instance has one TOPdesk mapping, used for every municipality it imports.
+
+### D6. Seeding, and following the release only while unedited
+
+A stored pack carries `x-stackiq: {shippedVersion, shippedHash}`: the version of the shipped pack it was seeded from and the sha256 of that pack's canonical JSON (keys of every object sorted recursively, list order kept, because the order of `fieldMappings` decides which mapping wins a target). OpenRegister's validator ignores keys it does not know, so the marker travels with the definition. A stored definition that, without the marker, still hashes to `shippedHash` is unedited.
+
+`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack OpenRegister reports no last-saved time for is left alone too: the update's guard against an edit saved in between needs that time. `find()`, which the mapping view uses and which writes nothing, answers an unedited outdated pack as the next import will run it: the shipped pack and version with `updatePending: true`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the import's `CmdbImportProfile::load()` runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded by its next import. Viewing the mapping uses `loadForView()`, which calls `CmdbImportPackStore::find()`: it only reads, and a pack the store does not hold yet is shown from its shipped file. The update of an unedited pack passes the read pack's `updatedAt`, so OpenRegister refuses it (`PackChangedException`) when an administrator saved the pack in between; the store then keeps that edit. `CmdbImportPackStoreProbe` decides whether the store may be used at all: the slug-lock class exists and no write method of OpenRegister's pack controller carries an attribute or annotation that skips the CSRF check or admits non-admins.
+
+Version numbers alone cannot tell an edit from a release: OpenRegister's editor bumps the version on save, independently of stackiq's releases. The hash can.
+
+- **Alternative: seed only when missing, never update.** Rejected: an unedited pack would never get a fix that a release ships.
+- **Alternative: always overwrite on upgrade.** Rejected: it destroys the administrator's edits.
+
+### D7. Fail closed once the packs were stored
+
+The store counts as available only on an OpenRegister whose pack writes require a CSRF token and lock a built-in pack's slug, recognised by the class `OCA\OpenRegister\Exception\BuiltinPackSlugLockedException` that ships with them. Released OpenRegister versions have `MigrationPackService` but write packs without a CSRF check; since a pack drives writes with RBAC off, stackiq does not run packs stored behind those routes and treats such a store as not available.
+
+The store keeps the row id it resolved per slug in stackiq's app config (`cmdb_import_pack_ids`). Without OpenRegister's store and with no recorded ids, the import uses the shipped files and adds one import warning saying so. With recorded ids, a missing store is 503 `MAPPING_UNAVAILABLE` naming the packs: falling back would silently drop an edit. A slug lookup that misses while the recorded row still exists under another slug is a rename, and is 503 naming the row and both slugs, not a second seeded pack the import would read while the administrator edits the renamed one. A recorded row that was deleted is seeded again. Any other failure of the store is 503 naming the pack and the exception class, never its message.
+
+### D8. The import's rules on every pack
+
+OpenRegister's validator checks structure only, and the import writes with OpenRegister's RBAC and multitenancy off. `CmdbImportPackRules` therefore checks every pack, stored or shipped:
+
+- required mappings with `required: true`: module `name` and `externalNumber` (from the profile's key column), manufacturer, municipality and business-owner `name`;
+- the writable fields of a target are the properties of its schema in the register stackiq ships (fragments included, ADR-037), read from that file, so a property added to the schema can be mapped without a code change; the business owner may map `name` and `role` only, because it is resolved in Nextcloud Contacts and has no schema;
+- refused whatever the schema says: `@self`, `id`, the match keys and the relations the import sets (`externalKey`, `provider`, `consumer`, `module`, `businessOwner`, the organisation `type`), the profile's `neverWritten`, every property whose definition or items carry a `$ref`, an `inversedBy`, a `writeBack`, `format: uuid` or an object configuration that relates or names a schema, every property of `type: file`, `publicationDate` and `depublicationDate` on every target, and the admin fields `registeredBy`, `registrationStatus`, `mergedInto`, `contactsUid`, `pki`, `xml`, the organisation `status` and the module `type`;
+- the same rules for `defaults`, and the organisation defaults pinned as shipped: `type` (`Supplier`, `Municipality`), because the import finds organisations by name and type, and `status` `Active` and the manufacturer's `registeredBy` `Supplier`, the only way a pack sets those admin fields.
+
+A broken rule is 503 `MAPPING_UNAVAILABLE` naming the pack, its id and every rule it breaks.
+
+### D9. What a pack controls besides its mappings
+
+Only the profile's date columns used to be converted from Excel serial numbers, so a date transform on a new column would have received `45111`. The date columns are now the profile's plus every column a pack maps with a `date` transform. The cleanup of empty parts in the usage note splits on the separator of the concat mapping that writes `interneAnnotation`, not on a constant. `idColumns`, `emptyValues` and the sheet constants (`Beheer`) stay profile semantics.
+
+### D10. The editor link is built by Nextcloud
+
+`editUrl` is `IURLGenerator::linkToRouteAbsolute('openregister.dashboard.catchAll', ['path' => 'migration-packs/'])`, so an instance that needs `/index.php` gets it. Nextcloud's router answers an unknown route with an empty path; a link that does not end in the pack path is answered as null rather than as the instance's front page.
+
+## Declarative-vs-imperative decision (ADR-031)
+
+- **The mapping stays declarative:** OpenRegister migration packs, executed by `MappingEngine`; an edit changes the import without PHP.
+- **Imperative, because they guard the import:** the pack resolution, the seed and update policy and the import's rules. They are configuration management and security checks on writes with RBAC off, not object lifecycle, aggregation, notification or relation logic, and no `x-openregister-*` vocabulary covers them.
+
+## Seed Data
+
+No schema changes and no register fragment. The repair step stores four migration packs in OpenRegister (`stackiq-topdesk-module`, `stackiq-topdesk-manufacturer`, `stackiq-topdesk-usage`, `stackiq-topdesk-business-owner`) from the shipped files, each with the `x-stackiq` marker.
+
+## Nextcloud Integration
+
+- Controller: `SettingsController::getCmdbImportMapping()`; route `settings#getCmdbImportMapping`, `GET /api/settings/cmdb-import/mapping`.
+- Services: `CmdbImportProfile`, `CmdbImportPackStore`, `CmdbImportPackMarker`, `CmdbImportPackRules`.
+- Repair step: `SeedCmdbImportPacks`, in `` and ``.
+- OCP: `OCP\IL10N`, `OCP\IURLGenerator`, `OCP\IAppConfig`, `OCP\Migration\IRepairStep`, `OCP\AppFramework\Db\DoesNotExistException`.
+- OpenRegister: `Service\MigrationPackService`, `Service\MigrationPack\PackDefinitionValidator`, `Service\MigrationPack\MappingEngine`, resolved defensively; the page route `dashboard#catchAll`.
+
+## Security Considerations
+
+- Admin-only with CSRF (D2). OpenRegister's pack writes are admin-only with CSRF too; stackiq uses only a store that has those guards (D7).
+- The import's rules (D8) let an edited pack write only plain properties of its target schema: never object metadata, match keys, relations, publication dates or admin fields.
+- An edit can add a column to what the import reads: the read allowlist is every column the profile or a pack names. Only Nextcloud admins can edit packs and run the import.
+- Fail closed (D7): no silent fallback that would drop an administrator's edit.
+- Messages name packs, rules and configuration values, never a workbook cell or a person; a store failure is named by its exception class.
+
+## NL Design System
+
+Nextcloud and `@conduction/nextcloud-vue` components only (ADR-012), as in the first version of the block.
+
+## File Structure
+
+```
+appinfo/info.xml (+ SeedCmdbImportPacks in install and post-migration)
+lib/Repair/SeedCmdbImportPacks.php (new)
+lib/Service/Cmdb/CmdbImportPackStore.php (new)
+lib/Service/Cmdb/CmdbImportPackMarker.php (new)
+lib/Service/Cmdb/CmdbImportPackRules.php (new)
+lib/Service/Cmdb/CmdbImportProfile.php (resolution, sources, dateColumns, concatSeparator, overview)
+lib/Service/CmdbExportImportService.php (fallback warning, separator)
+lib/Controller/SettingsController.php (editUrl, details)
+lib/Controller/CmdbImportController.php (MAPPING_UNAVAILABLE names the pack)
+src/… (the section's mapping block)
+tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php, CmdbImportPackRulesTest.php, CmdbImportProfileTest.php
+tests/Unit/Repair/SeedCmdbImportPacksTest.php
+tests/Unit/Support/FakeMigrationPackService.php
+tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php, CmdbImportControllerTest.php
+tests/Unit/Service/CmdbExportImportServiceTest.php
+tests/e2e/spec-coverage/cmdb-import-packs.spec.ts (new)
+docs/features/cmdb-import.md
+l10n/en.json, en.js, nl.json, nl.js
+```
+
+## Testing
+
+- PHPUnit against an in-memory `MigrationPackService`: seeding with the marker, an unedited pack following a newer release, an edited pack kept with `newerShipped`, an unedited pack with no `updatedAt` kept, the view answering the shipped pack with `updatePending`, a pack without marker never replaced, a renamed pack and a failing store failing closed, a deleted pack seeded again, a seed that loses a race to another request, an OpenRegister without the hardened store, the canonical hash.
+- PHPUnit on the rules: the shipped packs pass, any plain schema field can be mapped, files are refused, relations, fields the register hides or calculates, publication dates and admin fields are refused as mappings and as defaults, a plain field the register turns into a relation is refused.
+- PHPUnit on the loader: a stored pack is what runs, an invalid stored pack and thirteen broken rules are 503 naming the pack and id, no store before a seed uses the files, no store after a seed fails closed, a stored date column is converted, the concat separator is the pack's, also with a later plain mapping of the note.
+- PHPUnit on the import: an edit in OpenRegister changes the mapped usage, the separator and a new date column; without a store the report carries the fallback warning.
+- PHPUnit on the endpoint and the repair step: the extended answer, `editUrl` from the page route and null when it cannot be built, 503 naming the pack with the link to its editor; seeding four packs, updating and keeping, no store, a failing store, the registration order.
+- Playwright, through the API (`cmdb-import-packs.spec.ts`): the four packs stored as built-in packs with the marker, the mapping with source, `packId` and `editUrl`, a broken stored pack refused by the mapping and by the import, a module pack without its required name refused, an edit in OpenRegister run by the import, a deleted pack seeded again, the 403 for a non-admin. Each test that edits a pack puts back the definition it read.
+- Playwright, on the settings page (Task 7, with the section in Task 4, both in #1289): the section shows the source and the editor link per pack.
+- Install, upgrade and a missing or older store are not driven in e2e: they need another OpenRegister or a reinstall. The requirements state them, and the PHPUnit tests above prove them.
diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md
new file mode 100644
index 000000000..ff4576dc9
--- /dev/null
+++ b/openspec/changes/cmdb-import-mapping-view/proposal.md
@@ -0,0 +1,92 @@
+---
+kind: code
+depends_on: []
+---
+
+# Proposal: cmdb-import-mapping-view
+
+## Summary
+
+A Nextcloud administrator can view and edit the column mapping of the CMDB import in OpenRegister. The four sheet packs of the import (module, manufacturer, usage and business owner) move into OpenRegister's existing migration-pack store, where OpenRegister's pack editor shows and changes them. The import reads each pack from that store first and runs exactly what the administrator saved. The packs that ship under `lib/Settings/cmdb-import/` become the seed and the fallback: a repair step stores them in OpenRegister as built-in packs, and replaces a stored pack nobody edited when a later release ships a newer one. An edited pack is never overwritten. A stored pack that is invalid, or that breaks a rule of the import, stops the import with 503 `MAPPING_UNAVAILABLE` naming the pack; it never falls back to the files silently. `GET /api/settings/cmdb-import/mapping` says per pack where it comes from, which versions are stored and shipped, whether it was edited, and links to its editor in OpenRegister. Showing that per pack in the "CMDB import" section of stackiq's admin settings is the frontend part (#1289).
+
+## Motivation
+
+The municipality's application manager (Jira WOO-588, sub-task of WOO-586) must be able to see and change which TOPdesk column lands in which stackiq field, and which export values the import recognises, without editing JSON on the server. The first version of this change (2026-10-09) only showed the mapping read-only and left editing in the files, which an app update overwrites. On 2026-10-12 it was decided that mapping lives in OpenRegister: the packs are already OpenRegister migration packs, executed by OpenRegister's `MappingEngine` and validated by its `PackDefinitionValidator`, and OpenRegister already stores and serves such packs (`/api/migration-packs`). Storing the import's packs there makes the edited object exactly the object the import runs, and gives the administrator one place for mappings. Integriq is not changed; its mapping screens are the pattern for OpenRegister's editor.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `cmdb-export-import`: ADDED REQ-CMDB-020 (the mapping endpoint, now with source, versions and the editor link; the section that shows it is #1289), REQ-CMDB-021 (the import takes the sheet packs from OpenRegister's store, with the import's rules and fail-closed answers) and REQ-CMDB-022 (seeding and updating the packs in OpenRegister); MODIFIED REQ-CMDB-005 (where the packs come from, the date columns a pack adds, the pack's own concat separator).
+
+## Affected Projects
+
+- [ ] Project: `stackiq`: the pack resolution and rules in `lib/Service/Cmdb/`, a repair step, the extended endpoint, the slimmed mapping block in the "CMDB import" section (#1289), documentation, translations, tests.
+- [ ] Project: `openregister` (separate change and PR): the migration-pack list and editor pages that the `editUrl` opens, the envelope body on `PUT /api/migration-packs/{id}`, and a builtin pack keeping its slug.
+
+## Scope
+
+### In Scope
+
+- Reading the module, manufacturer, usage and business-owner packs from OpenRegister's migration-pack store by slug, with the shipped file as the seed and, while nothing was ever stored, as the fallback with one import warning.
+- The import's own rules on every pack, stored or shipped: the required mappings, and the fields a pack may write: every plain property of its target schema, read from the register stackiq ships.
+- Failing closed with 503 `MAPPING_UNAVAILABLE` when a stored pack is invalid or breaks a rule, when it was renamed, or when the store is gone after the packs were stored there.
+- The repair step `SeedCmdbImportPacks` on install and upgrade: seeds missing packs as built-in, replaces unedited packs with the shipped pack of the release, keeps edited ones and reports them; the row ids are kept in stackiq's app config.
+- The `x-stackiq` marker in a stored definition: the shipped version and the hash of the shipped pack it was seeded from.
+- Excel date conversion for every column a pack maps with a date transform; the note cleanup on the pack's own concat separator.
+- `GET /api/settings/cmdb-import/mapping` extended per pack with `source`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `updatePending`, `editable` and `editUrl`, and `openRegisterPacks` at the top level.
+- The section's mapping block (#1289): per pack its source, version, a hint when a newer shipped pack was not applied, and a link to its editor in OpenRegister (Task 4); the sheet names in the help text come from the endpoint.
+- `docs/features/cmdb-import.md`: viewing and editing the mapping in OpenRegister.
+
+### Out of Scope
+
+- The editor itself: OpenRegister's change for its migration-pack pages.
+- The municipality pack: it maps the import options, not a sheet, and stays a shipped file that is not editable.
+- The import profile (`topdesk-profile.json`): sheets, key and required columns, limits and write rules stay a shipped file, shown read-only.
+- More than one mapping per instance: OpenRegister's pack store has no organisation scope, so an instance has one TOPdesk mapping for every municipality it imports.
+- Promoting `MappingEngine`, `PackDefinitionValidator` or `MigrationPackService` to an OpenRegister contract.
+
+## Approach
+
+`CmdbImportProfile::load()` reads each shipped pack, validates it, and for the four sheet targets asks `CmdbImportPackStore` for the stored pack. The store resolves OpenRegister's `MigrationPackService` defensively from the container, finds the pack by slug, seeds it when it is missing, and replaces it when its `x-stackiq` marker shows it was not edited and the shipped pack has changed. `CmdbImportPackRules` checks every pack against the import's rules; `CmdbImportPackMarker` computes the canonical hash. The repair step calls the same store. `SettingsController` adds `editUrl` from OpenRegister's page route through `IURLGenerator`.
+
+## New Dependencies
+
+None.
+
+## Impact
+
+- **Backend**: `lib/Service/Cmdb/CmdbImportProfile.php` (resolution, sources, date columns, concat separator), new `CmdbImportPackStore.php`, `CmdbImportPackMarker.php`, `CmdbImportPackRules.php`, new `lib/Repair/SeedCmdbImportPacks.php` and its registration in `appinfo/info.xml`, `lib/Controller/SettingsController.php` (`editUrl`, details), `lib/Controller/CmdbImportController.php` (the message names the pack), `lib/Service/CmdbExportImportService.php` (fallback warning, separator).
+- **Frontend** (#1289): the mapping block in the "CMDB import" section.
+- **Data**: four rows in OpenRegister's `openregister_migration_packs`, created by the repair step or the first import; one app config key, `cmdb_import_pack_ids`.
+
+## Cross-Project Dependencies
+
+- **openregister** (consumed): `Service\MigrationPackService` (`findByPackSlug`, `find`, `create`, `update`), `Db\MigrationPack::getDefinitionArray()`, `Service\MigrationPack\PackDefinitionValidator`, `Service\MigrationPack\MappingEngine`, and the page route `dashboard#catchAll` for `/migration-packs/{id}`.
+- **openregister** (required before the store is used): OpenRegister's change for WOO-588 (`feat/woo588-migration-pack-editor`): the pack writes require a CSRF token and are admin-only (no `#[NoAdminRequired]`, `#[PublicPage]`, `#[AuthorizedAdminSetting]` or `#[NoCSRFRequired]` on a write method: stackiq refuses a store that has one), a built-in pack's slug is locked (`Exception\BuiltinPackSlugLockedException`), and it adds the migration-pack pages and the envelope body. Released OpenRegister versions write packs without a CSRF check, and the packs drive writes with RBAC off, so stackiq uses the store only when `BuiltinPackSlugLockedException` exists; on an older OpenRegister the import keeps the shipped files with a warning. Do not promote this change to beta before that OpenRegister change is released.
+
+## Risks
+
+### Risk 1: An edit in OpenRegister writes fields the import must not write
+**Severity:** High — **Mitigation:** the import writes with OpenRegister's RBAC and multitenancy off, so every pack must follow the import's rules (REQ-CMDB-021): only plain properties of the target schema, never object metadata, match keys, relations, publication dates or admin fields, and the required mappings kept. A pack that breaks a rule stops the import with 503 naming the pack.
+
+### Risk 2: An administrator's edit is lost or silently ignored
+**Severity:** High — **Mitigation:** the repair step never overwrites an edited pack; a renamed pack, or a store that is gone after the packs were stored, fails closed instead of falling back to the files.
+
+### Risk 3: An edit widens what the import reads
+**Severity:** Medium — **Mitigation:** the read allowlist is every column the profile or a pack names, so an administrator who maps a new column makes the import read it. Only Nextcloud admins can edit packs and run the import; the documentation says that a mapped column is read.
+
+### Risk 4: OpenRegister's internal classes change
+**Severity:** Medium — **Mitigation:** resolved defensively and checked for the methods called and for the hardened store's `BuiltinPackSlugLockedException`; a missing or older store before any seed falls back to the files with a warning, afterwards fails closed.
+
+## Rollback Strategy
+
+Revert the PR. The import then reads the shipped files again; the four rows in OpenRegister's pack store stay and are ignored, and can be deleted there. The app config key `cmdb_import_pack_ids` can be deleted with `occ config:app:delete stackiq cmdb_import_pack_ids`.
+
+## Open Questions
+
+- Does any instance import for more than one municipality? The store holds one mapping per instance; per-municipality packs would need a slug per municipality or a pack chosen in the import options.
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
new file mode 100644
index 000000000..2560fc008
--- /dev/null
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -0,0 +1,236 @@
+# cmdb-export-import Specification (delta: cmdb-import-mapping-view)
+
+**Status**: in-progress
+**Scope**: stackiq
+**OpenSpec changes**:
+- [cmdb-export-import](../../../archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_
+- [cmdb-import-mapping-view](../../)
+
+## Purpose
+
+The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now the packs were JSON files under `lib/Settings/cmdb-import/`, which an administrator could only read on the server and an app update overwrote. This delta moves the four sheet packs into OpenRegister's migration-pack store, where an administrator views and edits them, and makes the import run what is stored there; the mapping endpoint says where each pack comes from with a link to its editor, and the settings section that shows it follows in #1289 (Jira WOO-588, sub-task of WOO-586; decision 2026-10-12: mapping lives in OpenRegister).
+
+Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the editor link), `OCP\IAppConfig` (the stored pack ids), `OCP\Migration\IRepairStep` (seeding). OpenRegister: `Service\MigrationPackService`, `Service\MigrationPack\PackDefinitionValidator` and `Service\MigrationPack\MappingEngine`, resolved defensively.
+
+## ADDED Requirements
+
+### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
+
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack through the same loader, store and validator the import uses (REQ-CMDB-021), and SHALL answer 200 with a flat envelope of three keys:
+
+- `profile`: the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `archiveSheet` (the sheet of which the import reads only the APPID column, REQ-CMDB-015, or null when the profile names none or names one of its source sheets), `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns` (the columns the import converts from Excel dates, REQ-CMDB-005), `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`;
+- `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack), `updatePending` (an unedited stored pack that the next import run replaces with the shipped one; then `version`, `fieldMappings`, `defaults` and the other pack fields show the shipped pack and `storedVersion` what OpenRegister holds) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built);
+- `openRegisterPacks`: whether OpenRegister's migration-pack store is available.
+
+Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). Reading the mapping SHALL change nothing: it SHALL NOT seed, update or delete a pack in OpenRegister and SHALL NOT write stackiq's app config; a pack the store does not hold yet SHALL be answered from its shipped file (`source` `file`), and an unedited stored pack that a newer shipped pack would replace SHALL be answered as the import will run it, that is as the shipped pack with `updatePending` `true`, without writing anything; a stored pack of which OpenRegister reports no last-saved time is kept by the import and answered as stored. The "CMDB import" section SHALL show, per pack, its source, its version, a hint when a newer shipped pack was not applied, a hint when an update is pending (`updatePending`: the shipped version replaces the stored one at the next import), and a link "Edit in OpenRegister" when the pack has an `editUrl`; when the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. Per sheet pack it SHALL also show, as text in accessible tables, the field mappings (`source`, `target`, `required`, the transform's type) and the write rules that have values (`defaults`, `createOnlyDefaults`, `createOnlyFields`, `neverWritten`). It SHALL offer no editing itself.
+
+#### Scenario: An admin reads the mapping the import uses
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** a Nextcloud admin, with OpenRegister's migration-pack store holding the seeded packs
+- **WHEN** they request `GET /api/settings/cmdb-import/mapping`
+- **THEN** the endpoint SHALL answer 200 with `profile.sheets` naming "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", `profile.keyColumn` `APPID` and `openRegisterPacks` `true`
+- **AND** `packs` SHALL hold five entries with targets module, manufacturer, municipality, usage and businessOwner
+- **AND** the module, manufacturer, usage and businessOwner packs SHALL have `source` `openregister`, a `packId` and an `editUrl` ending in `/apps/openregister/migration-packs/`
+- **AND** the municipality pack SHALL have `source` `file`, `editable` `false` and `editUrl` `null`
+- **AND** the usage pack SHALL list a mapping from "Applicatie Status" to `status` with a `lookup` transform whose `map` sends "In productie" to "In production"
+
+#### Scenario: A broken stored pack is reported with its name
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** the usage pack in OpenRegister maps a column to `provider`
+- **WHEN** a Nextcloud admin requests the mapping
+- **THEN** the endpoint SHALL answer 503 with error `MAPPING_UNAVAILABLE`
+- **AND** `details.pack` SHALL be `stackiq-topdesk-usage`, `details.packId` its row id, `details.editUrl` the link to its editor ending in `/apps/openregister/migration-packs/`, and `details.reason` SHALL name the pack, its id and the broken rule on one line of at most 500 characters
+
+#### Scenario: A user who is not a Nextcloud admin cannot read the mapping
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** a signed-in user who is not a Nextcloud admin
+- **WHEN** they request `GET /api/settings/cmdb-import/mapping`
+- **THEN** Nextcloud SHALL answer 403
+
+### Requirement: The import SHALL run the mapping packs stored in OpenRegister (REQ-CMDB-021)
+
+For the module, manufacturer, usage and business-owner targets the import SHALL use the pack stored in OpenRegister's migration-pack store under the shipped pack's slug (`stackiq-topdesk-module`, `stackiq-topdesk-manufacturer`, `stackiq-topdesk-usage`, `stackiq-topdesk-business-owner`), without the `x-stackiq` marker (REQ-CMDB-022). When the store is available and such a pack is missing, the import SHALL first seed it from the shipped file. The municipality pack maps the import options, not a sheet; it SHALL always come from its shipped file. An instance SHALL have one mapping, used for every municipality it imports.
+
+Every pack, stored or shipped, SHALL pass OpenRegister's `PackDefinitionValidator` and the import's own rules, or the import SHALL stop with 503 `MAPPING_UNAVAILABLE` before any row is read, naming the pack (its file, or its OpenRegister slug and row id) and every rule it breaks; it SHALL NOT fall back to the shipped file. The rules are:
+
+- the module pack SHALL map `name`, and SHALL map the profile's key column (`APPID`) to `externalNumber`, both with `required: true`; the manufacturer, municipality and business-owner packs SHALL map `name` with `required: true`;
+- a pack SHALL map, and set as a default, only properties of its target schema in the register stackiq ships, fragments included (module; organisation for manufacturer and municipality; usage); every such property is allowed unless a rule below refuses it, so no list of fields is kept in code; the business-owner pack, which writes into no schema, only `name`, `role`, `email` and `employeeNumber` (since cmdb-import-owner-identity);
+- no pack SHALL map or default `@self`, `id`, `externalKey`, `provider`, `consumer`, `module`, `businessOwner`, a field the profile lists as never written, a relation or a file (a property whose definition or items carry a `$ref`, an `inversedBy`, a `writeBack`, `format: uuid`, `type: file` or a related object), a property the register hides from forms (`hideOnForm: true`, in the base register or a fragment) or calculates (an `x-openregister-calculations` entry), which other subsystems own (the service desk exchange's `serviceDeskSystem`, `serviceDeskRecordId`, `serviceDeskUrl` and `serviceDeskSyncedAt`, the calculated `suggestedTimeClassification`), a usage's `installedVersion`, `publicationDate`, `depublicationDate`, `registeredBy`, `registrationStatus`, `mergedInto`, `contactsUid`, `pki`, `xml`, an organisation's `status` or a module's `type`;
+- the manufacturer and municipality packs SHALL NOT map `type` and SHALL keep the defaults as shipped: `type` `Supplier` and `Municipality`, `status` `Active`, and the manufacturer's `registeredBy` `Supplier`.
+
+OpenRegister's store SHALL count as available only when its pack writes require a CSRF token and lock a built-in pack's slug (the class `OCA\OpenRegister\Exception\BuiltinPackSlugLockedException` exists), and none of the pack controller's write methods (create, update, destroy, import, preview) is opened to non-admins or exempted from the CSRF check (`#[NoAdminRequired]`, `#[PublicPage]`, `#[AuthorizedAdminSetting]` or `#[NoCSRFRequired]`). When OpenRegister's store is not available and no pack was ever stored there, the import SHALL use the shipped files and SHALL add one warning to the report saying so. Once the packs were stored there, a store that is not available SHALL stop the import with 503 `MAPPING_UNAVAILABLE` naming the stored packs. A pack whose stored row now carries another slug SHALL stop the import with 503 naming the row and both slugs; a pack whose row was deleted SHALL be seeded again. The import's 503 message SHALL name the OpenRegister pack and its id when one is at fault.
+
+#### Scenario: An edit in OpenRegister is what the import runs
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** the usage pack in OpenRegister maps "In productie" to `Planned`
+- **WHEN** a row with "Applicatie Status" `In productie` is imported
+- **THEN** the usage SHALL have `status` `Planned`
+- **AND** the report SHALL carry no warning about the mapping
+
+#### Scenario: A stored pack that writes a field the import manages is refused
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** the usage pack in OpenRegister maps "Applicatie Status" to `provider`
+- **WHEN** an import starts
+- **THEN** the import SHALL answer 503 `MAPPING_UNAVAILABLE` before any row is read, with `details.pack` `stackiq-topdesk-usage` and `details.packId` its row id, and SHALL write nothing
+- **AND** the mapping endpoint's reason SHALL name `stackiq-topdesk-usage`, its row id and `"provider" is set by the import and cannot be mapped`
+
+#### Scenario: A stored module pack without the required name mapping is refused
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** the module pack in OpenRegister maps "Applicatie Naam" to `name` without `required: true`
+- **WHEN** an import starts
+- **THEN** the import SHALL answer 503 `MAPPING_UNAVAILABLE` with `details.pack` `stackiq-topdesk-module`
+- **AND** the mapping endpoint's reason SHALL name the pack and `"name" must be mapped as required`
+
+#### Scenario: A deleted pack is seeded again
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** an administrator deleted the business-owner pack in OpenRegister
+- **WHEN** a Nextcloud admin requests the mapping
+- **THEN** the business-owner pack SHALL have `source` `openregister`, a new `packId` and `edited` `false`
+- **AND** OpenRegister SHALL hold `stackiq-topdesk-business-owner` again as a built-in pack
+
+### Requirement: Stackiq SHALL seed its mapping packs into OpenRegister and keep unedited ones current (REQ-CMDB-022)
+
+On install and on every upgrade, after its register is initialised, stackiq SHALL create each of the four sheet packs that is missing from OpenRegister's migration-pack store as a built-in pack, from the shipped file, never through OpenRegister's import (which overwrites). Each stored definition SHALL carry `x-stackiq` with `shippedVersion` and `shippedHash`: the version of the shipped pack and the sha256 of its canonical JSON (every object's keys sorted, recursively; list order kept). A stored pack whose definition without `x-stackiq` still hashes to its `shippedHash` is unedited: when the shipped pack of the release differs, stackiq SHALL replace it with the shipped pack and a new marker. An edited pack, or one without marker, SHALL NOT be changed; for an edited pack whose marker differs from the shipped pack, the upgrade SHALL warn that the shipped version was not applied, and the mapping endpoint SHALL answer `newerShipped` `true`. Stackiq SHALL record the row id of every pack it stored or found in its app config. Running the step again on an unchanged store SHALL write nothing. Seeding SHALL never throw: without OpenRegister's store, or when the store fails, it SHALL report and leave the instance as it was.
+
+#### Scenario: The four sheet packs are stored in OpenRegister
+@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+
+- **GIVEN** stackiq is installed next to an OpenRegister with the hardened migration-pack store
+- **WHEN** a Nextcloud admin lists OpenRegister's migration packs
+- **THEN** the store SHALL hold `stackiq-topdesk-module`, `stackiq-topdesk-manufacturer`, `stackiq-topdesk-usage` and `stackiq-topdesk-business-owner` as built-in packs with the `x-stackiq` marker
+- **AND** no municipality pack SHALL be stored
+
+## MODIFIED Requirements
+
+### Requirement: Field mapping SHALL be declarative and executed by OpenRegister's mapping engine (REQ-CMDB-005)
+
+The service SHALL map each normalised row with OpenRegister's `MigrationPack\MappingEngine::mapRow()`, once per target pack: module, manufacturer, municipality, usage, business owner. The packs and the import profile SHALL ship as JSON under `lib/Settings/cmdb-import/`; the module, manufacturer, usage and business-owner packs SHALL be taken from OpenRegister's migration-pack store when it holds them (REQ-CMDB-021), the shipped files being their seed. Each pack SHALL pass OpenRegister's `PackDefinitionValidator` and the import's rules when the import starts; an invalid pack, or a missing `MappingEngine`, SHALL stop the import with 503 `MAPPING_UNAVAILABLE` before any row is read. Before mapping, the service SHALL convert the cells of the date columns from Excel serial numbers to `Y-m-d`: the profile's date columns and every column a pack maps with a `date` transform. It SHALL turn numeric id cells into strings without a decimal part, SHALL read a value the profile lists as empty for its column (`NB` in "BNN Classificatie"; dates, "End-of-Life Functioneel" included, are kept as the file has them) as empty, and SHALL add the constants of the row's sheet (`Beheer` = `Beheer geregeld: nee` or `ja`). A mapping error on a mapping marked `required` in the module pack SHALL skip the row. In the manufacturer and owner packs it SHALL mean the row has no manufacturer or no such owner, without a warning. A mapping error on any other mapping SHALL drop only that field and add a row warning naming the column and the value. The empty parts of the usage's internal note SHALL be dropped on the separator of the concat mapping that writes it. The reader SHALL keep only the columns that the profile or a pack references, and SHALL discard every other cell when it reads the row.
+
+(Previously: the packs were read from the shipped files only, only the profile's date columns were converted, and the note was split on a fixed ` / `.)
+
+#### Scenario: Excel serial dates are converted before mapping
+@e2e exclude Pure transformation; tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php asserts the conversions below.
+
+- **GIVEN** the "Onbeh" row of the anonymised export with "Datum" = `45111.380322627316` and "Referentie datum wijziging" = `46232.552113113423`, and the "Beheerde" row with "End-of-Life Functioneel" = `53359`
+- **WHEN** the rows are normalised
+- **THEN** "Datum" SHALL be `2023-07-04`, "Referentie datum wijziging" SHALL be `2026-07-29`, and "End-of-Life Functioneel" SHALL be `2046-02-01`
+- **AND** "APPID" `1234` SHALL be the string `"1234"`
+- **AND** "BNN Classificatie" `NB` SHALL be empty
+- **AND** "End-of-Life Functioneel" `49675` SHALL be `2036-01-01`
+
+#### Scenario: Changing a pack changes the mapping without code
+@e2e exclude Configuration behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php imports against an alternate module pack and against an edited stored usage pack.
+
+- **GIVEN** the usage pack in OpenRegister is edited to add a mapping from "In productie sinds" to `startDateInProduction` with a `date` transform, and its note separator is changed to ` | `
+- **WHEN** an export is imported whose row has "In productie sinds" = `45111` and "Cluster" = `B10`
+- **THEN** the usage SHALL have `startDateInProduction` = `2023-07-04`
+- **AND** its `interneAnnotation` SHALL be `Beheer geregeld: ja | B10`
+- **AND** no PHP code SHALL have changed
+
+#### Scenario: An unknown status value drops only that field
+@e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the row outcome and warning.
+
+- **GIVEN** a row whose "Applicatie Status" is `Onbekende status`, which the usage pack's lookup does not contain
+- **WHEN** the row is imported
+- **THEN** the module and the usage SHALL be saved without a status from the export
+- **AND** the row's report entry SHALL carry a warning naming column "Applicatie Status" and value `Onbekende status`
+
+#### Scenario: The classifications map to the stackiq fields
+@e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php imports the fixture and asserts the fields.
+
+- **GIVEN** the "Beheerde" row of the anonymised export with "Applicatiesoort" `Saas`, "BNN Classificatie" `BBN2`, "Classificatie" `Tolereren` and "End-of-Life Functioneel" `53359`
+- **WHEN** it is imported
+- **THEN** the module SHALL have `applicationType` = `Saas`, `cloudDienstverleningsmodel` = `["SaaS"]` and `bbnLevel` = `BBN2`
+- **AND** the usage SHALL have `timeClassification` = `Tolerate` and `startDateOutPhased` = `2046-02-01`
+- **AND** the "Onbeh" row's "Applicatiesoort" `Webapplicatie`, which is not a hosting model, SHALL be stored as `applicationType` and SHALL leave `cloudDienstverleningsmodel` empty without a warning
+- **AND** "BNN Classificatie" `1`, `2`, `2+` SHALL be `BBN1`, `BBN2`, `BBN2+`
+
+### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
+
+Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including those four; a reconciliation row SHALL show no row number. The section SHALL also show the mapping the import uses (REQ-CMDB-020), as tables: the export's sheets and columns, and per sheet pack its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; the municipality pack, which maps the import options and no column, SHALL NOT be listed. When the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. The section SHALL offer no editing of the mapping itself. The sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, and so SHALL the archive sheet its texts name, with no archive sheet when the profile names none; the shipped names are the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
+
+(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server. This block also carries the missing-applications choice, the four counts and the reconciliation rows of cmdb-import-archive-reconciliation, which modifies REQ-CMDB-014 too (before it, the request always sent `keep`); both changes carry this same block, so archiving them in either order leaves the whole requirement.)
+
+#### Scenario: The admin runs an import from the settings page
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** a Nextcloud admin on stackiq's admin settings page
+- **WHEN** they choose "Gemeente Voorbeeldstad", choose the anonymised export and press "Import"
+- **THEN** a progress bar SHALL appear while the import runs
+- **AND** afterwards the summary and the report table SHALL be shown
+- **AND** filtering the table on `created` SHALL show the two imported rows
+
+#### Scenario: The admin chooses to keep missing applications
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** the section
+- **WHEN** the admin selects "Keep them as they are" and starts the import
+- **THEN** the request SHALL carry `missingRecords=keep`
+- **AND** without a choice it SHALL carry `missingRecords=archive`
+
+#### Scenario: The sheet names in the help come from the server
+@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+
+- **GIVEN** a Nextcloud admin on stackiq's admin settings page
+- **WHEN** the mapping endpoint has answered
+- **THEN** the help text under the file control SHALL name the sheets the endpoint returned
+- **AND** a profile with one, two or three sheets SHALL give a help text and an info text that name exactly those sheets, with no sheet the profile does not name and no description tied to a sheet's position
+- **AND** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error
+
+#### Scenario: The archive sheet in the help comes from the server
+@e2e exclude Not driven in e2e yet: the texts are derived in the page from the endpoint's answer, and src/views/settings/sections/CmdbImport.spec.js emits mappings with the archive sheet "Archief" and with none and asserts the info text, the missing-applications paragraph and the too-large hint; the route intercept in tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts could drive it as well.
+
+- **GIVEN** the mapping endpoint answers `profile.archiveSheet` `Archief`
+- **WHEN** the section renders its information panel and a too-large error
+- **THEN** the info text, the paragraph on applications missing from the export and the hint SHALL name the sheet `Archief`
+- **AND** when it answers `null`, none of them SHALL name an archive sheet, and the paragraph SHALL say that the import only archives
+
+#### Scenario: The section lists the mapping and links each pack to its editor
+@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+
+- **GIVEN** a Nextcloud admin on stackiq's admin settings page
+- **WHEN** the mapping block of the "CMDB import" section has loaded
+- **THEN** a table of the export SHALL list the sheets read and the match, name, required and date columns, and a table of the packs SHALL show each of the four sheet packs with its source and version and a link "Edit in OpenRegister" to its `editUrl`
+- **AND** the municipality pack, which maps the import options and no column, SHALL NOT be listed
+- **AND** when the endpoint answers 503 for a stored pack, the error SHALL show a link "Edit in OpenRegister" to its `details.editUrl`
+- **AND** the block SHALL hold no input field
+
+#### Scenario: An edited pack whose newer shipped version was not applied says so
+@e2e exclude Needs a stackiq release that ships a newer pack than the one installed; tests/vitest/cmdbImportMapping.spec.js mounts the block with an edited usage pack whose `newerShipped` is true and asserts the hint, and its absence for the unedited module pack.
+
+- **GIVEN** the mapping endpoint answers an edited usage pack with `newerShipped` `true` and `shippedVersion` `1.1.0`
+- **WHEN** the mapping block renders
+- **THEN** the usage row SHALL say that stackiq ships version `1.1.0`, that it was not applied because the pack was edited, and how to use the shipped version
+- **AND** a row whose `newerShipped` is `false` SHALL show no such hint
+
+## Non-Functional Requirements
+
+- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Writes happen only to seed or update a pack.
+- **Security:** admin-only with CSRF, as the import routes; OpenRegister's pack writes are admin-only with CSRF, and stackiq uses no store without those guards. The import writes with OpenRegister's RBAC and multitenancy off, so every pack SHALL follow the import's rules (REQ-CMDB-021). An edit that maps a new column makes the import read that column. The answer and the reasons hold column names, field names, lookup tables, pack names and ids; no object data and no person data. A store failure is named by its exception class, never its message.
+- **Reliability:** an edit is never overwritten by an upgrade and never silently replaced by the shipped files (REQ-CMDB-021, REQ-CMDB-022).
+- **Accessibility:** Target WCAG 2.2 AA. The mapping block's link has a visible, descriptive label (SC 2.4.4, 4.1.2; gate `link-name`); a status hint is text, not colour alone (SC 1.4.1). New in 2.2: 2.4.11 Focus Not Obscured applies (the block is in the page flow); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the link (Nextcloud default); 3.2.6 Consistent Help does not apply; 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply.
+- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the messages and the import warning.
+
+## Acceptance Criteria
+
+- [ ] After install, OpenRegister lists the four `stackiq-topdesk-*` packs as built-in packs.
+- [ ] A Nextcloud admin edits a lookup value of the usage pack in OpenRegister, and the next CMDB import uses it.
+- [ ] A pack edited to map a field the import manages makes the import answer 503 naming the pack; the mapping endpoint answers the same 503.
+- [ ] Deleting the stored pack in OpenRegister makes the next import seed the shipped pack again.
+- [ ] The "CMDB import" section shows per pack its source and an "Edit in OpenRegister" link that opens the pack's editor.
+- [ ] A signed-in user who is not a Nextcloud admin gets 403 from the endpoint.
+
+## Notes
+
+- Decision 2026-10-12: the mapping lives in OpenRegister. The packs use OpenRegister's existing migration-pack store, not its Twig mapping entity: the packs are already migration packs, executed by `MappingEngine` with structural validation and row and column errors.
+- One mapping per instance: OpenRegister's pack store has no organisation scope. An instance that imports for more than one municipality shares the mapping between them.
+- `MigrationPackService`, `PackDefinitionValidator` and `MappingEngine` are not part of OpenRegister's `Contract` namespace; stackiq resolves them defensively.
+- Deleting a stored pack means "back to the shipped pack": the next import or upgrade seeds it again.
+- REQ-CMDB-014 is modified by this change and by cmdb-import-archive-reconciliation; both carry the same block, which holds what each adds, so archiving them in either order leaves the whole requirement. A change to that block goes into both.
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
new file mode 100644
index 000000000..f0988cb0a
--- /dev/null
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -0,0 +1,93 @@
+# Tasks: cmdb-import-mapping-view
+
+Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md` (`SPEC` below).
+
+## Implementation Tasks
+
+### Task 1: The import reads the sheet packs from OpenRegister's migration-pack store
+- **spec_ref**: `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021) and `SPEC#requirement-field-mapping-shall-be-declarative-and-executed-by-openregisters-mapping-engine-req-cmdb-005` (cmdb-export-import#REQ-CMDB-005)
+- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Service/Cmdb/CmdbImportPackStore.php`, `lib/Service/Cmdb/CmdbImportPackMarker.php`, `lib/Service/Cmdb/CmdbImportPackRules.php`, `lib/Service/CmdbExportImportService.php`, `lib/Controller/CmdbImportController.php`, `tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php`, `tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Controller/CmdbImportControllerTest.php`, `tests/Unit/Support/FakeMigrationPackService.php`
+- **acceptance_criteria**:
+ - GIVEN OpenRegister's store holds an edited module pack WHEN the profile loads THEN the module pack is the stored one without its marker, and the missing sheet packs are seeded; the municipality pack is the shipped file
+ - GIVEN an unedited stored pack seeded from an older release WHEN the profile loads THEN it is replaced by the shipped pack; GIVEN an edited one THEN it is kept and reported `newerShipped`
+ - GIVEN a stored pack the validator refuses, or that breaks a rule of the import WHEN the profile loads THEN 503 `MAPPING_UNAVAILABLE` names the pack and its id in the message and the details
+ - GIVEN no store and no recorded pack ids THEN the shipped files are used and the import report carries one warning; GIVEN no store and recorded ids THEN 503 naming the packs
+ - GIVEN a renamed stored pack THEN 503 naming the row and both slugs; GIVEN a deleted one THEN it is seeded again
+ - GIVEN a stored pack that maps a new column with a date transform WHEN a row is imported THEN that column is converted from an Excel date; the note cleanup uses the pack's concat separator
+- [x] Implement
+- [x] Test
+
+### Task 2: Seed and update the packs in OpenRegister on install and upgrade
+- **spec_ref**: `SPEC#requirement-stackiq-shall-seed-its-mapping-packs-into-openregister-and-keep-unedited-ones-current-req-cmdb-022` (cmdb-export-import#REQ-CMDB-022)
+- **files**: `lib/Repair/SeedCmdbImportPacks.php`, `appinfo/info.xml`, `tests/Unit/Repair/SeedCmdbImportPacksTest.php`
+- **acceptance_criteria**:
+ - GIVEN an empty store WHEN the step runs THEN the four sheet packs are created as built-in packs with the `x-stackiq` marker and their ids are recorded; a second run writes nothing
+ - GIVEN an unedited pack from an older release THEN it is updated; GIVEN an edited one THEN it is kept and the step warns that the shipped version was not applied
+ - GIVEN no store, or a failing store THEN the step reports and never throws
+ - GIVEN `appinfo/info.xml` THEN the step runs after `InitializeSettings` in `` and ``
+- [x] Implement
+- [x] Test
+
+### Task 3: The mapping endpoint says where each pack comes from and links to its editor
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
+- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Controller/SettingsController.php`, `appinfo/routes.php`, `tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`
+- **acceptance_criteria**:
+ - GIVEN the route WHEN its method is reflected THEN it carries no attribute, declares `@auth admin-only` with a reason and no exemption annotation
+ - GIVEN the shipped files and no store THEN 200 with `{profile, packs, openRegisterPacks: false}`, every pack `source: file`, `editUrl: null`
+ - GIVEN the store THEN the four sheet packs answer `source: openregister`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `updatePending`, `editable: true` and `editUrl` built from OpenRegister's page route; the municipality pack answers `editable: false`
+ - GIVEN a broken stored pack THEN 503 with `details.reason`, `details.pack` and `details.packId`
+- [x] Implement
+- [x] Test
+
+### Task 4 (#1289): The section's mapping block shows the source and the editor link per pack
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) and `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
+- **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js`, `src/views/settings/sections/CmdbImport.spec.js`, `src/utils/cmdbImport.spec.js`
+- **acceptance_criteria**:
+ - GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and an "Edit in OpenRegister" link to `editUrl` when there is one
+ - GIVEN a pack with `updatePending` THEN the block says the shipped version replaces the stored one at the next import
+ - GIVEN a sheet pack THEN a collapsible block lists its field mappings (column, field, required, transform) and the write rules that have values, as text in tables with captions and `th` scopes
+ - GIVEN the block is collapsed while the answer loads THEN the loading announcement is in a live region outside the hidden block
+ - GIVEN a 503 THEN the block shows the error, the code and the reason, and for a stored pack an "Edit in OpenRegister" link to `details.editUrl`
+ - GIVEN the answer THEN the help text names the endpoint's sheets; before and on failure `PROFILE_DEFAULTS.sheets`
+ - GIVEN a profile with one, two or three sheets WHEN the section renders its help text and its info text THEN both name exactly those sheets in the profile's order
+ - GIVEN the answer names an archive sheet, or none THEN the info text, the missing-applications paragraph and the too-large hint name that sheet, or no archive sheet
+- [x] Implement
+- [x] Test
+
+### Task 5: Translations and documentation
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
+- **files**: `l10n/en.json`, `l10n/nl.json`, `l10n/en.js`, `l10n/nl.js`, `docs/features/cmdb-import.md`
+- **acceptance_criteria**:
+ - GIVEN the new strings WHEN `npm run test:l10n` and `npm run check:l10n-js` run THEN both pass, with Dutch for every string
+ - GIVEN the docs WHEN an administrator reads "Viewing and editing the mapping" THEN they know where to edit a pack, what the import refuses, what happens on an app update and how to go back to the shipped pack
+- [x] Implement (backend strings, the section's strings and documentation)
+- [x] Test
+
+### Task 6: End-to-end coverage of the endpoint, the import and the seed
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020), `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021) and `SPEC#requirement-stackiq-shall-seed-its-mapping-packs-into-openregister-and-keep-unedited-ones-current-req-cmdb-022` (cmdb-export-import#REQ-CMDB-022)
+- **files**: `tests/e2e/spec-coverage/cmdb-import-packs.spec.ts`
+- **acceptance_criteria**:
+ - GIVEN the rig with the hardened OpenRegister WHEN an admin lists OpenRegister's packs THEN the four sheet packs are built-in packs with the marker, and no municipality pack is stored
+ - GIVEN an admin WHEN they read the mapping THEN every sheet pack answers `source: openregister`, its `packId` and an `editUrl` to its editor; the municipality pack `source: file` and no link
+ - GIVEN the usage pack edited to map a column to `provider` THEN the mapping and the import answer 503 naming the pack, and nothing is written; GIVEN the module pack's name no longer required THEN the same with the rule named
+ - GIVEN an edited lookup in the usage pack WHEN the export is imported THEN the usages carry the edited value
+ - GIVEN the business-owner pack deleted in OpenRegister WHEN the mapping is read THEN it is shown from the shipped file and not stored; WHEN the next import runs THEN it is seeded again
+ - GIVEN a signed-in non-admin WHEN they call the endpoint THEN 403
+- [x] Implement
+- [x] Test
+
+### Task 7 (#1289): End-to-end coverage of the section
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020); the section itself is specified in #1289
+- **files**: `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts`
+- **acceptance_criteria**:
+ - GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and an "Edit in OpenRegister" link, and the error of a broken stored pack links to its editor
+- [x] Implement
+- [x] Test
+
+### Task 8: Archive
+- **spec_ref**: `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021)
+- **files**: `openspec/specs/cmdb-export-import/spec.md`
+- **acceptance_criteria**:
+ - GIVEN the change is archived WHEN this was the last open change of `cmdb-export-import` THEN the canonical spec's front matter `status:` and its `**Status**` line read `done` again, and the change's entry links the archive
+- [ ] Implement
+- [ ] Test
diff --git a/openspec/changes/cmdb-import-owner-identity/.openspec.yaml b/openspec/changes/cmdb-import-owner-identity/.openspec.yaml
new file mode 100644
index 000000000..943df390c
--- /dev/null
+++ b/openspec/changes/cmdb-import-owner-identity/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: conduction
+created: 2026-10-19
diff --git a/openspec/changes/cmdb-import-owner-identity/proposal.md b/openspec/changes/cmdb-import-owner-identity/proposal.md
new file mode 100644
index 000000000..1cf1c96e3
--- /dev/null
+++ b/openspec/changes/cmdb-import-owner-identity/proposal.md
@@ -0,0 +1,47 @@
+---
+kind: code
+depends_on: [cmdb-import-mapping-view]
+---
+
+# Proposal: cmdb-import-owner-identity
+
+## Summary
+
+The CMDB import recognises an application owner by employee number first, then by e-mail address, then by name. The business-owner pack maps two optional columns, `Applicatie Eigenaar (E-mail)` and `Applicatie Eigenaar (Personeelsnummer)`, whose names are placeholders: the administrator renames their source in OpenRegister's pack editor once the export's real column names are known, and no stackiq release is needed for that.
+
+## Motivation
+
+Owners are matched on their display name, so two people with the same name become one contact (Jira WOO-586, F29). Gemeente Rotterdam answered on 2026-10-15 that it can add the owner's e-mail address and employee number to the CMDB sheets, and that the employee number never changes; on 2026-10-19 it said it knows the column names but first wants to align them across its teams. Shipping placeholders now, editable in OpenRegister (cmdb-import-mapping-view), lets the municipality pick the names later without waiting for a release.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `cmdb-export-import`: MODIFIED REQ-CMDB-010 (owner matching on employee number and e-mail address, the two optional columns as placeholders, the business-owner pack's allowed fields).
+
+## Affected Projects
+
+- [ ] Project: `stackiq`: the pack rules, the business-owner pack and the profile under `lib/Settings/cmdb-import/`, the owner resolution in `CmdbExportImportService`, `StackiqContactSyncService`, documentation, tests.
+
+## Scope
+
+### In Scope
+
+- `email` and `employeeNumber` as fields the business-owner pack may map.
+- The shipped business-owner pack 1.1.0 with the two placeholder columns; both listed as absent on the CMDB sheets, so an export without them raises no warning.
+- Matching an owner on a contact UID derived from the employee number and the municipality, in the "Stackiq CMDB owners" address book; creating the contact with that UID.
+
+### Out of Scope
+
+- Storing the employee number itself anywhere.
+- Merging the contact an owner had before the employee number arrived into the new one.
+- Changing the profile's column lists in OpenRegister: the profile is not an editable pack.
+
+## Risks
+
+- An owner first imported by name or e-mail address gets a second contact once the employee number arrives; the old contact stays in the address book. Documented.
+- An unedited stored business-owner pack is replaced by 1.1.0 on update (REQ-CMDB-022); an edited one is kept and the new columns are then not added, with the existing "newer shipped version not applied" warning.
diff --git a/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md
new file mode 100644
index 000000000..82909bf0d
--- /dev/null
+++ b/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md
@@ -0,0 +1,113 @@
+# cmdb-export-import Specification (delta: cmdb-import-owner-identity)
+
+**Status**: in-progress
+**Scope**: stackiq
+**OpenSpec changes**:
+- [cmdb-export-import](../../../archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_
+- [cmdb-import-owner-identity](../../)
+
+## Purpose
+
+The CMDB import recognises an owner by display name, so two people with the same name become one contact (Jira WOO-586, finding F29/F51). The municipality will add the owner's e-mail address and employee number to its export, but has not named the columns yet. This delta lets the business-owner pack map both, matches an owner on the employee number first, and ships placeholder column names that the administrator renames in OpenRegister's pack editor once the export's names are known, without a stackiq release.
+
+Nextcloud OCP interfaces used: `OCP\Contacts\IManager` (through `StackiqContactSyncService`), `OCP\IL10N` (messages).
+
+## MODIFIED Requirements
+
+### Requirement: The owner SHALL become a contact person of the municipality through Nextcloud Contacts, never a user account, and SHALL never be publicly readable (REQ-CMDB-010)
+
+The business owner pack SHALL map "Applicatie Eigenaar (Persoon)" (the display name, which may be a function instead of a person's name) to `name` and "Applicatie Eigenaar (Functie)" (the role) to `role`, and SHALL map the optional columns "Applicatie Eigenaar (E-mail)" to `email` and "Applicatie Eigenaar (Personeelsnummer)" to `employeeNumber`. Those two column names are placeholders until the municipality names the columns of its export; an administrator renames their `source` in OpenRegister's pack editor (REQ-CMDB-021), and the import then reads the renamed columns without a stackiq release. The profile SHALL list the two placeholder columns as absent on both CMDB sheets, so an export without them raises no "optional column not found" warning; after a rename the absent list no longer names them, so an export with the renamed columns on only one CMDB sheet warns for the other. The business-owner pack SHALL map, and set as a default, only `name`, `role`, `email` and `employeeNumber` (extending the list in REQ-CMDB-021). No technical owner SHALL be imported; the functional administrator columns SHALL NOT be read. For the owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService`, all in a dedicated address book "Stackiq CMDB owners" of the admin who runs the import (`StackiqContactSyncService::syncToNamedAddressBook()` and `searchNamedAddressBook()`), which is created on first use: when the row has an employee number, by the contact whose UID is `stackiq-cmdb-owner-` followed by the HMAC-SHA256, keyed with a key derived from the instance's `secret`, of the municipality's uuid, `|` and the lower-cased, trimmed number (a number Excel stored as `1234.0`, or with leading zeros as `01234`, counts as `1234`), and otherwise by creating the contact with that UID, without matching another contact on the row's e-mail address, so two namesakes with their own number stay two owners, also with a shared e-mail address, and a renamed owner keeps the contact; the number itself SHALL NOT be stored on the contact, and SHALL NOT be recoverable from the UID without the instance's secret. A value without a digit other than 0 (such as `-`, `0` or `n.v.t.`) SHALL count as no employee number; without an instance secret the number SHALL NOT be used, and a warning SHALL be logged once per import. Without an employee number it SHALL match on the e-mail address when the row has one (any address of the contact), and otherwise on an exact match on the display name, and otherwise create one; neither match SHALL return a contact whose UID carries the `stackiq-cmdb-owner-` prefix, which belongs to an employee number. A new contact SHALL carry the e-mail address when the row has one. It SHALL NOT match a contact in any other address book of the admin, and SHALL NOT add an owner to them. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Applicatie Eigenaar (Functie)" when given, and SHALL set `usage.businessOwner` to it. An owner first imported by name or e-mail address and later with an employee number gets the employee-number contact and its own `contactPerson`; the usage follows the export (REQ-CMDB-009) and the earlier contact stays in the address book. The import SHALL NOT create Nextcloud user accounts. When Nextcloud Contacts is unavailable, the row SHALL be imported without an owner and SHALL carry a warning. `contactPerson` and `usage` SHALL have no public read rule, so the owner is never readable by an anonymous visitor; a published module SHALL refer to them by relation only.
+
+#### Scenario: The owner becomes the business owner
+@e2e exclude Needs a Contacts address book; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the calls to a StackiqContactSyncService test double and the saved contactPerson.
+
+- **GIVEN** the "Onbeh" row with "Applicatie Eigenaar (Persoon)" `Achternaam, Voornaam` and "Applicatie Eigenaar (Functie)" `Afdelingshoofd`, and the "Beheerde" row whose person column holds the function `Teamleider Applicatiebeheer`
+- **WHEN** they are imported for "Gemeente Voorbeeldstad"
+- **THEN** one `contactPerson` SHALL exist per owner with the resolved `contactsUid`, `organization` = "Gemeente Voorbeeldstad" and the function as `role`
+- **AND** each usage SHALL have `businessOwner` = its owner's contact person and no `technicalOwner`
+- **AND** no Nextcloud user account SHALL be created
+
+#### Scenario: Imported owners are never readable anonymously
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
+
+- **GIVEN** the anonymised export was imported, creating contact persons for its owners
+- **WHEN** a visitor who is not signed in lists the `contactPerson` and `usage` objects through OpenRegister, or searches OpenCatalogi for an imported application
+- **THEN** OpenRegister SHALL return no contact person and no usage
+- **AND** the OpenCatalogi search hit SHALL carry no owner name, and its `contactPerson` and `usages` SHALL be empty or ids only
+
+#### Scenario: The same owner on two rows is one contact person
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testTheSameOwnerOnTwoRowsIsOneContactPerson.
+
+- **GIVEN** two rows with the same "Applicatie Eigenaar (Persoon)"
+- **WHEN** they are imported
+- **THEN** exactly one `contactPerson` for that contact SHALL exist for the municipality, referenced by both usages
+
+#### Scenario: A namesake in the admin's own address book is not linked
+@e2e exclude Needs a CardDAV backend with two address books; tests/Unit/Service/CmdbExportImportServiceTest.php testOwnersAreMatchedOnlyInTheOwnersAddressBook asserts a personal contact with the owner's exact name is not linked while a contact in the owners' address book is reused, and tests/Unit/Service/StackiqContactSyncServiceTest.php testAnExistingContactIsMatchedOnlyInTheNamedAddressBook asserts the same for an e-mail address against the contacts manager's address book keys.
+
+- **GIVEN** an admin whose personal address book holds a contact "Voornaam Achternaam", and whose "Stackiq CMDB owners" address book holds "Teamleider Applicatiebeheer"
+- **WHEN** they import a row with owner `Achternaam, Voornaam` and a row with owner `Teamleider Applicatiebeheer`
+- **THEN** the first owner SHALL get a new contact in "Stackiq CMDB owners", and the personal contact SHALL NOT be linked or changed
+- **AND** the second owner SHALL reuse the contact already in "Stackiq CMDB owners"
+
+#### Scenario: A new owner contact goes into the dedicated address book
+@e2e exclude Needs a CardDAV backend; tests/Unit/Service/StackiqContactSyncServiceTest.php testNewContactsGoIntoTheNamedAddressBook asserts the "Stackiq CMDB owners" address book is created once and holds every new card, with no other address book written, and tests/Unit/Service/CmdbExportImportServiceTest.php testTheOwnerBecomesTheBusinessOwner asserts the import asks for that address book.
+
+- **GIVEN** an admin with a personal address book and no "Stackiq CMDB owners" address book
+- **WHEN** they import a row whose owner has no Nextcloud contact yet
+- **THEN** an address book "Stackiq CMDB owners" SHALL be created for that admin and SHALL hold the new contact
+- **AND** the admin's personal address book SHALL NOT receive a contact
+- **AND** when the address book cannot be created, the row SHALL be imported without an owner and SHALL carry a warning
+
+#### Scenario: Contacts disabled does not block the import
+@e2e exclude Environment condition; tests/Unit/Service/CmdbExportImportServiceTest.php sets isAvailable() to false.
+
+- **GIVEN** the Nextcloud Contacts app is disabled
+- **WHEN** the anonymised export is imported
+- **THEN** both modules and usages SHALL be saved without owners
+- **AND** each row with an owner SHALL carry the warning that owners were skipped because Contacts is unavailable
+
+#### Scenario: An owner with an employee number is matched on it, not on the name
+@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName asserts the contacts and contact persons through a StackiqContactSyncService test double, and tests/Unit/Service/StackiqContactSyncServiceTest.php testANewContactTakesTheGivenUid asserts the card is created with the given UID.
+
+- **GIVEN** two rows whose owner is `Achternaam, Voornaam`, with employee numbers `1001` and `2002`
+- **WHEN** they are imported for "Gemeente Voorbeeldstad"
+- **THEN** two contacts and two `contactPerson` objects SHALL exist, and the UID of the first SHALL be the one derived from `1001` and the municipality, without the number in it
+- **AND** when a later export names the owner of `1001` `Nieuwenaam, Voornaam` with the number stored as `1001.0`, the usage SHALL keep that same contact person
+
+#### Scenario: A shared e-mail address does not merge owners with their own employee number
+@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testASharedEmailAddressDoesNotMergeOwnersWithTheirOwnNumber asserts two contacts, and tests/Unit/Service/StackiqContactSyncServiceTest.php testAGivenUidIsNotOverriddenByAnEmailMatch asserts the given UID wins over an e-mail match.
+
+- **GIVEN** two rows whose owner is `Achternaam, Voornaam`, both with e-mail address `team@example.org`, with employee numbers `1001` and `2002`
+- **WHEN** the export is imported
+- **THEN** two contacts SHALL exist, with the UIDs derived from `1001` and `2002`, and two `contactPerson` objects
+
+#### Scenario: An owner without an employee number never takes an employee-number contact
+@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithoutANumberNeverTakesANumberContact covers it.
+
+- **GIVEN** a row with owner `Achternaam, Voornaam`, e-mail address `team@example.org` and employee number `1001`
+- **AND** a row with another owner and the same e-mail address, and a row with owner `Achternaam, Voornaam`, both without an employee number
+- **WHEN** the export is imported
+- **THEN** three contacts and three `contactPerson` objects SHALL exist
+
+#### Scenario: A placeholder in the employee number column is no employee number
+@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAPlaceholderInTheNumberColumnIsNoEmployeeNumber and tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php testAValueWithoutANumberGivesNoUid cover it.
+
+- **GIVEN** two rows with different owners whose employee number cell holds `-`
+- **WHEN** the export is imported
+- **THEN** the owners SHALL be matched on their names and two contacts SHALL exist, neither with a UID derived from an employee number
+
+#### Scenario: Without an employee number the e-mail address matches the owner
+@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithOnlyAnEmailAddressIsMatchedOnIt.
+
+- **GIVEN** the owners' address book holds a contact "Voornaam Achternaam" without an e-mail address
+- **WHEN** a row with owner `Achternaam, Voornaam` and e-mail address `v.achternaam@example.org` is imported twice
+- **THEN** one new contact with that e-mail address SHALL exist next to the namesake, and one `contactPerson`
+
+#### Scenario: Renamed owner columns are read without a stackiq release
+@e2e exclude Needs OpenRegister's pack store; tests/Unit/Service/CmdbExportImportServiceTest.php testRenamedOwnerColumnsInOpenRegisterAreRead uses the fake migration-pack service, and tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php testTheOwnerPackMayMapAnEmailAddressAndAnEmployeeNumber asserts the renamed pack passes the import's rules.
+
+- **GIVEN** the business-owner pack in OpenRegister maps "E-mailadres eigenaar" to `email` and "Pers.nr. eigenaar" to `employeeNumber`
+- **WHEN** a row with those columns is imported
+- **THEN** the owner SHALL be matched on the employee number and the new contact SHALL carry the e-mail address
+- **AND** the row SHALL carry no warning
diff --git a/openspec/changes/cmdb-import-owner-identity/tasks.md b/openspec/changes/cmdb-import-owner-identity/tasks.md
new file mode 100644
index 000000000..99508b7e4
--- /dev/null
+++ b/openspec/changes/cmdb-import-owner-identity/tasks.md
@@ -0,0 +1,32 @@
+# Tasks: cmdb-import-owner-identity
+
+Spec: `openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md` (`SPEC` below).
+
+## Implementation Tasks
+
+### Task 1: The business-owner pack maps an e-mail address and an employee number from placeholder columns
+- **spec_ref**: `SPEC#requirement-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable-req-cmdb-010` (cmdb-export-import#REQ-CMDB-010)
+- **files**: `lib/Service/Cmdb/CmdbImportPackRules.php`, `lib/Settings/cmdb-import/topdesk-business-owner.json`, `lib/Settings/cmdb-import/topdesk-profile.json`, `docs/features/cmdb-import.md`, `tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`, `tests/Unit/Repair/SeedCmdbImportPacksTest.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`
+- **acceptance_criteria**:
+ - GIVEN the business-owner pack with its sources renamed WHEN the import's rules check it THEN it passes; GIVEN it maps `telephone` THEN it is refused
+ - GIVEN the shipped pack THEN it maps `Applicatie Eigenaar (E-mail)` to `email` and `Applicatie Eigenaar (Personeelsnummer)` to `employeeNumber`, version 1.1.0
+ - GIVEN an export without those columns THEN no "optional column not found" warning is raised
+ - GIVEN the pack in OpenRegister maps renamed columns WHEN a row with them is imported THEN they are read
+- [x] Implement
+- [x] Test
+
+### Task 2: The owner is matched on the employee number, then the e-mail address, then the name
+- **spec_ref**: `SPEC#requirement-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable-req-cmdb-010` (cmdb-export-import#REQ-CMDB-010)
+- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/Cmdb/CmdbOwnerIdentity.php`, `lib/Service/StackiqContactSyncService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php`, `tests/Unit/Service/StackiqContactSyncServiceTest.php`
+- **acceptance_criteria**:
+ - GIVEN two namesakes with their own employee number THEN two contacts and two contact persons
+ - GIVEN a renamed owner with the same number (also as `1001.0`) THEN the same contact person
+ - GIVEN the same number at another municipality THEN another contact UID; the UID is keyed with the instance secret, so the number cannot be recovered from it by hashing candidate numbers
+ - GIVEN two namesakes with their own number and the same e-mail address THEN two contacts
+ - GIVEN a number cell holding `-`, `0` or `n.v.t.` THEN no employee number; GIVEN `01001` THEN the same UID as `1001`
+ - GIVEN an owner without a number who shares an e-mail address or name with an employee-number contact THEN a contact of their own
+ - GIVEN no instance secret THEN the number is not used and one warning is logged
+ - GIVEN only an e-mail address THEN the owner is matched on it, not on a namesake without one
+ - GIVEN a fixed UID WHEN a new contact is created THEN the card has that UID
+- [x] Implement
+- [x] Test
diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json
index 47603c105..60f0880bc 100644
--- a/openspec/parity/capabilities.json
+++ b/openspec/parity/capabilities.json
@@ -316,6 +316,477 @@
"name_nl": "Discovery en IT-beheer"
}
],
+ "features": [
+ {
+ "slug": "software-landscape-register",
+ "title": "Software landscape register",
+ "title_nl": "Softwarelandschap-register",
+ "area": "landscape",
+ "overlay": "software-landscape-register",
+ "specs": [
+ "view-enrichment-api",
+ "landscape-application-components",
+ "suite-wizard",
+ "application-page",
+ "application-hosting-model",
+ "landscape-change-entry-type",
+ "landscape-move-between-organisations",
+ "landscape-dependent-field-options",
+ "insight-knowledge-base",
+ "application-usage-pages",
+ "fe-object-modals"
+ ]
+ },
+ {
+ "slug": "landscape-import",
+ "title": "Importing an existing landscape",
+ "title_nl": "Een bestaand landschap importeren",
+ "area": "landscape",
+ "overlay": null,
+ "specs": [
+ "cmdb-export-import",
+ "first-time-setup"
+ ]
+ },
+ {
+ "slug": "data-quality",
+ "title": "Data quality and completeness",
+ "title_nl": "Datakwaliteit en volledigheid",
+ "area": "landscape",
+ "overlay": null,
+ "specs": [
+ "operations-record-reconciliation",
+ "landscape-owner-attestation",
+ "landscape-completeness-score"
+ ]
+ },
+ {
+ "slug": "ai-system-inventory",
+ "title": "AI system inventory and the AI Act",
+ "title_nl": "AI-systemen registreren en de AI-verordening",
+ "area": "landscape",
+ "overlay": null,
+ "specs": [
+ "ai-system-inventory"
+ ]
+ },
+ {
+ "slug": "offering-and-usage-listings",
+ "title": "Offering and usage listings",
+ "title_nl": "Aanbod- en gebruikslijsten",
+ "area": "market",
+ "overlay": "offering-and-usage-listings",
+ "specs": [
+ "aanbod-listings",
+ "gemma-faceted-search",
+ "open-data-publishing",
+ "application-page",
+ "aangeboden-gebruik-api"
+ ]
+ },
+ {
+ "slug": "application-connections",
+ "title": "Connections between applications",
+ "title_nl": "Koppelingen tussen applicaties",
+ "area": "connections",
+ "overlay": null,
+ "specs": [
+ "connections-api-catalogue",
+ "adopt-connection-registry",
+ "connections-derived-dependencies",
+ "connections-diagram-and-graph-export",
+ "catalogue-connection-pages"
+ ]
+ },
+ {
+ "slug": "shared-usage-on-gemma-views",
+ "title": "Shared usage on GEMMA views",
+ "title_nl": "Gedeeld gebruik op GEMMA-views",
+ "area": "market",
+ "overlay": "shared-usage-on-gemma-views",
+ "specs": [
+ "deelnames-gebruik",
+ "architecture-shared-usage-on-views"
+ ]
+ },
+ {
+ "slug": "archimate-import-and-export",
+ "title": "ArchiMate import and export",
+ "title_nl": "ArchiMate-import en -export",
+ "area": "architecture",
+ "overlay": "archimate-import-and-export",
+ "specs": [
+ "archimate-export",
+ "archimate-import",
+ "org-archimate-export",
+ "architecture-round-trip-check",
+ "architecture-views-to-office-documents",
+ "archimate-import-progress"
+ ]
+ },
+ {
+ "slug": "gemma-alignment",
+ "title": "GEMMA alignment",
+ "title_nl": "GEMMA-uitlijning",
+ "area": "architecture",
+ "overlay": "gemma-alignment",
+ "specs": [
+ "module-overlay-rendering",
+ "softwarecatalog-manifest-v1",
+ "architecture-reference-component-coverage",
+ "gemma-faceted-search",
+ "view-enrichment-api",
+ "architecture-data-model-and-ggm",
+ "insight-supplier-facet"
+ ]
+ },
+ {
+ "slug": "architecture-modelling",
+ "title": "Modelling architecture views",
+ "title_nl": "Architectuurplaten modelleren",
+ "area": "architecture",
+ "overlay": null,
+ "specs": [
+ "architecture-views-editor",
+ "architecture-process-mapping",
+ "architecture-future-state-scenarios",
+ "architecture-assistant-drafted-views",
+ "architecture-data-model-and-ggm",
+ "architecture-decision-register"
+ ]
+ },
+ {
+ "slug": "standards-compliance",
+ "title": "Standards compliance",
+ "title_nl": "Standaardencompliance",
+ "area": "compliance",
+ "overlay": "standards-compliance",
+ "specs": [
+ "module-compliance-assessment"
+ ]
+ },
+ {
+ "slug": "security-and-privacy-assessment",
+ "title": "BIO and privacy assessments",
+ "title_nl": "BIO- en privacybeoordelingen",
+ "area": "compliance",
+ "overlay": null,
+ "specs": [
+ "bio-compliance-assessment",
+ "security-baseline-classification",
+ "sharing-compliance-documents"
+ ]
+ },
+ {
+ "slug": "supplier-directory",
+ "title": "Supplier and product directory",
+ "title_nl": "Overzicht van leveranciers en producten",
+ "area": "market",
+ "overlay": null,
+ "specs": [
+ "fe-organizations",
+ "application-page",
+ "vendor-product-master-data"
+ ]
+ },
+ {
+ "slug": "reviews",
+ "title": "Reviews",
+ "title_nl": "Reviews",
+ "area": "market",
+ "overlay": "reviews",
+ "specs": [
+ "catalog-ratings"
+ ]
+ },
+ {
+ "slug": "maintenance-and-supplier-roadmap",
+ "title": "Maintenance and supplier roadmap",
+ "title_nl": "Onderhoud en leveranciersroadmap",
+ "area": "market",
+ "overlay": "maintenance-and-supplier-roadmap",
+ "specs": [
+ "maintenance-and-supplier-roadmap"
+ ]
+ },
+ {
+ "slug": "portfolio-roadmap",
+ "title": "Portfolio roadmap",
+ "title_nl": "Portfolio-roadmap",
+ "area": "lifecycle",
+ "overlay": "portfolio-roadmap",
+ "specs": [
+ "application-lifecycle-tracking"
+ ]
+ },
+ {
+ "slug": "lifecycle-and-end-of-support",
+ "title": "Lifecycle and end of support",
+ "title_nl": "Levenscyclus en einde-support",
+ "area": "lifecycle",
+ "overlay": "lifecycle-and-end-of-support",
+ "specs": [
+ "application-lifecycle-tracking",
+ "softwarecatalog-manifest-v1",
+ "eol-feed-integration",
+ "operations-technology-components"
+ ]
+ },
+ {
+ "slug": "portfolio-rationalisation",
+ "title": "Portfolio rationalisation",
+ "title_nl": "Portfoliorationalisatie",
+ "area": "lifecycle",
+ "overlay": null,
+ "specs": [
+ "architecture-reference-component-coverage",
+ "application-value-assessment",
+ "portfolio-rationalization-time"
+ ]
+ },
+ {
+ "slug": "contract-administration",
+ "title": "Contract administration",
+ "title_nl": "Contractadministratie",
+ "area": "contracts",
+ "overlay": "contract-administration",
+ "specs": [
+ "contract-administration",
+ "contracts-expiry-and-owner",
+ "application-page",
+ "portfolio-rationalization-time",
+ "software-license-posture"
+ ]
+ },
+ {
+ "slug": "contract-renewal-approval",
+ "title": "Contract renewal with approval",
+ "title_nl": "Contractverlenging met goedkeuring",
+ "area": "contracts",
+ "overlay": "contract-renewal-approval",
+ "specs": [
+ "contract-decision-delegation"
+ ]
+ },
+ {
+ "slug": "license-and-seat-tracking",
+ "title": "License and seat tracking",
+ "title_nl": "Licentie- en seatregistratie",
+ "area": "contracts",
+ "overlay": "license-and-seat-tracking",
+ "specs": [
+ "licence-seats",
+ "software-license-posture"
+ ]
+ },
+ {
+ "slug": "vulnerability-management",
+ "title": "Vulnerabilities and SBOMs",
+ "title_nl": "Kwetsbaarheden en SBOM's",
+ "area": "security",
+ "overlay": null,
+ "specs": [
+ "module-vulnerability-tracking",
+ "sbom-import"
+ ]
+ },
+ {
+ "slug": "sign-in-and-accounts",
+ "title": "Sign-in and accounts",
+ "title_nl": "Inloggen en accounts",
+ "area": "organisations",
+ "overlay": null,
+ "specs": [
+ "email-delivery"
+ ]
+ },
+ {
+ "slug": "organisation-register",
+ "title": "Organisation register",
+ "title_nl": "Organisatieregister",
+ "area": "organisations",
+ "overlay": null,
+ "specs": [
+ "fe-organizations",
+ "organisation-merge"
+ ]
+ },
+ {
+ "slug": "self-registration-with-moderation",
+ "title": "Self-registration with moderation",
+ "title_nl": "Zelfregistratie met moderatie",
+ "area": "organisations",
+ "overlay": "self-registration-with-moderation",
+ "specs": [
+ "open-data-publishing"
+ ]
+ },
+ {
+ "slug": "automatic-user-provisioning",
+ "title": "Automatic user provisioning",
+ "title_nl": "Automatische gebruikersinrichting",
+ "area": "organisations",
+ "overlay": "automatic-user-provisioning",
+ "specs": [
+ "softwarecatalog-contacts-to-nc",
+ "contactpersoon-sync",
+ "sc-handlers",
+ "operations-sync-status-and-progress",
+ "contactpersonen-api",
+ "organization-sync",
+ "softwarecatalogue-orchestration"
+ ]
+ },
+ {
+ "slug": "roles-and-access",
+ "title": "Roles and access rights",
+ "title_nl": "Rollen en toegangsrechten",
+ "area": "organisations",
+ "overlay": null,
+ "specs": [
+ "multi-org-membership",
+ "organisations-role-mapping-and-access-review",
+ "publication-field-rules",
+ "vendor-visibility-rbac"
+ ]
+ },
+ {
+ "slug": "open-data-publishing",
+ "title": "Publish as open data",
+ "title_nl": "Open data publiceren",
+ "area": "sharing",
+ "overlay": "open-data-publishing",
+ "specs": [
+ "open-data-publishing"
+ ]
+ },
+ {
+ "slug": "federation-between-catalogues",
+ "title": "Federation between catalogues",
+ "title_nl": "Federatie tussen catalogi",
+ "area": "sharing",
+ "overlay": "federation-between-catalogues",
+ "specs": [
+ "federated-catalog-sync"
+ ]
+ },
+ {
+ "slug": "api-and-integrations",
+ "title": "API and integrations",
+ "title_nl": "API en koppelingen",
+ "area": "sharing",
+ "overlay": null,
+ "specs": [
+ "sharing-generated-api-docs",
+ "sharing-itsm-exchange",
+ "portal-contribution"
+ ]
+ },
+ {
+ "slug": "portfolio-reporting",
+ "title": "Configurable portfolio reporting",
+ "title_nl": "Instelbare portfoliorapportage",
+ "area": "insight",
+ "overlay": "portfolio-reporting",
+ "specs": [
+ "insight-exports-and-custom-reports",
+ "portfolio-rationalization-time"
+ ]
+ },
+ {
+ "slug": "dashboard-and-overviews",
+ "title": "Dashboard and live overviews",
+ "title_nl": "Dashboard en actuele overzichten",
+ "area": "insight",
+ "overlay": null,
+ "specs": [
+ "fe-shell-navigation",
+ "concept-organizations-widget",
+ "operations-sync-status-and-progress",
+ "dashboard-views-api",
+ "realtime-updates-ui"
+ ]
+ },
+ {
+ "slug": "ai-assistant",
+ "title": "AI assistant for the catalogue",
+ "title_nl": "AI-assistent voor de catalogus",
+ "area": "insight",
+ "overlay": null,
+ "specs": [
+ "mcp-full-action-surface",
+ "insight-assistant-answers-and-action-trail"
+ ]
+ },
+ {
+ "slug": "audit-trail-view",
+ "title": "Audit trail view",
+ "title_nl": "Auditspoor-overzicht",
+ "area": "insight",
+ "overlay": "audit-trail-view",
+ "specs": [
+ "softwarecatalog-manifest-v1"
+ ]
+ },
+ {
+ "slug": "notifications",
+ "title": "Notifications",
+ "title_nl": "Meldingen",
+ "area": "lifecycle",
+ "overlay": "notifications",
+ "specs": [
+ "softwarecatalog-notifications",
+ "contracts-expiry-warning-fires",
+ "maintenance-and-supplier-roadmap"
+ ]
+ },
+ {
+ "slug": "asset-discovery",
+ "title": "Discovering hardware and software",
+ "title_nl": "Hardware en software automatisch ontdekken",
+ "area": "operations",
+ "overlay": null,
+ "specs": [
+ "operations-technology-components"
+ ]
+ },
+ {
+ "slug": "service-management",
+ "title": "Incidents, changes and service levels",
+ "title_nl": "Incidenten, wijzigingen en serviceniveaus",
+ "area": "operations",
+ "overlay": null,
+ "specs": []
+ },
+ {
+ "slug": "open-platform",
+ "title": "Self-hosted and adaptable",
+ "title_nl": "Zelf gehost en aanpasbaar",
+ "area": "operations",
+ "overlay": null,
+ "specs": [
+ "fe-settings-ui"
+ ]
+ }
+ ],
+ "internalSpecs": [
+ "app-id-rename",
+ "beta-alignment",
+ "cronjob-context",
+ "english-vocabulary-migration",
+ "fe-stores",
+ "gebruik-services",
+ "licence-declaration",
+ "method-decomposition",
+ "organisatie-service",
+ "progress-tracking",
+ "repair-init",
+ "settings-admin-controller",
+ "settings-service",
+ "software-catalog",
+ "softwarecatalog-adopt-or-abstractions",
+ "softwarecatalog-store-migration"
+ ],
"providers": [
{
"key": "stackiq",
@@ -384,6 +855,13 @@
"glpi": "source read at 11.0.9: src/Appliance.php:46 class Appliance is GLPI's application itemtype; install/mysql/glpi-empty.sql:8935 glpi_appliances carries name, comment (description), manufacturers_id and states_id (status); src/Appliance.php:350 search option Status; supplier through the Management tab Infocom (install/mysql/glpi-empty.sql:3263 glpi_infocoms.suppliers_id) and Contract_Item (src/Appliance.php:99); menu src/Html.php:1300 lists Appliance under Management, served by src/Glpi/Kernel/Listener/RequestListener/LegacyItemtypeRouteListener.php:100. Reached on: Management > Appliances (front/appliance.php). Driven on the lab at 11.0.9 (2026-09-26): created the appliance \"Zaaksysteem lab\" with a description through /front/appliance.form.php; it appears in the Appliances list and CSV export.",
"topdesk": "https://docs.topdesk.com/en/migrating-objects-to-asset-management.html: \"In the new Asset Management you design your own template for each type of asset you have\" (read 2026-09-26); https://docs.topdesk.com/en/managing-licences-in-asset-management.html: \"Create a new template for software cards\" (read 2026-09-26). Applications are a self-designed asset type, no application model ships. Reached on: Modules > Asset Management > Template Designer / Asset overview > New.",
"stackiq": "usage schema (consumer, module, moduleVersion, status with lifecycle plan/goLive/phaseOut/retire) in lib/Settings/softwarecatalogus_register.json; src/manifest.d/usages.json pages Gebruik (/gebruik, Applications in use) and GebruikDetail with lifecycleActions; ModuleDetail md-usages Add to our landscape; OrganisatieDetail org-usages; tests/Unit/Settings/UsageSchemaTest.php, tests/vitest/usages.spec.js"
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkInGebruik"
+ ]
}
},
{
@@ -407,7 +885,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "software-landscape-register",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "Stackiq's 'module' is the whole application, so there is no breakdown of one application into modules. The nearest thing is a suite (product) listing its applications, which answers 'which module belongs to which product' but not the decomposition. Specified in openspec/changes/landscape-application-components (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-modeling-guidelines: 'Applications often consist of multiple entities or modules within a common ecosystem or platform', modeled as parent/child hierarchy, e.g. Adobe Photoshop as child of Adobe Creative Cloud (read 2026-09-26). Reached on: Application fact sheet > parent/child relations.",
@@ -416,6 +894,13 @@
"vng-softwarecatalogus": "unknown: the docs model a pakket and its pakketversies only, no module level is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Beschrijving%20exportbestanden (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967518-grouping-and-child-objects-in-architecture-views: 'The child objects option allows you to define hierarchical relationships between objects based on the composition relationship type ... breaking down a system into its components' (read 2026-09-26). Reached on: View > object context menu > Child objects.",
"glpi": "source read at 11.0.9: src/Appliance_Item.php:45 links an Appliance to member items; src/autoload/CFG_GLPI.php:562 appliance_types includes Software, Appliance, Database and DatabaseInstance, so an application can be split into software and sub-appliances on its Items tab (src/Appliance.php:98). There is no module entity that belongs to a supplier product: grep -i 'module' src/Software.php src/Appliance.php returns no module concept. Reached on: Management > Appliances > Items tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkSuiteWizard"
+ ]
}
},
{
@@ -439,7 +924,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "lifecycle-and-end-of-support",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A version is created on the Module versions index with its module and date in use, and opens on its own detail page.",
"evidence": {
"glpi": "source read at 11.0.9: src/SoftwareVersion.php:42 SoftwareVersion child of Software, table install/mysql/glpi-empty.sql:6900 glpi_softwareversions with name, states_id, operatingsystems_id but no release or in-use date; the only date is per installation, install/mysql/glpi-empty.sql:1074 glpi_items_softwareversions.date_install. Reached on: Assets > Software > Versions tab (front/softwareversion.form.php).",
@@ -448,6 +933,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier: \"Titel is de naam van uw productversie ... Status geeft aan of uw product in ontwikkeling, in productie, of teruggetrokken is ... startdata van ontwikkeling, test en distributie\" (read 2026-09-26). Versions are recorded per package (pakketversie), not per module. Reached on: Supplier login > Productportfolio > product > plus (versie toevoegen).",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ (267 articles); versions appear only as a field in an example import source (https://help.bluedolphin.io/en/articles/11967767-uploading-a-source), no version records with dates are documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-modeling-guidelines: 'if versioning is relevant ... you can capture it in the Release field of the application fact sheets'; each fact sheet carries lifecycle phase dates, but the guide says 'versioning for applications doesn't add significant value' (read 2026-09-26). Reached on: Application fact sheet > Name and Description > Release."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModuleversie"
+ ]
}
},
{
@@ -471,7 +962,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "software-landscape-register",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A three-step wizard bundles existing applications into one suite. Caveat: a row click on the Suites list only toggles selection (the library returns before emitting row-click when selectable, SuitesIndexView.vue:35), so SuiteDetail is not opened from the list, and its data widget includes stale field names (src/manifest.json:683 beschrijvingKort, contactpersoon).",
"evidence": {
"stackiq": "src/dialogs/SuiteWizardDialog.vue:183 onSubmit saves a suite via objectStore.saveObject('suite') with applications[] (src/utils/suiteWizard.js:44 requires at least one); mounted by src/views/suites/SuitesIndexView.vue:46 on manifest page Suites (src/manifest.json:666)",
@@ -480,6 +971,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967518-grouping-and-child-objects-in-architecture-views: child objects by composition and an ArchiMate 'Grouping' object 'to cluster related elements logically'. Modeling structure only, not a product offered to others (read 2026-09-26). Reached on: View > Child objects or Grouping.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-modeling-guidelines: 'Adobe Creative Cloud is a suite that bundles various applications ... It is modeled as the parent entity'; a platform fact sheet can group applications. Modeled for the buyer's own inventory, not as a product offered to others (read 2026-09-26). Reached on: Application fact sheet hierarchy, Platform fact sheet.",
"glpi": "source read at 11.0.9: src/autoload/CFG_GLPI.php:562 appliance_types contains Appliance and Software, so an Appliance can bundle existing appliances and software through src/Appliance_Item.php:45 (table install/mysql/glpi-empty.sql:8979 glpi_appliances_items). There is no notion of offering the bundle as a product to others. Reached on: Management > Appliances > Items tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkSuiteWizard"
+ ]
}
},
{
@@ -503,7 +1000,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "offering-and-usage-listings",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A supplier's service over one or more applications is registered on the Services page. There is no 'hosting' service type (technical management is the nearest), and services have no detail page, so a row cannot be opened.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/pakketversies: facet \"Ondersteunde technologie: On-premise, Dienst - Software as a Service (SAAS)\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/30355: \"Onder het tabblad Technologie selecteer je de onderliggende technieken van het pakket. Veelal Saas of on-premise\" (read 2026-09-26). Hosting as SaaS is a property of a package version, no separate service record (hosting, support) is documented. Reached on: Alle pakketversies > filter Ondersteunde technologie.",
@@ -512,6 +1009,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967596-archimate-annotation-translation-en-nl lists ArchiMate object definitions 'Business Service', 'Technology Service' and 'Contract', which can model a hosting or support service; no supplier service register as such is documented (read 2026-09-26). Reached on: Objects > Technology Service object.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/it-component-modeling-guidelines: IT component subtype 'Service : Services refer to the provisioning of services related to IT components (usually provided by a 3rd party) ... Examples: Maintenance/Support Service ... Hosting Service'; linked to providers (read 2026-09-26). Reached on: Inventory > IT Component fact sheet, subtype Service.",
"glpi": "source read at 11.0.9: no supplier service itemtype; services are held as contracts, src/ContractType.php:37 admin dropdown of contract types (for example hosting or support), install/mysql/glpi-empty.sql:1550 glpi_contracts_suppliers ties the contract to a Supplier and install/mysql/glpi-empty.sql:1536 glpi_contracts_items ties it to the Appliance or Software it covers. Reached on: Management > Contracts (front/contract.php), Suppliers tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -533,6 +1036,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"note": "A sector schema exists, but no application, service or organisation can be tagged with a sector and no page lists sectors. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: aanvullende-informatie.md:511 lists VNG issue #4 (classify packages on the reference architectures of the relevant sectors, so buyers from several sectors find them) with the analysis \"Classificeren op meerdere sectorale referentiearchitecturen, buiten scope\", and issues/4.md carries the label \"Buiten scope oplevering\" and is closed. Tagging applications with sectors is the entry to that out-of-scope classification. The unused sector schema (lib/Settings/softwarecatalogus_register.json:1036) is what the specified state pointed at; no change directory existed.",
"evidence": {
"stackiq": "register.json:1034 sector schema (name, description only); no schema property references #/components/schemas/sector (grep found none), no manifest page for sector; only the admin schema mapping in src/views/settings/sections/OpenRegisterIntegration.vue:395",
@@ -541,6 +1045,10 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967603-manage-object-questionnaires: 'A questionnaire is a group of fields that add more details to objects' configured by admins, which can hold a sector field; no sector list is shipped (read 2026-09-26). Reached on: Admin > Object questionnaires.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/tags-and-custom-fields: tags and tag groups for 'quick, high-level classification', filterable and usable as reporting views; no predefined government sector list is documented (read 2026-09-26). Reached on: Fact sheet > Tags.",
"glpi": "source read at 11.0.9: no government sector concept, grep -i 'sector' over src/*.php and locales/glpi.pot hits only menu sectorization (src/Html.php:1019); the nearest holder is the single-valued Appliance type dropdown install/mysql/glpi-empty.sql:8941 appliancetypes_id, or an admin custom dropdown (install/mysql/glpi-empty.sql:10113 glpi_dropdowns_dropdowndefinitions) used as a field of a custom asset. Multi-valued tagging needs the separate tag plugin (github.com/pluginsGLPI/tag, not read). Reached on: Management > Appliances, Appliance type field."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -564,7 +1072,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "software-landscape-register",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "One contact person per application can be set, but there is no separate business owner and technical owner. ModuleDetail's data widget includes 'contactpersoon', a key the schema no longer has, so the contact may not show there. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-landscape-usage-registration.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/subscription-roles: 'Define roles that map to your organization's positions, such as application owner', with subscription types 'Responsible, Accountable, Observer' per fact sheet (read 2026-09-26). Reached on: Fact sheet > Subscriptions; Administration > Subscription Roles.",
@@ -573,6 +1081,13 @@
"vng-softwarecatalogus": "unknown: the landscape entry fields listed (pakketversie, referentiecomponenten, technologie, status) include no business or technical owner; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/node/30355 (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967633-datacollector-select-tricks: an example import maps '[Application Owner]' into object properties; ownership otherwise is modeled as questionnaire fields or ArchiMate relations. No built in business and technical owner fields are documented (read 2026-09-26). Reached on: Object properties or questionnaire fields.",
"glpi": "source read at 11.0.9: install/mysql/glpi-empty.sql:8935 glpi_appliances holds users_id and users_id_tech; src/Appliance.php:186 search option User and src/Appliance.php:195 Group (business side), src/Appliance.php:240 'Technician in charge' and the 'Group in charge' option a few lines below (technical side). Reached on: Management > Appliances, form fields User, Group, Technician in charge, Group in charge. Driven on the lab at 11.0.9 (2026-09-26): the saved appliance holds users_id and users_id_tech (glpi_appliances row 1), shown as User and Technician in charge."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInGebruik",
+ "SkGebruikDetail"
+ ]
}
},
{
@@ -596,6 +1111,7 @@
"reachedOn": "nothing reaches it",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"note": "Stackiq offers no way to add fields. Editing the schema in OpenRegister's own admin UI is possible there, but that is an OpenRegister feature, not a stackiq page. Specified in ConductionNL/openregister openspec/changes/fields-a-user-adds-and-choices-a-record-narrows (openregister lane, OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/fact-sheet-fields: 'Adding a Custom Field ... To add a custom field, follow these steps'; https://help.sap.com/docs/leanix/ea/tags-and-custom-fields: custom fields as text, dates, numbers, set in the meta model configuration by an admin (read 2026-09-26). Reached on: Administration > Meta Model Configuration > Fact Sheet Fields.",
@@ -604,6 +1120,12 @@
"topdesk": "https://docs.topdesk.com/en/creating-new-fields.html: \"Whether it is a contact person, a purchase price, or a reminder date ... Use fields in a fieldset or dataset widget to register any useful information about an asset\" (read 2026-09-26). Reached on: Asset Management > Template Designer > Fields.",
"vng-softwarecatalogus": "unknown: no user-defined fields are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967603-manage-object-questionnaires: 'it is possible to add questionnaires to an object type in the Admin module. A questionnaire is a group of fields that add more details to objects' (read 2026-09-26). Reached on: Admin > Object questionnaires."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -626,6 +1148,7 @@
"reachedOn": "Modules /modules, Import in the index actions (also every type:index page)",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "landscape-import",
"note": "The generic index page offers a file import (CSV per schema, or a register-wide JSON/Excel) that OpenRegister executes. Nothing stackiq-specific maps a spreadsheet's supplier names to organisation references, so relation columns must already hold identifiers.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/importing-fact-sheet-data-through-excel-file and https://help.sap.com/docs/leanix/ea/adding-and-editing-data-in-fact-sheets: 'using the import option, you can update multiple fact sheets in bulk' via Excel export and import (read 2026-09-26). Reached on: Inventory > Import (Excel).",
@@ -634,6 +1157,12 @@
"topdesk": "https://docs.topdesk.com/en/generate-import-file.html: \"Importing assets speeds up this task ... export an asset template to XLSX format\" (read 2026-09-26); https://docs.topdesk.com/en/creating-a-new-import.html: \"You can use a file (CSV or XLSX), connect with an MS SQL database or import from Microsoft Intune , or Lansweeper\" (read 2026-09-26). Reached on: Settings > Import settings > Asset Management imports.",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C19: \"Een import vanuit die tools naar de Softwarecatalogus zodat 2-richtingverkeer mogelijk wordt voor actualisatie, is vooralsnog niet voorhanden\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/19703: inventory goes in a spreadsheet, then \"Kies met de + toets het pakket dat opgevoerd moet worden\", one package at a time (read 2026-09-26)",
"glpi": "source read at 11.0.9: core has CSV export only (src/Glpi/Csv/ holds CsvResponse and export classes, no importer) and no spreadsheet import of assets or appliances. Bulk import is the separate datainjection plugin, read at pluginsGLPI/datainjection tag 2.15.11: inc/backendcsv.class.php CSV backend and inc/applianceinjection.class.php:33 PluginDatainjectionApplianceInjection extends Appliance; setup.php:36 requires GLPI 11.0.5 or later. Reached on: plugin Data injection (Tools > Data injection)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -657,7 +1186,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "software-landscape-register",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "One page per application shows its data, versions, usages, compliance claims and the contracts behind it, and opens from the Applications list. Built by openspec/changes/archive/2026-09-29-landscape-application-page.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/pakket/archi: package page shows versions with status and start dates, \"Pakket geschikt voor (GEMMA 2) Ingevuld door (28)\", and per version the mandatory and recommended standards with support, compliancy and testrapport (read 2026-09-26). No contracts on the page. Reached on: Alle pakketten > package name.",
@@ -666,6 +1195,12 @@
"topdesk": "https://docs.topdesk.com/en/designing-templates-for-assets.html: twelve widgets incl. \"History\", \"Relationships\", \"Relationship grid\", \"Documents\" (read 2026-09-26); https://docs.topdesk.com/en/linking-assets-to-cards.html: assets linked to calls, changes, services (read 2026-09-26). Versions and compliance are not part of it. Reached on: Asset card.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967521-object-viewer: 'shows all the object properties like title, name, or lifecycle state. Also, you can see the relationships, history, objects being used in other views, or questionnaires' (read 2026-09-26). Reached on: Objects > object properties / Objectviewer.",
"glpi": "source read at 11.0.9: src/Appliance.php:98 onwards defineTabs puts Items, Contracts, Documents, Management (Infocom), Certificates, Domains, Knowledge base, Tickets, Problems, Changes and Impact on the one appliance page; versions sit on the separate Software page (src/SoftwareVersion.php:42), and no compliance tab exists (grep -i 'complian' src/Appliance.php returns nothing). Reached on: Management > Appliances > appliance form tabs."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -689,7 +1224,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "open-data-publishing",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "Many entries can be selected and deleted together through the library index page. The mass publish and mass lock dialogs exist but hang off a view modal no page opens, and the publish endpoint (lib/Controller/PublicationController.php, PUT /api/publication/...) has no frontend caller.",
"evidence": {
"stackiq": "Library CnIndexPage mass delete/copy/export (showMassDelete default true, CnIndexPage.vue:1617; selfModeActions.js:63 handleMassDelete) on index pages; stackiq's MassPublishObjects/MassLockObjects dialogs (src/dialogs/Dialogs.vue:8-14) are only opened from src/modals/object/ViewObject.vue:4865, and ViewObject is only mounted for modals 'viewOrganisatie'/'viewContactpersoon' (src/modals/Modals.vue:7,19) which nothing in src/ sets",
@@ -698,6 +1233,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967533-edit-multiple-objects: 'edit multiple objects at the same time'; the API offers 'Delete multiple objects' (https://help.bluedolphin.io/en/articles/11967756-delete-multiple-objects); no publish or lock action (read 2026-09-26). Reached on: Objects > Edit selected objects.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/adding-and-editing-data-in-fact-sheets: 'switch to table view in the inventory, you can perform inline editing across multiple fact sheets'; https://help.sap.com/docs/leanix/ea/archiving-deleting-and-recovering-fact-sheets: 'Archiving Fact Sheets in Bulk' through an Excel action column. No publish or lock action is documented (read 2026-09-26). Reached on: Inventory > Table view; Excel import with action column.",
"glpi": "source read at 11.0.9: every search list offers massive actions, src/MassiveAction.php:666 Update (any field, for example status), src/MassiveAction.php:698 Put in trashbin, src/MassiveAction.php:652 Delete permanently, src/MassiveAction.php:656 Restore. There is no publish state; locking applies to inventory fields only. Reached on: any list, for example Management > Appliances, Actions button. Driven on the lab at 11.0.9 (2026-09-26): the Appliances list carries the massive actions control."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -720,6 +1261,7 @@
"reachedOn": "OrganisatieDetail /organisaties/:id, Merge panel (admins only)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "data-quality",
"note": "Only organisations can be merged, with a dry run first, and only by a Nextcloud admin. Applications, services and other entries have no merge. Specified in openspec/changes/operations-record-reconciliation (OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "src/components/organisations/OrganisationMergePanel.vue:46 (admin-only controls) mounted as OrganisatieDetail bodyWidget org-merge (src/manifest.json:430); lib/Controller/MergeController.php:106 execute with isAdmin check at :142; generic src/modals/object/MergeObject.vue only mounted for modal 'mergeOrganisatie' which nothing sets",
@@ -728,6 +1270,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; imports merge source records that share a key into one object (https://help.bluedolphin.io/en/articles/11967635-four-things-you-need-to-know-before-you-start-importing-sources), but merging two existing entries by a user is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea (all 663 EA pages grepped for merge); duplicates are handled by archiving (https://help.sap.com/docs/leanix/ea/archiving-deleting-and-recovering-fact-sheets 'removing outdated or duplicate fact sheets'), no merge of two fact sheets is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: src/Software.php:109 'Merging' tab on a recursive software, src/Software.php:926 showMergeCandidates lists same named software, src/Software.php:997 massive action Merge, src/Software.php:1011 private function merge moves versions and licences into the kept entry; dropdowns get Replace, src/CommonDropdown.php:680. Reached on: Assets > Software > Merging tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDatakwaliteit",
+ "SkOrganisatie"
+ ]
}
},
{
@@ -750,6 +1299,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "data-quality",
"note": "Nothing asks owners to confirm or correct their entries. Specified in openspec/changes/landscape-owner-attestation (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/reviewing-responses: 'Review and approve survey responses before they are saved to fact sheets'; https://help.sap.com/docs/leanix/ea/application-modernization-collect-data: 'Create a new survey to get key information from application or business owners' (read 2026-09-26). Reached on: Surveys.",
@@ -758,6 +1308,12 @@
"topdesk": "unknown: Survey Management runs general surveys (and \"will reach end of life ... November 2026\"), not confirmation of entries by owners; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C7: \"Leveranciers krijgen ook periodiek notificatiemails met een aantal automatische controles en de Te corrigeren fouten staan ook op het dashboard van ingelogde leveranciers\" (read 2026-09-26); https://www.softwarecatalogus.nl/suggesties_overnemen: suppliers send suggestions that municipalities accept or decline (read 2026-09-26). No survey to application owners.",
"glpi": "source read at 11.0.9: the native form builder (src/Glpi/Form/Form.php:92) only has ITIL destinations, src/Glpi/Form/Destination/ holds FormDestinationTicket, FormDestinationChange and FormDestinationProblem, so a form answer opens a ticket but never confirms or updates an appliance record; the only 'survey' in core is ticket satisfaction (src/Central.php:220). No owner review campaign exists."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDatakwaliteit"
+ ]
}
},
{
@@ -780,6 +1336,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "data-quality",
"note": "No page scores how complete or current an entry is. Specified in openspec/changes/landscape-completeness-score (OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "No completeness or data-quality score in lib/ or src/ (searched completeness/volledigheid/score outside reviews)",
@@ -788,6 +1345,13 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967713-governance-insights: governance report 'Object completeness : Lists all objects and the completeness score of each object' (read 2026-09-26). Reached on: Insights > Governance reports > Object completeness.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/fact-sheet-completeness: 'The fact sheet completion score measures how much of the required data has been filled out for a fact sheet ... You can view the fact sheet completion score in the fact sheet's header' (read 2026-09-26). Reached on: Fact sheet header > completion score.",
"glpi": "source read at 11.0.9: grep -ril 'completeness' over src/ templates/ locales/glpi.pot returns nothing; no score of how filled in an item is exists in core. The item form tabs (src/Appliance.php:98 onwards) show no score."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkDatakwaliteit"
+ ]
}
},
{
@@ -811,6 +1375,7 @@
"reachedOn": "First-run setup wizard (admin), step 'Load example data'",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "landscape-import",
"note": "An admin picks the demo dataset in the setup wizard and it is imported through OpenRegister. Admin-only, which suits an installation task.",
"evidence": {
"stackiq": "src/manifest.json:3 setup wizard step load-demo-data (:24); lib/Controller/SetupController.php:202 runAction load-demo-data (AuthorizedAdminSetting); lib/Service/DemoDataService.php:202 install() imports lib/Settings/stackiq_mock_register.json (120 objects, 6 per schema) through OpenRegister ConfigurationService::importFromApp",
@@ -819,6 +1384,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967592-download-and-update-the-templates-in-the-library: 'A quick way of getting started with BlueDolphin and discovering its possibilities is by downloading a standard template'; standard templates are 'Preconfigured BlueDolphin sites that can contain objects, views, questionnaires' (https://help.bluedolphin.io/en/articles/11967590-introduction-to-templates) (read 2026-09-26). Reached on: Admin > Templates > Library > Standard templates.",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; demo and sandbox workspaces are mentioned (https://help.sap.com/docs/leanix/ea/automations 'Demo and sandbox workspaces allow up to 10,000 automations per month') but loading an example data set into a workspace is not described (read 2026-09-26)",
"glpi": "source read at 11.0.9: on a clean install dashboards render placeholder figures from src/Glpi/Dashboard/FakeProvider.php:65 FakeProvider, with the banner 'You are viewing demonstration data.' and a 'Disable demonstration' button (src/Glpi/Dashboard/Grid.php:427, :428, :448); CHANGELOG.md 11.0.0 lists it. No example records (applications, contracts, suppliers) are loaded; the console entry point src/Glpi/Console/Application.php:66 has no demo data command. Reached on: Home > Dashboard on a fresh install. Driven on the lab at 11.0.9 (2026-09-26): glpi_configs.is_demo_dashboards is 1 on the fresh install, so the dashboard cards come from FakeProvider until an administrator disables the demonstration."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -841,6 +1412,7 @@
"reachedOn": "Suites /suites, New suite wizard only",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"note": "Only suites get a step-by-step wizard. Applications and services are added through a plain form, and connections have no page at all.",
"evidence": {
"stackiq": "src/dialogs/SuiteWizardDialog.vue (3 steps) is the only wizard, opened from src/views/suites/SuitesIndexView.vue:46; applications, services and connections use the bare CnIndexPage form (FacetedCatalogIndexView.vue:108) or nothing",
@@ -849,6 +1421,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; creating an object is a form (https://help.bluedolphin.io/en/articles/11967530-create-a-new-object), and the survey setup has three steps, but a step by step wizard for adding an application is not documented (read 2026-09-26)",
"sap-leanix": "unknown: https://help.sap.com/docs/leanix/ea/creating-fact-sheets describes creating a fact sheet in the inventory with reference catalog suggestions and duplicate hints, not a step by step wizard; wizards are documented only for KPI and integration mapping configuration (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -ril 'wizard' over src/ templates/ locales/glpi.pot returns nothing for item creation; appliances are added through the plain form only (install/mysql/glpi-empty.sql:8935 glpi_appliances has no is_template column)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkSuiteWizard"
+ ]
}
},
{
@@ -871,6 +1449,7 @@
"reachedOn": "ModuleversieDetail /moduleversies/:id, sidebar tab Components",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "A module version's page lists the third-party components imported from its SBOM.",
"evidence": {
"stackiq": "src/components/sbom/SbomComponentsPanel.vue lists sbomComponent rows (name/version/purl/licenses) as the ModuleversieDetail 'Components' sidebar tab (src/manifest.json:961); register.json:7920 sbomComponent schema",
@@ -879,6 +1458,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SBOM and bill of materials, no hits (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/uploading-sboms-from-fact-sheets: 'Upload SBOM files directly from a microservice fact sheet'; https://help.sap.com/docs/leanix/ea/tech-stack-discovery-from-sboms: 'the system automatically analyzes the SBOM components and builds a structured view of your technology stack'. Part of SAP LeanIX Technology Risk and Compliance (read 2026-09-26). Reached on: Microservice fact sheet > SBOM (Technology Risk and Compliance).",
"glpi": "source read at 11.0.9: grep -ril 'sbom\\|cyclonedx\\|spdx' over src/ templates/ locales/glpi.pot returns nothing; software versions carry no component list (install/mysql/glpi-empty.sql:6900 glpi_softwareversions)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModuleversie"
+ ]
}
},
{
@@ -902,7 +1487,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "software-landscape-register",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The application form records on-premises, IaaS, PaaS or SaaS plus hosting location and jurisdiction. There is no field naming the hosting party itself.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/applications-in-reference-catalog lists application fields 'Hosting Type' and 'Hosting Description'; https://help.sap.com/docs/leanix/ea/configuring-kpis filters applications on 'Hosting Type = \"Cloud\"'; IT component subtypes SaaS, IaaS, PaaS, Hardware (read 2026-09-26). Reached on: Application fact sheet > Hosting Type.",
@@ -911,6 +1496,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/30355: \"Indien een leverancier zowel SaaS als On premise ondersteunt, kan je aangeven welke van deze twee varianten gebruikt wordt binnen de gemeente\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Pakketten > toevoegen > tabblad Technologie.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967633-datacollector-select-tricks: an example import carries '[Hosting Type]' into object properties; hosting is otherwise modeled with ArchiMate technology objects. No built in hosting field is documented (read 2026-09-26). Reached on: Object properties or questionnaire field.",
"glpi": "source read at 11.0.9: no hosting model field; the closest holders are the admin editable Environment dropdown on an appliance, src/Appliance.php:277 ApplianceEnvironment search option (install/mysql/glpi-empty.sql:8945 applianceenvironments_id), plus locations_id and the Appliance type dropdown. grep -i 'saas' over src/ finds nothing; 'On-premise' in locales/glpi.pot:12997 is only an icon label. Reached on: Management > Appliances, Environment field."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -933,8 +1524,8 @@
"reachedOn": "API only: OpenRegister objects API",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "software-landscape-register",
- "featureConfidence": "low",
+ "feature": "application-connections",
+ "featureConfidence": "medium",
"note": "The field for a national provision exists on the connection schema, but with no connection page nobody can fill it in stackiq. Specified in openspec/changes/connections-catalogue-pages (OpenSpec pass 2026-09-27). The provision picker now finds the GEMMA provisions, and the connection page shows and edits the field (built by openspec/changes/archive/2026-09-29-connections-catalogue-pages).",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Opvoeren%20koppeling%20iJw%20en%20iWmo: \"De richting van het berichtenverkeer, de landelijke voorziening waarmee gekoppeld is/wordt, in dit geval GGK\" (read 2026-09-26); https://www.softwarecatalogus.nl/hoe-werkt-de-catalogus: \"kunnen koppelingen tussen applicaties onderling en met Landelijke Voorzieningen vastgelegd worden\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Koppelingen > koppeling toevoegen.",
@@ -943,6 +1534,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; connections are generic ArchiMate relationships between objects (https://help.bluedolphin.io/en/articles/11967550-working-with-object-relationships), national provisions such as basisregistraties are not mentioned (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-modeling-guidelines: 'Modeling External Applications ... applications of business partners, service providers, authorities', linked 'through an interface to analyze dependencies'. Generic external application modeling, no catalogue of national provisions such as basisregistraties (read 2026-09-26). Reached on: Application fact sheet (external) + Interface fact sheet.",
"glpi": "source read at 11.0.9: no national provision concept (grep -ril 'basisregistratie' src/ locales/glpi.pot returns nothing); a provision can be held as another Appliance and linked through an impact relation, install/mysql/glpi-empty.sql:1247 glpi_impactrelations (source item, impacted item, name), with Appliance enabled for impact at src/autoload/CFG_GLPI.php:649. Reached on: Appliance > Impact analysis tab, Add relation."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKoppelingen"
+ ]
}
},
{
@@ -965,7 +1562,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "software-landscape-register",
+ "feature": "application-connections",
"featureConfidence": "medium",
"note": "Connections have a list under Applications, Connections, and a page per connection with its lifecycle actions. Built by openspec/changes/archive/2026-09-29-connections-catalogue-pages.",
"evidence": {
@@ -975,6 +1572,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967713-governance-insights: governance report 'Objects and relationships : Lists all relations between two objects with relevant information like relation definition, related object title', with clickable object titles. A report, not a dedicated connections page (read 2026-09-26). Reached on: Insights > Governance reports > Objects and relationships.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/interface-modeling-guidelines: 'Interfaces are connections between applications that illustrate how data exchange occurs', each a fact sheet listed in the inventory by fact sheet type (read 2026-09-26). Reached on: Inventory > filter fact sheet type Interface.",
"glpi": "source read at 11.0.9: src/ImpactRelation.php:39 ImpactRelation has only prepareInputForAdd and getIDFromInput (src/ImpactRelation.php:47, :107), no search options and no list page; relations are only seen per item in the Impact analysis tab (src/Impact.php:91). Driven on the lab at 11.0.9 (2026-09-26): /front/impactrelation.php opens by URL only (no menu entry) as a generic list whose only criterion and column is ID, so relations cannot be browsed by endpoint; rating unchanged."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKoppelingen"
+ ]
}
},
{
@@ -997,8 +1600,8 @@
"reachedOn": "ModuleDetail /modules/:id, Related panel (untyped); API /api/koppelingen-gebruik/{uuid}",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "software-landscape-register",
- "featureConfidence": "low",
+ "feature": "application-connections",
+ "featureConfidence": "medium",
"note": "The application page lists the connections that start at the application and the ones that end there, each opening the connection. Built by openspec/changes/archive/2026-09-29-connections-catalogue-pages.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Mijn pakketoverzicht ... Onder het eerste tabblad zitten de pakketten en onder het tweede tabblad de koppelingen\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/30890: \"Door op een applicatienaam in het Models venster te klikken, zie je in de Visualiser alle koppelingen tussen die applicatie met andere applicaties\" (in Archi after export) (read 2026-09-26). No per-application connection view inside the catalogue is described. Reached on: Mijn softwarecatalogus > Koppelingen.",
@@ -1007,6 +1610,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967550-working-with-object-relationships: 'select the Objects and go to the Relationships tab ... View the current relationships of an object' (read 2026-09-26). Reached on: Objects > object > Relationships tab.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/circle-map-report: the Interface Circle Map 'helps you track changes and updates in application dependencies', and https://help.sap.com/docs/leanix/ea/adding-and-editing-data-in-fact-sheets: relations incl. interfaces shown on the fact sheet and in the Relations Explorer (read 2026-09-26). Reached on: Application fact sheet > Relations (Provided and Consumed Interfaces).",
"glpi": "source read at 11.0.9: src/Impact.php:91 getTabNameForItem adds the 'Impact analysis' tab (src/Impact.php:80) to every impact enabled itemtype, Appliance and Software included (src/autoload/CFG_GLPI.php:649 and :663), with a list view src/Impact.php:270 displayListView of every related item in both directions. Reached on: Management > Appliances > Impact analysis tab. Driven on the lab at 11.0.9 (2026-09-26): the appliance page carries an Impact analysis tab with Add assets for every impact enabled item type."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -1029,8 +1638,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "archimate-import-and-export",
- "featureConfidence": "low",
+ "feature": "application-connections",
+ "featureConfidence": "medium",
"note": "No page draws connections. The ArchiMate export can be opened in Archi, but it carries GEMMA views and usages, not the catalogue's connections. Specified in openspec/changes/connections-diagram-and-graph-export (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/data-flow: data flow diagrams help 'understand how applications are connected, identify dependencies, and trace data movement between systems' (read 2026-09-26). Reached on: Diagrams > Data Flow Diagram.",
@@ -1039,6 +1648,12 @@
"stackiq": "No diagram rendering in src/ (no graph library; src/store/modules/view.js GET /api/views has no importer outside itself); lib/Service/ArchiMateExportService.php exports GEMMA views as ArchiMate XML without koppeling objects (no 'koppeling' in lib/Service/ArchiMate*)",
"topdesk": "https://docs.topdesk.com/en/linking-assets-to-other-assets.html: \"After you define the relationship between assets, you can use the graphical overview to see a visual representation of their relationship\" (read 2026-09-26). Reached on: Asset card > graphical overview.",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/30890: \"Tekenen van een view met koppelingen ... Een koppeling is gemodelleerd als een Archimate flow relatie\" (read 2026-09-26). Diagrams are drawn in Archi after an AMEFF export, not in the catalogue."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKoppelingen"
+ ]
}
},
{
@@ -1061,6 +1676,7 @@
"reachedOn": "ModuleDetail /modules/:id, Related panel",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "application-connections",
"note": "The only way to see what depends on an application is the generic list of objects that reference it, which OpenRegister's relation index fills. There is no impact view or retirement check. Specified in ConductionNL/openregister openspec/changes/relations-that-travel-and-what-they-expose (openregister lane, OpenSpec pass 2026-09-27).",
"evidence": {
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin: 'overlapping application functions are quickly made visible, and their impact analyses are available in no time'; spider tool shows derived relationships (https://help.bluedolphin.io/en/articles/11967545-spider-tool) (read 2026-09-26). Reached on: Views > spider tool.",
@@ -1069,6 +1685,12 @@
"stackiq": "src/manifest.json:502 ModuleDetail md-related (library CnRelatedObjectsWidget: /used lists every object that references this module: usages, connections, suites, services, vulnerabilities); no dependency or impact computation in lib/ or src/",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1: \"Pakketversies die niet meer in gebruik zijn, kunnen verwijderd worden waarbij feedback gegeven wordt over de koppelingen die ook automatisch verwijderd zullen worden\" (read 2026-09-26). Only on delete, no dependency analysis.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/meta-model: 'These relations are the basis for all the reports and allow you to analyze impact of changes, dependencies, risks, redundancies'; https://help.sap.com/docs/leanix/ea/data-flow: 'directional interface mapping, dependency analysis' (read 2026-09-26). Reached on: Reports and Data Flow Diagrams."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -1091,6 +1713,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "application-connections",
"note": "The connections list filters on type, status and direction from its filter menu, and on status from quick filters. Built by openspec/changes/archive/2026-09-29-connections-catalogue-pages.",
"evidence": {
"stackiq": "src/manifest.d/connections.json Koppelingen filterMenu: true over the enum columns; lib/Settings/softwarecatalogus_register.json connection type, status and dataExchangeDirection facetable (register 2.5.2, connection 0.3.3); tests/Unit/Settings/ConnectionSchemaTest.php",
@@ -1099,6 +1722,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967713-governance-insights: 'Objects and Relationships (with metadata): Select a source object definition, a relationship type, and a target object definition'; relationship types follow ArchiMate definitions, not API or file exchange categories (read 2026-09-26). Reached on: Insights > Objects and Relationships (with metadata).",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/interface-modeling-guidelines: interface subtypes 'Logical interface, API, MCP server' and 'You can capture data flow directions, type of transfer, and frequency with the interface fact sheet'; subtypes and fields are filterable in the inventory (read 2026-09-26). Reached on: Inventory > Interface > filter by subtype or transfer type.",
"glpi": "source read at 11.0.9: install/mysql/glpi-empty.sql:1247 glpi_impactrelations has only a free name besides the two endpoints, no connection type (API, file, message), so there is nothing to filter on; the graph settings (src/Impact.php:1167 impact_settings) cover depth and direction."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKoppelingen"
+ ]
}
},
{
@@ -1121,6 +1750,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "application-connections",
"note": "Stackiq has no record for an API an application exposes. Specified in openspec/changes/connections-api-catalogue (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/interface-modeling-guidelines: interface subtype 'API ... APIs provide functionalities accessible to external applications ... Examples: Metrics API, Import API', related to the providing application (read 2026-09-26). Reached on: Inventory > Interface fact sheet, subtype API.",
@@ -1129,6 +1759,12 @@
"vng-softwarecatalogus": "unknown: no register of APIs an application exposes is described; standards are declared instead; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967596-archimate-annotation-translation-en-nl lists the ArchiMate object definition 'Application Interface' (Applicatie-interface), which can hold an application's APIs as related objects; no API catalogue feature is documented (read 2026-09-26). Reached on: Objects > Application Interface object.",
"glpi": "source read at 11.0.9: no API entity in core, grep -ril 'openapi' src/*.php finds no itemtype for exposed APIs; an admin can define an 'API' custom asset type (src/Html.php:1330 Setup > Asset definitions, src/Glpi/Asset/AssetDefinition.php) and attach it to the application as an Appliance item (src/Appliance_Item.php:45) or impact relation (install/mysql/glpi-empty.sql:1247). Reached on: Setup > Asset definitions, then Appliance > Items tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -1151,8 +1787,8 @@
"reachedOn": "nothing reaches it (nearest: admin settings ArchiMate organisation export)",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "archimate-import-and-export",
- "featureConfidence": "low",
+ "feature": "application-connections",
+ "featureConfidence": "medium",
"note": "The organisation ArchiMate export carries modules and usages but not connections, so an application's link graph cannot be exported. Specified in openspec/changes/connections-diagram-and-graph-export (OpenSpec pass 2026-09-27).",
"evidence": {
"glpi": "source read at 11.0.9: front/impactcsv.php streams Glpi\\Csv\\ImpactCsvExport for an item, linked from the impact list view at src/Impact.php:393; the graph Download button src/Impact.php:1165 calls js/impact.js:2528 download, which writes PNG (js/impact.js:2539) or JPEG (js/impact.js:2546). Reached on: Appliance > Impact analysis tab, Download and CSV export.",
@@ -1161,6 +1797,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Handleiding%20koppeling%20architectuurtools: \"Pakketten én koppelingen worden in 1 model geëxporteerd ... AMEFF-export\" (read 2026-09-26); https://www.softwarecatalogus.nl/Beschikbare%20downloads: \"Mijn koppelingen: Knop [Exporteren] op tabblad Koppelingen\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Pakketten or Koppelingen > Exporteren > AMEFF-export.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967514-download-a-view: a view downloads as 'PNG, SVG, PDF, and AMEFF format ... You can use AMEFF files to exchange Architecture views between different applications that support ArchiMate'. The graph is exported per drawn view (read 2026-09-26). Reached on: View > Download > AMEFF.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams: diagrams export 'in the following formats: PDF, SVG, PNG, HTML embed code, and XML' and open in draw.io; https://help.sap.com/docs/leanix/ea/exporting-fact-sheet-data-as-excel-file exports fact sheet data. No export of an application's relation graph as data is documented as such (read 2026-09-26). Reached on: Diagrams > Export (XML, draw.io); Inventory > Export."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKoppelingen"
+ ]
}
},
{
@@ -1183,6 +1825,7 @@
"reachedOn": "Integrations /settings/integrations (settings menu, admin, only when app id 'integriq' is installed)",
"provider": "integriq",
"providerHow": "read-from-code",
+ "feature": "application-connections",
"note": "With integriq installed, an admin sees stackiq's three integrations and their checked status on one page, and Add integration opens integriq. Without integriq the page is hidden, and the checking is integriq's. Building: ConductionNL/integriq openspec/changes/connection-registry, an existing open change, covers it (integriq lane, OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "src/manifest.d/connection-registry.json:23 Integrations page (register integriq, schema app_connection) with visibleIf appInstalled 'integriq' (:17) and permission admin (:15,:27); Add integration handler leaves for integriq (src/services/connectionRegistry.js:43); rows synced from lib/Settings/connections.json (email, federation, eol-feed); status worked out by integriq",
@@ -1191,6 +1834,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967779-add-an-integration-in-bluedolphin: 'Click Add integration in the bottom right corner of the Marketplace page' for TOPdesk, ServiceNow and JIRA; 'Marketplace (Integration) is a paid add-on' (read 2026-09-26). Reached on: System settings > Marketplace > Add integration.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/discovering-ai-agents-using-api: 'Go to the Integrations section in the administration area. Choose Add Integration'; https://help.sap.com/docs/leanix/ea/collibra-data-catalog-integration: 'Administration > Integrations > Sync Log' to check each integration (read 2026-09-26). Reached on: Administration > Integrations (Add Integration, Sync Log).",
"glpi": "source read at 11.0.9: outside integrations are set up on separate Setup pages, not one overview: src/Html.php:1331 Webhook, src/Html.php:1333 OAuthClient and MailCollector, Auth (LDAP, SSO) on src/Html.php:1332; src/Webhook.php:64 Webhook and src/OAuthClient.php:45 OAuthClient each have their own list. Reached on: Setup > Webhooks, Setup > OAuth clients, Setup > Authentication."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
}
},
{
@@ -1223,6 +1872,12 @@
"vng-softwarecatalogus": "unknown: only export to AMEFF is documented; importing an ArchiMate file into the catalogue is not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Handleiding%20koppeling%20architectuurtools (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea (all EA pages grepped for ArchiMate and exchange format); diagram import supports '.drawio, Lucidchart, .vsdx, PNG, SVG, JPEG' only (https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams), ArchiMate appears only as a shape template (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -ril 'archimate\\|gemma\\|togaf' over src/ templates/ locales/glpi.pot returns nothing; no model import exists. Import paths in core are inventory (src/Glpi/Inventory/Inventory.php:106) and form import (src/Glpi/Controller/Form/Import/), neither for models."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -1255,6 +1910,12 @@
"topdesk": "unknown: ArchiMate is not mentioned anywhere in the documentation (0 hits); searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; exports are PDF, SVG, PNG, HTML and draw.io XML (https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams); no ArchiMate exchange format export is documented, ArchiMate 3.2 is only a visual template (https://help.sap.com/docs/leanix/ea/styles-and-patterns) (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -ril 'archimate' over src/ templates/ locales/glpi.pot returns nothing; exports are CSV, PDF and spreadsheet search output (src/Glpi/Csv/) only. Export formats are src/Glpi/Search/Output/Csv.php:38 and src/Glpi/Search/Output/Pdf.php:43 and the spreadsheet siblings."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -1287,6 +1948,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for GEMMA, no hits; views export to AMEFF (https://help.bluedolphin.io/en/articles/11967514-download-a-view), but an organisation's landscape plotted on GEMMA is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no GEMMA content and no ArchiMate file export are documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -ril 'archimate\\|gemma' over src/ templates/ locales/glpi.pot returns nothing; no GEMMA plotting or per entity model export. Export formats are src/Glpi/Search/Output/Csv.php:38 and src/Glpi/Search/Output/Pdf.php:43, no model file."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -1310,7 +1977,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "archimate-import-and-export",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "There is an endpoint, but its comparison is broken (a missing key against a placeholder string) and no page calls it. The compare_archimate.py/.php scripts in the repo root are developer tools, not a user capability. Specified in openspec/changes/architecture-round-trip-check (OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "appinfo/routes.php:107 POST /api/archimate/test-round-trip -> lib/Controller/SettingsController.php:2886 (@NoAdminRequired, any logged-in user) -> lib/Service/ArchiMateService.php:1496 testRoundTrip: reads $importResult['imported_count'] (:1528), a key no import path sets, and compares it to exportToArchiMate's 'exported_count' which is the literal string 'calculated_in_export_service' (:271), so it can never report success; it also imports a test model into the live register",
@@ -1319,6 +1986,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; AMEFF import and per view AMEFF export exist (https://help.bluedolphin.io/en/articles/11967637-import-ameff-files, https://help.bluedolphin.io/en/articles/11967514-download-a-view), but a check that a model survives the round trip is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no model file import and export pair (ArchiMate or similar) is documented, so no round trip check exists in the docs (read 2026-09-26)",
"glpi": "source read at 11.0.9: there is no model import or export to round trip, grep -ril 'archimate' over src/ templates/ locales/glpi.pot returns nothing. Export formats are src/Glpi/Search/Output/Csv.php:38 and src/Glpi/Search/Output/Pdf.php:43, no model file."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -1342,7 +2015,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "archimate-import-and-export",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The import records its phase and the objects saved so far, the settings page follows it every two seconds, and a cancel stops it before its next save batch, keeping what was already saved. Built by openspec/changes/archive/2026-09-29-architecture-import-progress-and-cancel.",
"evidence": {
"stackiq": "lib/Service/ArchiMateImportService.php startTracking() and saveSchemaGroups() record the phase and saved count in ProgressTracker under the page-named operation id and stop before the next schema group on a cancel; lib/Service/ArchiMateService.php cancelArchiMateImport() sets the cancel flag (the method was missing); src/views/settings/sections/ArchiMateImportExport.vue polls /api/progress/{id} every two seconds and shows the phase, a progress bar and a Cancel import button (tests/Unit/Service/ArchiMateImportProgressTest.php, ArchiMateServiceCancelTest.php, src/utils/archiMateImportProgress.spec.js)",
@@ -1351,6 +2024,12 @@
"bluedolphin": "unknown: https://help.bluedolphin.io/en/articles/11967637-import-ameff-files says only 'When BlueDolphin has completed the action, a checkmark will appear'; progress while running and cancelling are not documented (read 2026-09-26)",
"sap-leanix": "https://updates.leanix.net/announcements/product-update-march-2026: 'The new asynchronous import process runs entirely in the background ... A real-time progress widget in the inventory side-panel keeps you informed of import status'. This is the Excel import, not a model import, and cancelling is not described (read 2026-09-26). Reached on: Inventory side panel > import progress widget.",
"glpi": "source read at 11.0.9: no model import exists (grep -ril 'archimate' src/ returns nothing), so there is no import to follow; the generic progress endpoint src/Glpi/Controller/ProgressController.php serves other long operations. The progress route is src/Glpi/Controller/ProgressController.php:50 /progress/check/{key}, used by the installer (src/Glpi/Controller/InstallController.php:57)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -1374,7 +2053,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "You can open a standard's page and read its definition, but reference components and other GEMMA terms have no page and there is no inline definition where they appear.",
"evidence": {
"stackiq": "src/manifest.json StandaardDetail /standaarden/:id st-data widget shows all element fields (documentation, gemmaNotes) of a standard; FacetedCatalogIndexView.vue:150 shows reference components by name only",
@@ -1383,6 +2062,14 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for GEMMA, no hits; object definitions follow ArchiMate (https://help.bluedolphin.io/en/articles/11967599-object-definitions) but in place definitions of GEMMA terms are not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no GEMMA terms and no in-place glossary of reference architecture terms are documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -ril 'gemma\\|glossary' over src/ templates/ locales/glpi.pot returns nothing; no reference term definitions ship in core. The knowledge base (src/KnowbaseItem.php:57) is the only place definitions could be written by hand."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkGemmaPlaat",
+ "SkStandaarden"
+ ]
}
},
{
@@ -1406,7 +2093,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The mapping to GEMMA reference components exists, but there is no map view in stackiq; you only see it as a facet list or in Archi after an admin export. Specified in openspec/changes/architecture-reference-component-coverage (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/meta-model: 'A business capability is supported by an application'; https://help.sap.com/docs/leanix/ea/application-portfolio-assessment lists a 'Business capability map' (read 2026-09-26). Reached on: Reports > Landscape Report on Business Capabilities.",
@@ -1415,6 +2102,12 @@
"topdesk": "unknown: no capability or function map is described; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Hoe%20print%20ik%20een%20kaart%3F: \"de pakketten die getoond worden in het pakketoverzicht te plotten op een GEMMA architectuurkaart ... [Toon kaart]\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/30890: view \"RD02 Bedrijfsfuncties ruimtelijk domein met referentiecomponenten\" with the municipality's packages plotted (read 2026-09-26). Mapping runs through fixed GEMMA reference components and business functions, not the organisation's own capability model. Reached on: Mijn softwarecatalogus > Pakketten > kaart kiezen > Toon kaart.",
"glpi": "source read at 11.0.9: grep -rli 'business capabilit\\|business process' over src/ and locales/glpi.pot returns nothing; 'Capacity' in src/Glpi/Asset/Capacity.php is a feature toggle for custom assets, not a business capability. src/Glpi/Asset/Capacity.php:39 is the custom asset capacity class."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
}
},
{
@@ -1437,6 +2130,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"note": "Models are imported and exported as ArchiMate files; nothing lets a user draw or edit a model inside stackiq. Specified in openspec/changes/architecture-views-editor (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams and https://help.sap.com/docs/leanix/ea/working-with-fact-sheets-in-diagrams: 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/styles-and-patterns) (read 2026-09-26). Reached on: Diagrams > New Diagram (diagram editor).",
@@ -1445,6 +2139,12 @@
"topdesk": "unknown: no architecture modelling is described; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)",
"vng-softwarecatalogus": "unknown: the docs send users to Archi or other tools for modelling; drawing inside the catalogue is not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/node/30890 (read 2026-09-26)",
"glpi": "source read at 11.0.9: the impact graph is an editable diagram, src/Impact.php:1160 add asset, add relation and add group tools, groups stored in install/mysql/glpi-empty.sql:1264 glpi_impactcompounds and node positions in install/mysql/glpi-empty.sql:1276 glpi_impactitems; it draws dependency graphs only, with no architecture notation or views. Reached on: Appliance > Impact analysis tab, graph edit mode."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
}
},
{
@@ -1467,6 +2167,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"note": "Business processes are not modelled; the only link applications have is to GEMMA reference components. Specified in openspec/changes/architecture-process-mapping (OpenSpec pass 2026-09-27).",
"evidence": {
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967673-process-linked-to-ea-perspectives: 'The Create BPMN diagram button allows you to create a diagram directly from a business process'; https://help.bluedolphin.io/en/articles/11967500-getting-started-with-process-publication-portal: 'For each application, you will find different processes in which the selected application is involved' (read 2026-09-26). Reached on: Processes > Process browser.",
@@ -1475,6 +2176,12 @@
"vng-softwarecatalogus": "unknown: no process modelling is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/business-context-modeling-guidelines: business context subtype 'Process : Processes show the different steps and interactions', related to applications; SAP Signavio integration documented under Discovery and Integrations (read 2026-09-26). Reached on: Business Context fact sheet, subtype Process.",
"glpi": "source read at 11.0.9: grep -rli 'business process' over src/ and locales/glpi.pot returns nothing; no process itemtype to link to applications. Appliance tabs (src/Appliance.php:98 onwards) link no process."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
}
},
{
@@ -1497,6 +2204,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"note": "Planned usage and planned replacements exist per record, but there is no future-state model and no comparison with today. Specified in openspec/changes/architecture-future-state-scenarios (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/sap-leanix-architecture-and-road-map-planning: 'plan your target architecture and monitor initiative progress', transformation templates and impacts; https://updates.leanix.net/announcements/plan-with-consistent-future-architecture-data-introducing-the-committed-future (2026-09-24): 'Understanding your architecture across the past, present, and future ... introducing the committed future'. In the Architecture and Road Map Planning add on (read 2026-09-26). Reached on: Architecture and Road Map Planning > Transformations, target architecture diagrams.",
@@ -1505,6 +2213,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/19703: \"geplande harmonisaties ... met een status gepland met bijbehorende datum. Zo kan ook het uiteindelijke doel-landschap in 1 overzicht inzichtelijk worden gemaakt\" (read 2026-09-26). No side-by-side comparison of today and target. Reached on: Mijn softwarecatalogus (samenwerking) > Pakketten > status Gepland.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967531-object-lifecycle-state: 'Objects can be either Current (default) or Future state. Select the Future option to reflect how the object will look in the future', shown distinctly on views; https://bluedolphin.io/capability-based-planning/: 'Map current and future state capabilities' (read 2026-09-26). Reached on: Objects and views with Current and Future lifecycle state.",
"glpi": "source read at 11.0.9: grep -rli 'future state\\|what-if\\|scenario' over src/*.php returns nothing; the impact graph (src/Impact.php:49) shows only the current relations, with no plateau or target landscape."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
}
},
{
@@ -1527,6 +2241,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "gemma-alignment",
"note": "No report or view lists reference components that no application in your landscape covers. Specified in openspec/changes/architecture-reference-component-coverage (OpenSpec pass 2026-09-27).",
"evidence": {
"bluedolphin": "https://bluedolphin.io/capability-based-planning/: 'Run gap analyses to define a clear roadmap' and 'Identify capability gaps'. Gaps are found on the customer's own capability map; no reference architecture such as GEMMA is documented (read 2026-09-26). Reached on: Capability maps.",
@@ -1535,6 +2250,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1: dashboard tile \"Pakketten met meer mogelijkheden: U heeft in uw pakketoverzicht pakketten die geschikt zijn voor referentiecomponenten waarbij u nog geen pakket heeft opgevoerd\" (read 2026-09-26). It lists uncovered components only where an owned package could fill them. Reached on: Dashboard tile Pakketten met meer mogelijkheden.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/report-types: Matrix Report 'Displays relations between fact sheets in a two-dimensional matrix' for 'Coverage gap analysis'. Gaps are business capabilities without supporting applications; no reference architecture such as GEMMA is shipped (read 2026-09-26). Reached on: Reports > Matrix Report.",
"glpi": "source read at 11.0.9: no reference component model to compare against, grep -ril 'gemma\\|reference component' over src/ locales/glpi.pot returns nothing; appliances are typed only by the free Appliance type dropdown (install/mysql/glpi-empty.sql:8941)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
}
},
{
@@ -1557,6 +2278,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"note": "Nothing drafts diagrams. Specified in openspec/changes/architecture-assistant-drafted-views (OpenSpec pass 2026-09-27).",
"evidence": {
"bluedolphin": "https://help.bluedolphin.io/en/articles/12528662-ai-capabilities-of-bluedolphin: 'Modelling Assistant or BPMN Generator instantly creates BPMN 2.0-compliant process diagrams from a simple prompt or by uploading existing documentation' (read 2026-09-26). Reached on: Processes > Modeling Assistant.",
@@ -1565,6 +2287,12 @@
"vng-softwarecatalogus": "unknown: no assistant is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"sap-leanix": "https://updates.leanix.net/announcements/build-and-edit-architecture-diagrams-with-ai-agents (2026-09-15): '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' (read 2026-09-26). Reached on: MCP server with an AI agent > diagrams.",
"glpi": "source read at 11.0.9: grep -rli 'openai\\|llm\\|mistral\\|chatgpt\\|artificial intelligence' over src/ templates/ returns nothing; diagrams are drawn by hand in the impact graph (src/Impact.php:1160)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
}
},
{
@@ -1597,6 +2325,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no register of standards applications must support is documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/technology-standards-management-capabilities: Technology Risk and Compliance helps 'establish technology standards' for frameworks and languages. These are technology standards for components, not interoperability standards an application must support (read 2026-09-26). Reached on: Technology Risk and Compliance > technology standards.",
"glpi": "source read at 11.0.9: no standards itemtype, grep -ril 'standaard\\|forum standaardisatie' src/ locales/glpi.pot returns nothing and the word standard in src/Appliance.php occurs only in addStandardTab (src/Appliance.php:100)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkStandaarden"
+ ]
}
},
{
@@ -1629,6 +2363,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967535-object-attachments: 'You can enrich your Blue Dolphin objects with additional information from saved files'. Files attach to the object, not to a specific compliance claim (read 2026-09-26). Reached on: Object > General tab > attachments.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/collaborating-and-managing-data-in-fact-sheets: 'The Resources tab ... You can upload files up to 10 MB' and links on any fact sheet. Files attach to the fact sheet, not to a specific compliance claim (read 2026-09-26). Reached on: Fact sheet > Resources tab.",
"glpi": "source read at 11.0.9: any document can be attached to an appliance through the Documents tab, src/Appliance.php:100 Document_Item tab and src/Document_Item.php:46, stored in install/mysql/glpi-empty.sql:2585 glpi_documents; there is no compliance claim to attach it to. Reached on: Management > Appliances > Documents tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkComplianceMatrix"
+ ]
}
},
{
@@ -1661,6 +2401,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no distinction between verified and asserted compliance claims is documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; a quality seal approves fact sheet data as a whole (https://help.sap.com/docs/leanix/ea/updating-lifecycle-phase-and-approving-quality-seal) but no distinction between verified and supplier asserted compliance claims is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: no compliance claim model, grep -rli 'complian' over src/Appliance.php src/Software.php returns nothing; nothing to mark verified or claimed (src/Appliance.php:46 fields are inventory and management fields only)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkComplianceMatrix"
+ ]
}
},
{
@@ -1693,6 +2439,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; heat maps by conditional layout and a Power BI relations cross-table exist (https://help.bluedolphin.io/en/articles/11967711-power-bi-for-bluedolphin-quickstart), but a matrix of applications against standards is not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/meta-model-dora-extension: 'You can create a regulatory dictionary, categorize DORA requirements, and link DORA obligations directly to specific IT and business architecture elements'; https://help.sap.com/docs/leanix/ea/report-types: Matrix Report maps relations cell by cell. Regulation obligations, not interoperability standards (read 2026-09-26). Reached on: DORA extension + Reports > Matrix Report.",
"glpi": "source read at 11.0.9: there are no standards in core (grep -rli 'standaard\\|forum standaardisatie' src/ locales/glpi.pot returns nothing), so no application by standard matrix; search output (src/Glpi/Search/Output/Spreadsheet.php) only tabulates item fields. The spreadsheet output is src/Glpi/Search/Output/Csv.php:38 and siblings."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkComplianceMatrix"
+ ]
}
},
{
@@ -1716,7 +2468,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "standards-compliance",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "One action updates every module's standards from its compliance records, but only a Nextcloud admin can run it and it stops at 1000 compliance records.",
"evidence": {
"stackiq": "appinfo/routes.php:275 POST /api/bulk-sync-standards -> lib/Controller/SettingsController.php:3598 (isAdmin check) -> lib/Service/ModuleComplianceService.php:527 bulkSyncModuleStandards (reads up to 1000 compliancy records and updates module standards); UI src/views/settings/sections/StatisticsOverview.vue:79 button -> src/modals/BulkSyncDialog.vue:321",
@@ -1725,6 +2477,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no standards set to refresh across applications is documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no action that refreshes the standards set of many applications is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: no standards model exists (see comp-standards-register); massive actions (src/MassiveAction.php:666 Update) update item fields only."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -1747,7 +2505,7 @@
"reachedOn": "BioMaatregelen /bio-maatregelen, menu BIO measures; detail BioMaatregelDetail /bio-maatregelen/:id",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "standards-compliance",
+ "feature": "security-and-privacy-assessment",
"featureConfidence": "medium",
"note": "The page exists and shows the theme, but the level column is wired to a key that does not exist (bbnNiveau vs bbnLevel; level only shows on the detail page), and the BIO measures are not shipped, so someone has to type them in.",
"evidence": {
@@ -1757,6 +2515,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for BIO and Baseline Informatiebeveiliging, no hits; the only security template is an 'ISO27001 Management System template' for 'an Information Security Management System' (https://help.bluedolphin.io/en/articles/11967582-iso27001-management-system-template), not the BIO measure set (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for BIO and Baseline Informatiebeveiliging, no hits; LeanIX documents DORA and GDPR content only (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'iso 27001\\|27002' and grep -rwi 'bio' over src/ locales/glpi.pot return nothing; no security measure catalogue ships (menus at src/Html.php:1295 onwards list none)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkStandaarden"
+ ]
}
},
{
@@ -1779,7 +2543,7 @@
"reachedOn": "Komplianties /komplianties (create a claim with a BIO measure); ComplianceMatrix /compliance-matrix with BIO measures as columns",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "standards-compliance",
+ "feature": "security-and-privacy-assessment",
"featureConfidence": "medium",
"note": "You can record that an application meets a BIO measure, but there is no way to record that it does not meet one: the absence of a claim is the only negative, which cannot be told apart from 'not assessed'. The md-compliance column key 'bioMaatregel' also differs from the property bioMeasure.",
"evidence": {
@@ -1789,6 +2553,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for BIO, no hits; recording which BIO measures an application meets is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for BIO, no hits; no BIO measure assessment per application is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: no measure catalogue and no assessment itemtype; grep -rli 'iso 27001\\|27002' over src/ locales/glpi.pot returns nothing and Appliance tabs (src/Appliance.php:98 onwards) hold no assessment."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkComplianceMatrix"
+ ]
}
},
{
@@ -1811,8 +2581,8 @@
"reachedOn": "ModuleDetail /modules/:id Application data widget; Modules /modules column dpiaStatus and 'Without DPIA' quick filter",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "standards-compliance",
- "featureConfidence": "low",
+ "feature": "security-and-privacy-assessment",
+ "featureConfidence": "medium",
"note": "DPIA status, date, next review and document link are fields on the application and shown and filterable on the Applications pages. The scheduled 'dpia-review-overdue' notification is only a register declaration.",
"evidence": {
"stackiq": "lib/Settings/softwarecatalogus_register.json module.dpiaStatus (enum not required/required/executed), dpiaDate, dpiaNextAssessment, dpiaDocumentRef; src/manifest.json ModuleDetail md-data includes the DPIA fields; Modules /modules quick filter 'Without DPIA (BBN2+)'",
@@ -1821,6 +2591,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for DPIA, GDPR and AVG, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for DPIA and data protection impact assessment, no hits; only data classification of data objects for GDPR is documented (https://help.sap.com/docs/leanix/ea/data-object-modeling-guidelines 'classified into personally identifiable data (e.g., to cater to GDPR use cases)') (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'dpia\\|impact assessment\\|gdpr' over src/ returns nothing, locales/glpi.pot:12792 'gdpr-tools' is only an icon name. The GDPR records plugin yild/gdprropa read at tag 1.0.3 has 'PIA required' and 'PIA status' (inc/record.class.php:459, :468) but declares GLPI 10 only, setup.php:56 max 10.99.99, so it does not run on 11.0.9."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkApplicatie"
+ ]
}
},
{
@@ -1842,6 +2619,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "standards-compliance",
"note": "There is no list or check for the Forum Standaardisatie comply-or-explain list; only GEMMA standards imported with the model. Decided no on 2026-09-29 by the build-all lane, reversible: no competitor rates yes, no demand, outside the core areas (compliance).",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/inkoopondersteuning%20standaarden: \"De verplichte standaarden zijn: ... de open standaarden die onder het pas-toe-of-leg-uit regime van de overheid binnen het werkingsgebied vallen\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakket/archi: package version shows \"Verplichte standaarden ... Ondersteuning Compliancy Testrapport\" (read 2026-09-26). Comply-or-explain standards are mixed into the mandatory set, not shown as their own list. Reached on: Package page > Standaarden; Inkoopondersteuning.",
@@ -1850,6 +2628,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for Forum Standaardisatie, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for Forum Standaardisatie and comply or explain, no hits (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'standaard\\|forum standaardisatie\\|comply' over src/ locales/glpi.pot returns nothing relevant; no comply or explain list in core (Setup menu src/Html.php:1330 onwards)."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -1872,6 +2654,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "data-quality",
"note": "No rule-based score of register correctness or completeness exists on any page. Specified in openspec/changes/landscape-completeness-score (OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "grep for completeness/health score/quality score in lib and src: no hits; lib/Command/ReferencesAuditCommand.php:34 (occ stackiq:references:audit) only audits cross-app uuid references, it is not a scored rule set",
@@ -1880,6 +2663,13 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967713-governance-insights: 'Governance reports will help maintain oversight of object usage, completeness, and relationships', incl. 'Object completeness ... completeness score of each object' and 'Objects not on any view' (read 2026-09-26). Reached on: Insights > Governance reports.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-portfolio-management-dashboard: 'Data Quality KPI ... Overall Completion of Applications ... Broken quality seal ... Missing Business Capability'; completion score weights set by admins (https://help.sap.com/docs/leanix/ea/fact-sheet-completeness) (read 2026-09-26). Reached on: Dashboards > Application Portfolio Management Dashboard > Data Quality KPIs.",
"glpi": "source read at 11.0.9: grep -ril 'completeness' over src/ templates/ locales/glpi.pot returns nothing and no rule set scores register quality; the rules engine (src/Glpi/Rules/, src/RuleCollection.php) assigns and imports data, it does not score it. The rules engine base is src/RuleCollection.php:48."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkComplianceMatrix",
+ "SkDatakwaliteit"
+ ]
}
},
{
@@ -1901,6 +2691,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "standards-compliance",
"note": "Nothing sends questionnaires to suppliers or stores their answers. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (compliance).",
"evidence": {
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967524-create-a-survey: surveys 'allowing external stakeholders to contribute directly to the Enterprise Architecture repository', sent 'to multiple recipients simultaneously via email' from an object's questionnaire, answers kept on the object (read 2026-09-26). Reached on: Object > Questionnaire tab > Create survey.",
@@ -1909,6 +2700,10 @@
"vng-softwarecatalogus": "unknown: no questionnaire to suppliers is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/reviewing-responses: survey answers are reviewed and saved to the fact sheet; recipients are the users 'assigned to the fact sheet'. Sending to an outside supplier is not documented (read 2026-09-26). Reached on: Surveys.",
"glpi": "source read at 11.0.9: native forms (src/Glpi/Form/Form.php:92) are filled by logged in or helpdesk users and only produce tickets, changes or problems (src/Glpi/Form/Destination/FormDestinationTicket.php:47); nothing sends a questionnaire to a supplier or stores answers on an appliance."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -1932,7 +2727,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "offering-and-usage-listings",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A supplier in aanbod-beheerder creates modules and services on the index pages and publishes them by setting publicationDate in the form. The dedicated publish endpoint (PUT /api/publication/...) is not called by any page (src/utils/openDataProjection.js has no importer).",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier: \"Deze handleiding is bedoeld voor de leveranciers en legt uit hoe de leveranciers hun productportfolio kunnen aanvullen en beheren ... voeg pakket toe\" (read 2026-09-26). Reached on: Supplier login > Productportfolio.",
@@ -1941,6 +2736,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; BlueDolphin is a per customer EA repository, supplier publication of offerings is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; LeanIX is a customer's internal EA workspace, no supplier facing publication of offerings is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: suppliers are records kept by the buying organisation, install/mysql/glpi-empty.sql:7067 glpi_suppliers, with no supplier login or offering page; profiles (src/Profile.php) cover internal users and the self-service helpdesk only."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -1964,7 +2765,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "Live facet counts on reference component and standard narrow the module and service lists. Scope is whatever the RBAC read rules let the viewer see (published entries are public). Corrections round 8 (2026-09-28): the domain facet on the same sidebar is always empty, stackiq#1141; rating kept because this row filters on reference component and standard, which FacetService fills from other fields.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Ik ben op zoek naar een nieuw pakket voor referentiecomponent voor BAG-administratie\" and standards filters combine (read 2026-09-26); https://www.softwarecatalogus.nl/pakketten: facets Referentiecomponent and Standaard (read 2026-09-26). Reached on: Alle pakketten > filters.",
@@ -1973,6 +2774,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no market wide software search is documented (read 2026-09-26)",
"sap-leanix": "unknown: https://help.sap.com/docs/leanix/ea/applications-in-reference-catalog: the catalog 'covers SAP applications, SAP AI agents, and SaaS applications' and is used to link your own fact sheets; market search by reference component and standard is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: search covers only the organisation's own records (src/Glpi/Search/SearchEngine.php); there is no market wide catalogue and no reference component or standard to filter on (grep -ril 'gemma\\|reference component' src/ returns nothing). The marketplace (src/Glpi/Marketplace/) lists GLPI plugins, not software for a task. The search engine is src/Glpi/Search/SearchEngine.php:101; the marketplace is src/Glpi/Marketplace/Controller.php:64."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -1995,6 +2802,7 @@
"reachedOn": "Organisations /organisaties (main menu)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "supplier-directory",
"note": "The Organisations index lists organisations with search, a type facet and a status filter. There is no filter for 'offers at least one product'; type Supplier is the proxy.",
"evidence": {
"stackiq": "src/manifest.json:386 Organisaties type:index over schema organization with status filter and OrganisatieCard; register :2022 organization.type is facetable (Municipality/Supplier/Collaboration/Community); public read of Active suppliers in organization authorization (:2467)",
@@ -2003,6 +2811,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no directory of suppliers across the market is documented (read 2026-09-26)",
"sap-leanix": "unknown: https://help.sap.com/docs/leanix/ea/provider-modeling-guidelines: 'SAP LeanIX has a catalog of 9000 providers' added automatically with IT components; browsing that catalog as a directory with filters is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: src/Supplier.php:46 Supplier list under Management (src/Html.php:1298) with search, filters and a supplier type (install/mysql/glpi-empty.sql:7072 suppliertypes_id), but it holds only the suppliers this organisation entered, not all organisations offering to government. Reached on: Management > Suppliers (front/supplier.php). Driven on the lab at 11.0.9 (2026-09-26): created supplier \"Lab Leverancier BV\" through /front/supplier.form.php; it appears in the Suppliers list."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisaties"
+ ]
}
},
{
@@ -2024,6 +2838,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "shared-usage-on-gemma-views",
"note": "Nothing lets an organisation find or contact other organisations using the same product. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (market).",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Alle gemeenten ... inclusief contactgegevens van gemeenten ... U kunt contact onderhouden met deze gemeenten\" (read 2026-09-26). Reached on: Inlogmenu > Alle gemeenten.",
@@ -2032,6 +2847,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; a vendor community is referenced in the docs (https://help.bluedolphin.io/en/articles/11967582-iso27001-management-system-template 'a post on the community'), but finding peers who use the same product is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; a vendor community exists (https://community.leanix.net/) but no in product way to find organisations using the same product is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: each GLPI instance holds one organisation's data (entities are internal subdivisions, src/Entity.php), so there is no view of other organisations using the same product; grep -rli 'peer' src/*.php matches only relation and network code such as src/CommonDBConnexity.php, no peer organisation feature."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -2055,7 +2874,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "maintenance-and-supplier-roadmap",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A supplier can register a future version with status 'in development' and planned dates, which reads as a crude release plan. There is no roadmap view or declared roadmap; the overlay marks maintenance-and-supplier-roadmap as 'soon'. Specified in openspec/changes/lifecycle-maintenance-and-supplier-roadmap (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-lifecycle-maintenance-and-supplier-roadmap.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Alle pakketversies en planningen ... gelijk zichtbaar de planning van de diverse pakketversies\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakketversies: facet \"Status planning ... Filter op in ontwikkeling en zie de distributie planningsdata\" (read 2026-09-26). Reached on: Wat is er te vinden > Alle pakketversies en planningen.",
@@ -2064,6 +2883,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; roadmap templates are for the customer's own plans (https://help.bluedolphin.io/en/articles/11967586-roadmap), suppliers' declared roadmaps are not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/it-components-in-reference-catalog: 'The catalog provides standardized IT component data including lifecycle dates, vendor information ... You no longer need to track vendor lifecycle dates manually'. Vendor lifecycle and support dates, not planned releases; needs Technology Risk and Compliance (read 2026-09-26). Reached on: IT Component fact sheet linked to the reference catalog.",
"glpi": "source read at 11.0.9: suppliers (install/mysql/glpi-empty.sql:7067 glpi_suppliers) carry address and contact fields only, and software versions (install/mysql/glpi-empty.sql:6900) carry no planned release date; grep -rli 'roadmap' src/*.php returns nothing."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkModuleversie"
+ ]
}
},
{
@@ -2096,6 +2922,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; user reviews with ratings of applications are not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no user review of an application with a rating is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: no product review model; satisfaction surveys rate ticket handling only (src/CommonITILSatisfaction.php) and knowledge base comments (src/KnowbaseItem_Comment.php) carry no rating. Ticket satisfaction is src/CommonITILSatisfaction.php:43 and knowledge base comments src/KnowbaseItem_Comment.php:43."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -2128,6 +2960,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no review ratings exist in the docs to aggregate (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no review ratings exist in the docs to aggregate (read 2026-09-26)",
"glpi": "source read at 11.0.9: with no product reviews there is no average rating; the only averages are ticket satisfaction statistics (src/CommonITILSatisfaction.php). Ticket satisfaction is src/CommonITILSatisfaction.php:43."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -2150,6 +2988,7 @@
"reachedOn": "Applications /modules create/edit form; not shown on ModuleDetail /modules/:id",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "supplier-directory",
"note": "Each product can point at its own contact person of the supplier through the form. The application page's data widget names the old Dutch keys, so the product's contact person (and its descriptions) do not show there; the same stale keys are on SuiteDetail (src/manifest.json:683). Specified in openspec/changes/landscape-application-page (OpenSpec pass 2026-09-27). The application page now shows the contact person under the current key contactPerson (built by openspec/changes/archive/2026-09-29-landscape-application-page).",
"evidence": {
"stackiq": "register :6856 module.contactPerson -> contactPerson with x-relation-filter organization = @object.provider; catalogService and suite also carry contactPerson; src/manifest.json:500 ModuleDetail md-data include lists 'contactpersoon', 'beschrijvingKort', 'beschrijvingLang', which are not module properties (renamed to contactPerson/shortDescription/longDescription)",
@@ -2158,6 +2997,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; supplier contact persons per product are not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; subscriptions name internal users per fact sheet, but supplier contact persons per product are not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: contacts link to a supplier as a whole, src/Contact_Supplier.php:39 (install/mysql/glpi-empty.sql:1429 glpi_contacts_suppliers), not to a product; per application there is only the free text contact field on an appliance (src/Appliance.php:214) and the user or technician in charge. Reached on: Management > Suppliers > Contacts tab; Appliance contact field."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -2180,6 +3025,7 @@
"reachedOn": "Organisations /organisaties and OrganisatieDetail /organisaties/:id (services and applications lists)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "supplier-directory",
"note": "One organisation record is the supplier; products reference it and the detail page lists them. A contract reaches the supplier only through its service, not by its own field.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/provider-modeling-guidelines: 'Providers are companies or entities that supply IT solutions, services, or technologies'; one provider fact sheet linked from IT components and contracts (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model) (read 2026-09-26). Reached on: Inventory > Provider fact sheet.",
@@ -2188,6 +3034,13 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C19: \"De verbinding met de leveranciersgegevens garandeert juiste schrijfwijzes van leveranciers- en pakketnamen en juiste versienummering\" (read 2026-09-26); https://www.softwarecatalogus.nl/leveranciers: one record per supplier with contact and addenda (read 2026-09-26). There are no contracts to point to it. Reached on: Alle leveranciers > supplier page.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967745-update-an-object-definition shows 'Supplier' as an object property value on an application definition; a supplier could be a separate ArchiMate actor object, but a vendor record that products and contracts point to is not documented (read 2026-09-26). Reached on: Object property Supplier.",
"glpi": "source read at 11.0.9: one Supplier record (src/Supplier.php:46, install/mysql/glpi-empty.sql:7067) is referenced by contracts through install/mysql/glpi-empty.sql:1550 glpi_contracts_suppliers and by the financial record of any item through install/mysql/glpi-empty.sql:3263 glpi_infocoms.suppliers_id. Reached on: Management > Suppliers."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisaties",
+ "SkOrganisatie"
+ ]
}
},
{
@@ -2220,6 +3073,12 @@
"stackiq": "src/manifest.json LifecycleRoadmap /portfolio-roadmap custom page -> src/views/LifecycleRoadmapView.vue:55 groups the selected organisation's usages by phase, derived from usage dates by src/utils/lifecyclePhase.js derivePhase (:408); usage.status enum Acquisition/Planned/In production/To be phased out/Phased out in lib/Settings/softwarecatalogus_register.json",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967531-object-lifecycle-state: 'Objects can be either Current (default) or Future state'; https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'TIME powered application lifecycle management ... Capture lifecycle'. Only current and future are documented as states, not planned, in use and phasing out (read 2026-09-26). Reached on: Objects > Object lifecycle state.",
"glpi": "source read at 11.0.9: appliances carry a status, install/mysql/glpi-empty.sql:8950 glpi_appliances.states_id and src/Appliance.php:350 search option Status, whose values are an admin defined tree dropdown src/State.php:45 (install/mysql/glpi-empty.sql:7027 glpi_states), so phases such as planned, in use and being phased out are set up and filtered on. Reached on: Management > Appliances, Status field; Setup > Dropdowns > Statuses of items."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRoadmap"
+ ]
}
},
{
@@ -2252,6 +3111,12 @@
"topdesk": "unknown: no replacement roadmap is described; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967586-roadmap: a roadmap template where 'The first axis shows the years and the second axis shows per business unit what should be done during those years', drawn as a view; https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'Portfolio decisions flow directly into roadmaps' (read 2026-09-26). Reached on: Views > Roadmap template.",
"glpi": "source read at 11.0.9: replacements can be planned as projects linked to the appliance, src/Appliance.php:108 Item_Project tab (src/Item_Project.php:45) and src/Project.php:50 Project with Kanban; the Gantt view is the separate gantt plugin (src/Project.php:599 checks isActivated('gantt')). No per organisation application roadmap view exists. Reached on: Tools > Projects, Appliance > Projects tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRoadmap"
+ ]
}
},
{
@@ -2274,6 +3139,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "portfolio-rationalisation",
"note": "Nothing finds applications in your landscape that fulfil the same reference component; the Reports card promises 'overlapping' software but the report does not compute it. Specified in openspec/changes/architecture-reference-component-coverage (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-rationalization: 'Application rationalization reduces costs, eliminates redundancies', using applications mapped to business capabilities; overlap is found per customer capability, not per reference component (read 2026-09-26). Reached on: Reports > Landscape Report by Business Capability.",
@@ -2282,6 +3148,13 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1: dashboard tile \"Referentiecomponenten met meerdere pakketten: Deze tegel signaleert dat er meer dan 1 pakket(versie) bij eenzelfde referentiecomponent in productie is\" (read 2026-09-26). Reached on: Dashboard tile Referentiecomponenten met meerdere pakketten.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin: 'overlapping application functions are quickly made visible'; https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'automatically detect redundancies' and 'Connect applications to capabilities for capability-based rationalization' (read 2026-09-26). Reached on: Application functions and capability maps.",
"glpi": "source read at 11.0.9: no reference component model to detect overlap on, grep -rli 'reference component\\|gemma' over src/ locales/glpi.pot returns nothing; appliances only carry a free type dropdown (install/mysql/glpi-empty.sql:8941 appliancetypes_id)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat",
+ "SkRapportages"
+ ]
}
},
{
@@ -2304,6 +3177,7 @@
"reachedOn": "PortfolioReport /portfolio-report, from Reports /reports card 'Portfolio rationalization' (footer menu Reports)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "portfolio-rationalisation",
"note": "The report covers ageing software (EOL exposure) with TIME quadrants, cost and a CSV export, but not overlap, even though its card says 'overlapping and ageing'. Specified in openspec/changes/architecture-reference-component-coverage (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-rationalization-evaluate-data: 'Leverage automated TIME classification, application portfolio and landscape reports ... to streamline application rationalization' (read 2026-09-26). Reached on: Use case Application Rationalization > reports.",
@@ -2312,6 +3186,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1: \"Referentiecomponenten met meerdere pakketten ... per referentiecomponent aan welke pakketversies daaraan gekoppeld zijn\" (read 2026-09-26). Overlap only, ageing not covered. Reached on: Dashboard tile.",
"bluedolphin": "https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'TIME analysis across your entire application portfolio', 'automatically detect redundancies, surface lifecycle risks' and 'Out of the box reports and insights'. No named rationalisation report is described in the help center (read 2026-09-26). Reached on: Insights and APM views.",
"glpi": "source read at 11.0.9: the built in report list src/Report.php:77 to src/Report.php:111 holds default, by contract, by year, financial, network, loan and status reports; none covers overlapping or ageing software."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRapportages"
+ ]
}
},
{
@@ -2335,7 +3215,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "lifecycle-and-end-of-support",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "The version an organisation runs is a field on its usage and drives the EOL badges, but no stackiq page lets the organisation set or change it. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-landscape-usage-registration.",
"evidence": {
"stackiq": "usage.moduleVersion (x-relation-filter module) set on the usage form from Add to our landscape and on GebruikDetail (src/manifest.d/usages.json); shown in the Applications in use list; tests/vitest/usages.spec.js",
@@ -2344,6 +3224,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; recording the version an organisation runs is not documented beyond configurable fields (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-modeling-guidelines: version can be captured 'in the Release field of the application fact sheets', which the guide says rarely adds value; IT components carry release per catalog item (https://help.sap.com/docs/leanix/ea/it-components-in-reference-catalog) (read 2026-09-26). Reached on: Application or IT Component fact sheet > Release.",
"glpi": "source read at 11.0.9: src/Item_SoftwareVersion.php:39 records which software version is installed on which item (install/mysql/glpi-empty.sql:1064 glpi_items_softwareversions with date_install at :1074), filled by hand or by native inventory, and listed on the Software Installations tab (src/Software.php:129). Reached on: Assets > Software > Installations tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInGebruik",
+ "SkGebruikDetail"
+ ]
}
},
{
@@ -2367,7 +3254,7 @@
"provider": "openregister",
"providerHow": "read-from-code",
"feature": "notifications",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The only rule is a register declaration, and it addresses the version's own managers and catalogue admins, not the organisations that use the application, so even if OpenRegister dispatches it a user of the application is not told. Specified in ConductionNL/openregister openspec/changes/notifications-new-notes-and-referrers (openregister lane, OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "lib/Settings/softwarecatalogus_register.json moduleVersion x-openregister-notifications module-version-published (trigger created, recipients object-acl manage + group software-catalog-admins); no notification code in lib (no INotificationManager use)",
@@ -2376,6 +3263,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; notifications about supplier releases are not documented (read 2026-09-26)",
"sap-leanix": "unknown: https://updates.leanix.net/announcements/work-with-complete-technology-version-coverage-without-raising-manual-requests (2026-09-17) says the catalog is 'kept current as new versions are released', with version concurrency management planned for Q4; a notification to users about a new version is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: GLPI has no feed of supplier releases; software versions appear only when entered or inventoried (src/SoftwareVersion.php:42), and notification events for software are licence expiry only (src/NotificationTargetSoftwareLicense.php)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard",
+ "SkModuleversie"
+ ]
}
},
{
@@ -2398,6 +3292,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "lifecycle-and-end-of-support",
"note": "You could register a database as its own 'System software' module and feed its EOL, but nothing ties it to the applications that depend on it; SBOM components carry no lifecycle. Specified in openspec/changes/operations-technology-components (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/obsolescence-risk-management: 'SAP LeanIX helps you gain an overview of your application landscape's obsolescence risk exposure' over the technology layer; lifecycle dates of IT components from the catalog in Technology Risk and Compliance (read 2026-09-26). Reached on: Use case Obsolescence Risk Management; IT Component lifecycle.",
@@ -2406,6 +3301,13 @@
"vng-softwarecatalogus": "unknown: technologies per version are recorded (\"Pakketversie is beschikbaar voor één of meerdere technologien; databases, OS, SAAS\") but no lifecycle for them is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Beschrijving%20exportbestanden (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; technology objects carry only current or future state (https://help.bluedolphin.io/en/articles/11967531-object-lifecycle-state), end of support tracking of technology is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: underlying technology is held as items (database instances src/DatabaseInstance.php, operating systems, software) and each can carry a financial record with warranty and decommission date, install/mysql/glpi-empty.sql:3282 glpi_infocoms.decommission_date; there is no end of support date or lifecycle feed (grep -i 'end_of_support' install/mysql/glpi-empty.sql returns nothing). Reached on: any item > Management tab (Infocom)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail",
+ "SkModuleversie"
+ ]
}
},
{
@@ -2427,6 +3329,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "portfolio-roadmap",
"note": "Strategic goals are not modelled. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (lifecycle).",
"evidence": {
"bluedolphin": "https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'Applications natively connected to BPMN, ERD, strategy, and initiatives' and 'Align applications with strategic initiatives and roadmaps'; https://bluedolphin.io/capability-based-planning/: 'Connect capabilities directly to business objectives' (read 2026-09-26). Reached on: Objects and relationships to strategy elements.",
@@ -2435,6 +3338,10 @@
"vng-softwarecatalogus": "unknown: no strategic goals are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/objective-modeling-guidelines: 'Objectives link to business capabilities and initiatives so progress can be tracked over time'. Applications reach objectives through capabilities and initiatives; a direct application to objective relation is not described (read 2026-09-26). Reached on: Objective fact sheet.",
"glpi": "source read at 11.0.9: grep -i 'strategic\\|goal\\|objective' over src/Appliance.php and src/Project.php returns nothing; no goal itemtype exists and the Appliance tabs (src/Appliance.php:98 onwards) link items, contracts, documents, tickets and projects only."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -2467,6 +3374,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; planned maintenance of applications is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; planned maintenance is documented only for LeanIX's own service status (https://help.sap.com/docs/leanix/ea/status-pages-and-status-emails), not for applications in the inventory (read 2026-09-26)",
"glpi": "source read at 11.0.9: no maintenance window on an item ('Maintenance mode' in locales/glpi.pot:7534 is GLPI's own downtime switch); planned work is a change linked to the appliance, src/Appliance.php:107 Change_Item tab, with planned tasks carrying begin and end (install/mysql/glpi-empty.sql:792 glpi_changetasks, :799 begin, :800 end) shown in the planning. Reached on: Appliance > Changes tab; Assistance > Planning."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard",
+ "SkApplicatie"
+ ]
}
},
{
@@ -2499,6 +3413,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; 'Contract' exists as an ArchiMate object definition (https://help.bluedolphin.io/en/articles/11967596-archimate-annotation-translation-en-nl) and contract dates appear in an import example (https://help.bluedolphin.io/en/articles/11967633-datacollector-select-tricks), but a contract status that moves by itself is not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: 'The contract fact sheet uses lifecycle phases to represent the current state of a contract': Plan, Phase In, Contract Start Date (active), Contract Notice Period, Contract End Date (expired). Requires the contract extension (read 2026-09-26). Reached on: Contract fact sheet > Lifecycle.",
"glpi": "source read at 11.0.9: contract status is a manual dropdown (install/mysql/glpi-empty.sql:1512 glpi_contracts.states_id); expiry is computed, src/Contract.php:654 virtual 'Expiration' column from begin date, duration and renewal, and src/Contract.php:1092 cronContract sends end and notice alerts, but the status itself never moves by itself. Reached on: Management > Contracts list, Expiration column."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContracten",
+ "SkContract"
+ ]
}
},
{
@@ -2532,6 +3453,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; contract renewal decisions are not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: 'Contract Renewal Status ... Backlog, In Review, In Progress, or Done', 'Contract Renewal Decision ... Renew, Terminate, or No Decision', 'Contract Renewal Comments' (read 2026-09-26). Reached on: Contract fact sheet > Contract Renewal.",
"glpi": "source read at 11.0.9: src/Contract.php:60 to :62 renewal kinds never, tacit and express, with the renewal computation in the alert cron (src/Contract.php:1293); there is no renewal decision record or outcome, only the contract's renewal setting and an optional approval through a change. Reached on: Management > Contracts, Renewal field."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContract"
+ ]
}
},
{
@@ -2555,7 +3482,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "contract-administration",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The application page lists the contracts behind the application, found through its usages and the services that offer it. Built by openspec/changes/archive/2026-09-29-landscape-application-page.",
"evidence": {
"glpi": "source read at 11.0.9: src/Appliance.php:99 and src/Software.php:131 add the Contract_Item tab (src/Contract_Item.php:43, install/mysql/glpi-empty.sql:1536 glpi_contracts_items) listing every contract of the application. Reached on: Management > Appliances > Contracts tab. Driven on the lab at 11.0.9 (2026-09-26): after linking the contract, the appliance Contracts tab showed \"Lab contract, 2025-01-01, 12 months -> 2025-12-31\".",
@@ -2564,6 +3491,13 @@
"vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967596-archimate-annotation-translation-en-nl lists the object definition 'Contract', which can be related to an application and then shows in its Relationships tab (https://help.bluedolphin.io/en/articles/11967550-working-with-object-relationships); https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'Capture lifecycle, technical debt, business value, risk, cost, contracts' (read 2026-09-26). Reached on: Application object > Relationships tab (Contract objects).",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: relation 'Attached to Contract - Application Many-to-Many Links the contract to the applications it licenses or supports' (read 2026-09-26). Reached on: Application fact sheet > Contracts relation."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkContract"
+ ]
}
},
{
@@ -2596,6 +3530,13 @@
"topdesk": "https://docs.topdesk.com/en/creating-a-contract.html: \"Costs (Services) ... Total internal cost, based on the service levels linked\" (read 2026-09-26); https://docs.topdesk.com/en/obtain-insights-with-asset-type-report.html: \"do you need an overview of the total costs of new assets? Use the Asset Type Report\" (read 2026-09-26). Reached on: Contract card > Financial; Asset Type Report.",
"vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967733-quick-start-guide shows a questionnaire field 'Estimate of annual application costs' of type currency; https://bluedolphin.io/application-portfolio-management-application-rationalization/: 'portfolio analysis from cost, risk, lifecycle' (read 2026-09-26). Reached on: Application questionnaire > annual cost field."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRapportages",
+ "SkLicenties"
+ ]
}
},
{
@@ -2619,7 +3560,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "license-and-seat-tracking",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "An application records open versus closed source and which open-source licence. There is no licence metric such as per user, per organisation or per seat. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27). A licence contract now records its licence metric, per user, per device, per inhabitant or per organisation (built by openspec/changes/archive/2026-09-29-contracts-licence-seats).",
"evidence": {
"glpi": "source read at 11.0.9: each licence has a type, install/mysql/glpi-empty.sql:6775 glpi_softwarelicenses.softwarelicensetypes_id, an admin editable dropdown seeded with types such as OEM (install/empty_data.php:9294). Reached on: Management > Licenses (front/softwarelicense.php), Type field.",
@@ -2628,6 +3569,13 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/nieuws: \"is er de leverancier Open Source Pakketten aangemaakt. Onder deze leverancier staat nu een aantal veelgebruikte open source pakketten geregistreerd\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakket/archi: Archi listed under supplier \"Open Source pakketten\", version \"Open source\" (read 2026-09-26). Other licence models are not recorded. Reached on: Alle pakketten > Leverancier Open Source pakketten.",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; licence data appears only as example source columns ('Monthly License Costs', https://help.bluedolphin.io/en/articles/11967633-datacollector-select-tricks), a licence model field is not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: 'Contract Pricing Type Single select Consumption Based, Yearly Commitment, or Perpetual Licenses'. No open source or per user licence model field on the application is documented (read 2026-09-26). Reached on: Contract fact sheet > Contract Pricing Type."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContract",
+ "SkLicenties"
+ ]
}
},
{
@@ -2650,6 +3598,7 @@
"reachedOn": "License posture /license-posture (main menu)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "license-and-seat-tracking",
"note": "The page shows the open-source share of the running portfolio with per-vendor and per-organisation breakdowns, computed at read time.",
"evidence": {
"stackiq": "src/views/LicensePostureView.vue:11 open-source vs closed-source share weighted by in-production usage, :64 per-vendor rollup, per-organisation open-source-first report; derived in src/utils/licensePosture.js",
@@ -2658,6 +3607,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; a portfolio licence posture view is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; licence analysis exists only for SBOM components of self-built software (https://help.sap.com/docs/leanix/ea/sbom-explorer 'assess license risks by filtering for unpermitted licenses'), not a portfolio share of open source applications (read 2026-09-26)",
"glpi": "source read at 11.0.9: licences can be listed and filtered by type in search (install/mysql/glpi-empty.sql:6775 softwarelicensetypes_id), but the dashboard's per type charts cover asset types and Software only (src/Glpi/Dashboard/Grid.php:1452), not licences, and no portfolio share view exists. Reached on: Management > Licenses, filtered by type."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkLicenties"
+ ]
}
},
{
@@ -2690,6 +3645,13 @@
"vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; licences bought against used are not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for seats and licence counts, no hits; the contract fact sheet has cost fields but no licence quantity (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model) (read 2026-09-26)"
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContract",
+ "SkLicenties"
+ ]
}
},
{
@@ -2711,6 +3673,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "license-and-seat-tracking",
"note": "Without seat or consumption data there is nothing to compute an effective licence position from. Decided no on 2026-09-29 by the build-all lane, reversible: no competitor rates yes, no demand, outside the core areas (contracts).",
"evidence": {
"stackiq": "No entitlement or consumption fields exist (see ctr-seat-count); no service computes a licence position in lib/Service/",
@@ -2719,6 +3682,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no entitlement against consumption computation is documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no entitlement against consumption computation is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: entitlement is compared with licence assignments (src/SoftwareLicense.php:158 computeValidityIndicator), while measured installations are counted separately (src/Item_SoftwareVersion.php:39); core has no computed effective licence position report reconciling the two for an audit. Reached on: Assets > Software > Licenses and Installations tabs."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -2740,6 +3707,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "contract-administration",
"note": "Contracts carry a cost and a period, but there is no budget to charge them against. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (contracts).",
"evidence": {
"glpi": "source read at 11.0.9: src/Budget.php:46 Budget with a period and value (install/mysql/glpi-empty.sql:306 begin_date, :307 end_date, :308 value); contract costs (install/mysql/glpi-empty.sql:1466 budgets_id) and financial records (src/Infocom.php:599 budgets_id check) are charged to it. Reached on: Management > Budgets. Driven on the lab at 11.0.9 (2026-09-26): /front/budget.php lists budgets with type, start date, end date and value.",
@@ -2748,6 +3716,10 @@
"vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for budget, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; the contract has a 'Contract Cost Center' string (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model) but charging costs against a budget with a period is not documented (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -2769,6 +3741,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "contract-administration",
"note": "No purchase value, useful life or depreciation is recorded or computed. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (contracts).",
"evidence": {
"glpi": "source read at 11.0.9: the financial record of any item, licences included (src/SoftwareLicense.php:245 Infocom tab), carries depreciation type, duration and coefficient (install/mysql/glpi-empty.sql:3269 sink_time, :3270 sink_type, :3271 sink_coeff); src/Infocom.php:872 Linear and degressive types and src/Infocom.php:1085 linearAmortise compute the table. Reached on: Management > Licenses > Management tab.",
@@ -2777,6 +3750,10 @@
"vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for depreciation, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for depreciation and amortisation, no hits (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -2801,7 +3778,7 @@
"provider": "openregister",
"providerHow": "read-from-code",
"feature": "contract-administration",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The contract page has a Documents files panel for the signed contract, plus a document reference field.",
"evidence": {
"stackiq": "src/manifest.json:567 ContractDetail ct-files integration 'files' (Documents panel); catalogContract.documentReference text field (register :3252)",
@@ -2810,6 +3787,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967535-object-attachments: files can be uploaded to any object, which covers a Contract object (https://help.bluedolphin.io/en/articles/11967596-archimate-annotation-translation-en-nl); no contract record with a document slot is documented (read 2026-09-26). Reached on: Contract object > attachments.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/collaborating-and-managing-data-in-fact-sheets: 'Files : You can upload files up to 10 MB. Uploaded files are then visible in the Resources tab', which applies to the contract fact sheet of the contract extension (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model) (read 2026-09-26). Reached on: Contract fact sheet > Resources tab.",
"glpi": "source read at 11.0.9: src/Contract.php:126 adds the Documents tab (Document_Item) to every contract, storing the signed file in install/mysql/glpi-empty.sql:2585 glpi_documents. Reached on: Management > Contracts > Documents tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContract"
+ ]
}
},
{
@@ -2832,6 +3815,7 @@
"reachedOn": "Reports /reports -> Portfolio rationalization /portfolio-report",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "contract-administration",
"note": "The portfolio report shows each application in use with its cloud model and annualised cost, which lets you pick out SaaS spend. There is no SaaS subscription list and nothing discovers purchases made outside IT.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/saas-discovery: discovery helps 'Eliminate shadow IT and business-managed IT' but 'The SaaS discovery feature in SAP LeanIX does not provide insight into cost, adoptions, contracts, and other SaaS specifics'. Spend only through manually kept contract costs (read 2026-09-26). Reached on: SaaS Discovery inbox; Contract fact sheet costs.",
@@ -2840,6 +3824,13 @@
"vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SaaS and spend, no SaaS subscription tracking is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -i 'saas' over src/ returns nothing; spend is only what is entered on contracts and financial records (install/mysql/glpi-empty.sql:1458 glpi_contractcosts), with no discovery of subscriptions bought outside IT."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRapportages",
+ "SkLicenties"
+ ]
}
},
{
@@ -2862,6 +3853,7 @@
"reachedOn": "Vulnerabilities /kwetsbaarheden (menu), Report vulnerability",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "A vulnerability is registered with CVE code and CVSS score, and a severity band is derived from the score.",
"evidence": {
"vng-softwarecatalogus": "unknown: no vulnerability register is described; the docs do not state its absence either (the IBD-foto export only hands CPE identifiers to the IBD); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Hoe%20maak%20je%20een%20ICT-foto%20voor%20de%20IBD%3F (read 2026-09-26)",
@@ -2870,6 +3862,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for vulnerability and CVE, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no vulnerability record with CVE code and score exists, vulnerabilities are handled by searching SBOM components (https://help.sap.com/docs/leanix/ea/searching-for-sbom-library-components-by-package-url 'after a CVE alert') (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'cve\\|vulnerab\\|cvss' over src/ templates/ hits only src/Glpi/System/Requirement/PhpSupportedVersion.php:74 (a PHP version warning) and a session comment at src/Session.php:1085; no vulnerability itemtype exists."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKwetsbaarheden"
+ ]
}
},
{
@@ -2892,6 +3890,7 @@
"reachedOn": "Vulnerabilities /kwetsbaarheden form (Affected applications); ModuleversieDetail Components tab shows matches",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "A vulnerability links to applications, not to specific versions. Per-version affectedness only shows as a computed match against a version's imported SBOM.",
"evidence": {
"stackiq": "register.json:1701 vulnerability.modules links to module (applications), not moduleVersion; version level only via read-time SBOM match (src/utils/sbomVulnerabilityMatch.js, sbomComponent.vexCveIds register.json:8024) on the ModuleversieDetail Components tab",
@@ -2900,6 +3899,13 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for vulnerability and CVE, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; a PURL search finds services using a given library version, but linking a vulnerability record to affected versions is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: no vulnerability model (see src/Glpi/System/Requirement/PhpSupportedVersion.php:74 as the only 'vulnerabilities' hit), so nothing links to software versions (install/mysql/glpi-empty.sql:6900)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModuleversie",
+ "SkKwetsbaarheden"
+ ]
}
},
{
@@ -2922,6 +3928,7 @@
"reachedOn": "ModuleversieDetail /moduleversies/:id, sidebar tab Components, upload",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "An SBOM in CycloneDX JSON or SPDX 2.x JSON can be uploaded for a version. XML and tag-value formats are not accepted.",
"evidence": {
"stackiq": "src/components/sbom/SbomComponentsPanel.vue:53-90 upload control (.json) -> POST /api/moduleversies/{uuid}/sbom (appinfo/routes.php:92) -> lib/Controller/SbomController.php:129 importSbom -> lib/Service/SbomParserService.php:77 parse (CycloneDX JSON) and :127 parseSpdx (SPDX 2.x JSON)",
@@ -2930,6 +3937,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SBOM, CycloneDX and SPDX, no hits (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/uploading-sboms-from-fact-sheets: 'select a CycloneDX or SPDX file in JSON or XML format' on a microservice fact sheet; also through the Self-Built Software Discovery API. Technology Risk and Compliance product (read 2026-09-26). Reached on: Microservice fact sheet > SBOM > Upload SBOM.",
"glpi": "source read at 11.0.9: grep -ril 'sbom\\|cyclonedx\\|spdx' over src/ templates/ locales/glpi.pot returns nothing; inventory import (src/Glpi/Inventory/) takes glpi-agent JSON only. Inventory import is src/Glpi/Inventory/Inventory.php:106."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModuleversie"
+ ]
}
},
{
@@ -2951,6 +3964,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "Matching is deliberately local, against vulnerabilities typed into the catalogue. No public CVE feed is read.",
"evidence": {
"stackiq": "src/utils/sbomVulnerabilityMatch.js header: 'no HTTP request to an external advisory feed is ever made'; lib/Service/EolSyncService.php syncs endoflife.date support dates, not CVEs",
@@ -2959,6 +3973,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for CVE, no hits (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/api-updates-sbom-async-processing: 'Switching to asynchronous processing will allow us to add additional post-processing mechanisms in the future, such as vulnerability checks. Though there's no established timeline for these enhancements' (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'cve' over src/ templates/ finds no feed matching (only src/Glpi/System/Requirement/PhpSupportedVersion.php:74 text); no pluginsGLPI cve repository exists (git ls-remote https://github.com/pluginsGLPI/cve: repository not found)."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -2980,6 +3998,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "Only individual vulnerabilities get a severity band. No application gets a combined score from its vulnerabilities and support status. Decided no on 2026-09-29 by the build-all lane, reversible: no competitor rates yes, no demand, outside the core areas (security).",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/technology-obsolescence-risk-statuses-and-views-in-reports: 'Learn how risk statuses are determined for IT components and applications', e.g. 'Unaddressed Risk ... when the internal end-of-life date or vendor-provided end-of-support date is in the past'. Based on support status, not vulnerabilities (read 2026-09-26). Reached on: Technology Risk and Compliance > obsolescence risk status on applications.",
@@ -2988,6 +4007,10 @@
"vng-softwarecatalogus": "unknown: no vulnerability, SBOM or risk functions are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; risk can be captured in questionnaires ('Risk Assessment' example in https://help.bluedolphin.io/en/articles/11967710-using-the-odata-feed), but a risk score from vulnerabilities and support status is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: with no vulnerability data (only hit src/Glpi/System/Requirement/PhpSupportedVersion.php:74) and no support status field on software (install/mysql/glpi-empty.sql:6856 glpi_softwares), no risk score is computed; grep -i 'risk' over src/Appliance.php src/Software.php returns nothing."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3009,6 +4032,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "The exposure row shows which version is deployed, but no record says which version fixes the vulnerability, so patch status cannot be seen. Decided no on 2026-09-29 by the build-all lane, reversible: no competitor rates yes, no demand, outside the core areas (security).",
"evidence": {
"stackiq": "register.json:1619 vulnerability has no fixed-in version field; src/utils/vulnerabilityExposure.js computeExposure carries the deployed moduleVersion per usage but nothing compares it to a fix",
@@ -3017,6 +4041,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for patch and vulnerability, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no per vulnerability patch status is documented, and vulnerability checks are a future item (https://help.sap.com/docs/leanix/ea/api-updates-sbom-async-processing) (read 2026-09-26)",
"glpi": "source read at 11.0.9: installed versions are known (src/Item_SoftwareVersion.php:39) but no vulnerability or fixed in version exists to compare against (grep -rli 'cve' src/ hits only src/Glpi/System/Requirement/PhpSupportedVersion.php:74)."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3039,6 +4067,7 @@
"reachedOn": "Vulnerabilities /kwetsbaarheden (menu)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "All vulnerabilities are listed with severity filters and each opens in the record form. The separate KwetsbaarheidDetail page is not what a row opens.",
"evidence": {
"stackiq": "src/manifest.json:969 Kwetsbaarheden custom page -> src/views/KwetsbaarhedenView.vue table with severity tabs; row click openDetail (:417) opens the record in the generic ObjectModal",
@@ -3047,6 +4076,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for vulnerability, no hits (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no vulnerability list exists, only the SBOM explorer of components (https://help.sap.com/docs/leanix/ea/sbom-explorer) (read 2026-09-26)",
"glpi": "source read at 11.0.9: no vulnerability itemtype and no such entry in any menu (src/Html.php:1295 to :1334 list Management, Tools, Administration and Setup types)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKwetsbaarheden"
+ ]
}
},
{
@@ -3069,6 +4104,7 @@
"reachedOn": "Organisations /organisaties (main menu), Add form; walkthrough step create-organisatie",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "organisation-register",
"note": "Organisations are created on the index with their type. The required contactsUid is a Nextcloud Contacts UID the form asks for as text, which is awkward but does not block the capability.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/hoe-kan-ik-me-als-leverancier-aanmelden: \"Als leverancier kunt u zich aanmelden door de volgende gegevens te sturen naar softwarecatalogus@vng.nl\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/16564: C4 roles for gemeente and samenwerking accounts (read 2026-09-26); organisation types gemeente, samenwerking, leverancier. Reached on: Registration via VNG helpdesk.",
@@ -3077,6 +4113,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967491-business-units: business units 'represent more or less standalone parts of your organization, such as sales, marketing, OpCo France'; outside organisations can be ArchiMate business actor objects. No register of municipalities, suppliers or cooperations with a type is documented (read 2026-09-26). Reached on: System settings > Business Units.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/organization-modeling-guidelines: organization subtypes 'Business Unit, Customer, Region, Legal Entity, Team' for 'your hierarchical business architecture'; suppliers are separate provider fact sheets. No municipality or cooperation types for outside organisations (read 2026-09-26). Reached on: Inventory > Organization fact sheet.",
"glpi": "source read at 11.0.9: external organisations are suppliers with a type dropdown (src/Supplier.php:46, install/mysql/glpi-empty.sql:7072 suppliertypes_id); the organisation's own units are entities (src/Entity.php:58). There is no generic organisation register covering municipalities or cooperations. Reached on: Management > Suppliers; Administration > Entities."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisaties"
+ ]
}
},
{
@@ -3109,6 +4151,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; organisations do not self register, so no moderation queue is documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; organisations do not self register, so no moderation queue is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: suppliers are created by staff (src/Supplier.php:75 sets is_active on a new record) and there is no self registration or approval queue for organisations; grep -i 'moderat' over src/Supplier.php src/Entity.php returns nothing."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkBeheerToegang"
+ ]
}
},
{
@@ -3130,6 +4178,7 @@
"reachedOn": "nothing reaches it (Organisations /organisaties can only filter by status)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "organisation-register",
"note": "No working path moves an organisation between Draft, Active and Inactive: the field is hidden on the form and locked on the detail page, the status dialog is orphaned, and the Nextcloud dashboard widget still uses the old Dutch values so it lists nothing and would write an invalid value. Missing half decided no on 2026-09-29 (defer retired 2026-09-28): only GLPI rates partial, no competitor rates yes, no demand, outside the core areas (organisations). The broken half stays: the organisation status field is hidden and locked, the status dialog is never opened, and the concept organisations dashboard widget filters and writes the old Dutch values concept and actief, which are not in the enum.",
"evidence": {
"stackiq": "register organization.status enum Draft/Active/Inactive is visible:false (:2022); src/manifest.json:403 OrganisatieDetail overrides status editable:false; src/modals/object/ChangeOrganisatieStatusDialog.vue:211 is never opened (no setDialog('changeOrganisatieStatus') in src); src/views/widgets/ConceptOrganisatiesWidget.vue:100 filters status 'concept' and :129 writes 'actief', neither an enum value",
@@ -3138,6 +4187,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; concept, active and inactive states for organisations are not documented (read 2026-09-26)",
"sap-leanix": "unknown: https://help.sap.com/docs/leanix/ea/organization-modeling-guidelines does not describe concept, active and inactive states for organizations; only archiving of fact sheets in general (https://help.sap.com/docs/leanix/ea/archiving-deleting-and-recovering-fact-sheets) (read 2026-09-26)",
"glpi": "source read at 11.0.9: a supplier is active or inactive, src/Supplier.php:365 is_active search option (install/mysql/glpi-empty.sql:7087 glpi_suppliers.is_active); there is no concept state. Reached on: Management > Suppliers, Active field."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3160,6 +4213,7 @@
"reachedOn": "OrganisatieDetail /organisaties/:id 'Contact persons' list -> ContactpersoonDetail /contactpersonen/:id",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "organisation-register",
"note": "Contact persons are listed on the organisation with their function and roles. The Contactpersonen index page exists but has no menu entry.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/30402: \"Bij elke leverancier is een contactpersoon opgevoerd. Deze persoon is verantwoordelijk voor de inhoud\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/16564: E4 contact details of municipalities (read 2026-09-26). One contact, no roles. Reached on: Supplier page header.",
@@ -3168,6 +4222,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; contact persons with roles per organisation are not documented beyond generic questionnaire fields (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/subscription-roles: users subscribe to any fact sheet, including organization fact sheets, with roles 'such as application owner, project manager'. Contacts must be workspace users; external contact persons are not documented (read 2026-09-26). Reached on: Organization fact sheet > Subscriptions.",
"glpi": "source read at 11.0.9: contacts (src/Contact.php:45, install/mysql/glpi-empty.sql:1390 glpi_contacts) carry a contact type and a title (:1402 contacttypes_id, :1405 usertitles_id) and are linked to suppliers through src/Contact_Supplier.php:39. Reached on: Management > Contacts; Supplier > Contacts tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
}
},
{
@@ -3191,6 +4251,7 @@
"reachedOn": "App header OrganisationSwitcher (src/App.vue:55) -> Manage members",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"note": "A beheerder of the organisation grants an existing Nextcloud user access to it from the header switcher; membership is stored by OpenRegister. It adds existing users; it does not send an invitation to a new person.",
"evidence": {
"stackiq": "src/components/organisations/OrganisationSwitcher.vue:48 'Manage members' (isBeheerder) opens src/modals/GrantOrganisationAccessModal.vue; POST /api/organisations/{uuid}/members -> lib/Controller/OrganisationMembersController.php:108 authorizeBeheerder then OpenRegister joinOrganisation (:123)",
@@ -3199,6 +4260,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967616-user-management: admins 'Add new users' and BlueDolphin sends 'An invitation with a link to activate the account'; business units scope what users see (https://help.bluedolphin.io/en/articles/11967491-business-units) (read 2026-09-26). Reached on: Admin > Users.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/users-overview: 'Invited users who haven't yet accepted the invitation request. You can reinvite a user'; https://help.sap.com/docs/leanix/ea/virtual-workspaces-configuration: 'Set up virtual workspaces to manage access for custom user groups' (read 2026-09-26). Reached on: Administration > Users > Invite.",
"glpi": "source read at 11.0.9: an administrator gives a user a profile on an entity, install/mysql/glpi-empty.sql:5917 glpi_profiles_users with profiles_id and entities_id, set on the user's Authorizations tab or automatically by authorisation rules (src/RuleRight.php:297 profiles_id action, :273 entities_id action). There is no emailed invitation link. Reached on: Administration > Users > Authorizations tab."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
}
},
{
@@ -3222,6 +4289,7 @@
"reachedOn": "App header OrganisationSwitcher on every page",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"note": "A user who belongs to several organisations switches the active one from the header; OpenRegister holds the memberships.",
"evidence": {
"stackiq": "src/components/organisations/OrganisationSwitcher.vue:197 POST /apps/openregister/api/organisations/{uuid}/set-active; mounted in src/App.vue:55",
@@ -3230,6 +4298,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967491-business-units: 'When there are multiple business units, you can switch between them by selecting a business unit from the dropdown'. Switching is between parts of one tenant, not between separate organisations (read 2026-09-26). Reached on: Top menu > business unit dropdown.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/manage-workspace-access: 'Provide users with access to exactly their relevant workspaces ... direct URL, base URLs or the workspace chooser to access available workspaces' (read 2026-09-26). Reached on: Workspace chooser.",
"glpi": "source read at 11.0.9: one user can hold several profile and entity pairs (install/mysql/glpi-empty.sql:5917 glpi_profiles_users) and switch between them in the session, src/Session.php:461 changeActiveEntities and src/Session.php:592 changeProfile. Reached on: user menu, entity and profile selector."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
}
},
{
@@ -3252,6 +4326,7 @@
"reachedOn": "OrganisatieDetail /organisaties/:id, merge panel (admin only)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "organisation-register",
"note": "An admin can merge a municipality into another and its usages, contacts and contracts follow. A supplier takeover leaves the source's applications and services pointing at the tombstoned supplier, because module and service provider fields are not in the relation map. Corrections round 8 (2026-09-28): besides module and service providers, a merge also leaves usage providers, collaboration memberships and GEMMA model organisations on the merged-away organisation, stackiq#1086; rating kept because usages, contacts, connections and contracts are still re-pointed.",
"evidence": {
"stackiq": "src/manifest.json:430 OrganisationMergePanel on OrganisatieDetail; POST /api/organisaties/{uuid}/merge -> lib/Controller/MergeController.php:142 isAdmin; lib/Service/MergeOrganisatieService.php:111 re-points usage.consumer/participants, contactPerson.organization, connection.provider and @self.organisation of catalogContract/compliancy; module.provider and catalogService.provider are not re-pointed",
@@ -3260,6 +4335,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; objects can be copied or moved between business units and workspaces (https://help.bluedolphin.io/en/articles/11967491-business-units), but merging two organisations with their relations is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for merge of fact sheets or organizations, no hits (read 2026-09-26)",
"glpi": "source read at 11.0.9: entities cannot be merged, src/Entity.php:202 forbids the dropdown merge action for Entity; records can be moved into another entity with src/Transfer.php:50 Transfer (massive action add_transfer_list, src/MassiveAction.php:598), keeping or cleaning linked items per transfer options. Reached on: Administration > Entities; list Actions > Add to transfer list."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
}
},
{
@@ -3282,6 +4363,7 @@
"reachedOn": "OrganisatieDetail /organisaties/:id, merge panel (admin only)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "organisation-register",
"note": "The merge panel shows per-type counts of what would be re-pointed before the admin confirms. It inherits the merge's blind spot: module and service provider links are not counted because they are not moved. Corrections round 8 (2026-09-28): the preview omits the six organisation references the merge does not move, stackiq#1086; rating kept because the preview matches exactly what the merge will change.",
"evidence": {
"glpi": "source read at 11.0.9: with no merge (src/Entity.php:202 forbids merge for Entity) there is nothing to preview; the transfer (src/Transfer.php:50) runs directly without a what would change report.",
@@ -3290,6 +4372,12 @@
"vng-softwarecatalogus": "unknown: no merge of organisations is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no merge preview is documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no merge, so no merge preview is documented (read 2026-09-26)"
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
}
},
{
@@ -3312,6 +4400,7 @@
"reachedOn": "admin settings section 'User groups'",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"note": "An admin configures which groups count as generic users, organisation admins and super users. The catalogue roles themselves map to fixed, hard-coded group names and by organisation type, so an admin cannot map e.g. 'buyer' onto a group of their choice. Specified in openspec/changes/organisations-role-mapping-and-access-review (OpenSpec pass 2026-09-27). Corrections round 8 (2026-09-28): contacts of municipalities, suppliers and collaborations get no role group, only Community contacts do, stackiq#1137; rating kept because generic user groups are still configurable and an admin can still put a user in a catalogue group by hand (src/dialogs/ManageUserGroupsDialog.vue:175).",
"evidence": {
"glpi": "source read at 11.0.9: roles are profiles with per right settings (src/Profile.php:55), mapped onto groups and directory attributes by authorisation rules, src/RuleRight.php:236 group criterion and src/RuleRight.php:297 profile action. Reached on: Administration > Profiles; Administration > Rules > Authorizations assignment rules.",
@@ -3320,6 +4409,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C4 roles gemeentebeheerder, raadpleger, samenwerkingsbeheerder; \"Er is géén rol voor het raadplegen van een samenwerking\" (read 2026-09-26). Fixed roles, no mapping onto groups. Reached on: Gebruikersbeheer.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967624-manage-roles-and-permissions: 'BlueDolphin uses Role-Based Access Control (RBAC). Every user is linked to one or more roles ... Add and delete custom roles' (read 2026-09-26). Reached on: Admin > Roles.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/sso-attribute-overview: 'The role to be assigned to the user. Required values: ADMIN, MEMBER, or VIEWER' and 'customer_roles ... The custom role to be assigned', mapped from identity provider groups (read 2026-09-26). Reached on: Administration > Users; SSO role attributes."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkBeheerToegang"
+ ]
}
},
{
@@ -3341,6 +4436,7 @@
"reachedOn": "nothing reaches it in stackiq; Nextcloud's own user_oidc/user_saml apps would apply platform-wide",
"provider": "nextcloud",
"providerHow": "read-from-code",
+ "feature": "sign-in-and-accounts",
"note": "Stackiq does nothing for single sign-on. A Nextcloud admin can add an identity provider app, but that is the platform, not a stackiq page. Decided no (OpenSpec pass 2026-09-27): Platform capability: Nextcloud signs users in through its identity provider apps (user_oidc, user_saml) for every app, and stackiq users are Nextcloud users. built.owner corrected to nextcloud/server.",
"evidence": {
"glpi": "source read at 11.0.9: src/Auth.php:106 EXTERNAL (web server provided identity, for example a SAML or OIDC module in front of GLPI), src/Auth.php:107 CAS with phpCAS::client at src/Auth.php:557, and src/Auth.php:108 X509 certificates, next to LDAP at src/Auth.php:105. Reached on: Setup > Authentication > Other authentication methods.",
@@ -3349,6 +4445,10 @@
"vng-softwarecatalogus": "unknown: login is by username and password (\"Vul uw GEMMA Softwarecatalogus-gebruikersnaam in\"); no identity provider sign-in is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/user/login (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967579-configure-single-sign-on-sso-with-okta-saml and https://help.bluedolphin.io/en/articles/11967575-configure-single-sign-on-sso-with-office-365-openid: SSO setup guides for Okta SAML, ADFS and Office 365 OpenID (read 2026-09-26). Reached on: Admin > Identity providers.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/managing-users: 'SSO : You manage access through your identity provider (IdP) system. Users sign in through your IdP'; guides for Entra ID, Okta, OneLogin (read 2026-09-26). Reached on: Administration > Single Sign-On."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3370,6 +4470,7 @@
"reachedOn": "nothing reaches it in stackiq; Nextcloud's user_ldap would apply platform-wide",
"provider": "nextcloud",
"providerHow": "read-from-code",
+ "feature": "sign-in-and-accounts",
"note": "Stackiq has no directory sync for users or groups. Nextcloud's LDAP app could do it platform-wide, outside stackiq. Decided no (OpenSpec pass 2026-09-27): Platform capability: Nextcloud user_ldap keeps users and groups in step with a directory for every app, and stackiq users and groups are Nextcloud users and groups. built.owner corrected to nextcloud/server.",
"evidence": {
"glpi": "source read at 11.0.9: src/AuthLDAP.php:59 LDAP directories with user import and group import (src/AuthLDAP.php:2816 ldapImportGroup), and the CLI src/Glpi/Console/Ldap/SynchronizeUsersCommand.php:79 ldap:synchronize_users (alias ldap:sync at :80). Reached on: Administration > Users > LDAP directory link; Setup > Authentication > LDAP directories. Driven on the lab at 11.0.9 (2026-09-26): /front/ldap.php offers \"Bulk import users from a LDAP directory\" and \"Synchronizing already imported users\".",
@@ -3378,6 +4479,10 @@
"vng-softwarecatalogus": "unknown: no directory sync is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967618-automatic-user-provisioning-with-scim-2-0: 'BlueDolphin supports automatic user provisioning ... manage BlueDolphin users and their role assignments', with guides for Entra ID, Okta and SailPoint (read 2026-09-26). Reached on: Admin > SCIM provisioning.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/scim-provisioning: 'SCIM facilitates the transfer of user information from a source system, such as an external identity provider (IdP), to a target system, such as SAP LeanIX'; setup guides for Entra ID and Okta (read 2026-09-26). Reached on: Administration > SCIM provisioning."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3399,6 +4504,7 @@
"reachedOn": "Organisations /organisaties card -> contact persons view -> Change Password on your own row",
"provider": "nextcloud",
"providerHow": "read-from-code",
+ "feature": "sign-in-and-accounts",
"note": "A user can change their own password from their contact row in the organisation card, which is hard to find. There is no 'my account' page in stackiq; /api/me feeds only the organisation switcher. Nextcloud's personal settings do both natively. Decided no (OpenSpec pass 2026-09-27): Platform capability: Nextcloud personal settings let every user change their password and see their account details. The stackiq half (change password from your own contact row) stays as built evidence. built.owner corrected to nextcloud/server, provider nextcloud.",
"evidence": {
"stackiq": "src/components/ContactpersonenList.vue:115 'Change Password' opens src/dialogs/ChangePasswordDialog.vue -> POST /api/contactpersonen/change-password -> lib/Controller/ContactpersonenController.php:718, self-reset allowed at :753; GET /api/me (:1578) used only by src/App.vue and OrganisationSwitcher",
@@ -3407,6 +4513,10 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967481-user-profile: users see 'Profile > Personal information'; https://help.bluedolphin.io/en/articles/11967487-forgotten-password: 'To have your password reset, you need to contact your BlueDolphin administrator' and change the temporary password after login (read 2026-09-26). Reached on: Account > Profile.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/user-profile: 'Manage your user profile settings. Change or reset your password if needed' on the My Settings profile page (read 2026-09-26). Reached on: User menu > My Settings.",
"glpi": "source read at 11.0.9: front/preference.php renders the user's own form through src/User.php:3105 showMyForm (template pages/admin/user/user.html.twig), whose preference variant shows a 'Change password' button to front/updatepassword.php at templates/pages/admin/user/user.html.twig:277 to :279; the new password form is templates/password_form.html.twig:79."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3429,6 +4539,7 @@
"reachedOn": "admin settings section 'Email configuration', Templates tab (save does not persist)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "sign-in-and-accounts",
"note": "The mails are sent from templates, but the admin template editor's Save button does not call the backend and reports success anyway, so an administrator cannot edit a template from the UI. The transport defaults to 'null', which sends nothing until configured.",
"evidence": {
"glpi": "source read at 11.0.9: src/NotificationTargetUser.php:44 user events passwordexpires, passwordforget and passwordinit; their text lives in admin editable templates, src/NotificationTemplate.php:47 and translations src/NotificationTemplateTranslation.php:42, seeded at install/empty_data.php:3212 (passwordinit) and :5640. Reached on: Setup > Notifications > Notification templates.",
@@ -3437,6 +4548,12 @@
"vng-softwarecatalogus": "unknown: new users receive a login mail, but administrator-editable templates are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersbeheer (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967616-user-management: BlueDolphin sends 'An invitation with a link to activate the account' and temporary credentials; editing the mail templates is not documented (read 2026-09-26). Reached on: Admin > Users > send invites.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/users-overview: users are invited 'individually or in bulk' with a 'Send Invitation Email' option; editing the text of account mails is not documented (read 2026-09-26). Reached on: Administration > Users > Invite."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkBeheerToegang"
+ ]
}
},
{
@@ -3469,6 +4586,12 @@
"bluedolphin": "unknown: https://help.bluedolphin.io/en/articles/11967513-publish-a-view publishes a read-only view to 'all BlueDolphin users in your tenant'; publishing an entry as open data is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; publishing a single entry as open data is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'open data\\|opendata' over src/ locales/glpi.pot returns nothing; items have no publish state, the closest is is_helpdesk_visible (install/mysql/glpi-empty.sql:8956 on glpi_appliances), which only shows the item to helpdesk users of the same instance."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -3500,6 +4623,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no open data publication is documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no open data publication is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: no open data publication exists (grep -rli 'open data' src/ returns nothing); anonymisation settings cover ticket actors only (install/mysql/glpi-empty.sql:2824 anonymize_support_agents on entities)."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -3532,6 +4659,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; announcing a catalogue in a shared directory is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no shared directory of catalogues or federation between workspaces is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'federat\\|activitypub' over src/ returns nothing; every instance is standalone and the only outbound registration is the plugin marketplace client (src/Glpi/Marketplace/). The marketplace client is src/Glpi/Marketplace/Controller.php:64."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
}
},
{
@@ -3564,6 +4697,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; AMEFF files can move views between BlueDolphin tenants (https://help.bluedolphin.io/en/articles/11967636-import-bluedolphin-ameff-files), but pulling published entries from peer catalogues with provenance is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; pulling entries from peer catalogues is not documented, only integrations with named tools (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'federat' over src/ returns nothing; data enters only through the UI, the APIs (src/Glpi/Api/APIRest.php:60, src/Glpi/Api/HL/Router.php) and inventory, never from peer catalogues."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
}
},
{
@@ -3596,6 +4735,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; peer catalogue management is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no peer catalogue management is documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'federat' over src/ returns nothing; there is no peer list in the Setup menu (src/Html.php:1330 onwards)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
}
},
{
@@ -3618,6 +4763,7 @@
"reachedOn": "API: /apps/openregister/api/objects plus stackiq /api/*; the stackiq pages themselves use the same API",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "api-and-integrations",
"note": "Read and write through a REST API is live through OpenRegister, with stackiq's own role-scoped endpoints on top.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/sap-leanix-apis: 'These APIs are ideal for integration with systems that support RESTful interactions' and 'The GraphQL API enables you to retrieve and update fact sheets and related data' (read 2026-09-26). Reached on: Developer Guide > SAP LeanIX APIs.",
@@ -3626,6 +4772,10 @@
"topdesk": "https://docs.topdesk.com/en/required-knowledge.html: \"basic knowledge of REST API requests (see developers.topdesk.com )\" (read 2026-09-26); https://docs.topdesk.com/en/details-about-certain-permissions.html: \"API access > REST API : this permission is necessary for operator cards that are used for accessing the TOPdesk API\" (read 2026-09-26). Reached on: developers.topdesk.com.",
"stackiq": "Every catalogue entity is an OpenRegister object (src/manifest.json pages read register @resolve:voorzieningen_register), so OpenRegister's /apps/openregister/api/objects/{register}/{schema} CRUD applies, gated by the schema authorization rules (lib/Settings/softwarecatalogus_register.json:6777 module and others). Stackiq adds its own routed endpoints in appinfo/routes.php (e.g. :202 /api/aanbod, :255-261 /api/aangeboden-gebruik, :298 /api/gebruik, :303 /api/portfolio-report).",
"vng-softwarecatalogus": "unknown: https://www.softwarecatalogus.nl/api answers only \"Services Endpoint api has been setup successfully.\" and no API is documented; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/api (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Een REST-API, geen scherm: de pagina's gebruiken dezelfde API."
}
},
{
@@ -3649,6 +4799,7 @@
"reachedOn": "API only: GET /api/views/docs and /api/aangeboden-gebruik/docs; docs site https://stackiq.conduction.nl (Documentation footer link)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "api-and-integrations",
"note": "The generated OpenAPI file is empty. What exists is hand-written: two JSON doc endpoints and markdown pages. OpenRegister may generate an OAS per register, but stackiq does not surface it. Specified in openspec/changes/sharing-generated-api-docs (OpenSpec pass 2026-09-27).",
"evidence": {
"glpi": "source read at 11.0.9: src/Glpi/Api/HL/Controller/CoreController.php:322 route /doc serves a Swagger UI 'GLPI API Documentation' (:329 to :331) over the spec built by src/Glpi/Api/HL/OpenAPIGenerator.php. Reached on: /api.php/doc. Driven on the lab at 11.0.9 (2026-09-26): on a fresh install /api.php/v2/doc answers 403 \"The High-Level API is disabled\"; after switching on enable_hlapi in Setup > General > API it serves the Swagger UI \"GLPI API Documentation\".",
@@ -3657,6 +4808,10 @@
"vng-softwarecatalogus": "unknown: no API documentation for the current catalogue was found; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/api, https://www.softwarecatalogus.nl/Beschikbare%20downloads (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967733-quick-start-guide: 'Open API Documentation EU https://public-api.eu.bluedolphin.app /swagger/index.html' and per endpoint reference articles in the help center (read 2026-09-26). Reached on: Swagger at https://public-api.eu.bluedolphin.app/swagger/index.html.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/sap-leanix-apis: 'we provide the OpenAPI explorer . This tool enables you to explore APIs, send requests, and view responses directly in your browser' (read 2026-09-26). Reached on: Workspace > OpenAPI explorer."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Gegenereerde API-documentatie op de docs-site, geen scherm in de app."
}
},
{
@@ -3679,7 +4834,7 @@
"reachedOn": "admin settings section ArchiMate Import/Export; Portfolio rationalization /portfolio-report (Export CSV, one organisation at a time)",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "archimate-import-and-export",
+ "feature": "portfolio-reporting",
"featureConfidence": "medium",
"note": "A full ArchiMate export exists but is only reached from admin settings. The one export on a user page is the portfolio report CSV, and the catalogue list pages offer no export. Specified in openspec/changes/insight-exports-and-custom-reports (OpenSpec pass 2026-09-27).",
"evidence": {
@@ -3689,6 +4844,13 @@
"topdesk": "https://docs.topdesk.com/en/asset-dashboard.html: tile actions \"Export to .CSV Export to Excel\" (read 2026-09-26); https://docs.topdesk.com/en/create-odata-reports-for-asset-management.html: \"generate reports by using the TOPdesk OData feed ... Microsoft Power BI, or Microsoft Excel\" (read 2026-09-26). Reached on: Asset dashboard; OData feed.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967710-using-the-odata-feed: 'working with the data available through the BlueDolphin OData service'; views export as AMEFF (https://help.bluedolphin.io/en/articles/11967514-download-a-view) (read 2026-09-26). Reached on: OData feed; view download.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/exporting-fact-sheet-data-as-excel-file: 'export fact sheet data as an Excel file'; https://help.sap.com/docs/leanix/ea/exporting-workspace-snapshots: 'Export snapshots of your workspace data through the Pathfinder REST API' (read 2026-09-26). Reached on: Inventory > Export; snapshot API."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkRapportages"
+ ]
}
},
{
@@ -3711,6 +4873,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "api-and-integrations",
"note": "No integration with a service management tool exists. Specified in openspec/changes/sharing-itsm-exchange (OpenSpec pass 2026-09-27).",
"evidence": {
"sap-leanix": "https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf: 'ServiceNow integration An integration that connects the subscription services to the customer's ServiceNow subscription to synchronize infrastructure and software asset information'; Jira Service Management integration at https://help.sap.com/docs/leanix/ea/jira-service-management-integration-faqs (read 2026-09-26). Reached on: Administration > Integrations > ServiceNow.",
@@ -3719,6 +4882,12 @@
"stackiq": "No ITSM connector: lib/Settings/connections.json lists only email, federation and eol-feed; grep for topdesk/servicenow/itsm in lib/ and src/ finds nothing.",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C19: \"Met de exportfunctie kunt u de gegevens in andere tools synchroon houden ... We verwachten in 2019 een pilot met Topdesk ... Een import vanuit die tools naar de Softwarecatalogus ... is vooralsnog niet voorhanden\" (read 2026-09-26); https://www.softwarecatalogus.nl/RID%20de%20Liemers: RID de Liemers signals updates through its TOPdesk change process by hand (read 2026-09-26)",
"glpi": "source read at 11.0.9: GLPI is itself the service management tool, so application records are used directly by tickets, problems and changes, src/Appliance.php:105 onwards Item_Ticket, Item_Problem and Change_Item tabs; the menu src/Html.php:1283 Assistance holds Ticket, Problem and Change. Reached on: Appliance > Tickets, Problems, Changes tabs."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
}
},
{
@@ -3741,6 +4910,7 @@
"reachedOn": "the whole app runs on the customer's own Nextcloud",
"provider": "nextcloud",
"providerHow": "read-from-code",
+ "feature": "open-platform",
"note": "As a Nextcloud app it runs on whatever infrastructure hosts the Nextcloud instance.",
"evidence": {
"sap-leanix": "https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf describes 'The SaaS services' and a customer 'workspace'; https://www.leanix.net/hubfs/Legal/Operational-Terms-Exhibit-v.2.0.pdf defines 'the data center utilized by LeanIX to host Customer's Data' with maintenance windows per hosting region. Only a vendor hosted subscription is offered (read 2026-09-26)",
@@ -3749,6 +4919,10 @@
"topdesk": "https://docs.topdesk.com/VA2026R3/index.html: \"TOPdesk Virtual Appliance documentation\", releases VA 2023 R2 to VA 2026 R3 (read 2026-09-26); https://tip.topdesk.com/c/255-va-release-q4-2026: roadmap card in column \"Planned\", \"VA Release Q4 2026\" in section \"On premise - VA releases\" (read 2026-09-26). Reached on: Virtual Appliance.",
"stackiq": "appinfo/info.xml: a Nextcloud app (id stackiq, EUPL-1.2) installed on the customer's own Nextcloud with OpenRegister; stackiq-compose.yaml at repo root for a local stack.",
"vng-softwarecatalogus": "unknown: the catalogue is run by VNG Realisatie; self-hosting is not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Hosting: de hele app draait op de eigen Nextcloud."
}
},
{
@@ -3772,7 +4946,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "Search plus reference-component, standard, application-service and domain facets work. Supplier is not offered as a facet, which is half of the row's example. Specified in openspec/changes/insight-supplier-facet (OpenSpec pass 2026-09-27). Corrections round 8 (2026-09-28): the domain facet is always empty, so of the four facets only reference component, standard and application service narrow the list, stackiq#1141; rating kept because search and the reference component facet work and supplier was already missing.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Aan de linkerkant staan zogenaamde filter mogelijkheden. Deze werken ook in combinatie ... Achter de te zetten filters staat een getal\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakketversies: facets Leverancier, Standaard, Referentiecomponent, Status planning, Domein, Doelgroep, Bedrijfsfunctie (read 2026-09-26). Reached on: Alle pakketten / Alle pakketversies.",
@@ -3781,6 +4955,12 @@
"topdesk": "https://docs.topdesk.com/en/the-asset-management-module-page.html: \"Asset overview : view all your assets in a filterable list\" (read 2026-09-26). Facets with counts are not described. Reached on: Asset Management > Asset overview.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967727-add-filters-to-the-data: 'narrow down repository data based on object properties' with 'Created by, Created on, Changed by, Changed on, and Completeness' and related objects (read 2026-09-26). Reached on: Repository > filters.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/advanced-filter-options: inventory filters by lifecycle, subscription, tags and fields, and https://help.sap.com/docs/leanix/ea/filtering-in-report-urls: 'Apply a filter using the facet filter column' (read 2026-09-26). Reached on: Inventory > facet filter column."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -3803,6 +4983,7 @@
"reachedOn": "Applications /modules and Services /diensten, Saved views menu",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "gemma-alignment",
"note": "A user can save the facet and search selection as a named view and reopen it. Storage is OpenRegister's views API. It covers only those two pages.",
"evidence": {
"glpi": "source read at 11.0.9: src/SavedSearch.php:52 SavedSearch, private or shared, listed under Tools > Saved searches (src/Html.php:1309) with optional alerts (front/savedsearch_alert.form.php). Reached on: Tools > Saved searches (front/savedsearch.php). Driven on the lab at 11.0.9 (2026-09-26): Tools > Saved searches (/front/savedsearch.php) lists saved searches with private or shared scope and a default flag.",
@@ -3811,6 +4992,12 @@
"vng-softwarecatalogus": "unknown: only VNG-defined \"Marktscans-voorgedefinieerde filters\" are described; users saving their own view is not; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Marktscans (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967727-add-filters-to-the-data: 'You can now save any set of filters as a reusable Quick filter'; https://help.bluedolphin.io/en/articles/14996415-custom-insights: 'You can save, rename, and share report configurations' (read 2026-09-26). Reached on: Repository > Save Query (Quick filter).",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/advanced-filter-options mentions 'creating saved searches' and shareable filtered URLs; the feature list says 'Reports can be saved and shared with user to retrieve the specific view later' (https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf) (read 2026-09-26). Reached on: Inventory > Saved searches."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
}
},
{
@@ -3833,6 +5020,7 @@
"reachedOn": "Dashboard / (first menu entry)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "dashboard-and-overviews",
"note": "The dashboard shows counts of organisations, applications, services and contracts. The counts are computed by OpenRegister through the library stat widget.",
"evidence": {
"sap-leanix": "https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf: 'Configurable dashboards visualize inventory content as charts, lists, and KPIs'; https://help.sap.com/docs/leanix/ea/application-portfolio-management-dashboard (read 2026-09-26). Reached on: Dashboards.",
@@ -3841,6 +5029,12 @@
"stackiq": "src/manifest.json:72 Dashboard page with stat widgets kpi-organisations, kpi-modules, kpi-services, kpi-contracts (source metric count on organization/module/catalogService/catalogContract) plus catalog-panels.",
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1: dashboard tiles for logged-in municipalities (read 2026-09-26); https://www.softwarecatalogus.nl/: \"Voortgang gemeenten Aantal gemeenten per voortgangscategorie ... Aantal ingelogde gemeenten in 2026: 69\" (read 2026-09-26). No contracts. Reached on: Dashboard; homepage.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/14600459-home-page-guide: 'The traditional dashboard has been replaced by a new Home page'; count dashboards come as Power BI templates, 'the Basic Dashboard and the Governance Dashboard' (https://help.bluedolphin.io/en/articles/11967711-power-bi-for-bluedolphin-quickstart) (read 2026-09-26). Reached on: Power BI template dashboards over OData."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard"
+ ]
}
},
{
@@ -3863,6 +5057,7 @@
"reachedOn": "Nextcloud dashboard widget 'Concept organisaties'",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "dashboard-and-overviews",
"note": "The widget is registered and loads, but it filters on a status value the data no longer holds, so it always lists nothing. Its accept button would write an invalid status.",
"evidence": {
"stackiq": "lib/Dashboard/ConceptOrganisatiesWidget.php registered at lib/AppInfo/Application.php:760; src/views/widgets/ConceptOrganisatiesWidget.vue:100 filters organisations on status === 'concept', but the status enum is Draft/Active/Inactive/merged (lib/Settings/softwarecatalogus_register.json:2321) after lib/Repair/RenameDutchCatalogValues.php:76 renamed 'Concept' to 'Draft'. Accept writes status 'actief' (:129), which is not in the enum either.",
@@ -3871,6 +5066,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; organisations in concept and a Nextcloud dashboard are not covered (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; organisations have no concept state and there is no Nextcloud dashboard, so this is not covered (read 2026-09-26)",
"glpi": "source read at 11.0.9: GLPI is not a Nextcloud app and has no concept organisation state (suppliers only have is_active, install/mysql/glpi-empty.sql:7087), so no such widget exists in its own dashboards (src/Glpi/Dashboard/Grid.php:67)."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard"
+ ]
}
},
{
@@ -3893,6 +5094,7 @@
"reachedOn": "Reports /reports (footer menu), one card to Portfolio rationalization /portfolio-report",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "portfolio-reporting",
"note": "The report picker exists and opens a working report. The list holds exactly one report.",
"evidence": {
"glpi": "source read at 11.0.9: src/Report.php:52 Report builds a pick list of ready made reports, src/Report.php:77 default, :81 by contract, :85 by year, :87 financial information, :110 status, chosen from 'Select the report you want to generate' (src/Report.php:143). Reached on: Tools > Reports (front/report.php). Driven on the lab at 11.0.9 (2026-09-26): /front/report.php offers Default report, By contract, By year, Hardware financial and administrative information, Other financial and administrative information, Network report, Loan and Status.",
@@ -3901,6 +5103,12 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/rapportages: \"Regelmatig worden er rapportages gemaakt over het gebruik van de Softwarecatalogus\" (read 2026-09-26); compliancy monitor and digital accessibility monitor pages. Reached on: Rapportages; Compliancy monitor.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967713-governance-insights: 'Access the Insights from the Main menu under the Visualization section. When you open Insights, the column lists all available reports' (read 2026-09-26). Reached on: Main menu > Visualization > Insights.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/report-types lists ready report types such as 'Landscape Report', 'Matrix Report', 'Roadmap Report'; the feature list names 'Pre-configured reports with adjustable filters' (https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf) (read 2026-09-26). Reached on: Reports."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRapportages"
+ ]
}
},
{
@@ -3933,6 +5141,12 @@
"topdesk": "https://docs.topdesk.com/en/reporting.html: \"The Report Wizard is not available for the Asset Management module. Further reporting can be done with the Asset Type Report or the OData feed\" (read 2026-09-26). Reached on: Asset Type Report; OData.",
"vng-softwarecatalogus": "unknown: only CSV exports for use in a spreadsheet are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/Beschikbare%20downloads (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/14996415-custom-insights: save configurations of supported built in reports; for deeper reports 'you need to use our OData feed and connect it to a BI tool of your choice' (https://help.bluedolphin.io/en/articles/11967711-power-bi-for-bluedolphin-quickstart) (read 2026-09-26). Reached on: Insights > Custom insights; OData with Power BI."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRapportages"
+ ]
}
},
{
@@ -3956,7 +5170,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "portfolio-reporting",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "One fixed report exports to CSV for a selected organisation. The filtered catalogue lists cannot be exported. Specified in openspec/changes/insight-exports-and-custom-reports (OpenSpec pass 2026-09-27).",
"evidence": {
"glpi": "source read at 11.0.9: src/Glpi/Search/Output/Csv.php, Ods.php, Xlsx.php and Pdf.php export the filtered list; the output format selector is rendered by src/Html.php:4219 Dropdown::showOutputFormat. Reached on: any filtered list, Export. Driven on the lab at 11.0.9 (2026-09-26): the filtered Appliances list exported to CSV with the created record.",
@@ -3965,6 +5179,13 @@
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/pakketversies: \"Export to CSV\" on the filtered package-version list (read 2026-09-26); https://www.softwarecatalogus.nl/Beschikbare%20downloads: \"Ook beschikbaar via knop [Export to csv] op pagina Alle pakketten\" (read 2026-09-26). Reached on: Alle pakketversies > Export to CSV.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967708-connect-power-bi-and-excel-to-the-odata-feed-windows-os connects Excel to the OData feed; the DataCollection 'Export is no longer supported' (https://help.bluedolphin.io/en/articles/11967630-install-and-configure-datacollection-frontend-and-service). A direct export of a filtered list is not documented (read 2026-09-26). Reached on: Excel via OData feed.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/exporting-fact-sheet-data-as-excel-file: 'In the inventory, apply filters to narrow down to the fact sheets that you need to export ... export fact sheet data as an Excel file' (read 2026-09-26). Reached on: Inventory > table view > Export."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkRapportages"
+ ]
}
},
{
@@ -3986,6 +5207,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "portfolio-reporting",
"note": "Reports cannot be scheduled or sent to people. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (insight).",
"evidence": {
"stackiq": "No scheduled report or report mail job: lib/BackgroundJob/ holds only ContractStatusJob, EolSyncJob, FederationSyncJob and OrganizationContactSyncJob; grep for report scheduling in lib/ and src/ finds nothing.",
@@ -3994,6 +5216,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; surveys can be scheduled (https://bluedolphin.io/application-portfolio-management-application-rationalization/ 'Keep data current by scheduling surveys'), but sending reports on a schedule is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; scheduled notification digests (https://help.sap.com/docs/leanix/ea/notifications-center) and scheduled snapshot exports by API (https://help.sap.com/docs/leanix/ea/export) exist, but mailing a report to named people on a schedule is not documented (read 2026-09-26)",
"glpi": "source read at 11.0.9: saved search alerts, src/SavedSearch_Alert.php:45 with count conditions (src/SavedSearch_Alert.php:53 to :58) and a frequency, run by src/SavedSearch_Alert.php:307 cronSavedSearchesAlerts and sent through the notification system to configured recipients; they notify on a result count rather than sending the report itself. Reached on: Tools > Saved searches > Alerts tab."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4017,7 +5243,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "contract-administration",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "Cost is reported per vendor and, within the portfolio report, for one organisation at a time. There is no cross-organisation or per-domain cost report.",
"evidence": {
"glpi": "source read at 11.0.9: src/Budget.php:537 showValuesByEntity shows spend per entity and item type for a budget, and src/Report.php:89 'Other financial and administrative information (licenses, cartridges, consumables)' (front/report.infocom.conso.php); there is no cost per domain or reference component view. Reached on: Management > Budgets > budget tabs; Tools > Reports.",
@@ -4026,6 +5252,13 @@
"vng-softwarecatalogus": "unknown: costs are not recorded; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; cost is a questionnaire field (https://help.bluedolphin.io/en/articles/11967733-quick-start-guide) but no cost report per organisation or domain is documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/application-portfolio-management-dashboard: 'application run cost broken down by business capability'; https://help.sap.com/docs/leanix/ea/dashboard-modeling: 'Report on cost per business capability' (read 2026-09-26). Reached on: Dashboards > Application Portfolio Management Dashboard."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkRapportages",
+ "SkLicenties"
+ ]
}
},
{
@@ -4048,6 +5281,7 @@
"reachedOn": "Features & roadmap /features-roadmap (footer menu)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "open-platform",
"note": "The page lists features with their status. Note that the overlay feeding it is a self-description and may be stale in the app's favour.",
"evidence": {
"stackiq": "src/manifest.json:1048 FeaturesRoadmap page (type roadmap, line 1050), footer menu entry FeaturesRoadmapMenu; content from openspec/features.overlay.json statuses stable/beta/soon.",
@@ -4056,6 +5290,12 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; an in app view of available, beta and coming features is not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/ai-governance-extension: 'Go to Administration > Optional Features and Early Access' to activate extensions; the public roadmap is outside the app at https://roadmap.leanix.net/ (read 2026-09-26). Reached on: Administration > Optional Features and Early Access.",
"glpi": "source read at 11.0.9: grep -rli 'coming soon' over src/ templates/ returns no in app feature status page; src/Glpi/Features/ holds item traits (for example src/Glpi/Features/Kanban.php), not feature flags. src/Glpi/Features/Kanban.php:45 is a trait, typical of that directory."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkFunctiesRoadmap"
+ ]
}
},
{
@@ -4078,6 +5318,7 @@
"reachedOn": "admin settings sections Organization Synchronization and ArchiMate Import/Export (status and result only); progress endpoints API only",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "dashboard-and-overviews",
"note": "A progress API exists but no page reads it. Admins see a sync status and final import results, not the progress of a running job. Specified in openspec/changes/operations-sync-status-and-progress (OpenSpec pass 2026-09-27). Corrections round 8 (2026-09-28): progress lives in the starting user's PHP session, so no other login or background job can read it, stackiq#1138; rating kept because the partial rests on the sync status block and the final import result, which do not use the tracker.",
"evidence": {
"glpi": "source read at 11.0.9: massive actions over many records show a progress bar, src/MassiveAction.php:1294 displayProgressBar; the LDAP synchronisation command shows one per user batch, src/Glpi/Console/Ldap/SynchronizeUsersCommand.php:382; long web operations report through src/Glpi/Controller/ProgressController.php:50 /progress/check/{key}. Inventory imports run per agent request without a progress view. Reached on: massive action screen; CLI ldap:sync.",
@@ -4086,6 +5327,13 @@
"vng-softwarecatalogus": "unknown: no progress display for sync or import is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967642-source-processing: the source processing module shows when 'the system is currently handling multiple items simultaneously' and items ready for processing; AMEFF import only shows a checkmark when done (https://help.bluedolphin.io/en/articles/11967637-import-ameff-files) (read 2026-09-26). Reached on: Source processing module.",
"sap-leanix": "https://updates.leanix.net/announcements/product-update-march-2026: 'A real-time progress widget in the inventory side-panel keeps you informed of import status'; integration runs are followed in 'Administration > Integrations > Sync Log' (https://help.sap.com/docs/leanix/ea/collibra-data-catalog-integration) (read 2026-09-26). Reached on: Inventory side panel import progress; Administration > Integrations > Sync Log."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard",
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -4108,6 +5356,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"note": "There is no knowledge base. Files can be attached to an application, but that is not searchable articles. Specified in openspec/changes/insight-knowledge-base (OpenSpec pass 2026-09-27).",
"evidence": {
"glpi": "source read at 11.0.9: src/KnowbaseItem.php:57 knowledge base articles with categories and visibility, linked to items through src/KnowbaseItem_Item.php:45 and shown on the appliance Knowledge base tab (src/Appliance.php:104); menu src/Html.php:1307. Reached on: Tools > Knowledge base (front/knowbaseitem.php). Driven on the lab at 11.0.9 (2026-09-26): /front/knowbaseitem.php opens the knowledge base with Search and Browse.",
@@ -4116,6 +5365,12 @@
"vng-softwarecatalogus": "unknown: the FAQ and manuals are site help, no knowledge base about applications is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; the Knowledge AI Assistant 'only uses Bluedolphin's publicly available documentation' (https://help.bluedolphin.io/en/articles/12528662-ai-capabilities-of-bluedolphin), a customer knowledge base of articles about applications is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; fact sheets hold links and files in a Resources tab (https://help.sap.com/docs/leanix/ea/collaborating-and-managing-data-in-fact-sheets), but no searchable knowledge base of articles is documented (read 2026-09-26)"
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
}
},
{
@@ -4137,6 +5392,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "asset-discovery",
"note": "Stackiq is a catalogue, and nothing discovers installed software. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" Agent-based discovery of installed software is a discovery agent.",
"evidence": {
"glpi": "source read at 11.0.9: native inventory receives glpi-agent submissions at src/Glpi/Controller/InventoryController.php:61 /Inventory (legacy :62 /front/inventory.php), processed by src/Glpi/Inventory/Inventory.php:106 with src/Glpi/Inventory/Asset/Software.php creating software and installations. The agent is the separate glpi-project/glpi-agent repository. Reached on: Administration > Inventory; Assets > Software.",
@@ -4145,6 +5401,10 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; the DataCollector uploads data from databases and files (https://help.bluedolphin.io/en/articles/11967629-import-and-export-options-with-bluedolphin), an installation discovery agent is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; LeanIX has no own installation agent, software assets come in through the ServiceNow integration (https://help.sap.com/docs/leanix/ea/obsolescence-risk-management-import-software-assets) (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4166,6 +5426,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "asset-discovery",
"note": "No device discovery. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" Network device discovery is a discovery agent.",
"evidence": {
"glpi": "source read at 11.0.9: src/Glpi/Inventory/Request.php:97 NETDISCOVERY_ACTION calls src/Glpi/Inventory/Request.php:237 networkDiscovery, importing devices found by the agent's network discovery; scheduling discovery tasks from the server goes through the HANDLE_NETDISCOVERY_TASK hook (src/Glpi/Inventory/Request.php:448), which the separate glpiinventory plugin implements. Reached on: Administration > Inventory; Assets > Network devices.",
@@ -4174,6 +5435,10 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; network discovery is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; network device discovery is not documented (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4195,6 +5460,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "asset-discovery",
"note": "No discovery of unregistered SaaS use. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" Discovering unregistered SaaS use is a discovery agent.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/saas-discovery: 'SaaS discovery identifies your organization's SaaS applications through integrations with third-party systems like Single-Sign-on (SSO) ... Eliminate shadow IT and business-managed IT' (read 2026-09-26). Reached on: Discovery > SaaS discovery inbox.",
@@ -4203,6 +5469,10 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SaaS discovery, no hits (read 2026-09-26)",
"glpi": "source read at 11.0.9: inventory handlers under src/Glpi/Inventory/Asset/ (Software.php, Process.php, VirtualMachine.php and others) read what the agent sees on devices; grep -i 'saas' over src/ returns nothing, so cloud subscriptions nobody registered are not discovered. The software handler is src/Glpi/Inventory/Asset/Software.php:56."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4225,6 +5495,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "asset-discovery",
"note": "Only software is registered, not hardware. Specified in openspec/changes/operations-technology-components (OpenSpec pass 2026-09-27).",
"evidence": {
"glpi": "source read at 11.0.9: src/autoload/CFG_GLPI.php:208 asset_types lists Computer, Monitor, NetworkEquipment and the other hardware types managed next to software, under the Assets menu. Reached on: Assets > Computers, Monitors, Network devices.",
@@ -4233,6 +5504,12 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967596-archimate-annotation-translation-en-nl lists 'Device' and 'Node' object definitions, and the welcome page names 'servers, applications' as assets (https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin). Modeled as architecture objects, not as an asset register (read 2026-09-26). Reached on: Objects > Device or Node object.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/it-component-modeling-guidelines: IT component subtype 'Hardware ... (e.g., servers, mainframe computers, storage devices)'. Modeled as technology types an application depends on, not individual laptops as assets (read 2026-09-26). Reached on: IT Component fact sheet, subtype Hardware."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkCmdb"
+ ]
}
},
{
@@ -4255,6 +5532,7 @@
"reachedOn": "Applications /modules/:id and Suites /suites/:id Related panels; no page for connections",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "asset-discovery",
"note": "The landscape and its relations are recorded as catalogue objects, not as a CMDB with CI classes. Connections (koppelingen) have no index or detail page of their own. Specified in openspec/changes/operations-technology-components (OpenSpec pass 2026-09-27).",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C19: \"Dubbel beheer (én in de Softwarecatalogus én in de CMDB) ... Een CMDB die separaat wordt bijgehouden\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/19703: \"Zolang de CMDB niet gekoppeld is aan de Softwarecatalogus\" (read 2026-09-26). The docs treat the CMDB as a separate tool.",
@@ -4263,6 +5541,13 @@
"stackiq": "Applications (module lib/Settings/softwarecatalogus_register.json:6777), versions (moduleVersion :7649), suites (suite :1135) and application-to-application connections (connection :3563) with relations; ModuleDetail and SuiteDetail show a Related panel. No manifest page has register+schema 'connection' or 'usage', so connections are not listed or created on their own page.",
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin lists 'Configuration management' among configurable application areas, with objects such as servers and their relationships; CMDB data comes in from ServiceNow and TOPdesk (https://help.bluedolphin.io/en/articles/11967783-servicenow-to-bluedolphin-integration). An architecture repository rather than an operational CMDB (read 2026-09-26). Reached on: Objects and relationships; ServiceNow integration.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/jira-service-management-integration: 'Use the Jira Service Management integration to synchronize data between your configuration management database (CMDB) and SAP LeanIX ... Configuration items from Jira Service Management can be mapped to various fact sheet types'. LeanIX consumes a CMDB, it does not act as one (read 2026-09-26). Reached on: Jira Service Management and ServiceNow integrations."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkCmdb"
+ ]
}
},
{
@@ -4285,6 +5570,7 @@
"reachedOn": "Organisation /organisaties/:id, Merge organisation panel (admin only)",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "data-quality",
"note": "An admin can merge duplicate organisations with a dry run. Nothing deduplicates applications or reconciles records arriving from several sources. Specified in openspec/changes/operations-record-reconciliation (OpenSpec pass 2026-09-27).",
"evidence": {
"stackiq": "Organisation merge: src/manifest.json:430 OrganisationMergePanel on OrganisatieDetail, calling /api/organisaties/{uuid}/merge/dry-run and /merge (src/store/modules/organisatie.js:486/524, lib/Controller/MergeController.php:106, admin-only body guard). Federation mirrors are reconciled per peer by lib/Service/Federation/FederationMerger.php.",
@@ -4293,6 +5579,13 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967635-four-things-you-need-to-know-before-you-start-importing-sources: 'for each record an object will be created or merged with an already existing object'; https://help.bluedolphin.io/en/articles/11967644-use-datasource-to-enrich-objects enriches objects from a second source (read 2026-09-26). Reached on: Admin > Sources; Source processing.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/aggregation-and-linkage-of-software-records: 'import software records from ServiceNow as aggregated software fact sheets'; https://help.sap.com/docs/leanix/ea/matching-rules: custom matching to avoid 'duplicate fact sheets'; SaaS discovery links the same SaaS found in several SSOs (https://help.sap.com/docs/leanix/ea/saas-discovery) (read 2026-09-26). Reached on: ServiceNow integration > aggregation; matching rules; SaaS discovery inbox.",
"glpi": "source read at 11.0.9: src/RuleImportAsset.php:46 import and link rules decide whether an incoming inventory record matches an existing asset (by serial, UUID, MAC and similar) or creates one; src/RuleDictionnarySoftware.php:44 normalises software names and publishers from different sources; duplicates can be merged afterwards (src/Software.php:1011). Reached on: Administration > Rules > Rules for import and link equipments; Dictionaries."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDatakwaliteit",
+ "SkOrganisatie"
+ ]
}
},
{
@@ -4314,6 +5607,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "service-management",
"note": "No ticketing. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" Logging incidents and requests is a service desk. Exchange with the organisation's service desk is specified under share-itsm-integration.",
"evidence": {
"glpi": "source read at 11.0.9: src/autoload/CFG_GLPI.php:301 ticket_types includes Appliance (line 305), so tickets link to an application through src/Item_Ticket.php:41, shown on the appliance Tickets tab (src/Appliance.php:105). Reached on: Assistance > Tickets; Appliance > Tickets tab. Driven on the lab at 11.0.9 (2026-09-26): the default Super-Admin profile lists Computer, Monitor, NetworkEquipment, Peripheral, Phone, Printer, Software, DCRoom, Rack, Enclosure and Database as associable to tickets, not Appliance, so an appliance shows no Tickets tab until an administrator adds it in the profile; rating kept.",
@@ -4322,6 +5616,10 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; incidents stay in the ITSM tool, logging them against an application in BlueDolphin is not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/portals: an Application Portal 'accessible to everyone to order software, issue support tickets' through configurable links to the helpdesk; incidents themselves stay in the ITSM tool (read 2026-09-26). Reached on: Portals > links to helpdesk."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4343,6 +5641,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "service-management",
"note": "Contracts go through an approval, but changes to an application do not. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" Change approval workflows are service desk change management.",
"evidence": {
"glpi": "source read at 11.0.9: changes link to the appliance (src/Appliance.php:107 Change_Item tab) and go through approvals, src/ChangeValidation.php:39 ChangeValidation extends CommonITILValidation. Reached on: Assistance > Changes; Appliance > Changes tab.",
@@ -4351,6 +5650,10 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; a change approval workflow on an application is not documented (read 2026-09-26)",
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; architecture decisions have a review process (https://help.sap.com/docs/leanix/ea/architecture-decisions) but a change approval workflow on an application is not documented (read 2026-09-26)"
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4372,6 +5675,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "service-management",
"note": "A contract can be typed as SLA, but no service level targets are recorded or tracked. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" The competitor evidence tracks service desk response and resolution targets on calls and tickets.",
"evidence": {
"glpi": "source read at 11.0.9: src/SLM.php:42 service level management with src/SLA.php:44 SLA and OLA targets on tickets (install/mysql/glpi-empty.sql:7304 glpi_tickets.slas_id_ttr), assigned by business rules (src/RuleCommonITILObject.php:73) that can key on the linked appliance (src/RuleCommonITILObject.php:305 assign_appliance). Reached on: Setup > Service levels.",
@@ -4380,6 +5684,10 @@
"vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)",
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SLA and service level, no hits (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: 'Service Level Agreement (SLA) Single Select Corporate-Level SLA, Customer-Level SLA, Service-Level SLA' and an SLA description on the contract. No tracking of targets against results (read 2026-09-26). Reached on: Contract fact sheet > Governance and Regulations."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4401,6 +5709,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "service-management",
"note": "End users cannot request software. Decided no (OpenSpec pass 2026-09-27): Recorded non-goal: openspec/parity/capabilities.json category: \"It is not a discovery agent, a service desk or a developer portal, and the operations area exists to record that on purpose.\" A self-service portal where end users request software is a service desk.",
"evidence": {
"topdesk": "https://docs.topdesk.com/en/mobile-access-to-the-self-service-portal.html: \"The SSP layout is suited to be displayed in a mobile interface\" (read 2026-09-26); https://tip.topdesk.com/c/86-webshop-is-connected-with-asset-management: roadmap card in column \"Building\", \"End-user can order items which are in Asset Management\" (read 2026-09-26); pricing lists \"Self-Service Portal\" and \"Webshop\". Reached on: Self-Service Portal.",
@@ -4409,6 +5718,10 @@
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; the process portal publishes processes (https://help.bluedolphin.io/en/articles/11967500-getting-started-with-process-publication-portal), but requesting software is not documented (read 2026-09-26)",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/portals: 'create an Application Portal that is accessible to everyone to order software ... Action button: Perform an action, in this case \"Request new Application\"' (read 2026-09-26). Reached on: Portals > Application Portal.",
"glpi": "source read at 11.0.9: the 11.0 service catalog src/Glpi/Form/ServiceCatalog/ServiceCatalog.php:45 lists native forms for self service users (menu src/Html.php:1283), and a form question can offer software items to pick, src/Glpi/Form/QuestionType/QuestionTypeItem.php:180 Software::class, creating a ticket from the answer. Reached on: Self service > Service catalog. Driven on the lab at 11.0.9 (2026-09-26): /ServiceCatalog shows the service catalog with \"Report an issue\" and \"Request a service\"."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4430,6 +5743,7 @@
"reachedOn": "nothing reaches it",
"provider": "nextcloud",
"providerHow": "read-from-code",
+ "feature": "open-platform",
"note": "There is no native mobile app. Decided no on 2026-09-29 by the build-all lane, reversible: single competitor, no demand, outside the core areas (operations).",
"evidence": {
"topdesk": "https://docs.topdesk.com/en/installing-the-topdesk-mobile-store-application.html: \"Scan the QR code to download the app from the Play store\" (read 2026-09-26). Reached on: TOPdesk Mobile app.",
@@ -4438,6 +5752,10 @@
"bluedolphin": "unknown: https://help.bluedolphin.io/en/articles/11967477-minimum-system-requirements lists desktop browsers (Chrome, Firefox, Edge) and says Safari 'is not supported'; a native mobile app is not documented (read 2026-09-26)",
"sap-leanix": "https://updates.leanix.net/announcements/explore-portals-on-mobile-devices (2025-09-18): 'Since the mobile app was decommissioned in June 2023, we planned to make portals mobile responsive'; only portals are mobile friendly (read 2026-09-26)",
"glpi": "source read at 11.0.9: grep -rli 'android\\|ios app\\|mobile app' over src/ templates/ returns nothing; the repository ships only the responsive web interface (templates/) and APIs (src/Glpi/Api/HL/Controller/CoreController.php:322 docs), no native mobile client."
+ },
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
}
},
{
@@ -4461,7 +5779,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "automatic-user-provisioning",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The sync runs on a schedule and its last run time is shown, but only an admin can see and configure it, which is the rule for partial. Specified in openspec/changes/operations-sync-status-and-progress (OpenSpec pass 2026-09-27). Corrections round 8 (2026-09-28): switching the job off in the settings does not stop it, stackiq#1139; rating kept because the schedule and the last run time, which this row rates, work.",
"evidence": {
"stackiq": "lib/BackgroundJob/OrganizationContactSyncJob.php:75 TimedJob every 300 s calling performScheduledSync; admin section src/views/settings/sections/CronjobConfiguration.vue:56 shows each job's interval and an enable switch; src/views/settings/sections/OrganizationSynchronization.vue:211 shows Last Sync from app config last_sync_time (lib/Service/OrganizationSyncService.php:1609, written by recordSyncTime :1674).",
@@ -4470,6 +5788,12 @@
"bluedolphin": "https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin: 'This data can then be synchronized with BlueDolphin based on a scheduled task, periodically (for example, every hour)'; https://help.bluedolphin.io/en/articles/11967629-import-and-export-options-with-bluedolphin: 'Automatically via interval' (read 2026-09-26). Reached on: DataCollection Service; Admin > Sources.",
"sap-leanix": "https://help.sap.com/docs/leanix/ea/configuring-automated-nightly-runs-for-inbound-processors: 'enable automated nightly runs for an inbound Integration API processor'; SaaS discovery retrieves data 'usually twice a day' (https://help.sap.com/docs/leanix/ea/saas-discovery); runs visible in 'Administration > Integrations > Sync Log' (read 2026-09-26). Reached on: Administration > Integrations > Sync Log.",
"glpi": "source read at 11.0.9: automatic actions are scheduled and show their last run, src/CronTask.php:59 with install/mysql/glpi-empty.sql:1610 glpi_crontasks.lastrun, but LDAP user and group synchronisation is not one of them (grep -i ldap over the cron seed in install/empty_data.php:438 finds none); it is the CLI src/Glpi/Console/Ldap/SynchronizeUsersCommand.php:79 ldap:synchronize_users, which must be scheduled by the system cron. Organisation (supplier) records have no sync at all. Reached on: Setup > Automatic actions; CLI bin/console ldap:sync. Driven on the lab at 11.0.9 (2026-09-26): /front/crontask.php lists automatic actions with run mode, frequency and last run."
+ },
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
}
},
{
@@ -4489,7 +5813,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "Helmond's architecture repository tender (REQ3, REQ61) asks to relate the repository to the GGM. GGM metadata survives an ArchiMate import on architecture elements, but no field or page links an application to a GGM entity. Specified in openspec/changes/architecture-data-model-and-ggm (OpenSpec pass 2026-09-27).",
"vng-softwarecatalogus": "unknown",
"evidence": {
@@ -4502,7 +5826,13 @@
"sap-leanix": "unknown",
"bluedolphin": "partial",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ }
},
{
"id": "arch-views-office",
@@ -4521,7 +5851,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "archimate-import-and-export",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "Helmond REQ19 asks for architecture views embedded in office documents. stackiq renders no view at all (see arch-gemma-views) and exports only ArchiMate files. Specified in openspec/changes/architecture-views-to-office-documents (OpenSpec pass 2026-09-27).",
"vng-softwarecatalogus": "partial",
"evidence": {
@@ -4534,7 +5864,13 @@
"sap-leanix": "partial",
"bluedolphin": "partial",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ }
},
{
"id": "arch-data-model",
@@ -4552,7 +5888,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "high",
+ "feature": "architecture-modelling",
+ "featureConfidence": "medium",
"note": "Helmond REQ54 asks for entity relationship or UML data models. stackiq holds imported ArchiMate elements but models nothing itself. Specified in openspec/changes/architecture-data-model-and-ggm (OpenSpec pass 2026-09-27).",
"vng-softwarecatalogus": "unknown",
"evidence": {
@@ -4565,7 +5902,13 @@
"sap-leanix": "partial",
"bluedolphin": "yes",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
+ }
},
{
"id": "org-access-review",
@@ -4583,6 +5926,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"featureConfidence": "medium",
"note": "Helmond REQ78 asks administrators to check periodically that every user still needs access. stackiq fetches the last login of each contact's account and never shows it. Specified in openspec/changes/organisations-role-mapping-and-access-review (OpenSpec pass 2026-09-27).",
"vng-softwarecatalogus": "partial",
@@ -4596,7 +5940,13 @@
"sap-leanix": "unknown",
"bluedolphin": "partial",
"glpi": "no",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkBeheerToegang"
+ ]
+ }
},
{
"id": "comp-processing-register",
@@ -4614,8 +5964,8 @@
"reachedOn": "ModuleDetail /modules/:id Application data widget (reached from OrganisatieDetail, see land-detail-page)",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "standards-compliance",
- "featureConfidence": "high",
+ "feature": "security-and-privacy-assessment",
+ "featureConfidence": "medium",
"note": "Helmond REQ4 wants the processing register kept apart from the repository but linked. stackiq stores and shows a reference per application; it does not hold the register.",
"vng-softwarecatalogus": "unknown",
"evidence": {
@@ -4628,7 +5978,13 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "mkt-tender-product-info",
@@ -4646,7 +6002,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "offering-and-usage-listings",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "Standard municipal tender text (Noordwijk 418890, Reimerswaal 417169, FUMO 415897, HLT Samen 383984): a supplier inside the GEMMA scope can make its product information transparent through the Softwarecatalogus. stackiq publishes the data; the page a buyer opens lives in the external frontend. Decided no (OpenSpec pass 2026-09-27): Recorded design: README.md:266 says stackiq runs \"with a separate React-based public frontend\", and README.md:273 names it (ConductionNL/tilburg-woo-ui) as the public search and detail pages. The missing half is the public page a buyer opens, which that frontend serves; stackiq publishes the data (module read rule for group public after publicationDate).",
"vng-softwarecatalogus": "yes",
"evidence": {
@@ -4659,7 +6015,11 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "life-value-assessment",
@@ -4677,7 +6037,7 @@
"reachedOn": "usage page Value assessment and Risk signals; Reports > Portfolio rationalization value against fit chart and Recorded class differs from scores switch",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "portfolio-reporting",
+ "feature": "portfolio-rationalisation",
"featureConfidence": "medium",
"note": "Helmond REQ41 asks for analysis of application use, cost, risk and value. stackiq records a TIME classification (life-time-classification) without the scores that would justify it. Specified in openspec/changes/lifecycle-application-value-assessment (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment.",
"vng-softwarecatalogus": "unknown",
@@ -4691,7 +6051,14 @@
"sap-leanix": "yes",
"bluedolphin": "yes",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail",
+ "SkRapportages"
+ ]
+ }
},
{
"id": "comp-security-officer-signoff",
@@ -4708,7 +6075,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "security-and-privacy-assessment",
+ "featureConfidence": "low",
"vng-softwarecatalogus": "unknown",
"evidence": {
"vng-softwarecatalogus": "unknown: the live softwarecatalogus.nl does not describe this and does not state its absence; the open PvE wens on the successor repository is not evidence about the incumbent (ruling 2026-09-26). Live page searched: Open PvE wens #136 'Als CISO wil ik ons gemeentelijk pakketoverzicht kunnen controleren en vervolgens fiatteren'; no approval step for the landscape appears in the incumbent manuals (https://www.softwarecatalogus.nl/node/19703). (read 2026-09-26)",
@@ -4721,7 +6089,11 @@
"sap-leanix": "partial",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "comp-processing-register-generate",
@@ -4738,7 +6110,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "security-and-privacy-assessment",
+ "featureConfidence": "low",
"vng-softwarecatalogus": "unknown",
"evidence": {
"vng-softwarecatalogus": "unknown: the live softwarecatalogus.nl does not describe this and does not state its absence; the open PvE wens on the successor repository is not evidence about the incumbent (ruling 2026-09-26). Live page searched: Open PvE wens #82 'Als gebruik-raadpleger wil ik een register van verwerkingen kunnen genereren'; the incumbent exports only package, connection and IBD-foto files (https://www.softwarecatalogus.nl/Beschikbare%20downloads). (read 2026-09-26)",
@@ -4751,7 +6124,11 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "ctr-collective-agreements",
@@ -4768,7 +6145,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "supplier-directory",
+ "featureConfidence": "low",
"vng-softwarecatalogus": "partial",
"evidence": {
"vng-softwarecatalogus": "Open PvE wens #50 (search) and #48 (register collective agreements incl. AVG and BIO terms); the incumbent lists signed addenda per supplier with a filter (https://www.softwarecatalogus.nl/addenda), which covers part of it. (read 2026-09-26)",
@@ -4781,7 +6159,11 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "share-compliance-documents",
@@ -4799,6 +6181,7 @@
"reachedOn": "KompliantieDetail /komplianties/:id Evidence documents; ModuleDetail DPIA document field",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "security-and-privacy-assessment",
"featureConfidence": "medium",
"vng-softwarecatalogus": "unknown",
"evidence": {
@@ -4812,7 +6195,14 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkComplianceMatrix"
+ ]
+ }
},
{
"id": "sec-baseline-classification",
@@ -4830,6 +6220,7 @@
"reachedOn": "ModuleDetail /modules/:id and Modules /modules BBN filter",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "security-and-privacy-assessment",
"featureConfidence": "medium",
"vng-softwarecatalogus": "unknown",
"evidence": {
@@ -4843,7 +6234,14 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "mkt-side-by-side-compare",
@@ -4860,7 +6258,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "supplier-directory",
+ "featureConfidence": "low",
"vng-softwarecatalogus": "unknown",
"evidence": {
"vng-softwarecatalogus": "unknown: the live softwarecatalogus.nl does not describe this and does not state its absence; the open PvE wens on the successor repository is not evidence about the incumbent (ruling 2026-09-26). Live page searched: Open PvE wens #31 'meerdere pakketten kunnen selecteren en deze in een overzichtelijke tabel naast elkaar vergelijken'; the incumbent offers filtered lists only (https://www.softwarecatalogus.nl/node/13683). (read 2026-09-26)",
@@ -4873,7 +6272,11 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "partial",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "org-act-as-user",
@@ -4890,7 +6293,8 @@
"reachedOn": "nothing reaches it",
"provider": "nextcloud",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "roles-and-access",
+ "featureConfidence": "low",
"vng-softwarecatalogus": "unknown",
"evidence": {
"vng-softwarecatalogus": "unknown: the live softwarecatalogus.nl does not describe this and does not state its absence; the open PvE wens on the successor repository is not evidence about the incumbent (ruling 2026-09-26). Live page searched: Open PvE wens #104 'Als functioneel beheerder wil ik me kunnen voordoen als een ander account'; not in the incumbent FAQ (https://www.softwarecatalogus.nl/node/16564). (read 2026-09-26)",
@@ -4903,7 +6307,11 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "yes",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "comp-common-ground-fit",
@@ -4920,7 +6328,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "standards-compliance",
+ "featureConfidence": "low",
"vng-softwarecatalogus": "partial",
"evidence": {
"vng-softwarecatalogus": "Open PvE wens #147; the incumbent shows whether a supplier signed the Groeipact Common Ground addendum (https://www.softwarecatalogus.nl/leveranciers), a supplier-level signal, not per product. (read 2026-09-26)",
@@ -4933,7 +6342,11 @@
"sap-leanix": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "land-version-carry-connections",
@@ -4951,6 +6364,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "application-connections",
"featureConfidence": "medium",
"vng-softwarecatalogus": "partial",
"evidence": {
@@ -4964,7 +6378,14 @@
"sap-leanix": "partial",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail",
+ "SkModuleversie"
+ ]
+ }
},
{
"id": "comp-retention-cleanup",
@@ -4981,7 +6402,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "security-and-privacy-assessment",
+ "featureConfidence": "low",
"topdesk": "yes",
"evidence": {
"topdesk": "Card 'More control over card & file removal' in Launched (updated 2026-02-09); docs https://docs.topdesk.com/en/file-maintenance.html: 'you can set how many days after closing or archiving a card its uploaded files and linked emails should be removed'. (read 2026-09-26)",
@@ -4994,7 +6416,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "partial",
"bluedolphin": "unknown",
- "glpi": "partial"
+ "glpi": "partial",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "land-dependent-fields",
@@ -5012,6 +6438,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"featureConfidence": "medium",
"topdesk": "no",
"evidence": {
@@ -5025,7 +6452,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"bluedolphin": "unknown",
- "glpi": "partial"
+ "glpi": "partial",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "land-change-entry-type",
@@ -5043,6 +6476,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"featureConfidence": "medium",
"topdesk": "no",
"evidence": {
@@ -5056,7 +6490,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"bluedolphin": "unknown",
- "glpi": "partial"
+ "glpi": "partial",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
+ }
},
{
"id": "ins-ai-action-audit",
@@ -5074,6 +6514,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "ai-assistant",
"featureConfidence": "medium",
"topdesk": "no",
"evidence": {
@@ -5087,7 +6528,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"bluedolphin": "unknown",
- "glpi": "no"
+ "glpi": "no",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard"
+ ]
+ }
},
{
"id": "sec-multi-factor-sign-in",
@@ -5104,7 +6551,8 @@
"reachedOn": "nothing reaches it in stackiq; Nextcloud two-factor apps apply platform-wide",
"provider": "nextcloud",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "sign-in-and-accounts",
+ "featureConfidence": "low",
"topdesk": "partial",
"evidence": {
"topdesk": "Card 'Support Multi-Factor Authentication (MFA)' in Under consideration; today MFA only comes from the identity provider behind SAML SSO (https://docs.topdesk.com/en/topdesk-mobile.html: 'We test the store application with the Microsoft two-step authentication (2FA) for the SSO'). (read 2026-09-26)",
@@ -5117,7 +6565,11 @@
"glpi": "yes",
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
- "bluedolphin": "unknown"
+ "bluedolphin": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "sec-password-policy",
@@ -5134,7 +6586,8 @@
"reachedOn": "nothing reaches it in stackiq; the Nextcloud password policy applies platform-wide",
"provider": "nextcloud",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "sign-in-and-accounts",
+ "featureConfidence": "low",
"topdesk": "no",
"evidence": {
"topdesk": "Card 'Enforce strong passwords' in Under consideration: 'By setting rules for things like minimum length, numbers, symbols, or uppercase letters, weak passwords are blocked'. (read 2026-09-26)",
@@ -5147,7 +6600,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "yes",
"bluedolphin": "unknown",
- "glpi": "yes"
+ "glpi": "yes",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "ops-change-risk-score",
@@ -5164,7 +6621,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "service-management",
+ "featureConfidence": "low",
"topdesk": "no",
"evidence": {
"topdesk": "Card 'AI - Risk & prediction' in Under consideration: 'Change risk prediction helps change managers assess how risky a planned change is before it's approved'. (read 2026-09-26)",
@@ -5177,7 +6635,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"bluedolphin": "unknown",
- "glpi": "no"
+ "glpi": "no",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "conn-auto-populate-dependencies",
@@ -5195,6 +6657,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "application-connections",
"featureConfidence": "medium",
"glpi": "no",
"evidence": {
@@ -5208,7 +6671,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "yes",
"bluedolphin": "yes",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "ctr-linked-contracts",
@@ -5225,7 +6694,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "contract-administration",
+ "featureConfidence": "low",
"glpi": "no",
"evidence": {
"glpi": "source read at 11.0.9: open request #182 (2026-03-27) states contracts cannot be linked in GLPI 11; install/mysql/glpi-empty.sql:1536 glpi_contracts_items links a contract to items, and Contract is not among the linkable item types there, with no parent contract column in install/mysql/glpi-empty.sql:1483 glpi_contracts.",
@@ -5238,7 +6708,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"bluedolphin": "unknown",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "ctr-contract-owner",
@@ -5256,6 +6730,7 @@
"reachedOn": "ContractDetail /contracten/:id contract details",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "contract-administration",
"featureConfidence": "medium",
"glpi": "no",
"evidence": {
@@ -5269,7 +6744,14 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "yes",
"bluedolphin": "unknown",
- "topdesk": "yes"
+ "topdesk": "yes",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContracten",
+ "SkContract"
+ ]
+ }
},
{
"id": "life-dates-follow-status",
@@ -5286,7 +6768,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "portfolio-roadmap",
+ "featureConfidence": "low",
"glpi": "partial",
"evidence": {
"glpi": "source read at 11.0.9: request #457 (2026-07-27, open) asks to tie dates to status; core already lets an entity fill financial dates when an item enters a chosen status, install/mysql/glpi-empty.sql:2800 autofill_use_date and :2822 autofill_decommission_date on glpi_entities, applied by src/Infocom.php:505 autofillDates, but only for the financial record's dates.",
@@ -5299,7 +6782,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"bluedolphin": "unknown",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "org-assigned-only-rights",
@@ -5316,7 +6803,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "roles-and-access",
+ "featureConfidence": "low",
"glpi": "yes",
"evidence": {
"glpi": "source read at 11.0.9: CHANGELOG.md:281 11.0.0 added 'View assigned' and 'Update assigned' rights; src/Glpi/Features/AssignableItem.php:68 grants read when the user or group is assigned and src/Glpi/Features/AssignableItem.php:79 checks UPDATE_ASSIGNED; Appliance uses this trait (src/Appliance.php:46 implements AssignableItemInterface).",
@@ -5329,7 +6817,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "partial",
"bluedolphin": "unknown",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "ops-own-house-style",
@@ -5347,7 +6839,8 @@
"reachedOn": "every stackiq page, through Nextcloud theming",
"provider": "nextcloud",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "open-platform",
+ "featureConfidence": "low",
"glpi": "yes",
"evidence": {
"glpi": "source read at 11.0.9: CHANGELOG.md:276 11.0.0 added custom palette and theme support; src/Glpi/UI/ThemeManager.php:103 getCustomThemesDirectory and :112 getCustomThemes load admin supplied themes, offered in src/Config.php:1612 getPalettes.",
@@ -5360,7 +6853,11 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "yes",
"bluedolphin": "unknown",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "board": null,
+ "reason": "Nextcloud-theming kleurt elk scherm, er is geen eigen scherm voor."
+ }
},
{
"id": "land-copy-entry",
@@ -5378,7 +6875,8 @@
"reachedOn": "index pages such as Module versions /moduleversies: select rows, Copy",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "software-landscape-register",
+ "featureConfidence": "low",
"glpi": "yes",
"evidence": {
"glpi": "source read at 11.0.9: CHANGELOG.md:285 and :286 11.0.0 added cloning of templates and creating a template from an existing item; appliances are clonable (src/Appliance.php:49 use Clonable) together with their items, contracts, documents and financial record (src/Appliance.php:62 getCloneRelations).",
@@ -5391,7 +6889,13 @@
"vng-softwarecatalogus": "partial",
"sap-leanix": "yes",
"bluedolphin": "partial",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
+ }
},
{
"id": "land-ai-agent-inventory",
@@ -5409,6 +6913,7 @@
"reachedOn": "Applications > AI systems (/ai-systems), AI system page, and the AI systems section on the application page",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "ai-system-inventory",
"featureConfidence": "medium",
"sap-leanix": "yes",
"evidence": {
@@ -5423,7 +6928,13 @@
"vng-softwarecatalogus": "unknown",
"bluedolphin": "unknown",
"glpi": "partial",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "arch-decision-register",
@@ -5441,6 +6952,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"featureConfidence": "medium",
"sap-leanix": "yes",
"evidence": {
@@ -5454,7 +6966,13 @@
"vng-softwarecatalogus": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
+ }
},
{
"id": "ins-natural-language-query",
@@ -5472,6 +6990,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "ai-assistant",
"featureConfidence": "medium",
"sap-leanix": "yes",
"evidence": {
@@ -5485,7 +7004,13 @@
"bluedolphin": "yes",
"vng-softwarecatalogus": "unknown",
"glpi": "no",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard"
+ ]
+ }
},
{
"id": "comp-ai-act-classification",
@@ -5503,6 +7028,7 @@
"reachedOn": "Applications > AI systems (/ai-systems), AI system page, and the AI systems section on the application page",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "ai-system-inventory",
"featureConfidence": "medium",
"sap-leanix": "partial",
"evidence": {
@@ -5517,7 +7043,13 @@
"vng-softwarecatalogus": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "life-version-tolerance",
@@ -5534,7 +7066,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "lifecycle-and-end-of-support",
+ "featureConfidence": "low",
"sap-leanix": "no",
"evidence": {
"sap-leanix": "Roadmap card 'EA Assistant: Technology Successor Planning' is On roadmap; https://updates.leanix.net/announcements/work-with-complete-technology-version-coverage-without-raising-manual-requests (2026-09-17): 'version concurrency management, which we plan to implement in Q4. You will be able to define version tolerance windows' (read 2026-09-26)",
@@ -5547,7 +7080,11 @@
"vng-softwarecatalogus": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "arch-diagram-version-compare",
@@ -5565,6 +7102,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"featureConfidence": "medium",
"sap-leanix": "yes",
"evidence": {
@@ -5578,7 +7116,13 @@
"vng-softwarecatalogus": "unknown",
"bluedolphin": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
+ }
},
{
"id": "share-lifecycle-conditional-sync",
@@ -5596,7 +7140,8 @@
"reachedOn": "nothing reaches it",
"provider": "openregister",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "api-and-integrations",
+ "featureConfidence": "low",
"bluedolphin": "yes",
"evidence": {
"bluedolphin": "November 2025 update (2025-11-13): 'Conditional Syncing for Integrations and Webhooks' lets teams 'control exactly what data syncs to third-party systems based on lifecycle state' (read 2026-09-26)",
@@ -5609,7 +7154,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"glpi": "yes",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
+ }
},
{
"id": "ins-usage-analytics",
@@ -5626,7 +7177,8 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
- "featureConfidence": "medium",
+ "feature": "dashboard-and-overviews",
+ "featureConfidence": "low",
"bluedolphin": "yes",
"evidence": {
"bluedolphin": "February 2026 update (2026-02-10): new reports show 'which guest users have access to BlueDolphin' and 'which views are being used across the platform'; https://help.bluedolphin.io/en/articles/13868249-usage-insights (read 2026-09-26)",
@@ -5639,7 +7191,11 @@
"vng-softwarecatalogus": "partial",
"sap-leanix": "unknown",
"glpi": "no",
- "topdesk": "partial"
+ "topdesk": "partial",
+ "screen": {
+ "board": null,
+ "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm."
+ }
},
{
"id": "land-move-between-workspaces",
@@ -5657,6 +7213,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "software-landscape-register",
"featureConfidence": "medium",
"bluedolphin": "yes",
"evidence": {
@@ -5670,7 +7227,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"glpi": "yes",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
+ }
},
{
"id": "land-cmdb-import",
@@ -5688,7 +7251,15 @@
"owner": "ConductionNL/stackiq",
"change": "2026-10-05-cmdb-export-import"
},
- "source": "spec-round-2026-10-07"
+ "feature": "landscape-import",
+ "source": "spec-round-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties",
+ "SkCmdb"
+ ]
+ }
},
{
"id": "arch-process-step-fields",
@@ -5706,6 +7277,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"featureConfidence": "medium",
"bluedolphin": "yes",
"evidence": {
@@ -5719,7 +7291,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
+ }
},
{
"id": "arch-view-tags",
@@ -5737,6 +7315,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "architecture-modelling",
"featureConfidence": "medium",
"bluedolphin": "yes",
"evidence": {
@@ -5750,7 +7329,13 @@
"vng-softwarecatalogus": "unknown",
"sap-leanix": "unknown",
"glpi": "no",
- "topdesk": "unknown"
+ "topdesk": "unknown",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkModelleren"
+ ]
+ }
},
{
"id": "land-usage-record",
@@ -5785,7 +7370,15 @@
"glpi": "source read at 11.0.9: use is recorded as installations separate from the software product, src/Item_SoftwareVersion.php:39 (install/mysql/glpi-empty.sql:1064 glpi_items_softwareversions) per device, and licence assignments per item or user; there is no usage record of a module by an organisation as such. Reached on: Assets > Software > Installations tab."
},
"question": "Does the external VNG Softwarecatalogus frontend create usage (gebruik) records through /api/aangeboden-gebruik or the OpenRegister objects API, and is that frontend part of what stackiq ships?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie",
+ "SkInGebruik",
+ "SkGebruikDetail"
+ ]
+ }
},
{
"id": "land-migrate-legacy",
@@ -5808,6 +7401,7 @@
"reachedOn": "nothing reaches it",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "landscape-import",
"note": "There is still no importer for the previous VNG Softwarecatalogus's registrations. Stackiq can now import applications from a TOPdesk CMDB export or a CSV/XLSX file, which is a manual workaround at best, not a migration of the old catalogue's entries.",
"evidence": {
"stackiq": "lib/Repair/* still only rename stackiq's own data (MigrateRegisterSlug.php, RenameDutchCatalogValues.php); appinfo/routes.php:110 POST /api/cmdb-import (lib/Service/CmdbExportImportService.php) imports a TOPdesk CMDB xlsx; :307 POST /api/itsm/import with lib/Service/ItsmFileImportService.php imports applications from a CSV/XLSX through the service desk flow; neither reads the previous VNG Softwarecatalogus's registrations; no change or spec names this row",
@@ -5818,7 +7412,13 @@
"glpi": "source read at 11.0.9: data from earlier GLPI versions is carried over by src/Update.php:172 doUpdates with the chain in install/migrations/ (update_0.85.x_to_0.90.0.php up to the 11.0 steps); data from another catalogue needs the separate datainjection plugin (pluginsGLPI/datainjection tag 2.15.11, inc/backendcsv.class.php). Reached on: web installer update; plugin Data injection."
},
"question": "Is the data from the previous VNG Softwarecatalogus meant to be brought over by a one-off migration outside this repo (for example an OpenRegister register import of an export file)?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicaties"
+ ]
+ }
},
{
"id": "conn-register-connection",
@@ -5841,7 +7441,7 @@
"reachedOn": "Applications > Connections /koppelingen and /koppelingen/:id (generic create/edit form); read-only lists on the application page /modules/:id",
"provider": "stackiq",
"providerHow": "read-from-code",
- "feature": "software-landscape-register",
+ "feature": "application-connections",
"featureConfidence": "medium",
"note": "Connections now have a list and a detail page where a connection is created and edited with both applications and its direction. The standard-version picker points at GEMMA elements in the AMEF register while the page works on the voorzieningen register, so picking a standard may not resolve, and a connection cannot be added from the application page.",
"evidence": {
@@ -5853,7 +7453,13 @@
"glpi": "source read at 11.0.9: install/mysql/glpi-empty.sql:1247 glpi_impactrelations stores a directed link from a source item to an impacted item with a name, added via src/Impact.php:1161 'Add relation'; there is no field for the standard or protocol used. Reached on: Appliance > Impact analysis tab, Add relation."
},
"question": "Does the external VNG Softwarecatalogus frontend register koppelingen through the OpenRegister objects API, and does it count as part of stackiq?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkKoppelingen"
+ ]
+ }
},
{
"id": "conn-usage-of-connection",
@@ -5877,7 +7483,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "offering-and-usage-listings",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A usage page now carries the connections the organisation runs within that usage, edited on the usage form and shown in its related panel; both usages and connections have their own pages.",
"evidence": {
"stackiq": "register.json:3019 usage.koppelingen (array of related connection, visible, not hideOnForm) editable on src/manifest.d/usages.json:37 GebruikDetail /gebruik/:id, whose gb-related widget (:51 'Connections and services') shows the linked connections; connections themselves have pages at src/manifest.d/connections.json:14,36; lib/Controller/AangebodenGebruikController.php /api/koppelingen-gebruik/{uuid} (appinfo/routes.php:273) still has no src caller",
@@ -5888,7 +7494,13 @@
"glpi": "source read at 11.0.9: an impact relation (install/mysql/glpi-empty.sql:1247) is a single fact in one instance; there is no separate usage record saying the organisation runs it, and grep -n 'usage' src/ImpactRelation.php returns nothing."
},
"question": "Does the external VNG Softwarecatalogus frontend let an organisation record the connections it runs, through /api/koppelingen-gebruik or the objects API?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail"
+ ]
+ }
},
{
"id": "conn-shared-with-others",
@@ -5912,7 +7524,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "shared-usage-on-gemma-views",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The owning organisation can now name participants on a usage page, but the participating organisations cannot see that shared usage or its connections in stackiq: the usage read rules ignore participants and only the API endpoint answers it.",
"evidence": {
"stackiq": "register.json:2717 usage.participants (visible, editable on /gebruik/:id via src/manifest.d/usages.json:37); register.json:3136-3172 usage read rules match only _organisation, consumer and provider, not participants, so a participating org does not see the shared usage on /gebruik; appinfo/routes.php:262 GET /api/aangeboden-gebruik/deelnemers and :273 /api/koppelingen-gebruik/{uuid} still have no caller in src/",
@@ -5923,7 +7535,14 @@
"glpi": "source read at 11.0.9: relations live inside one instance (install/mysql/glpi-empty.sql:1247 glpi_impactrelations) and entities are subdivisions of one organisation (src/Entity.php:58); no cross organisation sharing of connections exists (grep -rli 'federat' src/ returns nothing)."
},
"question": "Does the external VNG Softwarecatalogus frontend show shared connections from /api/aangeboden-gebruik/deelnemers?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail",
+ "SkKoppelingen"
+ ]
+ }
},
{
"id": "arch-refcomp-mapping",
@@ -5958,7 +7577,13 @@
"glpi": "source read at 11.0.9: grep -rli 'gemma\\|reference component' over src/ locales/glpi.pot returns nothing; the only classification of an appliance is its type dropdown (install/mysql/glpi-empty.sql:8941 appliancetypes_id)."
},
"question": "Does the Modules page's CnIndexPage create/edit dialog in nextcloud-vue 2.55.1 render module.referenceComponents (hideOnForm:true, items.$ref element in the AMEF register) as a working multiselect of reference components? nextcloud-vue's fieldsFromSchema (src/utils/schema.js:529) does not check hideOnForm.",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail"
+ ]
+ }
},
{
"id": "arch-gemma-views",
@@ -5982,7 +7607,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "No stackiq page renders a GEMMA view. The enriched view data is served for an external frontend, and the org export draws applications into view copies that open in Archi.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Hoe%20print%20ik%20een%20kaart%3F: \"de pakketten die getoond worden in het pakketoverzicht te plotten op een GEMMA architectuurkaart ... [Toon kaart]\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Pakketten > Toon kaart.",
@@ -5993,7 +7618,13 @@
"glpi": "source read at 11.0.9: grep -rli 'gemma\\|archimate' over src/ templates/ locales/glpi.pot returns nothing; the only diagram is the impact graph (src/Impact.php:252 displayGraphView)."
},
"question": "Does the external VNG Softwarecatalogus frontend render /api/views/{id} with include_gebruik, and is that frontend in scope for stackiq's column?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ }
},
{
"id": "arch-shared-overlay",
@@ -6028,7 +7659,13 @@
"glpi": "source read at 11.0.9: no GEMMA views (grep -rli 'gemma' src/ returns nothing) and no partner sharing; the impact graph (src/Impact.php:1559 makeDataForCytoscape) draws one instance's items only."
},
"question": "Does the external VNG frontend draw deelnames nodes differently from own usage on a GEMMA view?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ }
},
{
"id": "arch-gemma-api",
@@ -6052,7 +7689,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "gemma-alignment",
- "featureConfidence": "low",
+ "featureConfidence": "high",
"note": "A pure API row: the views endpoint is routed, authorised for logged-in users and returns the imported GEMMA views; elements come through OpenRegister's generic objects API.",
"evidence": {
"stackiq": "appinfo/routes.php:189-191 GET /api/views, /api/views/docs, /api/views/{viewId} -> lib/Controller/ViewController.php:82,218 (@NoAdminRequired at :75,:211) -> lib/Service/ViewService.php:108,166 read views from the AMEF register; element schema at register.json:4127 in register vng-gemma, readable through the OpenRegister objects API",
@@ -6063,7 +7700,11 @@
"glpi": "source read at 11.0.9: the v2 API controllers (src/Glpi/Api/HL/Controller/, for example AssetController.php:149 /Assets) expose GLPI itemtypes only; grep -rli 'gemma' src/ returns nothing."
},
"question": "Is the view API reachable without a Nextcloud login (no #[PublicPage] on ViewController), and does the row require public access?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Een API die de GEMMA-architectuur levert, geen scherm."
+ }
},
{
"id": "comp-declare-standard",
@@ -6098,7 +7739,13 @@
"glpi": "source read at 11.0.9: no standards model (grep -rli 'standaard' src/ locales/glpi.pot returns nothing); software versions (install/mysql/glpi-empty.sql:6900) carry no supported standard."
},
"question": "On /komplianties, does the create form's standardVersion select (items $ref element, which lives only in the AMEF register) list standard versions, or does it resolve against the voorzieningen register and come back empty/404 as the Standaarden page did before its register fix?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkComplianceMatrix"
+ ]
+ }
},
{
"id": "mkt-offer-accept",
@@ -6133,7 +7780,13 @@
"glpi": "source read at 11.0.9: suppliers have no login or role (install/mysql/glpi-empty.sql:7067 glpi_suppliers is a plain record; profiles in src/Profile.php:55 are for users), so no supplier can accept or decline a claimed usage."
},
"question": "Does the external VNG Softwarecatalogus frontend (not in this repo) call PUT /api/aanbod/{uuid}/accept and /api/aangeboden-gebruik/{id}/set-self, and is that frontend part of what we ship?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
+ }
},
{
"id": "mkt-compare-peers",
@@ -6168,7 +7821,13 @@
"glpi": "source read at 11.0.9: no reference components (grep -rli 'reference component' src/ returns nothing) and no data from comparable organisations, since each instance is standalone (src/Entity.php:58 entities are internal)."
},
"question": "Does the external VNG frontend render /api/views with the deelnames enrichment, and does that count as a stackiq page?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ }
},
{
"id": "mkt-who-uses-it",
@@ -6192,7 +7851,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "offering-and-usage-listings",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "The application page now has a Usages list naming the using organisation, version and status. RBAC narrows it: a supplier sees usages whose hidden provider field is its organisation, and a municipality sees only its own usages. Usages created through stackiq's own forms do not set provider, so a supplier only sees usages from imports or external data.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Marktscans: \"kunnen ingelogde gemeenten of samenwerkingen zien bij welke collega-gemeenten betreffende pakketversie in het applicatielandschap staat (klik daarvoor op het getal boven Ingevuld door)\" (read 2026-09-26). Reached on: Package page > Ingevuld door.",
@@ -6203,7 +7862,13 @@
"glpi": "source read at 11.0.9: within one instance the version Summary tab shows installations per entity, src/Item_SoftwareVersion.php:850 showForVersionByEntity, and the installation list carries the entity column (src/Item_SoftwareVersion.php:484); organisations outside the instance are not visible. Reached on: Assets > Software > version > Summary tab."
},
"question": "Does the generic 'related' widget on ModuleDetail list usage objects that point at the module (inverse relation), and for which roles?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "mkt-review-moderation",
@@ -6238,7 +7903,13 @@
"glpi": "source read at 11.0.9: there are no product reviews (see mkt-reviews); the only moderation is knowledge base publication by rights on src/KnowbaseItem.php:57, unrelated to reviews."
},
"question": "Does the installed OpenRegister let a catalogue user create a software-review with status=approved through /reviews (the generic create), bypassing ReviewService?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkBeheerToegang"
+ ]
+ }
},
{
"id": "mkt-hide-landscape-from-vendors",
@@ -6262,6 +7933,7 @@
"reachedOn": "no page of its own: enforced by OpenRegister RBAC on every read, including /gebruik, /koppelingen and the /modules/:id Usages list",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"note": "The register declares organisation-scoped reads, so a supplier sees only usages of its own products (where provider is set) and published connections, including on the new usage, connection and application pages. This rests on the installed OpenRegister executing the declared match rules.",
"evidence": {
"stackiq": "lib/Settings/softwarecatalogus_register.json:3149-3172 usage read scoped to _organisation or consumer for gebruik-beheerder, and _organisation or provider = own organisation for aanbod-beheerder; usage.provider is hidden (:2690-2701) and set only by lib/Service/CmdbExportImportService.php:1193; connection read :3865-3891 public when publicationDate <= now, plus own organisation and provider for aanbod-beheerder; these rules now also govern the new pages /gebruik, /gebruik/:id (src/manifest.d/usages.json), /koppelingen (src/manifest.d/connections.json) and the application page Usages list (src/manifest.json:514); openspec/specs/vendor-visibility-rbac",
@@ -6272,7 +7944,11 @@
"glpi": "source read at 11.0.9: suppliers are records without accounts (install/mysql/glpi-empty.sql:7067 glpi_suppliers) and all data is visible only through the profiles and entities assigned to users (install/mysql/glpi-empty.sql:5917 glpi_profiles_users), so a supplier sees nothing unless given an account. Reached on: Administration > Profiles."
},
"question": "Does the installed OpenRegister enforce the declared authorization.read match rules (e.g. {group: aanbod-beheerder, match: {provider: $organisation}}) on usage and connection reads?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Afgedwongen door OpenRegister-RBAC bij elke lezing, zonder scherm."
+ }
},
{
"id": "mkt-free-public-service",
@@ -6295,6 +7971,7 @@
"reachedOn": "no page: a property of the app and its licence",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "open-data-publishing",
"note": "The software is free and published entries are publicly readable, but the repo ships no hosted public catalogue; the public-facing frontend is the external VNG one.",
"evidence": {
"vng-softwarecatalogus": "unknown: public browsing is open (\"Iedereen kan de softwarecatalogus raadplegen\", FAQ E6) but no page states the use is free of charge; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/hoe-kan-ik-me-als-leverancier-aanmelden (read 2026-09-26)",
@@ -6305,7 +7982,11 @@
"bluedolphin": "https://bluedolphin.io/pricing/: plans 'Tactical', 'Strategic' and 'Enterprise', with 'Contact sales for pricing' and a free trial only on the Strategic plan, after which 'your workspace continues with limited access unless upgraded' (read 2026-09-26)"
},
"question": "Is a hosted, free-of-charge stackiq instance offered to municipalities and suppliers (the competitor offer is a public service, not software)?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Een eigenschap van de app en haar licentie, geen scherm."
+ }
},
{
"id": "life-eol-warning",
@@ -6340,7 +8021,15 @@
"glpi": "source read at 11.0.9: alerts exist for dates GLPI stores, licence expiry (src/NotificationTargetSoftwareLicense.php:46 'Alarms on expired licenses'), contract end and notice (src/NotificationTargetContract.php:47) and warranty expiry (src/Infocom.php:651 cronInfocom); no end of support date exists on an application or version (install/mysql/glpi-empty.sql:6900 glpi_softwareversions). Reached on: Setup > Notifications; Setup > Automatic actions."
},
"question": "Does the installed OpenRegister dispatch the scheduled x-openregister-notifications rule 'eol-approaching' on moduleVersion, and to whom?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInGebruik",
+ "SkGebruikDetail",
+ "SkRoadmap"
+ ]
+ }
},
{
"id": "life-eol-feed",
@@ -6375,7 +8064,13 @@
"glpi": "source read at 11.0.9: grep -i 'end_of_support\\|endoflife' over install/mysql/glpi-empty.sql and src/ returns nothing; no external lifecycle feed is read (the only outbound catalogues are the plugin marketplace, src/Glpi/Marketplace/). The marketplace client, the only outbound catalogue, is src/Glpi/Marketplace/Controller.php:64."
},
"question": "Is integriq's endoflife-date source (eol_product/eol_cycle register) provisioned on a default install, so that switching the sync on actually finds cycles?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ }
},
{
"id": "life-planned-replacement",
@@ -6410,7 +8105,14 @@
"glpi": "source read at 11.0.9: a software can be flagged as an 'Upgrade from' another software, templates/pages/assets/software.html.twig:46 to :50 (is_update with softwares_id, install/mysql/glpi-empty.sql:6864 and :6865); this records succession after the fact, with no planned replacement link for appliances. Reached on: Assets > Software form, Upgrade from."
},
"question": "Is the external VNG Softwarecatalogus frontend (which writes usage objects) counted as part of stackiq for this row?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail",
+ "SkRoadmap"
+ ]
+ }
},
{
"id": "life-time-classification",
@@ -6433,6 +8135,7 @@
"reachedOn": "Applications in use /gebruik (column) and /gebruik/:id (Value assessment section and edit form); PortfolioReport /portfolio-report",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "portfolio-rationalisation",
"note": "A user sets the TIME classification, rationale and review date on the usage edit form of Applications in use. The usage page shows it next to the class the value scores suggest, and the portfolio report groups usages per quadrant.",
"evidence": {
"sap-leanix": "https://help.sap.com/docs/leanix/ea/time: 'TIME stands for Tolerate, Invest, Migrate, and Eliminate. It categorizes applications based on their strategic value' (read 2026-09-26). Reached on: Application fact sheet > TIME classification.",
@@ -6443,7 +8146,15 @@
"glpi": "source read at 11.0.9: grep -rli 'tolerate\\|eliminate' over src/ locales/glpi.pot returns nothing; a TIME class can only be held in a repurposed single choice dropdown such as the appliance status (install/mysql/glpi-empty.sql:8950 states_id, values from src/State.php:45) or type (install/mysql/glpi-empty.sql:8941). Reached on: Management > Appliances, Status or Type field."
},
"question": "Is the external VNG frontend or OpenRegister's generic object editor the intended place to set timeClassification, and does it count here?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInGebruik",
+ "SkGebruikDetail",
+ "SkRapportages"
+ ]
+ }
},
{
"id": "ctr-register",
@@ -6478,7 +8189,13 @@
"sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: contract fact sheet with 'Contract Number', 'Contract Pricing Type', lifecycle phases for start, notice and end, and licensing, maintenance and support costs, linked to applications and providers (read 2026-09-26). Reached on: Inventory > Contract fact sheet (contract extension)."
},
"question": "Does the installed OpenRegister accept a catalogContract without the required usage (schema hardValidation false), and can the related-object picker create a usage inline?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContracten"
+ ]
+ }
},
{
"id": "ctr-expiry-alert",
@@ -6514,7 +8231,14 @@
"glpi": "source read at 11.0.9: src/Contract.php:1092 cronContract computes end and notice dates and sends the events of src/NotificationTargetContract.php:47 (end of contract, notice, periodicity, periodicity notice); install/mysql/glpi-empty.sql:1508 glpi_contracts.alert sets which alerts apply. Reached on: Management > Contracts, Email alarms field; Setup > Notifications."
},
"question": "Does the installed OpenRegister dispatch scheduled x-openregister-notifications, and does it compare the filter value case/locale-insensitively (it cannot map 'Actief' to 'Active')?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard",
+ "SkContracten"
+ ]
+ }
},
{
"id": "ctr-approval",
@@ -6550,7 +8274,13 @@
"glpi": "source read at 11.0.9: grep -n 'alidation' src/Contract.php returns nothing and no ContractValidation class exists; approvals exist only for ITIL objects (src/ChangeValidation.php:39), and the contract status is a free dropdown (install/mysql/glpi-empty.sql:1512 states_id)."
},
"question": "Does the installed OpenRegister enforce x-openregister-lifecycle transitions on catalogContract.status, given its states are Dutch ('Actief') and the enum is English ('Active')?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkContract"
+ ]
+ }
},
{
"id": "sec-exposure",
@@ -6573,6 +8303,7 @@
"reachedOn": "Vulnerabilities /kwetsbaarheden, Exposed usages column; Applications in use /gebruik/:id risk signals; KwetsbaarheidDetail /kwetsbaarheden/:id Exposure tab by URL only",
"provider": "stackiq",
"providerHow": "read-from-code",
+ "feature": "vulnerability-management",
"note": "The list shows how many in-production usages each vulnerability exposes, and each usage page now shows how many vulnerabilities touch its application. The per-organisation exposure list is still on a detail tab that no page opens, so a user cannot easily see who is exposed to a given vulnerability.",
"evidence": {
"stackiq": "src/utils/vulnerabilityExposure.js:54 in-production join vulnerability.modules -> usage.module -> usage.consumer; src/views/KwetsbaarhedenView.vue:111 Exposed usages count per row; src/manifest.json:1025 VulnerabilityExposurePanel (per-organisation rows) as Exposure sidebar tab of KwetsbaarheidDetail /kwetsbaarheden/:id, but a list row still opens the generic edit modal (KwetsbaarhedenView.vue:94 -> openDetail :419 navigationStore.setModal('vulnerability'), src/modals/Modals.vue:44) and nothing in src links to KwetsbaarheidDetail; new: src/manifest.d/usages.json:61 UsageRiskSignals on GebruikDetail shows the count of vulnerabilities linked to that usage's application (src/components/portfolio/UsageRiskSignals.vue:58)",
@@ -6583,7 +8314,14 @@
"glpi": "source read at 11.0.9: no vulnerability data exists (grep -rli 'cve' src/ hits only src/Glpi/System/Requirement/PhpSupportedVersion.php:74), so exposure cannot be derived even though installations per entity are known (src/Item_SoftwareVersion.php:850)."
},
"question": "Is KwetsbaarheidDetail reachable from any page, for example by clicking a vulnerability in ModuleDetail's Related panel?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkGebruikDetail",
+ "SkKwetsbaarheden"
+ ]
+ }
},
{
"id": "sec-vulnerability-alert",
@@ -6618,7 +8356,14 @@
"glpi": "source read at 11.0.9: no vulnerability events among notification targets; software notifications are licence expiry only (src/NotificationTargetSoftwareLicense.php:46)."
},
"question": "Does the installed OpenRegister dispatch the x-openregister-notifications 'vulnerability-reported' rule on the vulnerability schema, and can recipients be the consumers of the affected modules?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard",
+ "SkKwetsbaarheden"
+ ]
+ }
},
{
"id": "org-self-registration",
@@ -6653,7 +8398,13 @@
"glpi": "source read at 11.0.9: suppliers are created by staff only (src/Supplier.php:75 sets is_active on add from the staff form) and there is no public sign up page among front/ pages (front/lostpassword.php and front/initpassword.php are the only anonymous account pages)."
},
"question": "Does the external VNG frontend post to /api/intake/register, and does OpenRegister's generic public create on organization let an anonymous caller set registrationStatus/status/publicationDate directly?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkPubliek"
+ ]
+ }
},
{
"id": "org-contact-to-account",
@@ -6688,7 +8439,13 @@
"glpi": "source read at 11.0.9: contacts (src/Contact.php:45) and users are separate itemtypes with no conversion action; user accounts come from manual creation, LDAP import (src/AuthLDAP.php:59) or authorisation rules (src/RuleRight.php:297 profile action)."
},
"question": "Does a contactPerson created via the current form reach createUserAccount with an email (e.g. resolved from Nextcloud Contacts by contactsUid somewhere I did not find), or does conversion fail for every post-migration contact?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkOrganisatie"
+ ]
+ }
},
{
"id": "org-cooperation",
@@ -6712,7 +8469,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "shared-usage-on-gemma-views",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "A cooperation can be registered with its participants, and the usages it shares can now be recorded with participants on the Applications in use page. No page shows a participant the landscape it shares: the deelnemers API has no caller, and the usage read rules do not let participants see those usages.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/19703: \"Handleiding beheer gemeente-samenwerking ... Samenwerkingsverbanden die als doel hebben om de applicatielandschappen van de aangesloten gemeenten te harmoniseren\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/30355: cooperation selects the member municipalities per package (read 2026-09-26). Reached on: Samenwerking account > Pakketten.",
@@ -6723,7 +8480,15 @@
"glpi": "source read at 11.0.9: entities form a tree (src/Entity.php:58 extends CommonTreeDropdown) and records flagged recursive are shared with all child entities (src/Appliance.php:325 is_recursive), so a parent entity can hold a landscape shared by several subordinate units; there is no cooperation of independent organisations. Reached on: Administration > Entities; Appliance Child entities field."
},
"question": "Does the external VNG frontend show a cooperation's shared landscape from /api/aangeboden-gebruik/deelnemers?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInGebruik",
+ "SkGebruikDetail",
+ "SkOrganisaties"
+ ]
+ }
},
{
"id": "org-data-segregation",
@@ -6747,6 +8512,7 @@
"reachedOn": "no page: enforced by OpenRegister RBAC/multitenancy on every list and detail page",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"note": "Organisation-scoped reads are declared per schema, and OpenRegister applies the match rules on list and detail reads. Contact persons are still readable across organisations by gebruik-beheerder and gebruik-raadpleger, and every civil servant (ambtenaar) can read every organisation's contracts.",
"evidence": {
"glpi": "source read at 11.0.9: every query on entity scoped items is restricted to the user's active entities, src/DbUtils.php:920 getEntitiesRestrictCriteria; records carry entities_id and is_recursive (install/mysql/glpi-empty.sql:8937 and :8938 on glpi_appliances), so an entity's records stay invisible to other entities unless shared down the tree. Reached on: entity selector in the header.",
@@ -6757,7 +8523,11 @@
"sap-leanix": "https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf: 'Virtual workspaces to control users' read and edit permissions for fact sheets and their content in a federated organization'; https://help.sap.com/docs/leanix/ea/virtual-workspaces-configuration (read 2026-09-26). Reached on: Administration > Virtual Workspaces."
},
"question": "Does the installed OpenRegister evaluate authorization.read match rules with $organisation on the generic object list and detail endpoints the stackiq pages use?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Afgedwongen door OpenRegister-multitenancy bij elke lijst en pagina, zonder scherm."
+ }
},
{
"id": "org-hierarchy",
@@ -6781,7 +8551,7 @@
"provider": "stackiq",
"providerHow": "read-from-code",
"feature": "automatic-user-provisioning",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "On automatic provisioning, the first user of an organisation joins the maintainer (beheerder) group, which may manage the organisation's members, and later users get that user as manager. The first contact no longer gets the organisation admin groups (switched off on 28 Sep), the manual Convert to User does not run this step, and it all depends on the email field the contact schema lacks.",
"evidence": {
"stackiq": "lib/Service/Stackiq/HierarchyHandler.php:79 ensureOrganizationBeheerder: when the organisation has no beheerder (OrganizationHandler getOrganizationBeheerders = members of group 'maintainer'), the user gets assignBeheerderRole (ContactPersonHandler.php:1368, adds group 'maintainer' :1398), and :130 setupManagerRelationships makes later users report to the primary beheerder; called only on the automatic path (lib/Service/StackiqService.php:247, lib/Service/ContactpersoonService.php:323), not by the manual convertToUser; ContactPersonHandler.php:641 the first contact is no longer added to the organisation admin groups (cab7bc32, 28 Sep); 'maintainer' members manage the organisation's members via lib/Controller/OrganisationMembersController.php",
@@ -6792,7 +8562,11 @@
"glpi": "source read at 11.0.9: an administrator can delegate user management per entity with a profile holding user rights, and GLPI stops a delegate from granting a profile stronger than their own, src/Profile.php:744 currentUserHaveMoreRightThan and src/Profile.php:679 getUnderActiveProfileRestrictCriteria; nothing makes the first user of an organisation its administrator automatically. Reached on: Administration > Users > Authorizations tab."
},
"question": "Same as org-contact-to-account: does conversion get an email for a post-migration contact?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Gebeurt vanzelf bij het aanmaken van het eerste account, zonder scherm."
+ }
},
{
"id": "share-public-browse",
@@ -6816,7 +8590,7 @@
"provider": "openregister",
"providerHow": "read-from-code",
"feature": "open-data-publishing",
- "featureConfidence": "medium",
+ "featureConfidence": "high",
"note": "Anonymous browsing rests on RBAC read rules in the register that OpenRegister executes, and since 1 Oct anonymous readers get published objects without their private fields. Stackiq ships no public page; the browsing surface is OpenCatalogi or the external VNG frontend. The module rule still exposes every registeredBy=Supplier module whatever its publication date.",
"evidence": {
"vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: E6: \"Iedereen kan de softwarecatalogus raadplegen ... De gegevens ingevoerd door de leveranciers zijn openbaar\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakketten: package list readable without login (read 2026-09-26). Reached on: Alle pakketten.",
@@ -6827,7 +8601,13 @@
"glpi": "source read at 11.0.9: the only anonymous content is the public FAQ, gated by use_public_faq (src/KnowbaseItem.php:131, src/Document.php:717); asset, appliance and software lists all require a session (src/Glpi/Controller/GenericListController.php checks canView)."
},
"question": "Does the installed OpenRegister honour the {group: public, match: {publicationDate: {$lte: $now}}} read rule on module/catalogService for an anonymous GET /apps/openregister/api/objects, and which public frontend (the external VNG Softwarecatalogus site) is the intended browse surface?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkPubliek"
+ ]
+ }
},
{
"id": "share-public-api",
@@ -6850,6 +8630,7 @@
"reachedOn": "API only: OpenRegister objects API for anonymous reads; no stackiq page",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "open-data-publishing",
"note": "There is no dedicated developer-facing public API. Public access to the offering is whatever OpenRegister serves from the declared read rules, which now leave out private fields. Stackiq's own offering endpoint needs a login, and generated API documentation is only specified (sharing-generated-api-docs).",
"evidence": {
"glpi": "source read at 11.0.9: the v2 API routes without authentication are only the index, documentation and getting started pages, src/Glpi/Api/HL/Controller/CoreController.php:301, :322, :397, :407; all data routes require OAuth or session auth, and there is no supplier offering to expose.",
@@ -6860,7 +8641,11 @@
"sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; all APIs authenticate with workspace tokens (https://help.sap.com/docs/leanix/ea/authentication-to-sap-leanix-services), a public API over a supplier offering is not documented (read 2026-09-26)"
},
"question": "Does the installed OpenRegister execute the module/catalogService public read rules for anonymous API callers, and is any API key or rate limit applied to that public surface?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Een publieke API voor ontwikkelaars, geen scherm."
+ }
},
{
"id": "share-portal",
@@ -6883,6 +8668,7 @@
"reachedOn": "Portaliq external portal (not a stackiq page), only for signed-in portal subjects of a supplier or participant organisation",
"provider": "portaliq",
"providerHow": "read-from-code",
+ "feature": "api-and-integrations",
"note": "The contribution is declarative and read-only, and it covers only an organisation's own records for portal subjects. It shows nothing publicly, and the create/accept actions are deferred per its own docblock.",
"evidence": {
"stackiq": "lib/Portal/PortalContributionProvider.php:130 getContribution() declares read-only, organisation-scoped collections for audiences vendor-org (:167 catalogService, :184 usage, :204 catalogContract, :228 compliancy) and participant-org (:270 usage, :291 catalogContract); :97 audiences; docblock :24 says accept/deny actions are deferred. No change to lib/Portal since 26 Sep. Inert without portaliq.",
@@ -6893,7 +8679,11 @@
"glpi": "source read at 11.0.9: no embeddable widget or external portal integration for catalogue content; the self service interface (src/Glpi/Form/ServiceCatalog/ServiceCatalog.php:45) is GLPI's own helpdesk portal and grep -rli 'iframe embed\\|oembed' over src/ returns nothing."
},
"question": "Does the installed portaliq render stackiq's contribution (its PortalProviderLocator iterating installed apps), and is that portal live for any stackiq customer?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Wordt getoond op het portaal van portaliq, niet in stackiq."
+ }
},
{
"id": "share-ai-assistant",
@@ -6916,6 +8706,7 @@
"reachedOn": "nothing in stackiq reaches it; only OpenRegister's generic MCP endpoint",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "ai-assistant",
"note": "Stackiq has nothing of its own here. An AI client can reach catalogue objects only through OpenRegister's generic MCP 'objects' tool. A stackiq tool surface is specified in mcp-full-action-surface but not built.",
"evidence": {
"stackiq": "Still no MCP, tool-provider, TaskProcessing or ContextChat code in stackiq lib/, src/ or the register (grep 'mcp|ToolProvider|TaskProcessing|ContextChat|x-openregister-mcp' finds only an info.xml comment about OpenRegister's ContextChat). OpenRegister's generic MCP server (openregister appinfo/routes.php:2011 /api/mcp/v1/discover, :2018 POST /api/mcp; lib/Mcp/BuiltIn/ObjectsToolProvider.php:80-87 tool 'objects' with list/get/create/update/delete over any register) is the only path. Open changes mcp-full-action-surface (specs/mcp-tool-surface/spec.md: hand-written provider, read tools on 14 schemas, write tools behind existing guards; supersedes stackiq-mcp-adoption) and stackiq-mcp-adoption have no task ticked.",
@@ -6926,7 +8717,11 @@
"glpi": "source read at 11.0.9: grep -rli 'mcp\\|model context\\|openai\\|llm' over src/ returns nothing; AI tool access would go through the generic v2 API (src/Glpi/Api/HL/Controller/AssetController.php:149) with no tool interface of its own."
},
"question": "Does OpenRegister's MCP server expose the voorzieningen register's objects (module, catalogService, organization) for read and write to an AI client with a stackiq user's rights?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Een toolinterface (MCP) voor een AI-assistent, geen scherm."
+ }
},
{
"id": "share-webhooks",
@@ -6949,6 +8744,7 @@
"reachedOn": "Admin settings > Service desk exchange (sets up the outbound flow); Flows /flows; general webhooks only in OpenRegister's own admin UI",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "api-and-integrations",
"note": "Stackiq now pushes changes to one kind of outside system: once an admin sets up the service desk exchange, a change to an application in use is sent to TOPdesk or ServiceNow. Notifying any other system still goes through OpenRegister machinery, either its webhooks UI or a flow authored on stackiq's Flows page.",
"evidence": {
"stackiq": "New since 1 Oct (#1205): lib/Settings/flows/itsm-outbound-applications.json:5-30 is a stackiq flow template triggered on object.created/object.updated of usage that sends stackiq-owned fields to TOPdesk or ServiceNow through integriq; lib/Service/ItsmExchangeService.php:134 maps the outbound template; an admin creates and publishes it from Admin settings > Service desk exchange (src/views/settings/sections/ItsmExchange.vue, lib/Controller/ItsmExchangeController.php:98/:116 AuthorizedAdminSetting, appinfo/routes.php:304-307). There is still no generic webhook in stackiq: the Flows page (src/manifest.json:1085, menu FlowsMenu :208) authors OpenRegister flows, and OpenRegister has its own webhooks admin.",
@@ -6959,7 +8755,13 @@
"glpi": "source read at 11.0.9: src/Webhook.php:64 Webhook sends HTTP calls on new, update and delete events (src/Webhook.php:311 getDefaultEventsList) for management itemtypes including Appliance, Budget, Contact and others (src/Webhook.php:398). Reached on: Setup > Webhooks (src/Html.php:1331). Driven on the lab at 11.0.9 (2026-09-26): Setup > Webhooks (/front/webhook.php) lists webhooks with type, event and category."
},
"question": "Can a flow created on stackiq's Flows page be triggered by object.updated on a catalogue schema and make an outbound HTTP call to an external system?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
+ }
},
{
"id": "ins-audit-trail",
@@ -6994,7 +8796,13 @@
"sap-leanix": "https://help.sap.com/docs/leanix/ea/collaborating-and-managing-data-in-fact-sheets: the fact sheet change log shows 'Old Value / New Value', 'User', 'Time' and 'Entries in the log cannot be deleted manually' (read 2026-09-26). Reached on: Fact sheet > History log."
},
"question": "Is OpenRegister's audit trail enabled for the voorzieningen register on a default install, so the History tab shows entries?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkApplicatie"
+ ]
+ }
},
{
"id": "ins-live-updates",
@@ -7018,6 +8826,7 @@
"reachedOn": "Vulnerabilities /kwetsbaarheden, License posture /license-posture, Portfolio roadmap /portfolio-roadmap, Compliance matrix /compliance-matrix, Portfolio rationalization /portfolio-report",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "dashboard-and-overviews",
"note": "Five custom pages subscribe to collection events and refetch on a change. Whether the event ever arrives depends on OpenRegister and the push transport, which this repo cannot show.",
"evidence": {
"bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; views can be shared for collaboration (https://help.bluedolphin.io/en/articles/11967512-collaborate-on-an-architecture-view), but a list that refreshes by itself when another user changes an entry is not documented; the earlier intelligence citation alone is a claim (read 2026-09-26)",
@@ -7028,7 +8837,15 @@
"glpi": "source read at 11.0.9: grep -rli 'websocket\\|EventSource' over src/ finds only src/Glpi/Api/HL/Controller/NotificationController.php:303 'websocket: Not used by GLPI core'; lists refresh on reload only."
},
"question": "Does the installed OpenRegister publish or-collection-{register}-{schema} events over a transport (notify_push or SSE) that the nc-vue liveUpdatesPlugin receives, so these pages update without a reload?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard",
+ "SkKwetsbaarheden",
+ "SkRoadmap"
+ ]
+ }
},
{
"id": "ins-notifications",
@@ -7063,7 +8880,13 @@
"sap-leanix": "https://help.sap.com/docs/leanix/ea/notifications: 'Notification Channels ... Email : This is the primary channel for notifications ... Microsoft Teams', for fact sheet updates, subscriptions, to-dos and surveys; no in-app channel is listed (read 2026-09-26). Reached on: User menu > My Settings > Notifications."
},
"question": "Does the installed OpenRegister dispatch the x-openregister-notifications rules on vulnerability, software-review, moduleVersion and the scheduled rules on catalogContract/usage/module, as Nextcloud notifications to the listed recipients?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkDashboard"
+ ]
+ }
},
{
"id": "ops-plugins",
@@ -7086,6 +8909,7 @@
"reachedOn": "Store /store (footer menu)",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "open-platform",
"note": "The store installs configuration sets and flows, not code plugins, and it depends on a registry being configured.",
"evidence": {
"glpi": "source read at 11.0.9: src/Glpi/Marketplace/View.php:53 marketplace view at front/marketplace.php (src/Glpi/Marketplace/View.php:103) and src/Glpi/Marketplace/Controller.php:64 download and install of plugins; src/Glpi/Marketplace/View.php:185 notes that a registration, at least a free one, is required. Reached on: Setup > Plugins > Marketplace. Driven on the lab at 11.0.9 (2026-09-26): Setup > Plugins > Marketplace, Discover tab: \"A registration, at least a free one, is required to use marketplace\"; plugins can still be installed by hand from their repositories.",
@@ -7096,7 +8920,13 @@
"sap-leanix": "https://help.sap.com/docs/leanix/ea/extension-hub: 'The SAP LeanIX extension hub is a centralized location where you can discover and install ready-to-use extensions for your SAP LeanIX workspace. These include meta model extensions, surveys, and custom reports'; public hub at https://exthub.leanix.net/en (read 2026-09-26). Reached on: Extension Hub."
},
"question": "Is a store registry configured on a default install, and are any stackiq configuration sets published to it?",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "canvas": "design-system",
+ "board": [
+ "SkFunctiesRoadmap"
+ ]
+ }
},
{
"id": "org-field-level-permissions",
@@ -7115,6 +8945,7 @@
"reachedOn": "API: OpenRegister leaves the restricted fields out of anonymous reads; no page to configure the rules",
"provider": "openregister",
"providerHow": "read-from-code",
+ "feature": "roles-and-access",
"featureConfidence": "medium",
"topdesk": "no",
"evidence": {
@@ -7129,8 +8960,380 @@
"sap-leanix": "yes",
"bluedolphin": "partial",
"glpi": "partial",
- "source": "pending-moved-2026-10-07"
+ "source": "pending-moved-2026-10-07",
+ "screen": {
+ "board": null,
+ "reason": "Afgedwongen door OpenRegister bij elke lezing van een object, zonder scherm."
+ }
}
],
- "pending": []
+ "pending": [],
+ "specScreens": {
+ "aanbod-listings": {
+ "board": [
+ "SkApplicaties",
+ "SkOrganisatie"
+ ]
+ },
+ "aangeboden-gebruik-api": {
+ "board": [
+ "SkOrganisatie",
+ "SkGebruikDetail"
+ ]
+ },
+ "ai-system-inventory": {
+ "board": [
+ "SkApplicatie"
+ ]
+ },
+ "app-id-rename": {
+ "board": null,
+ "reason": "Interne migratie van de app-id, geen scherm."
+ },
+ "application-hosting-model": {
+ "board": [
+ "SkApplicatie"
+ ]
+ },
+ "application-lifecycle-tracking": {
+ "board": [
+ "SkRoadmap",
+ "SkGebruikDetail"
+ ]
+ },
+ "application-page": {
+ "board": [
+ "SkApplicatie"
+ ]
+ },
+ "application-usage-pages": {
+ "board": [
+ "SkInGebruik",
+ "SkGebruikDetail"
+ ]
+ },
+ "application-value-assessment": {
+ "board": [
+ "SkGebruikDetail",
+ "SkRapportages"
+ ]
+ },
+ "archimate-export": {
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ },
+ "archimate-import": {
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ },
+ "archimate-import-progress": {
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ },
+ "beta-alignment": {
+ "board": null,
+ "reason": "Afstemming van metadata, productpagina en docs, geen scherm."
+ },
+ "bio-compliance-assessment": {
+ "board": [
+ "SkStandaarden",
+ "SkComplianceMatrix"
+ ]
+ },
+ "catalog-ratings": {
+ "board": [
+ "SkApplicatie"
+ ]
+ },
+ "catalogue-connection-pages": {
+ "board": [
+ "SkKoppelingen",
+ "SkApplicatie"
+ ]
+ },
+ "cmdb-export-import": {
+ "board": [
+ "SkCmdb",
+ "SkApplicaties"
+ ]
+ },
+ "concept-organizations-widget": {
+ "board": [
+ "SkDashboard"
+ ]
+ },
+ "contactpersonen-api": {
+ "board": [
+ "SkOrganisatie"
+ ]
+ },
+ "contactpersoon-sync": {
+ "board": [
+ "SkOrganisatie",
+ "SkInstellingenGegevens"
+ ]
+ },
+ "contract-administration": {
+ "board": [
+ "SkApplicatie",
+ "SkContract",
+ "SkContracten",
+ "SkLicenties",
+ "SkRapportages"
+ ]
+ },
+ "contract-decision-delegation": {
+ "board": [
+ "SkContract"
+ ]
+ },
+ "cronjob-context": {
+ "board": null,
+ "reason": "Achtergrondtaak voor de synchronisatie, geen scherm."
+ },
+ "dashboard-views-api": {
+ "board": [
+ "SkDashboard",
+ "SkApplicaties"
+ ]
+ },
+ "deelnames-gebruik": {
+ "board": [
+ "SkGebruikDetail",
+ "SkGemmaPlaat"
+ ]
+ },
+ "email-delivery": {
+ "board": [
+ "SkBeheerToegang"
+ ]
+ },
+ "english-vocabulary-migration": {
+ "board": null,
+ "reason": "Datamigratie naar Engelse termen, geen scherm."
+ },
+ "eol-feed-integration": {
+ "board": [
+ "SkInstellingenGegevens",
+ "SkRoadmap"
+ ]
+ },
+ "fe-object-modals": {
+ "board": [
+ "SkApplicaties",
+ "SkKwetsbaarheden"
+ ]
+ },
+ "fe-organizations": {
+ "board": [
+ "SkOrganisaties",
+ "SkOrganisatie"
+ ]
+ },
+ "fe-settings-ui": {
+ "board": [
+ "SkInstellingenGegevens",
+ "SkInstellingenUitwisseling",
+ "SkBeheerToegang"
+ ]
+ },
+ "fe-shell-navigation": {
+ "board": [
+ "SkDashboard"
+ ]
+ },
+ "fe-stores": {
+ "board": null,
+ "reason": "Frontend-statelaag, geen eigen scherm."
+ },
+ "federated-catalog-sync": {
+ "board": [
+ "SkInstellingenUitwisseling"
+ ]
+ },
+ "first-time-setup": {
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ },
+ "gebruik-services": {
+ "board": null,
+ "reason": "Backendservice voor gebruiksrecords, geen scherm."
+ },
+ "gemma-faceted-search": {
+ "board": [
+ "SkApplicaties"
+ ]
+ },
+ "licence-declaration": {
+ "board": null,
+ "reason": "Licentieverklaring in metadata, geen scherm."
+ },
+ "licence-seats": {
+ "board": [
+ "SkContract",
+ "SkLicenties"
+ ]
+ },
+ "maintenance-and-supplier-roadmap": {
+ "board": [
+ "SkApplicatie",
+ "SkDashboard",
+ "SkModuleversie"
+ ]
+ },
+ "method-decomposition": {
+ "board": null,
+ "reason": "Refactoring van controllers en services, geen scherm."
+ },
+ "module-compliance-assessment": {
+ "board": [
+ "SkComplianceMatrix"
+ ]
+ },
+ "module-overlay-rendering": {
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ },
+ "module-vulnerability-tracking": {
+ "board": [
+ "SkKwetsbaarheden",
+ "SkModuleversie"
+ ]
+ },
+ "multi-org-membership": {
+ "board": [
+ "SkOrganisatie"
+ ]
+ },
+ "open-data-publishing": {
+ "board": [
+ "SkApplicaties",
+ "SkPubliek"
+ ]
+ },
+ "org-archimate-export": {
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ },
+ "organisatie-service": {
+ "board": null,
+ "reason": "Backendservice voor organisaties, geen scherm."
+ },
+ "organisation-merge": {
+ "board": [
+ "SkOrganisatie"
+ ]
+ },
+ "organization-sync": {
+ "board": [
+ "SkInstellingenGegevens"
+ ]
+ },
+ "portal-contribution": {
+ "board": null,
+ "reason": "Catalogusinhoud verschijnt op het portaal van portaliq, niet op een stackiq-scherm."
+ },
+ "portfolio-rationalization-time": {
+ "board": [
+ "SkRapportages",
+ "SkInGebruik"
+ ]
+ },
+ "progress-tracking": {
+ "board": null,
+ "reason": "Backendservice voor voortgang, geen eigen scherm."
+ },
+ "publication-field-rules": {
+ "board": null,
+ "reason": "Regels die OpenRegister bij elke publieke lezing toepast, zonder scherm."
+ },
+ "realtime-updates-ui": {
+ "board": [
+ "SkKwetsbaarheden",
+ "SkRoadmap"
+ ]
+ },
+ "repair-init": {
+ "board": null,
+ "reason": "Installatiestap die standaardinstellingen zet, geen scherm."
+ },
+ "sbom-import": {
+ "board": [
+ "SkModuleversie"
+ ]
+ },
+ "sc-handlers": {
+ "board": null,
+ "reason": "Event-handlers die op de achtergrond accounts en groepen bijwerken, zonder scherm."
+ },
+ "settings-admin-controller": {
+ "board": null,
+ "reason": "REST-endpoints voor instellingen, geen scherm."
+ },
+ "settings-service": {
+ "board": null,
+ "reason": "Backendservice voor instellingen, geen scherm."
+ },
+ "software-catalog": {
+ "board": null,
+ "reason": "Schemanamen in OpenRegister, geen scherm."
+ },
+ "software-license-posture": {
+ "board": [
+ "SkLicenties"
+ ]
+ },
+ "softwarecatalog-adopt-or-abstractions": {
+ "board": null,
+ "reason": "Gebruik van gedeelde OpenRegister-abstracties, geen scherm."
+ },
+ "softwarecatalog-contacts-to-nc": {
+ "board": [
+ "SkOrganisatie"
+ ]
+ },
+ "softwarecatalog-manifest-v1": {
+ "board": null,
+ "reason": "Het manifest dat de pagina's beschrijft; het levert alle schermen, geen eigen scherm."
+ },
+ "softwarecatalog-notifications": {
+ "board": [
+ "SkDashboard"
+ ]
+ },
+ "softwarecatalog-store-migration": {
+ "board": null,
+ "reason": "Migratie van Pinia-stores, geen scherm."
+ },
+ "softwarecatalogue-orchestration": {
+ "board": null,
+ "reason": "Reacties op gebeurtenissen die op de achtergrond draaien, zonder scherm."
+ },
+ "suite-wizard": {
+ "board": [
+ "SkSuiteWizard"
+ ]
+ },
+ "vendor-product-master-data": {
+ "board": [
+ "SkOrganisaties",
+ "SkOrganisatie"
+ ]
+ },
+ "vendor-visibility-rbac": {
+ "board": null,
+ "reason": "Afgedwongen door OpenRegister-RBAC bij elke lezing, zonder scherm."
+ },
+ "view-enrichment-api": {
+ "board": [
+ "SkGemmaPlaat"
+ ]
+ }
+ }
}
diff --git a/openspec/specs/app-id-rename/spec.md b/openspec/specs/app-id-rename/spec.md
index 3c9a0e03a..91e303393 100644
--- a/openspec/specs/app-id-rename/spec.md
+++ b/openspec/specs/app-id-rename/spec.md
@@ -102,9 +102,16 @@ The appId this app passes to OpenRegister's configuration importer is NOT frozen
- **GIVEN** a user who added the concept organisations widget to their Nextcloud dashboard before the rename
- **WHEN** the dashboard loads after the rename
-- **THEN** `ConceptOrganisatiesWidget::getId()` MUST still return `softwarecatalog_concept_organisaties_widget`, because the Dashboard app stores each user's chosen widgets by widget id in its own `oc_preferences` namespace, which this app's repair steps cannot reach
+- **THEN** `ConceptOrganisatiesWidget::getId()` MUST still return `softwarecatalog_concept_organisaties_widget`, because the Dashboard app stores each user's chosen widgets by widget id in its own `oc_preferences` namespace, which a renamed id silently stops matching
- @e2e exclude Cross-app persistence behaviour; asserted at the unit level against the frozen literal.
+#### Scenario: A dashboard that chose the widget under the renamed id keeps it too
+
+- **GIVEN** a user who added the widget while stackiq 0.1.148 through 0.2.0 or `beta` registered it as `stackiq_concept_organisaties_widget`
+- **WHEN** the app upgrades to a version that registers the frozen id again
+- **THEN** the repair step `RepointDashboardWidgetId` MUST rewrite `stackiq_concept_organisaties_widget` to `softwarecatalog_concept_organisaties_widget` in that user's `dashboard`/`layout` preference, in place and without listing the frozen id twice, because the Dashboard rows are ordinary user preferences that `IConfig` reaches like this app's own
+- @e2e exclude Cross-app persistence behaviour; asserted at the unit level against the rewritten layout.
+
#### Scenario: The OpenRegister import keeps addressing the existing register and schemas
- **GIVEN** OpenRegister holds this app's register and schemas created under the old id `softwarecatalog`
diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md
index 8804ce59d..5e34fe5df 100644
--- a/openspec/specs/application-usage-pages/spec.md
+++ b/openspec/specs/application-usage-pages/spec.md
@@ -46,6 +46,15 @@ A usage SHALL carry a business owner and a technical owner, each picked from the
- **WHEN** the supplier opens that usage
- **THEN** the owner contact persons do not open for the supplier
+#### Scenario: The usage list names an owner only for who can read the contact
+@e2e exclude Needs a Contacts address book per user; tests/Unit/Controller/ContactNamesControllerTest.php asserts the read with the caller's rights and the 401, and tests/Unit/Service/StackiqContactSyncServiceTest.php testANameLookupSkipsTheSystemAddressBookAndMatchesExactly asserts the lookup skips the system address book.
+
+- **GIVEN** a usage whose business owner is a contact person linked to a contact in the information manager's own address book
+- **WHEN** the information manager opens Applications in use
+- **THEN** the Business owner column shows the contact's name
+- **AND** a user without that contact in their own or shared address books sees the contact person's id, as before
+- **AND** a request that is not signed in gets 401 and no name, and a contactsUid that is a local or remote user id names nobody
+
### Requirement: REQ-UAP-004 A usage moves through its lifecycle from its page
The usage schema SHALL declare its lifecycle on the status values its rows hold, so the detail page offers Plan, Go live, Phase out and Retire from the matching status.
@@ -56,3 +65,25 @@ The usage schema SHALL declare its lifecycle on the status values its rows hold,
- **GIVEN** a usage with status Planned
- **WHEN** the information manager opens it and clicks Go live
- **THEN** its status reads In production
+
+### Requirement: REQ-UAP-005 A contact person's name shows only to who can read the contact
+
+The contact person page and the Name column of an organisation's contact person list SHALL show the name of the linked Nextcloud contact only to a user who can read that contact in their own or shared address books, and SHALL say why there is no name when there is none.
+
+#### Scenario: A reader of the contact sees the name
+@e2e exclude Needs a Contacts address book per user; tests/vitest/contactPersonName.spec.js asserts the name on the contact person page and tests/vitest/contactNames.spec.js the list column cell.
+
+- **GIVEN** a contact person linked to a contact in the information manager's own address book
+- **WHEN** the information manager opens the contact person, or the organisation it belongs to
+- **THEN** the contact person page opens with the contact's name
+- **AND** the Name column of the organisation's contact person list shows it
+
+#### Scenario: Without a name the page says why
+@e2e exclude Needs a Contacts address book per user; tests/vitest/contactPersonName.spec.js asserts each empty state, and that none shows before the object has loaded; tests/vitest/contactNames.spec.js asserts a failed request apart from an empty answer.
+
+- **GIVEN** a contact person whose name the signed-in user does not get
+- **WHEN** the user opens the contact person page
+- **THEN** a contact person without a contactsUid reads that it is not linked to a Nextcloud contact
+- **AND** a contact in an address book the user cannot read reads that the name is kept in Nextcloud Contacts, in an address book the user cannot read
+- **AND** a request that failed (for example rate limited) reads that the name could not be loaded, to reload the page
+- **AND** the list column keeps the contact person's id in each of these cases
diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md
index d2f7fd4b5..55747f9fb 100644
--- a/openspec/specs/cmdb-export-import/spec.md
+++ b/openspec/specs/cmdb-export-import/spec.md
@@ -1,15 +1,17 @@
---
capability: cmdb-export-import
-status: done
+status: in-progress
built_by: openspec/changes/archive/2026-10-05-cmdb-export-import
---
# cmdb-export-import Specification
-**Status**: done
+**Status**: in-progress
**Scope**: stackiq
**OpenSpec changes**:
- [cmdb-export-import](../../changes/archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, vendor organisations, usages and owner contact persons for one municipality from the two CMDB sheets, matched on APPID, mapped by OpenRegister migration packs (kind: code)
+- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the four sheet packs are seeded into OpenRegister's migration-pack store, where an administrator edits them, and the import runs what is stored there (shipped files as seed and fallback, fail-closed on an invalid pack); `GET /api/settings/cmdb-import/mapping` answers, without writing, per pack its source and a link to its editor (kind: code)
+- [cmdb-import-archive-reconciliation](../../changes/cmdb-import-archive-reconciliation/) — a re-import archives the applications that moved to the sheet "Gearchiveerde Applicaties" and soft-deletes the ones on no sheet, revives the ones that return, with `missingRecords: keep | archive` (default archive) and an "Archived" quick filter (kind: code)
## Purpose
@@ -294,7 +296,7 @@ For each row the service SHALL compute `externalKey` = `topdesk:
+
+
+ {{
+ name || id
+ }}
+
+
+
diff --git a/src/components/organisations/ContactPersonName.vue b/src/components/organisations/ContactPersonName.vue
new file mode 100644
index 000000000..4170a25f9
--- /dev/null
+++ b/src/components/organisations/ContactPersonName.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+ {{ name }}
+
+
+ {{ hint }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue
index 424cf2586..bad7e81ed 100644
--- a/src/components/portfolio/UsageRiskSignals.vue
+++ b/src/components/portfolio/UsageRiskSignals.vue
@@ -141,6 +141,14 @@ export default {
this.loading = true
this.error = false
try {
+ // Opened straight from a link, this widget can mount before the
+ // app has read its settings; without them no type resolves.
+ if (
+ !objectStore.settings
+ && typeof objectStore.fetchSettings === 'function'
+ ) {
+ await objectStore.fetchSettings()
+ }
this.ensureType('usage')
this.ensureType('moduleVersion')
this.ensureType('vulnerability')
@@ -216,6 +224,10 @@ export default {
}
.usage-risk-signals__list dt {
+ /* Undo the page-wide fixed width and right alignment of a dt, which made
+ a long label run into its value. */
+ width: auto;
+ text-align: start;
font-weight: bold;
}
diff --git a/src/customComponents.js b/src/customComponents.js
index d3c8c0f77..da401ac5e 100644
--- a/src/customComponents.js
+++ b/src/customComponents.js
@@ -23,6 +23,7 @@ import OrganisatieCard from './components/cards/OrganisatieCard.vue'
import ApplicationContractsPanel from './components/contracts/ApplicationContractsPanel.vue'
import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue'
import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue'
+import ContactPersonName from './components/organisations/ContactPersonName.vue'
import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue'
import UsageRiskSignals from './components/portfolio/UsageRiskSignals.vue'
import ReviewsPanel from './components/reviews/ReviewsPanel.vue'
@@ -107,6 +108,11 @@ export default {
// and the vulnerabilities, which no built-in widget does.
UsageRiskSignals,
+ // The name of a contact person on its detail page (landscape-usage-registration):
+ // the name lives in Nextcloud Contacts, read through /api/contact-names,
+ // which no built-in widget does.
+ ContactPersonName,
+
// AI Act evidence per tag on the AI system page (landscape-ai-system-inventory):
// it reads the object's files and their tags, which no built-in widget lists per tag.
AiActChecklist,
@@ -151,7 +157,7 @@ export default {
// object) and `referentiecomponent`/`standaard` are exposed here by display
// NAME, not the identifiers the schema stores — feeding them through that
// path would break the list. Stays a custom page (wrapping `CnFacetSidebar`
- // + a standalone `CnIndexPage` narrowed via `{ id: matchedObjectIds }`) until
+ // + a standalone `CnIndexPage` narrowed via `{ _ids: matchedObjectIds }`) until
// the lib grows a facet-sidebar mode whose counts/narrowing are computed by
// an external, non-schema-field aggregation (see FacetedCatalogIndexView.vue).
FacetedCatalogIndexView,
diff --git a/src/icons.js b/src/icons.js
index 865c514c0..34ffbfdfb 100644
--- a/src/icons.js
+++ b/src/icons.js
@@ -17,6 +17,7 @@ import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
import ApplicationOutline from 'vue-material-design-icons/ApplicationOutline.vue'
+import ArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import BookOpenVariant from 'vue-material-design-icons/BookOpenVariant.vue'
import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue'
@@ -79,6 +80,7 @@ export default {
AccountMultiple,
AlertCircle,
ApplicationOutline,
+ ArchiveOutline,
ArrowRight,
BookOpenVariant,
BookOpenVariantOutline,
diff --git a/src/manifest.d/connections.json b/src/manifest.d/connections.json
index 2e9072796..075d92be3 100644
--- a/src/manifest.d/connections.json
+++ b/src/manifest.d/connections.json
@@ -19,7 +19,15 @@
"register": "@resolve:voorzieningen_register",
"schema": "connection",
"description": "Every connection between applications, and from an application to a national provision.",
- "columns": ["name", "type", "status", "moduleA", "moduleB", "nonMunicipalProvision", "dataExchangeDirection"],
+ "columns": [
+ "name",
+ "type",
+ "status",
+ { "key": "moduleA", "label": "From application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } },
+ { "key": "moduleB", "label": "To application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } },
+ "nonMunicipalProvision",
+ "dataExchangeDirection"
+ ],
"filterMenu": true,
"quickFilters": [
{ "label": "All", "filter": {}, "default": true },
diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json
index 1ff71f97e..928e0b5b9 100644
--- a/src/manifest.d/usages.json
+++ b/src/manifest.d/usages.json
@@ -19,7 +19,15 @@
"register": "@resolve:voorzieningen_register",
"schema": "usage",
"description": "The applications your organisation uses, with the version it runs, where it stands and who owns it.",
- "columns": ["module", "moduleVersion", "status", "businessOwner", "technicalOwner", "timeClassification", "serviceDeskRecordId"],
+ "columns": [
+ { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } },
+ { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } },
+ "status",
+ { "key": "businessOwner", "label": "Business owner", "widget": "contactName" },
+ { "key": "technicalOwner", "label": "Technical owner", "widget": "contactName" },
+ "timeClassification",
+ "serviceDeskRecordId"
+ ],
"filterMenu": true,
"quickFilters": [
{ "label": "All", "filter": {}, "default": true },
@@ -27,7 +35,8 @@
{ "label": "Planned", "filter": { "status": "Planned" } },
{ "label": "In production", "filter": { "status": "In production" }, "icon": "CheckCircle" },
{ "label": "To be phased out", "filter": { "status": "To be phased out" }, "icon": "AlertCircle" },
- { "label": "Phased out", "filter": { "status": "Phased out" } }
+ { "label": "Phased out", "filter": { "status": "Phased out" } },
+ { "label": "Archived", "filter": { "_archived": "true" }, "icon": "ArchiveOutline" }
],
"sidebar": { "enabled": true, "showMetadata": true },
"documentationUrl": "https://stackiq.conduction.nl"
diff --git a/src/manifest.json b/src/manifest.json
index 13080eed0..2942f330d 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -421,9 +421,9 @@
{ "id": "org-stats-applications", "type": "stats-block", "title": "Applications", "icon": "ChartBar", "content": { "entries": [ { "title": "Applications", "register": "@resolve:voorzieningen_register", "schema": "module", "metric": "count", "filter": { "provider": "@objectId" } } ] } },
{ "id": "org-stats-contact-persons", "type": "stats-block", "title": "Contact persons", "icon": "ChartBar", "content": { "entries": [ { "title": "Contact persons", "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "metric": "count", "filter": { "organization": "@objectId" } } ] } },
{ "id": "org-diensten", "type": "object-list", "title": "Services", "icon": "HandshakeOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "catalogService", "filter": { "provider": "@objectId" }, "columns": [ { "key": "type", "label": "Type" } ], "limit": 25, "emptyText": "No services registered for this organisation yet" } },
- { "id": "org-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "emptyText": "No applications registered for this organisation yet" } },
- { "id": "org-usages", "type": "object-list", "title": "Applications in use", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "consumer": "@objectId" }, "columns": [ { "key": "module", "label": "Application" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "consumer": "@objectId" }, "allowCreate": false, "emptyText": "No applications in use recorded for this organisation yet" } },
- { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } }
+ { "id": "org-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "name", "label": "Name" }, { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "viewAllRoute": "Modules", "viewAllQuery": { "provider": "@objectId" }, "emptyText": "No applications registered for this organisation yet" } },
+ { "id": "org-usages", "type": "object-list", "title": "Applications in use", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "consumer": "@objectId" }, "columns": [ { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "consumer": "@objectId" }, "allowCreate": false, "emptyText": "No applications in use recorded for this organisation yet" } },
+ { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "id", "label": "Name", "widget": "contactName" }, { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } }
],
"layout": [
{ "id": "1", "widgetId": "org-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 5 },
@@ -486,6 +486,9 @@
{ "id": "1", "widgetId": "cp-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 4 },
{ "id": "2", "widgetId": "cp-related", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }
],
+ "bodyWidgets": [
+ { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId", "contactsUid": "@object.contactsUid", "loadedId": "@object.id" }, "placement": "before-body", "colSpan": 12 }
+ ],
"sidebar": {
"enabled": true,
"showMetadata": true,
@@ -511,9 +514,9 @@
{ "id": "md-related", "type": "related", "title": "Vendor & services", "icon": "LinkVariant" },
{ "id": "md-compliance", "type": "object-list", "title": "Compliance claims", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "module": "@objectId" }, "columns": [ { "key": "standardVersion", "label": "Standard" }, { "key": "bioMeasure", "label": "BIO measure" }, { "key": "url", "label": "Evidence" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } },
{ "id": "md-versions", "type": "object-list", "title": "Application versions", "icon": "SourceBranch", "content": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "filter": { "module": "@objectId" }, "columns": [ { "key": "version", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "ModuleversieDetail", "allowCreate": false, "emptyText": "No versions registered yet" } },
- { "id": "md-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 50, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "module": "@objectId" }, "addLabel": "Add to our landscape", "formIncludeFields": [ "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner" ], "emptyText": "No organisation registered a usage yet" } },
- { "id": "md-connections-out", "type": "object-list", "title": "Connections from this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleA": "@objectId" }, "columns": [ { "key": "moduleB", "label": "To application" }, { "key": "nonMunicipalProvision", "label": "To national provision" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleA": "@objectId" }, "allowCreate": false, "emptyText": "No connections start at this application" } },
- { "id": "md-connections-in", "type": "object-list", "title": "Connections to this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleB": "@objectId" }, "columns": [ { "key": "moduleA", "label": "From application" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleB": "@objectId" }, "allowCreate": false, "emptyText": "No connections end at this application" } },
+ { "id": "md-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "organization", "labelField": "name" } }, { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } }, { "key": "status", "label": "Status" } ], "limit": 50, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "module": "@objectId" }, "addLabel": "Add to our landscape", "formIncludeFields": [ "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner" ], "emptyText": "No organisation registered a usage yet" } },
+ { "id": "md-connections-out", "type": "object-list", "title": "Connections from this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleA": "@objectId" }, "columns": [ { "key": "moduleB", "label": "To application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "nonMunicipalProvision", "label": "To national provision" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleA": "@objectId" }, "allowCreate": false, "emptyText": "No connections start at this application" } },
+ { "id": "md-connections-in", "type": "object-list", "title": "Connections to this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleB": "@objectId" }, "columns": [ { "key": "moduleA", "label": "From application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleB": "@objectId" }, "allowCreate": false, "emptyText": "No connections end at this application" } },
{ "id": "md-ai-systems", "type": "object-list", "title": "AI systems", "icon": "RobotOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "aiSystem", "filter": { "module": "@objectId" }, "columns": [ { "key": "kind", "label": "Kind" }, { "key": "aiActRiskCategory", "label": "AI Act risk category" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "AiSystemDetail", "viewAllRoute": "AiSystems", "viewAllQuery": { "module": "@objectId" }, "allowCreate": false, "emptyText": "No AI systems registered for this application" } },
{ "id": "md-maintenance", "type": "object-list", "title": "Planned maintenance", "icon": "Calendar", "content": { "register": "@resolve:voorzieningen_register", "schema": "maintenanceWindow", "filter": { "module": "@objectId" }, "sort": { "field": "startsAt", "dir": "asc" }, "columns": [ { "key": "title", "label": "Title" }, { "key": "startsAt", "label": "Starts at" }, { "key": "endsAt", "label": "Ends at" }, { "key": "impact", "label": "Impact" }, { "key": "status", "label": "Status" } ], "limit": 25, "addLabel": "Announce maintenance", "formIncludeFields": [ "title", "description", "moduleVersion", "startsAt", "endsAt", "impact" ], "emptyText": "No maintenance planned on this application" } }
],
@@ -616,20 +619,21 @@
"type": "custom",
"title": "Applications",
"component": "FacetedCatalogIndexView",
- "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { id: matchedObjectIds }. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { id: ... } narrowing (disjoint keys, additive AND, no clobber).",
+ "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber). config.sidebar gives the page the Search and Columns sidebar of the other index pages; its search is the page's free-text search, sent by the list itself as _search, so only a GEMMA facet selection narrows by _ids (a search narrowed by _ids put every match in the URL and failed with HTTP 414 from about 170 matches). The one exception is the Archived tab: its filter carries an empty-string `_ids` that replaces the facet id set in the merge, because that set is built from the working set, which leaves archived applications out, so ANDing it with `_archived=true` could never match. That tab lists the whole archive and ignores the GEMMA facets; the search term still applies, because the list sends it as _search, outside the filter.",
"config": {
"register": "@resolve:voorzieningen_register",
"schema": "module",
"detailRoute": "ModuleDetail",
"description": "Browse the application (module) catalogue, filtered by GEMMA architecture dimension.",
+ "sidebar": { "enabled": true, "showMetadata": true },
"columns": [
- "name",
- "provider",
- "type",
- "licentietype",
- "publicationDate",
- "bbnLevel",
- "dpiaStatus"
+ { "key": "name", "label": "Name" },
+ { "key": "provider", "label": "Supplier", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "organization", "labelField": "name" } },
+ { "key": "type", "label": "Type" },
+ { "key": "licentietype", "label": "License type" },
+ { "key": "publicationDate", "label": "Publication date" },
+ { "key": "bbnLevel", "label": "BBN level" },
+ { "key": "dpiaStatus", "label": "DPIA status" }
],
"quickFilters": [
{
@@ -662,6 +666,14 @@
"dpiaStatus": ["not required", "required"]
},
"icon": "AlertCircle"
+ },
+ {
+ "label": "Archived",
+ "filter": {
+ "_archived": "true",
+ "_ids": ""
+ },
+ "icon": "ArchiveOutline"
}
]
}
@@ -677,6 +689,7 @@
"register": "@resolve:voorzieningen_register",
"schema": "catalogService",
"description": "Browse the service (dienst) catalogue, filtered by GEMMA architecture dimension.",
+ "sidebar": { "enabled": true, "showMetadata": true },
"columns": [
"name",
"provider",
@@ -756,7 +769,7 @@
{ "id": "st-data", "type": "data", "title": "Standard", "icon": "ShieldCheckOutline", "content": { "columns": 2 } },
{ "id": "st-files", "type": "integration", "integrationId": "files", "title": "Specification documents", "icon": "FolderOutline" },
{ "id": "st-related", "type": "related", "title": "Related", "icon": "LinkVariant" },
- { "id": "st-compliance", "type": "object-list", "title": "Compliance claims for this standard", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "standardGemma": "@object.name" }, "columns": [ { "key": "module", "label": "Module" }, { "key": "standardVersion", "label": "Standard version" }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }
+ { "id": "st-compliance", "type": "object-list", "title": "Compliance claims for this standard", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "standardGemma": "@object.name" }, "columns": [ { "key": "module", "label": "Module", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "standardVersion", "label": "Standard version" }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }
],
"layout": [
{ "id": "1", "widgetId": "st-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 4 },
@@ -808,7 +821,7 @@
"_note": "Catalog archetype (a BIO 2.0 measure), mirroring StandaardDetail's shape for the parallel bioMaatregel relation (design.md Decision 1: compliancy links to a standaardversie OR a bioMaatregel, never a third parallel model). Body: measure data 8-wide (code, naam, omschrijving, thema, bioVersie, bbnNiveau, bron). Below, a compliancy object-list shows every compliance claim referencing this measure (compliancy.bioMaatregel = @objectId), mirroring how StandaardDetail's st-compliance widget shows claims for a standard.",
"widgets": [
{ "id": "bm-data", "type": "data", "title": "BIO measure", "icon": "ShieldCheckOutline", "content": { "columns": 2 } },
- { "id": "bm-compliance", "type": "object-list", "title": "Compliance claims for this measure", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "bioMeasure": "@objectId" }, "columns": [ { "key": "module", "label": "Module" }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }
+ { "id": "bm-compliance", "type": "object-list", "title": "Compliance claims for this measure", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "bioMeasure": "@objectId" }, "columns": [ { "key": "module", "label": "Module", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }
],
"layout": [
{ "id": "1", "widgetId": "bm-data", "gridX": 0, "gridY": 0, "gridWidth": 12, "gridHeight": 4 },
@@ -943,11 +956,11 @@
"register": "@resolve:voorzieningen_register",
"schema": "moduleVersion",
"columns": [
- "version",
- "module",
- "dateInDevelopment",
- "dateInUse",
- "status"
+ { "key": "version", "label": "Version" },
+ { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } },
+ { "key": "dateInDevelopment", "label": "In development since" },
+ { "key": "dateInUse", "label": "In use since" },
+ { "key": "status", "label": "Status" }
],
"quickFilters": [
{ "label": "All", "filter": {}, "default": true },
diff --git a/src/store/modules/facets.js b/src/store/modules/facets.js
index 0f7ddfe3b..a8a322d79 100644
--- a/src/store/modules/facets.js
+++ b/src/store/modules/facets.js
@@ -35,7 +35,7 @@ import {
* `standaardVersies`, … — see design.md). Letting the bare dimension name
* leak into `$route.query` would make CnIndexPage attempt an incorrect
* direct-field filter (near-guaranteed zero results) IN ADDITION to this
- * feature's own `{ id: matchedObjectIds }` narrowing. The `_gf_` prefix
+ * feature's own `{ _ids: matchedObjectIds }` narrowing. The `_gf_` prefix
* keeps GEMMA facet state in the URL (deep-linkable, per spec) while staying
* invisible to that generic passthrough (`_`-prefixed keys are reserved/
* skipped there).
@@ -158,7 +158,7 @@ export const useFacetStore = defineStore('facets', {
/**
* The RBAC/filter/search-scoped object id set the last-fetched facet
* response describes (`_meta.matchedObjectIds`) — used to narrow the
- * schema's own object-list query via `{ id: [...] }` (see
+ * schema's own object-list query via `{ _ids: [...] }` (see
* `FacetService::computeFacetsForRequest()`'s docblock for why an
* id-based filter is used instead of re-deriving one from the facet
* selection: `domain`/`applicationService` are not module/catalogService
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index ab7fa7080..fe979eee0 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -31,6 +31,8 @@ export const PROFILE_DEFAULTS = Object.freeze({
maxFileBytes: 10 * 1024 * 1024,
maxRowsPerSheet: 10000,
sheets: Object.freeze(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']),
+ // Read for its APPID column only, in every import.
+ archiveSheet: 'Gearchiveerde Applicaties',
})
/**
@@ -42,8 +44,27 @@ export const PROFILE_DEFAULTS = Object.freeze({
*/
export const AS_TEXT = Object.freeze({ escape: false, sanitize: false })
-/** Every row outcome the report can carry, in display order. */
-export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed']
+/**
+ * Every row outcome the report can carry, in display order: the outcomes of a
+ * sheet row, then those of an application that left the export or came back.
+ */
+export const OUTCOMES = [
+ 'created',
+ 'updated',
+ 'unchanged',
+ 'skipped',
+ 'failed',
+ 'unarchived',
+ 'restored',
+ 'archived',
+ 'deleted',
+]
+
+/**
+ * What the import may do with applications missing from the export (the
+ * profile's `missingRecords`), the default first.
+ */
+export const MISSING_RECORDS_MODES = Object.freeze(['archive', 'keep'])
/**
* The words for one row outcome.
@@ -51,6 +72,7 @@ export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed']
* @param {string} outcome The outcome key from the report
* @return {string} The label
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
export function outcomeLabel(outcome) {
switch (outcome) {
@@ -64,6 +86,14 @@ export function outcomeLabel(outcome) {
return t('stackiq', 'Skipped')
case 'failed':
return t('stackiq', 'Failed')
+ case 'unarchived':
+ return t('stackiq', 'Unarchived')
+ case 'restored':
+ return t('stackiq', 'Restored')
+ case 'archived':
+ return t('stackiq', 'Archived')
+ case 'deleted':
+ return t('stackiq', 'Deleted')
default:
return String(outcome ?? '')
}
@@ -141,16 +171,19 @@ export function checkFile(file) {
* @param {{uuid: string|null, name: string}} options.municipality The chosen municipality: an existing one has a uuid, a new one only a name
* @param {boolean} options.updateExisting Whether matched rows are updated
* @param {boolean} [options.publish] Whether the modules the import creates are published; true when left out
+ * @param {string} [options.missingRecords] What happens to applications missing from the export: 'archive' (when left out) or 'keep'
* @param {string} options.operationId The progress operation id
* @return {FormData} The body
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin-req-cmdb-004
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-newly-created-module-shall-get-a-publicationdate-when-the-admin-publishes-and-an-existing-one-shall-keep-its-own-req-cmdb-007
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
export function buildImportForm({
file,
municipality,
updateExisting,
publish = true,
+ missingRecords = MISSING_RECORDS_MODES[0],
operationId,
}) {
const form = new FormData()
@@ -162,7 +195,7 @@ export function buildImportForm({
}
form.append('updateExisting', updateExisting ? 'true' : 'false')
form.append('publish', publish ? 'true' : 'false')
- form.append('missingRecords', 'keep')
+ form.append('missingRecords', missingRecords)
form.append('operationId', operationId)
return form
}
@@ -465,6 +498,7 @@ const KNOWN_ERRORS = new Set([
'SCHEMA_OUTDATED',
'IMPORT_IN_PROGRESS',
'MUNICIPALITY_AMBIGUOUS',
+ 'ARCHIVE_UNAVAILABLE',
'OPERATION_NOT_FOUND',
'IMPORT_INTERRUPTED',
'NOT_SIGNED_IN',
@@ -490,10 +524,12 @@ export function isKnownError(code) {
* to which sheet or part, and what to do about it.
*
* @param {object} details The error details: `maxPartBytes`, `part`, `size` and, for a sheet, `sheet`; `maxSharedStrings`; `maxReferencedStringBytes`; or `maxUncompressedBytes`
+ * @param {Array} sheetNames The sheets the import reads, for the hint
+ * @param {string|null} archiveSheet The archive sheet, of which the import reads the APPID column; null when the profile names none
* @return {{title: string, hint: string}} The words
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links-req-cmdb-002
*/
-function workbookTooLarge(details) {
+function workbookTooLarge(details, sheetNames, archiveSheet) {
const compressed = t(
'stackiq',
'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.',
@@ -503,7 +539,7 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Split the rows over two copies of the export and import them one after the other.',
+ 'Split the rows over two copies of the export and import both with "Keep them as they are": an import that archives treats the applications of the other copy as missing.',
)
if (Number(details.maxPartBytes) > 0) {
const sizes = {
@@ -530,15 +566,19 @@ function workbookTooLarge(details) {
const unread =
compressed
+ ' '
- + t(
- 'stackiq',
- 'Delete the sheets the import does not read from a copy of the export (it reads only "{first}" and "{second}"), and import that copy.',
- {
- first: PROFILE_DEFAULTS.sheets[0],
- second: PROFILE_DEFAULTS.sheets[1],
- },
- AS_TEXT,
- )
+ + (archiveSheet
+ ? t(
+ 'stackiq',
+ 'Delete the sheets the import does not read from a copy of the export (it reads {sheets}, and the APPID column of "{archive}"), and import that copy.',
+ { sheets: quotedSheetList(sheetNames), archive: archiveSheet },
+ AS_TEXT,
+ )
+ : t(
+ 'stackiq',
+ 'Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.',
+ { sheets: quotedSheetList(sheetNames) },
+ AS_TEXT,
+ ))
if (Number(details.maxSharedStrings) > 0) {
return {
title: t(
@@ -584,10 +624,16 @@ function workbookTooLarge(details) {
* when the server's message is not.
*
* @param {{error: string, message?: string, details?: object}} error The error in the server's shape
+ * @param {Array} [sheetNames] The sheets the import reads, from mappingSheetNames(); the shipped names when left out. Hints name all of them, whatever their number
+ * @param {string|null} [archiveSheet] The archive sheet, from mappingArchiveSheet(); the shipped name when left out, null when the profile names none
* @return {{title: string, hint: string}} The words
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422-req-cmdb-003
*/
-export function errorText(error) {
+export function errorText(
+ error,
+ sheetNames = PROFILE_DEFAULTS.sheets,
+ archiveSheet = mappingArchiveSheet(null),
+) {
const details = error?.details || {}
switch (error?.error) {
case 'NO_FILE_UPLOADED':
@@ -619,14 +665,14 @@ export function errorText(error) {
),
hint: t(
'stackiq',
- 'Remove sheets the import does not read, or split the export, and try again.',
+ 'Remove sheets the import does not read and try again. Or split the export and import every part with "Keep them as they are".',
),
}
case 'MISSING_RECORDS_UNSUPPORTED':
return {
title: t(
'stackiq',
- 'Records missing from the export can only be kept.',
+ 'Applications missing from the export can only be archived or kept.',
),
hint: '',
}
@@ -682,22 +728,28 @@ export function errorText(error) {
case 'NO_SOURCE_SHEET': {
const expected =
Array.isArray(details.expected) && details.expected.length > 0
- ? details.expected
- : PROFILE_DEFAULTS.sheets
+ ? details.expected.map((name) => String(name))
+ : sheetNames
+ const values = { sheets: quotedSheetList(expected) }
return {
title: t(
'stackiq',
'The workbook has none of the sheets the import reads.',
),
- hint: t(
- 'stackiq',
- 'Expected a sheet named "{first}" or "{second}". Sheet names must match exactly.',
- {
- first: String(expected[0] ?? PROFILE_DEFAULTS.sheets[0]),
- second: String(expected[1] ?? PROFILE_DEFAULTS.sheets[1]),
- },
- AS_TEXT,
- ),
+ hint:
+ expected.length === 1
+ ? t(
+ 'stackiq',
+ 'Expected the sheet {sheets}. Sheet names must match exactly.',
+ values,
+ AS_TEXT,
+ )
+ : t(
+ 'stackiq',
+ 'Expected one of the sheets {sheets}. Sheet names must match exactly.',
+ values,
+ AS_TEXT,
+ ),
}
}
case 'MISSING_COLUMN':
@@ -733,13 +785,13 @@ export function errorText(error) {
Number(details.limit) > 0
? t(
'stackiq',
- 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.',
+ 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with "Keep them as they are".',
{ limit: Number(details.limit).toLocaleString() },
AS_TEXT,
)
: t(
'stackiq',
- 'Split the export and import the parts one after the other.',
+ 'Split the export and import the parts one after the other with "Keep them as they are".',
),
}
case 'MAPPING_UNAVAILABLE':
@@ -747,7 +799,7 @@ export function errorText(error) {
title: t('stackiq', 'The import mapping cannot run.'),
hint: t(
'stackiq',
- "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.",
+ "OpenRegister's mapping engine is missing, or a mapping pack is invalid or breaks a rule of the import. The reason below names the pack: correct it in OpenRegister, or check the Nextcloud log.",
),
}
case 'READER_UNAVAILABLE':
@@ -767,7 +819,7 @@ export function errorText(error) {
),
}
case 'WORKBOOK_TOO_LARGE':
- return workbookTooLarge(details)
+ return workbookTooLarge(details, sheetNames, archiveSheet)
case 'SCHEMA_OUTDATED':
return {
title: details.schema
@@ -807,6 +859,17 @@ export function errorText(error) {
'Choose the municipality from the list instead of typing its name. Nothing was imported.',
),
}
+ case 'ARCHIVE_UNAVAILABLE':
+ return {
+ title: t(
+ 'stackiq',
+ 'Applications missing from the export cannot be archived.',
+ ),
+ hint: t(
+ 'stackiq',
+ 'OpenRegister is missing or too old to archive objects. Choose "Keep them as they are" and import again, or update OpenRegister. Nothing was imported.',
+ ),
+ }
case 'OPERATION_NOT_FOUND':
return {
title: t('stackiq', 'This import is no longer running.'),
@@ -886,9 +949,17 @@ export function cancelFailureText(error) {
* server has set them, because the tracker's own percentage is weighted by
* the phases of the ArchiMate import.
*
+ * In the phase `reconciling` the server's counts add the applications of the
+ * municipality to the rows, and those applications include ones that are on
+ * the sheets and need no change. They are neither rows nor applications
+ * missing from the export, so the view shows no "N of M" for this phase and
+ * keeps the bar where the rows left it (full) instead of letting it fall
+ * back when the total grows.
+ *
* @param {object|null} progress The snapshot from `GET /api/progress/{operationId}`
* @return {{percentage: number, detail: string}|null} The view, or null before any progress
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
export function cmdbProgressView(progress) {
if (!progress) {
@@ -900,6 +971,17 @@ export function cmdbProgressView(progress) {
total > 0
? Math.min(100, Math.round((processed / total) * 100))
: Number(progress.percentage) || 0
+ if (progress.phase === 'reconciling') {
+ return {
+ percentage: 100,
+ detail: t(
+ 'stackiq',
+ 'Checking the applications of this municipality against the export',
+ {},
+ AS_TEXT,
+ ),
+ }
+ }
return {
percentage,
detail:
@@ -960,9 +1042,13 @@ export function sortReportRows(rows, key, order) {
/**
* The rows of the report as the table shows them.
*
+ * An application the import archived or deleted after its rows has row 0
+ * in the report and no row number in the table.
+ *
* @param {Array
@@ -84,18 +84,7 @@
}}
- {{
- t(
- 'stackiq',
- 'Excel workbook (.xlsx) with the sheet "{first}" or "{second}". By default the file may be at most {size}.',
- {
- first: profileDefaults.sheets[0],
- second: profileDefaults.sheets[1],
- size: formatMegabytes(profileDefaults.maxFileBytes),
- },
- asText,
- )
- }}
+ {{ fileHelp }}
@@ -111,15 +100,7 @@
{{
t(
'stackiq',
- 'When off, applications imported before are left as they are and reported as skipped.',
- )
- }}
-
-
- {{
- t(
- 'stackiq',
- "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
+ 'Overwrites applications imported before with the values from the export. The (i) button above lists the fields.',
)
}}
@@ -138,11 +119,40 @@
{{
t(
'stackiq',
- 'A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.',
+ 'New applications become visible to everyone, also without logging in.',
)
}}
+
@@ -238,7 +248,7 @@
{{
t(
'stackiq',
- 'The import was cancelled. The rows processed before it stopped are kept.',
+ 'The import was cancelled. The rows and applications processed before it stopped are kept.',
)
}}
@@ -304,7 +314,7 @@
@@ -361,6 +371,9 @@
+
+
+
@@ -376,16 +389,14 @@
{{ t('stackiq', 'The file') }}
+
+ {{ sheetsReadInfo }}
+
{{
t(
'stackiq',
- 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.',
- {
- first: profileDefaults.sheets[0],
- second: profileDefaults.sheets[1],
- },
- asText,
+ 'Row 1 holds the column names. "APPID" and "Applicatie Naam" are required; column order does not matter.',
)
}}
@@ -393,7 +404,21 @@
{{
t(
'stackiq',
- 'Row 1 holds the column names. "APPID" and "Applicatie Naam" are required; column order does not matter.',
+ 'Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.',
+ )
+ }}
+
+
+ {{ missingRecordsWords.reimportInfo }}
+
+
+
{{ t('stackiq', 'Update existing records') }}
+
+
+ {{
+ t(
+ 'stackiq',
+ "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
)
}}
@@ -401,19 +426,30 @@
{{
t(
'stackiq',
- 'Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.',
+ 'When off, applications imported before are left as they are and reported as skipped.',
)
}}
+
+
+ {{
+ t('stackiq', 'Publish the applications this import creates')
+ }}
+
+
{{
t(
'stackiq',
- 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.',
+ 'A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.',
)
}}
+
{{ t('stackiq', 'Applications missing from the export') }}
+
+ {{ missingApplicationsInfo }}
+
@@ -436,6 +472,7 @@ import Close from 'vue-material-design-icons/Close.vue'
import DatabaseImport from 'vue-material-design-icons/DatabaseImport.vue'
import TrayArrowUp from 'vue-material-design-icons/TrayArrowUp.vue'
import AlwaysVisibleSection from '../../../components/AlwaysVisibleSection.vue'
+import CmdbImportMapping from './CmdbImportMapping.vue'
import { startProgressPolling } from '../../../utils/archiMateImportProgress.js'
import {
AS_TEXT,
@@ -445,12 +482,17 @@ import {
checkFile,
cmdbProgressView,
errorText,
+ fileHelpText,
followInterruptedImport,
- formatMegabytes,
importUrl,
interruptedImportError,
isKnownError,
makeCmdbOperationId,
+ mappingArchiveSheet,
+ mappingSheetNames,
+ MISSING_RECORDS_MODES,
+ missingApplicationsText,
+ missingRecordsTexts,
moduleUrl,
municipalityOptions,
normaliseError,
@@ -459,10 +501,20 @@ import {
PROFILE_DEFAULTS,
REPORT_PAGE_SIZE,
reportRows,
+ sheetsReadText,
sortReportRows,
typedMunicipalityOption,
} from '../../../utils/cmdbImport.js'
+/** The summary tiles shown only when their count is not zero. */
+const COUNTED_ONLY_TILES = [
+ 'unpublished',
+ 'unarchived',
+ 'restored',
+ 'archived',
+ 'deleted',
+]
+
/**
* The "CMDB import" section of stackiq's admin settings.
*
@@ -472,12 +524,14 @@ import {
* the text it renders; none of them may go into v-html.
*
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
export default {
name: 'CmdbImport',
components: {
AlwaysVisibleSection,
+ CmdbImportMapping,
CnDataTable,
CnStatusBadge,
NcButton,
@@ -500,6 +554,7 @@ export default {
selectedFile: null,
updateExisting: true,
publish: true,
+ missingRecords: MISSING_RECORDS_MODES[0],
importing: false,
cancelling: false,
cancelStatus: '',
@@ -515,6 +570,7 @@ export default {
reportPageSize: REPORT_PAGE_SIZE,
visibleRowCount: REPORT_PAGE_SIZE,
profileDefaults: PROFILE_DEFAULTS,
+ mapping: null,
asText: AS_TEXT,
outcomeColors: {
created: 'success',
@@ -522,11 +578,82 @@ export default {
unchanged: 'default',
skipped: 'warning',
failed: 'error',
+ unarchived: 'info',
+ restored: 'info',
+ archived: 'default',
+ deleted: 'warning',
},
}
},
computed: {
+ /**
+ * The names of the sheets the import reads, for the help texts: the
+ * profile's once the mapping block has loaded it, the shipped names
+ * until then or when it could not be loaded.
+ *
+ * @return {Array} Every sheet the profile names, at least one
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ sheetNames() {
+ return mappingSheetNames(this.mapping)
+ },
+
+ /**
+ * The archive sheet, of which the import reads only the APPID column:
+ * the profile's once the mapping block has loaded it (null when the
+ * profile names none), the shipped name until then or when it could
+ * not be loaded.
+ *
+ * @return {string|null} The sheet name, or null
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ archiveSheet() {
+ return mappingArchiveSheet(this.mapping)
+ },
+
+ /**
+ * The help text under the file chooser, naming every sheet the import reads.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ fileHelp() {
+ return fileHelpText(this.sheetNames, this.profileDefaults.maxFileBytes)
+ },
+
+ /**
+ * The info-panel sentence naming the sheets the import reads.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ sheetsReadInfo() {
+ return sheetsReadText(this.sheetNames, this.archiveSheet)
+ },
+
+ /**
+ * The info-panel paragraph on applications missing from the export,
+ * naming the profile's archive sheet.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ missingApplicationsInfo() {
+ return missingApplicationsText(this.archiveSheet)
+ },
+
+ /**
+ * The words of the missing-records choice and of the re-import line,
+ * which mention deleting only when the profile names an archive sheet.
+ *
+ * @return {{archiveOption: string, archiveHelp: string, reimportInfo: string}} The texts
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ missingRecordsWords() {
+ return missingRecordsTexts(this.archiveSheet)
+ },
+
/**
* Whether everything the import needs has been chosen.
*
@@ -563,7 +690,7 @@ export default {
if (!this.error) {
return null
}
- const words = errorText(this.error)
+ const words = errorText(this.error, this.sheetNames, this.archiveSheet)
return {
...words,
code: this.error.error,
@@ -578,6 +705,7 @@ export default {
*
* @return {Array} One tile per count
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
summaryTiles() {
const summary = this.report?.summary || {}
@@ -594,13 +722,21 @@ export default {
key: 'unpublished',
label: t('stackiq', 'Created unpublished'),
},
+ { key: 'unarchived', label: t('stackiq', 'Unarchived') },
+ { key: 'restored', label: t('stackiq', 'Restored') },
+ { key: 'archived', label: t('stackiq', 'Archived') },
+ { key: 'deleted', label: t('stackiq', 'Deleted') },
]
.map((tile) => ({
...tile,
value: Number(summary[tile.key]) || 0,
}))
- // Only an import run with publishing off leaves modules unpublished.
- .filter((tile) => tile.key !== 'unpublished' || tile.value > 0)
+ // Only an import run with publishing off leaves modules unpublished, and
+ // only a re-import archives, deletes or brings back applications.
+ .filter(
+ (tile) =>
+ !COUNTED_ONLY_TILES.includes(tile.key) || tile.value > 0,
+ )
)
},
@@ -619,28 +755,72 @@ export default {
updated: Number(summary.updated) || 0,
unchanged: Number(summary.unchanged) || 0,
}
+ let sentence
if (this.report?.cancelled) {
- return t(
+ sentence = t(
'stackiq',
'Import for {name} cancelled after {read} rows.',
counts,
AS_TEXT,
)
- }
- if (this.report?.municipality?.created) {
- return t(
+ } else if (this.report?.municipality?.created) {
+ sentence = t(
'stackiq',
'Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
counts,
AS_TEXT,
)
+ } else {
+ sentence = t(
+ 'stackiq',
+ 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
+ counts,
+ AS_TEXT,
+ )
}
- return t(
- 'stackiq',
- 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
- counts,
- AS_TEXT,
- )
+ const applications = this.applicationChangesText
+ return applications === '' ? sentence : `${sentence} ${applications}`
+ },
+
+ /**
+ * What the import did to applications that are not on the sheets: the
+ * non-zero counts, for the sentence the live region announces.
+ *
+ * @return {string} A sentence, or '' when no application was touched
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ applicationChangesText() {
+ const summary = this.report?.summary || {}
+ const parts = [
+ [
+ 'archived',
+ (count) => t('stackiq', '{count} archived', { count }, AS_TEXT),
+ ],
+ [
+ 'unarchived',
+ (count) =>
+ t('stackiq', '{count} unarchived', { count }, AS_TEXT),
+ ],
+ [
+ 'deleted',
+ (count) => t('stackiq', '{count} deleted', { count }, AS_TEXT),
+ ],
+ [
+ 'restored',
+ (count) => t('stackiq', '{count} restored', { count }, AS_TEXT),
+ ],
+ ]
+ .map(([key, text]) => [Number(summary[key]) || 0, text])
+ .filter(([count]) => count > 0)
+ .map(([count, text]) => text(count))
+ return parts.length === 0
+ ? ''
+ : t(
+ 'stackiq',
+ 'Applications: {changes}.',
+ { changes: parts.join(', ') },
+ AS_TEXT,
+ )
},
/**
@@ -767,10 +947,20 @@ export default {
methods: {
t,
- formatMegabytes,
moduleUrl,
outcomeLabel,
+ /**
+ * Keep the mapping the block loaded, so the help text names its sheets.
+ *
+ * @param {object} mapping The mapping endpoint's answer
+ * @return {void}
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ onMappingLoaded(mapping) {
+ this.mapping = mapping
+ },
+
/**
* Read the organisations of type Municipality from OpenRegister.
*
@@ -920,6 +1110,7 @@ export default {
},
updateExisting: this.updateExisting,
publish: this.publish,
+ missingRecords: this.missingRecords,
operationId: this.operationId,
})
const response = await axios.post(importUrl(), form)
@@ -1022,7 +1213,7 @@ export default {
})
this.cancelStatus = t(
'stackiq',
- 'Cancelling the import. It stops before the next row; the rows already processed stay imported.',
+ 'Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.',
)
} catch (error) {
// The import keeps running; say why, and let the admin press Cancel again.
@@ -1091,6 +1282,17 @@ export default {
color: var(--color-text-maxcontrast);
}
+.cmdb-import__choice {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.cmdb-import__legend {
+ padding: 0;
+ font-weight: bold;
+}
+
.cmdb-import__help--warning {
color: var(--color-warning-text);
}
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
new file mode 100644
index 000000000..67f26b848
--- /dev/null
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -0,0 +1,628 @@
+
+
+
+
+
+
+
+
+
+ {{ t('stackiq', 'Mapping') }}
+
+
+
+
+ {{ announcement }}
+
+
+
+
+ {{
+ t(
+ 'stackiq',
+ 'Which columns of the export the next import reads, and where each column goes.',
+ )
+ }}
+
+ {{
+ t(
+ 'stackiq',
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ )
+ }}
+
+ {{
+ t(
+ 'stackiq',
+ 'Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.',
+ {
+ version: String(
+ pack.shippedVersion ?? '',
+ ),
+ },
+ asText,
+ )
+ }}
+