diff --git a/.claude/openspec/architecture/adr-001-data-layer.md b/.claude/openspec/architecture/adr-001-data-layer.md
new file mode 100644
index 00000000..6b52c806
--- /dev/null
+++ b/.claude/openspec/architecture/adr-001-data-layer.md
@@ -0,0 +1,221 @@
+- ALL domain data → OpenRegister objects. NO custom Entity/Mapper for domain data.
+- App config → `IAppConfig`. NOT OpenRegister.
+- Cross-entity references: OpenRegister relations (register+schema+objectId). NO foreign keys.
+ MUST NOT store foreign keys or embed full objects.
+
+### Schema standards
+
+- Schemas: PascalCase, schema.org vocabulary, explicit types + required flags + description field.
+- MUST NOT invent custom property names when a schema.org equivalent exists.
+- Contact schemas MUST align with vCard properties (fn, email, tel, adr).
+- Dutch government fields SHOULD use a mapping layer translating between international standards
+ and Dutch specs — do not hardcode Dutch field names as primary.
+- Schema changes that remove or rename properties are BREAKING. Adding optional properties is non-breaking.
+
+### Register templates
+
+- Location: `lib/Settings/{app}_register.json` (OpenAPI 3.0 + `x-openregister` extensions).
+- Three template categories:
+ - **App configuration** — define data models (schemas/registers/views/mappings).
+ Mark with `x-openregister.type: "application"`.
+ - **Mock data** — fictional but realistic seed data for dev/test.
+ Mark with `x-openregister.type: "mock"`.
+ - **Government standards** — aligned to Dutch API specs (BAG, BRP, KVK, DSO).
+- Import mechanism: `ConfigurationService::importFromApp(appId, data, version, force)` →
+ `ImportHandler::importFromApp()`. Called from repair step or `SettingsLoadService`.
+- Idempotency: re-importing with `force: false` MUST NOT create duplicates. Match by slug
+ using `ObjectService::searchObjects` with `_rbac: false` and `_multitenancy: false`.
+ Use `version_compare` for skip logic.
+
+### Seed data
+
+Apps that store data in OpenRegister are empty on first install. An empty app cannot be
+meaningfully tested — there are no objects to view, search, filter, or interact with.
+This blocks both automated browser testing and manual QA. The Loadable Register Template
+pattern (see Register templates above) already supports seed data via `components.objects[]`
+with the `@self` envelope.
+
+**Requirements:**
+
+- Every app using OpenRegister MUST include 3-5 realistic objects per schema in
+ `lib/Settings/{app}_register.json`.
+- Use `@self` envelope: `{ "@self": { "register": ..., "schema": ..., "slug": ... }, ...properties }`.
+ Register/schema MUST match keys; slug is unique human-readable identifier for matching.
+- Use general organisation data (municipality, consultancy, travel agency, non-profit) —
+ NOT context-specific. Varied, realistic field values.
+- Mock data quality: real Dutch street names, valid postcodes (`[1-9][0-9]{3}[A-Z]{2}`),
+ correct municipality/KVK codes, BSNs that pass 11-proef. Fictional but distinguishable from real.
+- Cross-register consistency: BRP→BAG, KVK→BAG, DSO→BAG references must be valid.
+- Loaded on install alongside schemas via same `importFromApp()` pipeline.
+- MUST be idempotent — re-importing skips existing objects matched by slug.
+
+**In OpenSpec artifacts:**
+
+- **In design.md**: MUST include a Seed Data section when change introduces/modifies schemas —
+ define seed objects per schema with concrete field values and related items (files, notes, tasks, contacts).
+- **In tasks.md**: MUST include a seed data generation task when change introduces/modifies schemas.
+
+**Exceptions** (no seed data required):
+
+- **nldesign** — has no OpenRegister schemas.
+- **ExApp sidecar wrappers** (openklant, opentalk, openzaak, valtimo, n8n-nextcloud) — proxy
+ external services and do not use OpenRegister.
+- **nextcloud-vue** — shared library, no seed data applicable.
+- Changes that only modify frontend components or non-schema backend logic (e.g., settings,
+ permissions) do not require seed data.
+
+**Limitations:** OpenRegister's `ImportHandler` currently supports only flat seed objects.
+Related items (files, notes, tasks, contacts) linked through the relation system are tracked
+on the product roadmap. Until then, seed data is limited to object properties defined in schemas.
+
+### Deduplication check
+
+- Before proposing new capability: search `openspec/specs/` and `openregister/lib/Service/` for overlap
+ with ObjectService, RegisterService, SchemaService, ConfigurationService, and shared Vue components.
+- If similar capability exists: MUST reference it and explain why new code is needed rather than extending.
+- Proposals duplicating existing functionality without justification MUST be rejected.
+- **In design.md**: MUST include a "Reuse Analysis" section listing existing OpenRegister services leveraged.
+- **In tasks.md**: MUST include a "Deduplication Check" task verifying no overlap — document findings
+ even if "no overlap found".
+
+### Schema migrations
+
+- Breaking schema changes → new migration in repair step. NEVER modify existing migrations.
+
+### OpenRegister + @conduction/nextcloud-vue — DO NOT REBUILD
+
+The platform provides 258+ backend methods and 69+ frontend components. Apps ONLY build
+custom logic for domain-specific business rules. Everything below is provided for FREE.
+
+**CRUD & Data Management** (use ObjectService + CnIndexPage + CnDetailPage):
+- Single & bulk create, read, update, delete — `ObjectService.saveObject()`, `deleteObject()`
+- List with pagination, sorting, filtering — `ObjectService.findAll()` + `CnDataTable`
+- Schema-driven forms — `CnFormDialog` (auto-generates from schema) or `CnAdvancedFormDialog`
+- Detail views — `CnDetailPage` with `CnDetailGrid`, `CnDetailCard` sections
+- Record merging/deduplication — `ObjectService.mergeObjects()`
+- Object locking — `ObjectService.lockObject()` / `unlockObject()`
+
+**Import & Export** (use ImportService/ExportService + CnMassImportDialog/CnMassExportDialog):
+- CSV, Excel, JSON import with intelligent field mapping — `ImportService`
+- CSV, Excel, JSON export with column selection — `ExportService`
+- Bulk import with validation and progress — `CnMassImportDialog`
+- Filtered export with format picker — `CnMassExportDialog`
+- NO custom import dialogs, parsers, upload handlers, or export controllers
+
+**Search & Discovery** (use IndexService + CnFilterBar + CnFacetSidebar):
+- Full-text search with field weighting — `IndexService`
+- Faceted navigation with counts — `FacetBuilder` + `CnFacetSidebar`
+- Semantic search with embeddings — `VectorizationService`
+- Hybrid search (keyword + semantic) — automatic
+- Search analytics — `SearchTrailService` (popular terms, activity)
+- NO custom search endpoints, query builders, or search pages
+
+**File Management** (use FileService + CnObjectSidebar):
+- Upload (single/multipart), download, share links — `FileService`
+- File tagging, public/private toggle — `FileService`
+- Bulk download as ZIP — `createObjectFilesZip()`
+- Text extraction from PDFs/Office docs — `TextExtractionService`
+- File tab in object sidebar — `CnObjectSidebar` → `CnFilesTab`
+- NO custom file upload components, file controllers, or download handlers
+
+**Audit & Compliance** (use AuditTrailService + CnObjectSidebar):
+- Full change tracking with before/after snapshots — automatic
+- Audit trail tab — `CnObjectSidebar` → `CnAuditTrailTab`
+- GDPR data subject access requests — `inzageverzoek()`, `verwerkingsregister()`
+- Audit export and analytics — `AuditTrailController`
+- NO custom audit logging, change tracking, or compliance controllers
+
+**Dashboard & Analytics** (use CnDashboardPage + CnChartWidget + CnStatsBlock):
+- Drag-drop widget dashboard — `CnDashboardPage` with GridStack
+- KPI cards — `CnKpiGrid`, `CnStatsBlock`, `CnStatsPanel`
+- Charts (line/bar/pie/donut) — `CnChartWidget` (ApexCharts)
+- Data tables as widgets — `CnTableWidget`
+- Editable data grids — `CnObjectDataWidget`
+- NO custom dashboard layouts, chart components, or KPI cards
+
+**Forms & Dialogs** (use CnFormDialog + schema-driven generation):
+- Auto-generated create/edit forms — `CnFormDialog` reads schema → generates fields
+- JSON/metadata editing — `CnAdvancedFormDialog` with Properties/Data/Metadata tabs
+- Schema editor — `CnSchemaFormDialog`
+- Delete/Copy/Mass operations — `CnDeleteDialog`, `CnCopyDialog`, `CnMassDeleteDialog`
+- NO custom form components, validation logic, or dialog wrappers
+
+**Navigation & Pagination** (use CnPagination + CnActionsBar + useListView):
+- Pagination control with size selector — `CnPagination`
+- Action bar (add, search, toggle views) — `CnActionsBar`
+- List state management — `useListView` composable (handles search, filter, sort, page)
+- Detail state management — `useDetailView` composable
+- NO custom pagination logic, debounced search, or list state management
+
+**Authorization & RBAC** (use AuthorizationService + PropertyRbacHandler):
+- Role-based access control — `AuthorizationService`
+- Field-level permissions — `PropertyRbacHandler`
+- Object-level restrictions — `PermissionHandler`
+- Authorization audit — `AuthorizationAuditService`
+- NO custom permission checks, role systems, or access control middleware
+
+**Webhooks & Events** (use WebhookService):
+- Create, test, retry webhooks — `WebhookService`
+- CloudEvents format — automatic
+- Event subscriptions — selective per schema/action
+- NO custom webhook controllers or event dispatchers
+
+**Notifications & Activity** (use NotificationService + ActivityService):
+- Nextcloud notifications — `NotificationService`
+- Activity feed — `ActivityService`
+- Calendar events — `CalendarEventService`
+- Deck/Kanban cards — `DeckCardService`
+
+**Store & State** (use createObjectStore + plugins):
+- Object stores — `createObjectStore(name)` generates Pinia CRUD store
+- Store plugins: `auditTrails`, `files`, `lifecycle`, `relations`, `search`, `selection`
+- Column/field/filter generation from schema — `columnsFromSchema()`, `fieldsFromSchema()`
+- NO custom Pinia stores for CRUD, Vuex, or manual API call management
+
+**Chat & AI** (use ChatService):
+- Multi-turn conversation — `ChatService`
+- RAG-based knowledge retrieval — `ContextRetrievalHandler`
+- LLM response generation — `ResponseGenerationHandler`
+
+**Data Retention & Archival** (use ArchivalService):
+- Legal hold — `LegalHoldService`
+- Destruction schedules — `DestructionService`
+- Retention policies — `RetentionService`
+
+**Semantic & Hybrid Search** (use SolrController + SettingsController):
+- Semantic search via vector embeddings — `SettingsController.semanticSearch()`
+- Hybrid search (keyword + semantic combined) — `SolrController.hybridSearch()`
+- Vector embedding generation — `VectorizationService`
+- NO custom search algorithms — configure via OpenRegister settings
+
+**GraphQL API** (use GraphQLController):
+- Query objects across schemas via GraphQL — `GraphQLController.execute()`
+- Alternative to REST for complex cross-entity queries
+
+**Organization / Multi-Tenancy** (use OrganisationController):
+- Organization CRUD — `OrganisationController`
+- Tenant-scoped data isolation — automatic via `TenantLifecycleService`
+- NO custom multi-tenancy logic
+
+**Task & Workflow Management** (use TasksController + WorkflowEngineController):
+- Task creation and tracking — `TasksController`
+- Workflow orchestration — `WorkflowEngineRegistry`
+- Scheduled workflows — `ScheduledWorkflowController`
+- NO custom task/workflow systems
+
+**Text Extraction** (use FileTextController):
+- Extract text from PDFs and Office docs — `TextExtractionService`
+- Entity recognition (PII detection) — `EntityRecognitionHandler`
+- Content anonymization — automatic
+
+**Timeline & Stages** (use CnTimelineStages):
+- Workflow progression visualization — `CnTimelineStages` component
+- Stage tracking with status colors
+
+### What apps SHOULD build (custom business logic only):
+- External API integrations (SAP, Peppol, TenderNed, etc.)
+- PDF/document generation with business-specific templates
+- Workflow triggers and business rules specific to the domain
+- Notification dispatch with app-specific event types
+- Custom settings pages with app-specific configuration
+- Background jobs for domain-specific processing
diff --git a/.claude/openspec/architecture/adr-002-api.md b/.claude/openspec/architecture/adr-002-api.md
new file mode 100644
index 00000000..4f956593
--- /dev/null
+++ b/.claude/openspec/architecture/adr-002-api.md
@@ -0,0 +1,6 @@
+- URL pattern: `/index.php/apps/{app}/api/{resource}` — lowercase plural, hyphens.
+- Methods: GET=read, POST=create, PUT=update, DELETE=remove. No custom methods.
+- Pagination: support `_page` + `_limit`. Response includes `total`, `page`, `pages`.
+- Errors: appropriate HTTP status + `message` field. NO stack traces in responses.
+- Auth: Nextcloud built-in only. NO custom login/session/token flows.
+- Public endpoints: annotate `#[PublicPage]` + `#[NoCSRFRequired]`. Register CORS OPTIONS route.
diff --git a/.claude/openspec/architecture/adr-003-backend.md b/.claude/openspec/architecture/adr-003-backend.md
new file mode 100644
index 00000000..82abe764
--- /dev/null
+++ b/.claude/openspec/architecture/adr-003-backend.md
@@ -0,0 +1,14 @@
+- **Controller → Service → Mapper** (strict 3-layer). Controllers NEVER call mappers directly.
+- Controllers: thin (<10 lines/method). Routing + validation + response only.
+- Services: ALL business logic. Stateless — no instance state between requests.
+- Mappers: DB CRUD only. No business logic.
+- DI: constructor injection with `private readonly`. NO `\OC::$server` or static locators.
+- Entity setters: POSITIONAL args only. `$e->setName('val')` — NEVER `$e->setName(name: 'val')`.
+ (`__call` passes `['name' => val]` but `setter()` uses `$args[0]`.)
+- Routes: `appinfo/routes.php`. Specific routes BEFORE wildcard `{slug}` routes.
+- Config: `IAppConfig` with sensitive flag for secrets. NEVER read DB directly.
+- Lifecycle: schema init via repair steps (`IRepairStep`), background via job queue, events via dispatcher.
+- **Spec traceability**: every class and public method MUST have `@spec` PHPDoc tag(s) linking to
+ the OpenSpec change that caused it: `@spec openspec/changes/{name}/tasks.md#task-N`.
+ Multiple `@spec` tags allowed (code touched by multiple changes). File-level `@spec` in header docblock.
+ This enables: code → docblock → spec traceability alongside code → git blame → commit → issue → spec.
diff --git a/.claude/openspec/architecture/adr-004-frontend.md b/.claude/openspec/architecture/adr-004-frontend.md
new file mode 100644
index 00000000..2484aa21
--- /dev/null
+++ b/.claude/openspec/architecture/adr-004-frontend.md
@@ -0,0 +1,129 @@
+- **Vue 2 + Pinia + @nextcloud/vue + @conduction/nextcloud-vue**. NO Vuex. Options API only.
+- State: Pinia stores in `src/store/modules/`. Use `createObjectStore` for OpenRegister CRUD.
+- API calls: `axios` from `@nextcloud/axios` — auto-attaches CSRF token. NEVER raw `fetch()` for mutations.
+ Loading state with `try/finally`.
+- Translations: ALL user-visible strings via `t(appName, 'text')`. NO hardcoded strings.
+ Translation keys MUST be English — Dutch translations go in `l10n/nl.json`.
+- CSS: ONLY Nextcloud CSS variables (`var(--color-primary-element)`, etc.). NO hardcoded colors.
+ NEVER reference `--nldesign-*` directly — nldesign app handles theming.
+- Router: history mode, base `generateUrl('/apps/{app}/')`. Requires matching PHP routes in `routes.php`.
+ Deep link URL templates MUST match the router mode — use path format (`/apps/{app}/entities/{uuid}`),
+ NOT hash format (`/apps/{app}/#/entities/{uuid}`).
+- OpenRegister dependency: settings returns `openRegisters` (bool) + `isAdmin`.
+ Show empty state if OR missing. NEVER use `OC.isAdmin` — get from backend.
+- NEVER `window.confirm()` or `window.alert()` — use `NcDialog` or `CnFormDialog` (WCAG, theming).
+- NEVER read app state from DOM (`document.getElementById`, `dataset`) — use backend API or store.
+- EVERY `await store.action()` call MUST be wrapped in `try/catch` with user-facing error feedback.
+- NEVER import from `@nextcloud/vue` directly — use `@conduction/nextcloud-vue` which re-exports all
+ NC components plus Conduction components. This ensures consistent theming and component versions.
+- EVERY component used in `` MUST be imported AND registered in `components: {}`.
+ Vue 2 silently renders unknown elements — missing imports cause invisible runtime failures.
+
+### NL Design System
+
+- ALL UI components MUST use CSS custom properties from NL Design System tokens.
+- MUST support theme switching via nldesign app's token sets.
+- MUST meet WCAG AA compliance: keyboard-navigable, associated labels, color is not the sole
+ method of conveying information.
+- SHOULD work on 320px–1920px viewports; critical functionality MUST work at 768px (tablet).
+- Exceptions: PDF generation (docudesk), admin-only screens (simpler styling allowed).
+
+### @conduction/nextcloud-vue — ALWAYS check before building custom
+
+**Pages & Layout:**
+ `CnIndexPage` (schema-driven list+CRUD) | `CnDetailPage` (detail+sidebar) |
+ `CnPageHeader` (title+icon) | `CnActionsBar` (add+search+toggle)
+
+**Data Display:**
+ `CnDataTable` (sortable+paginated) | `CnCardGrid` + `CnObjectCard` (card views) |
+ `CnDetailGrid` (label-value pairs) | `CnFilterBar` (search+filters) |
+ `CnFacetSidebar` (faceted filters) | `CnPagination` | `CnCellRenderer` (type-aware)
+
+**Forms & Dialogs:**
+ `CnFormDialog` (schema-driven create/edit) | `CnAdvancedFormDialog` (properties+JSON+metadata) |
+ `CnSchemaFormDialog` (JSON Schema editor) | `CnTabbedFormDialog` (tabbed form framework) |
+ `CnDeleteDialog` | `CnCopyDialog`
+
+**Mass Actions:**
+ `CnMassDeleteDialog` | `CnMassCopyDialog` | `CnMassExportDialog` (CSV/JSON/XML) |
+ `CnMassImportDialog` (upload+summary) | `CnMassActionBar` (floating selection bar)
+
+**Dashboard & Widgets:**
+ `CnDashboardPage` (GridStack drag-drop layout) | `CnDashboardGrid` (layout engine) |
+ `CnWidgetWrapper` (widget shell) | `CnWidgetRenderer` (NC Dashboard API v1/v2) |
+ `CnChartWidget` (ApexCharts: area/line/bar/pie/donut/radial) |
+ `CnTableWidget` (data table widget) | `CnTileWidget` (quick-access tile) |
+ `CnInfoWidget` (label-value grid) | `CnKpiGrid` (responsive KPI layout) |
+ `CnStatsBlock` (metric card) | `CnStatsPanel` (stats sections) | `CnProgressBar` |
+ `CnObjectDataWidget` (schema-driven editable data grid, inline edit + save via objectStore) |
+ `CnObjectMetadataWidget` (read-only object metadata display)
+
+**UI Elements:**
+ `CnStatusBadge` | `CnEmptyState` | `CnIcon` (MDI) | `CnCard` | `CnDetailCard` |
+ `CnRowActions` | `CnTimelineStages` (workflow progression) |
+ `CnUserActionMenu` (user context menu) | `CnJsonViewer` (CodeMirror)
+
+**Detail Sidebar:**
+ `CnObjectSidebar` (Files/Notes/Tags/Tasks/Audit tabs) | `CnIndexSidebar` |
+ `CnNotesCard` (inline notes) | `CnTasksCard` (inline tasks)
+
+**Settings:**
+ `CnSettingsSection` + `CnVersionInfoCard` (MUST be first on admin pages) |
+ `CnSettingsCard` | `CnConfigurationCard` | `CnRegisterMapping`
+ User settings: `NcAppSettingsDialog` (NOT `NcDialog`)
+
+**Composables:**
+ `useListView` (search/filter/sort/pagination) | `useDetailView` (load/edit/delete) |
+ `useSubResource` (related items) | `useDashboardView` (widgets/layout/edit)
+
+**Store Plugins:**
+ `auditTrailsPlugin` | `relationsPlugin` | `filesPlugin` | `lifecyclePlugin` |
+ `selectionPlugin` | `searchPlugin` | `registerMappingPlugin`
+
+**Utilities:**
+ `columnsFromSchema()` | `filtersFromSchema()` | `fieldsFromSchema()` |
+ `formatValue()` | `buildHeaders()` | `buildQueryString()`
+
+### Page Construction Patterns (follow these recipes)
+
+**App.vue:** `NcContent` → 3 states: loading (`NcLoadingIcon`), no-OpenRegister (`NcEmptyContent`),
+ ready (`MainMenu` + `NcAppContent` + `router-view` + optional `CnIndexSidebar`).
+ Inject `sidebarState` for child components. `created()` calls `initializeStores()`.
+
+**MainMenu:** `NcAppNavigation` with `NcAppNavigationItem` per route (icon + name + `:to`).
+ Footer: `NcAppNavigationSettings` (gear foldout) with admin/config nav items.
+ Settings item emits `@click="$emit('open-settings')"` — opens `NcAppSettingsDialog` modal.
+ Do NOT route to `/settings` — in-app settings is a modal overlay, not a page.
+
+**Dashboard:** `CnDashboardPage` with `CnStatsBlock` KPIs (4 cards: open/overdue/value/completed),
+ status distribution chart, "My Work" list (grouped: overdue → due this week → rest).
+ Fetch all collections in parallel via `Promise.all`. Widget templates via `#widget-{id}` slots.
+
+**Index page:** `CnIndexPage` with `useListView(entityType, { sidebarState, objectStore })`.
+ Inject sidebarState. Row click → `$router.push({ name: 'EntityDetail', params: { id } })`.
+ Add button → new entity detail with id='new'.
+
+**Detail page:** Two modes — edit (form component) / view (`CnDetailPage` + `CnDetailCard` sections).
+ Header actions: Edit + Delete buttons. Related entities in table inside `CnDetailCard`.
+ Props: `entityId` from route. `isNew = entityId === 'new'`. Sidebar via `CnObjectSidebar`.
+ **Relations:** Every entity referenced in the spec MUST have a `CnDetailCard` section.
+ Use `fetchUsed` for reverse lookups (find objects that reference THIS entity) and
+ `fetchUses` for forward lookups (find objects THIS entity references).
+ If the spec lists a "linked X section", it MUST be implemented — not deferred or stubbed.
+
+**Settings — two surfaces, never a route:**
+ *Admin settings* (`/settings/admin/{appid}`): `AdminRoot.vue` rendered by `settings.js` entry point,
+ registered via `AdminSettings.php`. Layout: `CnVersionInfoCard` (FIRST) → `CnRegisterMapping` →
+ `CnSettingsSection` per feature. Load via `GET /api/settings`, save via `POST /api/settings`.
+ *In-app settings*: `UserSettings.vue` wrapping `NcAppSettingsDialog` — opened as a modal from the
+ gear menu (`@open-settings` event on MainMenu), handled in `App.vue` with `:open` / `@update:open`.
+ Do NOT create a `/settings` route. Do NOT create a standalone `SettingsView.vue` page component.
+
+**Router:** Flat routes (no nesting), all named, props via arrow function for params.
+ Routes: `/` (Dashboard), `/{entities}` (list), `/{entities}/:id` (detail).
+ No `/settings` route — settings is a modal (see Settings section above).
+
+**Store init:** `initializeStores()` in `store/store.js` — fetches settings, then calls
+ `objectStore.registerObjectType(name, schemaSlug, registerSlug)` for each entity.
+ Object store uses `createObjectStore` with plugins (files, auditTrails, relations).
+ Settings store: Pinia `defineStore` with `fetchSettings()` and `saveSettings()`.
diff --git a/.claude/openspec/architecture/adr-005-security.md b/.claude/openspec/architecture/adr-005-security.md
new file mode 100644
index 00000000..ae87c44a
--- /dev/null
+++ b/.claude/openspec/architecture/adr-005-security.md
@@ -0,0 +1,24 @@
+- Auth: Nextcloud built-in ONLY. NO custom login, sessions, tokens, password storage.
+- Admin check: `IGroupManager::isAdmin()` on BACKEND. Frontend-only checks = vulnerability.
+- Per-object authorization (IDOR prevention): every mutation endpoint that operates on a specific
+ object MUST check that the authenticated user owns, is in the group of, or is admin for THAT
+ object — not just that they are logged in. `#[NoAdminRequired]` opens the endpoint to all users;
+ without a per-object check, any user can modify any object by guessing its ID.
+ Pattern: fetch object → extract `assigneeUserId`/`assigneeGroupId`/`createdBy` → check
+ (owner OR in group OR admin) → throw `OCSForbiddenException` if none apply. Extract into a
+ reusable `authorizeXxx(object, user)` service method, called from every PUT/POST/DELETE.
+- Multi-tenant isolation: enforce at API/service level, not UI only.
+- NO PII in logs, error responses, or debug output.
+- Audit trails: use `$user->getUID()` — NEVER `$user->getDisplayName()` (mutable, spoofable).
+- Identity: always derive from `IUserSession` on backend — NEVER trust frontend-sent user IDs or display names.
+- Nextcloud endpoint defaults: NO annotation = admin-only. Non-admin endpoints (agent/staff actions)
+ MUST have `#[NoAdminRequired]` attribute. Pair every `#[NoAdminRequired]` with a per-object auth
+ check — never trust the session alone for mutation.
+- Input validation: all user-supplied strings that flow into URLs (query params, path segments)
+ MUST be URL-encoded (`encodeURIComponent` in Vue/JS, `rawurlencode` in PHP). Email Message-IDs,
+ file names, and free-text fields commonly contain `<`, `>`, `/`, `@`, `&` which break unencoded.
+- File uploads: validate type + size before storage.
+- API responses: NO stack traces, SQL, or internal paths.
+- Error messages: use static, generic messages (`'Operation failed'`, `'Not authorized'`) — NEVER
+ return `$e->getMessage()` to clients. Log the real error server-side with `$this->logger->error()`.
+- Test collections: NEVER commit default credentials — use env variable placeholders.
diff --git a/.claude/openspec/architecture/adr-006-metrics.md b/.claude/openspec/architecture/adr-006-metrics.md
new file mode 100644
index 00000000..58a9bf8e
--- /dev/null
+++ b/.claude/openspec/architecture/adr-006-metrics.md
@@ -0,0 +1,3 @@
+- Every app: `GET /api/metrics` (Prometheus text, admin auth) + `GET /api/health` (JSON, public).
+- Metric names: `{app}_` prefix. MUST include `{app}_health_status` and `{app}_info`.
+- Health check MUST verify OpenRegister connectivity (for apps that depend on it).
diff --git a/.claude/openspec/architecture/adr-007-i18n.md b/.claude/openspec/architecture/adr-007-i18n.md
new file mode 100644
index 00000000..3c44e099
--- /dev/null
+++ b/.claude/openspec/architecture/adr-007-i18n.md
@@ -0,0 +1,57 @@
+# ADR-007: Internationalization (i18n)
+
+## Status
+Accepted
+
+## Context
+All Conduction Nextcloud apps serve Dutch government users but must support multiple languages. We need a consistent approach to internationalization across all apps.
+
+## Decision
+
+### Primary Language: English
+- **English (en) is the source/primary language** for all code and translation keys.
+- All `t()` keys and `$this->l10n->t()` strings MUST be written in English.
+- `l10n/en.json` is the identity-mapped source file (key == value).
+- Hardcoded Dutch strings in code MUST be converted to English keys with Dutch translations in `nl.json`.
+
+### Sentence Case for All UI Strings
+- All translation keys and user-facing strings MUST use **sentence case**: only the first word is capitalized.
+- Correct: `"Add directory"`, `"No results found"`, `"Delete selected"`, `"Save configuration"`
+- Wrong (title case): `"Add Directory"`, `"No Results Found"`, `"Delete Selected"`
+- Wrong (all lowercase): `"add directory"`, `"no results found"`
+- **Exceptions** that keep their capitalization:
+ - Proper nouns and product names: `"OpenRegister"`, `"Nextcloud"`, `"GitHub"`, `"DocuDesk"`
+ - Acronyms: `"API"`, `"URL"`, `"PDF"`, `"SOLR"`, `"JSON"`, `"RBAC"`, `"OAS"`
+ - Single-word strings still start with a capital: `"Delete"`, `"Search"`, `"Save"`
+
+### Required Languages
+- Minimum: English (en) + Dutch (nl) translations.
+- `l10n/en.json` and `l10n/nl.json` MUST exist in every app with a UI.
+- Both files MUST contain exactly the same keys, with zero gaps.
+
+### Frontend Translation
+- JS: `t(appName, 'key')` for singular, `n(appName, 'singular', 'plural', count)` for plurals.
+- `Vue.mixin({ methods: { t, n } })` for Options API components.
+- `
+
+
+
+
+```
+
+Rules:
+- Store imports go in ``).
+Verify that CSRF tokens are present on forms.
+Check that navigation doesn't expose internal IDs in exploitable ways.
+```
+
+---
+
+### API
+
+````
+## Your Focus: API Quality
+Use `browser_evaluate` to test API endpoints directly via fetch():
+```javascript
+const r = await fetch(url, { headers: { requesttoken: OC.requestToken } });
+return { status: r.status, body: await r.json() };
+```
+Test all CRUD endpoints for the app's resources.
+Verify error responses have proper status codes and messages.
+Test with invalid/missing data — does the API return helpful errors?
+Check pagination parameters (_limit, _offset, _page).
+Verify that list endpoints return consistent data structures.
+````
diff --git a/.claude/skills/test-app/templates/summary-report-template.md b/.claude/skills/test-app/templates/summary-report-template.md
new file mode 100644
index 00000000..917fc998
--- /dev/null
+++ b/.claude/skills/test-app/templates/summary-report-template.md
@@ -0,0 +1,74 @@
+# {APP} — Test Results Summary
+
+**Date:** {today's date}
+**Environment:** {BACKEND}
+**Mode:** {Quick / Full (6 perspectives)}
+**Method:** Automated browser testing with Playwright MCP (headless)
+
+> Experimental agentic testing — results should be verified manually for critical findings.
+
+---
+
+## Overall Results
+
+| Status | Count | Percentage |
+|--------|-------|------------|
+| **PASS** | {n} | {pct}% |
+| **PARTIAL** | {n} | {pct}% |
+| **FAIL** | {n} | {pct}% |
+| **CANNOT_TEST** | {n} | {pct}% |
+
+---
+
+## FAIL Issues (Requires Attention)
+
+| Feature | Perspective | Summary | Severity |
+|---------|-------------|---------|----------|
+| {feature} | {perspective} | {one-line summary} | HIGH/MEDIUM/LOW |
+
+---
+
+## PARTIAL Issues (Needs Investigation)
+
+| Feature | Perspective | What Works | What Doesn't |
+|---------|-------------|------------|--------------|
+| {feature} | {perspective} | {working parts} | {broken parts} |
+
+---
+
+## CANNOT_TEST (Blocked)
+
+| Feature | Perspective | Reason |
+|---------|-------------|--------|
+| {feature} | {perspective} | {why it couldn't be tested} |
+
+---
+
+## Results by Perspective
+
+### {Perspective Name}
+- **PASS**: {n} | **PARTIAL**: {n} | **FAIL**: {n} | **CANNOT_TEST**: {n}
+- **Key findings**: {2-3 bullet points}
+
+{repeat for each perspective}
+
+---
+
+## Console Errors (Across All Perspectives)
+
+| Error | Occurrences | Pages |
+|-------|-------------|-------|
+| {error} | {n} | {pages} |
+
+---
+
+## Recommendations
+
+### High Priority
+{numbered list of FAIL items}
+
+### Medium Priority
+{numbered list of PARTIAL items}
+
+### For Next Test Run
+{improvements to testing approach}
diff --git a/.claude/skills/test-counsel/SKILL.md b/.claude/skills/test-counsel/SKILL.md
new file mode 100644
index 00000000..ffdc17b4
--- /dev/null
+++ b/.claude/skills/test-counsel/SKILL.md
@@ -0,0 +1,469 @@
+---
+name: test-counsel
+description: Test a project's features from 8 persona perspectives using browser, API, and documentation testing
+---
+
+# Test Counsel — Multi-Persona Feature Testing
+
+Test a project's implemented features from 8 persona perspectives using browser interaction, API testing, and documentation review — all driven by the project's OpenSpec specifications.
+
+**Input**: Optional argument after `/test-counsel`:
+- No argument → ask which project to test
+- Project name → test that project directly (e.g., `opencatalogi`, `openregister`)
+
+**Available projects**: Any directory under apps-extra with an `openspec/` folder.
+
+---
+
+## Personas
+
+The Test Counsel uses 8 personas representing the full spectrum of Dutch public sector users. Each persona card is stored in `.claude/personas/`:
+
+| Persona | File | Testing Focus |
+|---------|------|---------------|
+| Henk Bakker | `henk-bakker.md` | Readability, text size, Dutch language, simple navigation, elderly UX |
+| Fatima El-Amrani | `fatima-el-amrani.md` | Visual clarity, icon usage, mobile viewport, text density, literacy barriers |
+| Sem de Jong | `sem-de-jong.md` | Performance, keyboard nav, dark mode, console errors, modern UX patterns |
+| Noor Yilmaz | `noor-yilmaz.md` | Security controls, audit trails, RBAC, org isolation, data leaks, BIO2 |
+| Annemarie de Vries | `annemarie-de-vries.md` | API standards, NLGov compliance, GEMMA mapping, OpenAPI spec, publiccode.yml |
+| Mark Visser | `mark-visser.md` | Business workflows, CRUD efficiency, form clarity, status indicators, Dutch terms |
+| Priya Ganpat | `priya-ganpat.md` | API quality via browser fetch(), DX, error responses, pagination, integration |
+| Jan-Willem van der Berg | `janwillem-van-der-berg.md` | Plain language, jargon-free, findability, 3-click rule, contact info, help |
+
+---
+
+## Steps
+
+### Step -1: Environment Configuration
+
+Ask the user about the target environment using AskUserQuestion:
+
+**"Which environment do you want to test against?"**
+- **Local development** — Backend: localhost:8080, Frontend: localhost:3000 (if separate UI), Admin: admin/admin
+- **Custom environment** — I'll provide URLs and credentials
+
+If **Custom**, ask follow-up questions one at a time:
+1. "What is the backend URL?"
+2. "What is the frontend URL? (or same as backend if no separate UI)"
+3. "What are the test user credentials? (format: username:password)"
+
+Store as `{BACKEND}`, `{FRONTEND}`, `{TEST_USER}`, `{TEST_PASS}`.
+
+For **Local development**, use:
+- `{BACKEND}` = `http://localhost:8080`
+- `{FRONTEND}` = `http://localhost:8080` (or `http://localhost:3000` if project has separate UI)
+- `{TEST_USER}` = `admin`
+- `{TEST_PASS}` = `admin`
+
+### Step 0: Determine the Project
+
+If no project was provided as argument, use AskUserQuestion to ask:
+
+**"Which project would you like the Test Counsel to test?"**
+
+List the available projects by checking which directories have `openspec/` folders.
+
+Store the chosen project as `{PROJECT}`.
+
+### Step 1: Read the Project's Specs and Understand What to Test
+
+Read the following files:
+
+1. `{PROJECT}/project.md` — Project context, URLs, architecture
+2. `{PROJECT}/openspec/specs/` — All spec files (what was specified)
+3. `{PROJECT}/openspec/changes/` — Active changes (recently added features)
+4. `openspec/specs/` — Shared specs (api-patterns, nl-design, nextcloud-app)
+
+Build a test plan:
+- What features exist and should be testable?
+- What URLs/pages should be visited?
+- What API endpoints should be tested?
+- What documentation should exist?
+
+### Step 1.5a: Load Test Scenarios (optional)
+
+Check whether the project has saved test scenarios:
+```bash
+ls {PROJECT}/test-scenarios/TS-*.md 2>/dev/null
+```
+
+If scenario files exist, parse their frontmatter. Filter to those with `status: active` and `test-commands` containing `test-counsel`.
+
+Group them by persona relevance using the `personas` frontmatter field:
+
+```
+Found {N} test scenario(s) for {PROJECT}:
+
+Relevant to all personas:
+ TS-001 [HIGH] functional — Create a new register
+
+Relevant to specific personas:
+ TS-002 [MED] api — API returns paginated results → Priya Ganpat, Annemarie de Vries
+ TS-003 [HIGH] security — Unauthenticated access blocked → Noor Yilmaz
+ TS-004 [LOW] accessibility — Form labels are readable → Henk Bakker, Fatima El-Amrani
+```
+
+Ask the user using AskUserQuestion:
+
+**"Test scenarios exist for this project. Include them in this test run?"**
+- **Yes, include all** — each persona agent receives the scenarios relevant to their persona (matched by persona slug in frontmatter), plus any scenario with no specific persona
+- **Yes, let me choose** — show the list and let the user select which to include
+- **No, skip scenarios** — proceed with standard testing only
+
+Store `{INCLUDED_SCENARIOS}` — a mapping of persona slug → list of relevant scenario objects (id, title, steps, preconditions, acceptance criteria).
+
+Each persona sub-agent will receive only the scenarios matching their persona slug (or all scenarios if the user chose "include all" and no persona filter is set).
+
+**If no scenarios exist**: proceed silently. Note at the end: "No test scenarios defined yet. Create them with `/test-scenario-create`."
+
+---
+
+### Step 1.5: Select Agent Model
+
+Ask the user using AskUserQuestion:
+
+**"Which model should the persona agents use?"**
+
+| Model | Speed | Quota | Best for |
+|---|---|---|---|
+| **Haiku** | Fastest | Low | Parallel runs — broad coverage, efficient |
+| **Sonnet** | Balanced | Moderate | Better reasoning, more nuanced findings |
+| **Opus** | Slowest | High | Deepest analysis — for critical or final runs |
+
+- **Haiku (default)** — Recommended for parallel runs. Fast and quota-efficient. Its 200k context window is smaller than Sonnet/Opus (both 1M) — for browser-heavy runs with many snapshots, consider Sonnet.
+- **Sonnet** — Better reasoning depth for more nuanced findings. Uses more quota than Haiku across 8 parallel agents.
+- **Opus** — Highest quality analysis. With 8 agents running in parallel this uses substantial quota — best reserved for final pre-release testing or targeted critical reviews.
+
+Store as `{MODEL}`:
+- Haiku → `"haiku"`
+- Sonnet → `"sonnet"`
+- Opus → `"opus"`
+
+### Step 2: Launch Persona Test Agents in Parallel
+
+Launch 8 Task agents in parallel (all in a single message), one per persona. Each agent tests the live application from their persona's perspective. Use `subagent_type: "general-purpose"` and `model: "{MODEL}"` (from Step 1.5).
+
+**Browser assignment** — each agent gets its own browser to avoid conflicts:
+
+| Agent | Persona | Browser |
+|-------|---------|---------|
+| 1 | Henk Bakker | `browser-2` |
+| 2 | Fatima El-Amrani | `browser-3` |
+| 3 | Sem de Jong | `browser-4` |
+| 4 | Noor Yilmaz | `browser-5` |
+| 5 | Annemarie de Vries | `browser-7` |
+| 6 | Mark Visser | `browser-1` |
+| 7 | Priya Ganpat | `browser-2` (sequential after Henk) |
+| 8 | Jan-Willem van der Berg | `browser-3` (sequential after Fatima) |
+
+**Note**: With 7 browsers and 8 agents, launch the first 6 in parallel, then the remaining 2 after the first batch completes. Or launch all 8 and let 2 share browsers sequentially.
+
+**Sub-agent prompt template** (replace variables):
+
+```
+You are a Test Counsel agent testing the **{PROJECT}** application as **{PERSONA_NAME}**.
+
+## Your Persona
+Read the persona card at `.claude/personas/{PERSONA_FILE}` to understand your character completely. Stay fully in character throughout all testing.
+
+## Browser
+Use `browser-{N}` tools (`mcp__browser-{N}__*`) for all browser interactions.
+
+## Environment
+- **Backend**: {BACKEND}
+- **Frontend**: {FRONTEND}
+- **Login**: {TEST_USER} / {TEST_PASS}
+
+## What to Test
+Read the project specs to understand what features should exist:
+1. `{PROJECT}/project.md`
+2. All files in `{PROJECT}/openspec/specs/`
+
+## Test Scenarios for Your Persona
+
+{IF INCLUDED_SCENARIOS for this persona is non-empty:}
+The following test scenarios were defined specifically for your persona. Execute these **first**, before free exploration — they represent the highest-priority flows to verify:
+
+{For each scenario: ID, title, preconditions, Given-When-Then steps, acceptance criteria}
+
+For each scenario:
+1. Set up the preconditions
+2. Follow the Given-When-Then steps exactly as written, using the provided test data
+3. Verify each acceptance criterion — record PASS / FAIL / PARTIAL / BLOCKED
+4. Screenshot each step: `{PROJECT}/test-results/screenshots/personas/{PERSONA_SLUG}/{SCENARIO_ID}-step-{N}.png`
+5. Check `browser_console_messages` after each action
+
+Include a **"## Test Scenario Results"** section in your report with a table:
+| Scenario | Title | Criterion | Status | Observed |
+|---|---|---|---|---|
+
+{END IF}
+
+---
+
+## Testing Approach
+
+### 1. Browser Testing (UI)
+Log in and navigate through the application as your persona would:
+- Navigate to {FRONTEND} (or {BACKEND}/index.php/apps/{PROJECT} for Nextcloud apps)
+- Log in with the test credentials
+- Visit every major page/section mentioned in the specs
+- For each page:
+ - `browser_snapshot` — observe the page from your persona's perspective
+ - Test interactions your persona would attempt
+ - Check `browser_console_messages` for errors
+ - Note anything that doesn't match your persona's needs/expectations
+
+### 2. API Testing (from browser)
+Use `browser_evaluate` to test API endpoints mentioned in the specs:
+```javascript
+const response = await fetch('{BACKEND}/index.php/apps/{app}/api/{resource}', {
+ headers: { 'requesttoken': OC.requestToken }
+});
+return JSON.stringify({
+ status: response.status,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: await response.json()
+}, null, 2);
+```
+Test from your persona's perspective:
+- Can your persona's role access these endpoints?
+- Do the responses make sense for your persona?
+- Are errors helpful and understandable?
+
+### 3. Documentation Testing
+Check if documentation exists and serves your persona:
+- Is there in-app help?
+- Are API docs accessible if relevant to your persona?
+- Is the documentation in Dutch where needed?
+- Does it match the actual behavior?
+
+### 4. Spec Compliance Testing
+For each feature in the specs, verify:
+- Is it implemented?
+- Does it work as specified?
+- Does it serve your persona's needs?
+
+## {PERSONA_TESTING_FOCUS}
+
+## Output Format
+
+Write your results as a structured report:
+
+```markdown
+# Test Counsel Report: {PERSONA_NAME} — {PROJECT}
+
+**Date:** {today's date}
+**Environment:** {BACKEND}
+**Persona:** {PERSONA_NAME} ({one-line description})
+**Browser:** browser-{N}
+
+## Summary
+- **Features tested**: {count}
+- **PASS**: {count}
+- **PARTIAL**: {count}
+- **FAIL**: {count}
+- **NOT IMPLEMENTED**: {count}
+
+## Feature Test Results
+
+### {Spec Section / Feature Name}
+| Aspect | Status | Notes |
+|--------|--------|-------|
+| Implemented? | YES/NO/PARTIAL | {details} |
+| Works as specified? | YES/NO/PARTIAL | {details} |
+| Serves {PERSONA_NAME}'s needs? | YES/NO/PARTIAL | {persona perspective} |
+
+**{PERSONA_NAME}'s reaction**: "{in-character quote}"
+
+{repeat for each feature}
+
+## API Test Results (if applicable)
+| Endpoint | Method | Status | Response | Persona Notes |
+|----------|--------|--------|----------|--------------|
+| /api/{resource} | GET | {code} | {summary} | {persona perspective} |
+
+## Console Errors
+| Page | Error | Severity |
+|------|-------|----------|
+| {page} | {error} | HIGH/MEDIUM/LOW |
+
+## Persona-Specific Findings
+
+### {PERSONA_FOCUS_AREA} Assessment
+| Criterion | Status | Evidence | {PERSONA_NAME} would say... |
+|-----------|--------|----------|----------------------------|
+| {criterion} | PASS/FAIL | {what was observed} | "{in-character quote}" |
+
+## Top Issues
+| # | Issue | Severity | Category | Recommendation |
+|---|-------|----------|----------|----------------|
+| 1 | {issue} | CRITICAL/HIGH/MEDIUM/LOW | {category} | {suggestion} |
+
+## {PERSONA_NAME}'s Verdict
+"{A paragraph from the persona summarizing their overall experience testing this application}"
+```
+```
+
+**Persona-specific testing focus:**
+
+| Persona | Testing Focus Instructions |
+|---------|--------------------------|
+| Henk | Check text size (>=16px body), button size (>=44px), Dutch labels, simple navigation, clear errors, breadcrumbs, contrast ratios |
+| Fatima | Set viewport to 375x812 mobile, check icon clarity, text density, visual hierarchy, color-coded status, touch targets, scrolling discovery |
+| Sem | Measure page load time, test Tab/Escape/Enter/arrow keys, check dark mode, inspect console, monitor network requests, verify URL state management |
+| Noor | Navigate to settings first, look for audit logs, test RBAC boundaries, try URL manipulation for org isolation, check PII in URLs, verify session controls |
+| Annemarie | Test API endpoints for NLGov compliance, check pagination format, verify OpenAPI spec availability, look for publiccode.yml, assess GEMMA alignment |
+| Mark | Test CRUD workflows for efficiency (count clicks), check form field clarity, verify status indicators, test search, check Dutch business terminology |
+| Priya | Use browser_evaluate for API calls, test all CRUD via fetch(), verify error response format, check pagination/filtering/sorting, assess OpenAPI accuracy |
+| Jan-Willem | Check for jargon on every page, test search with plain Dutch terms, count clicks to complete tasks, find contact info, verify B1 language level |
+
+### Step 3: Synthesize Test Results
+
+After all agents complete, read their reports and create a synthesized Test Counsel report.
+
+**Write the synthesis to**: `{PROJECT}/test-results/test-counsel-report.md`
+
+```markdown
+# Test Counsel Report: {PROJECT}
+
+**Date:** {today's date}
+**Environment:** {BACKEND} / {FRONTEND}
+**Method:** 8-persona browser, API, and documentation testing against OpenSpec specifications
+**Personas:** Henk Bakker, Fatima El-Amrani, Sem de Jong, Noor Yilmaz, Annemarie de Vries, Mark Visser, Priya Ganpat, Jan-Willem van der Berg
+
+---
+
+## Overall Results
+
+| Persona | Features Tested | PASS | PARTIAL | FAIL | Not Implemented |
+|---------|----------------|------|---------|------|-----------------|
+| Henk Bakker | {n} | {n} | {n} | {n} | {n} |
+| Fatima El-Amrani | {n} | {n} | {n} | {n} | {n} |
+...{all 8 personas}
+| **Total** | {n} | {n} | {n} | {n} | {n} |
+
+---
+
+## Critical Issues (found by 3+ personas)
+
+| # | Issue | Severity | Found by | Recommendation |
+|---|-------|----------|----------|----------------|
+| 1 | {issue} | CRITICAL/HIGH | {persona names} | {recommendation} |
+
+---
+
+## Spec vs Implementation Gap Analysis
+
+| Spec Feature | Implemented? | Working? | Persona Feedback |
+|-------------|-------------|---------|-----------------|
+| {feature from spec} | YES/NO/PARTIAL | YES/NO | {summary of persona reactions} |
+
+---
+
+## Per-Persona Highlights
+
+### Henk Bakker (Elderly Citizen)
+- **Can Henk use this?** YES/WITH DIFFICULTY/NO
+- **Top blocker**: {issue}
+- **Quote**: "{in-character Dutch quote}"
+
+### Fatima El-Amrani (Low-Literate Migrant)
+...{repeat for all 8}
+
+---
+
+## Testing Categories
+
+### Accessibility & Readability
+| Issue | Severity | Personas | Spec Reference |
+|-------|----------|----------|---------------|
+| {issue} | {severity} | {who found it} | {spec section} |
+
+### Security & Compliance
+| Issue | Severity | Personas | Standard |
+|-------|----------|----------|----------|
+| {issue} | {severity} | {who found it} | {BIO2/AVG/etc} |
+
+### API Quality & Standards
+| Issue | Severity | Personas | NLGov Rule |
+|-------|----------|----------|-----------|
+| {issue} | {severity} | {who found it} | {rule} |
+
+### UX & Performance
+| Issue | Severity | Personas | Notes |
+|-------|----------|----------|-------|
+| {issue} | {severity} | {who found it} | {details} |
+
+### Language & Content
+| Issue | Severity | Personas | Notes |
+|-------|----------|----------|-------|
+| {issue} | {severity} | {who found it} | {details} |
+
+---
+
+## Console Errors Summary
+
+| Error | Occurrences | Pages | Severity |
+|-------|-------------|-------|----------|
+| {error} | {count} | {pages} | {severity} |
+
+---
+
+## Recommendations
+
+### CRITICAL (fix immediately)
+1. {recommendation + which personas affected}
+
+### HIGH (fix before next release)
+1. {recommendation + which personas affected}
+
+### MEDIUM (improve when possible)
+1. {recommendation + which personas affected}
+
+---
+
+## Suggested OpenSpec Changes
+
+| Change Name | Description | Related Issues | Personas Affected |
+|-------------|-------------|---------------|------------------|
+| {name} | {description} | {issue numbers from above} | {personas} |
+```
+
+### Step 4: Report to User
+
+Display a concise summary:
+- Total features tested across all personas
+- Overall pass/fail rates per persona
+- Top 5 critical issues
+- Any spec features that are not yet implemented
+- Link to the full report: `{PROJECT}/test-results/test-counsel-report.md`
+- Offer to create OpenSpec changes for any gaps found
+
+---
+
+## Capture Learnings
+
+After testing completes, review what happened and append any new observations to [learnings.md](learnings.md):
+
+- **Patterns That Work** — multi-persona approaches that found meaningful cross-cutting issues
+- **Mistakes to Avoid** — false consensus, persona overlap, or synthesis errors
+- **Domain Knowledge** — facts about cross-persona testing patterns or Dutch government accessibility
+- **Open Questions** — unresolved testing challenges
+
+Each entry must include today's date. One insight per bullet. Skip if nothing new was learned.
+
+---
+
+## Returning to caller
+
+After generating the report and summary, output a structured result line and return control:
+
+```
+COUNSEL_TEST_RESULT: PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+- **PASS** = no CRITICAL issues found across all personas
+- **FAIL** = any CRITICAL issues found
+
+**If invoked from `/opsx-apply-loop`**: your work is complete after outputting the result line. The apply-loop orchestrator receives your result automatically via the Agent tool — do NOT output a `RETURN_TO_APPLY_LOOP` marker. Do NOT offer to create OpenSpec changes, do NOT ask what to do next.
diff --git a/.claude/skills/test-counsel/evals/evals.json b/.claude/skills/test-counsel/evals/evals.json
new file mode 100644
index 00000000..2e12c9ef
--- /dev/null
+++ b/.claude/skills/test-counsel/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-counsel","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"multi-persona","description":"Spawn 8 persona agents","prompt":"Run /test-counsel on openregister","setup":"App running, specs defined in openspec/","expected":"Should spawn 8 persona agents in parallel","assertions":["Spawns all 8 personas (Henk, Fatima, Sem, Noor, Annemarie, Mark, Priya, Jan-Willem)","Each persona tests from their specific perspective","Personas run in parallel","Each produces independent findings"]},{"id":"synthesize","description":"Synthesize cross-persona results","prompt":"Run /test-counsel and check the synthesis","setup":"All 8 personas have completed testing","expected":"Should produce consensus findings","assertions":["Identifies issues found by multiple personas","Highlights persona-specific unique findings","Ranks issues by severity and cross-persona agreement","Produces structured test-counsel-report.md"]},{"id":"report-format","description":"Report includes per-persona and cross-persona sections","prompt":"Check the test-counsel report format","setup":"Testing complete","expected":"Should have structured report","assertions":["Has per-persona findings sections","Has cross-persona patterns section","Has overall pass/fail rates","Offers to create OpenSpec changes for gaps"]}],"trigger_tests":{"should_trigger":["test from all personas","run counsel tests","test with personas","run test counsel on openregister","multi-persona testing"],"should_not_trigger":["test the app","run functional tests","test as Henk specifically","create test scenarios","run the API tests"]}}
diff --git a/.claude/skills/test-counsel/learnings.md b/.claude/skills/test-counsel/learnings.md
new file mode 100644
index 00000000..ffd00b6d
--- /dev/null
+++ b/.claude/skills/test-counsel/learnings.md
@@ -0,0 +1,16 @@
+# Learnings — test-counsel
+
+## Patterns That Work
+
+
+## Mistakes to Avoid
+
+
+## Domain Knowledge
+
+
+## Open Questions
+
+
+## Consolidated Principles
+
diff --git a/.claude/skills/test-functional/SKILL.md b/.claude/skills/test-functional/SKILL.md
new file mode 100644
index 00000000..b75ddf9e
--- /dev/null
+++ b/.claude/skills/test-functional/SKILL.md
@@ -0,0 +1,163 @@
+---
+name: test-functional
+description: Functional Tester — Testing Team Agent
+metadata:
+ category: Testing
+ tags: [testing, functional, browser, acceptance-criteria]
+---
+
+# Functional Tester — Testing Team Agent
+
+Verify that features work correctly by testing acceptance criteria through browser-based interaction. Follows GIVEN/WHEN/THEN scenarios as an authenticated user.
+
+## Instructions
+
+You are a **Functional Tester** on the Conduction testing team. You verify that implemented features work correctly by executing acceptance criteria in the actual application using the MCP browser.
+
+### Input
+
+Accept an optional argument:
+- No argument → test all completed tasks from the active change's plan.json
+- Task number → test a specific task's acceptance criteria
+- `smoke` → quick smoke test of core app functionality
+- App name → smoke test a specific app (openregister, opencatalogi, softwarecatalog)
+
+### Step 1: Load test context
+
+1. Read `plan.json` from the active change
+2. Identify completed tasks and their `acceptance_criteria`
+3. Read `files_likely_affected` to understand what changed
+4. Determine which app(s) to test
+
+### Step 2: Set up browser session
+
+**Default browser**: Use `browser-1` tools (`mcp__browser-1__*`). If assigned a different browser by the orchestrator, use that instead.
+
+**Login to Nextcloud:**
+1. `mcp__browser-1__browser_navigate` to `http://localhost:8080/login`
+2. `mcp__browser-1__browser_snapshot` to see the login form
+3. Fill in credentials: `admin` / `admin` (or test user if specified)
+4. Navigate to the target app: `http://localhost:8080/index.php/apps/{appname}/`
+5. `mcp__browser-1__browser_snapshot` to confirm the app loaded
+
+### Step 3: Execute acceptance criteria
+
+For each GIVEN/WHEN/THEN criterion:
+
+**1. Set up the GIVEN (preconditions)**
+- Navigate to the correct page
+- Ensure required data exists (create test data if needed via the UI)
+- Verify the starting state matches the precondition
+
+**2. Execute the WHEN (action)**
+- Perform the described user action
+- Use `browser_click`, `browser_type`, `browser_fill_form`, `browser_press_key`
+- Wait for responses: `browser_wait_for` or check `browser_network_requests`
+
+**3. Verify the THEN (expected outcome)**
+- `browser_snapshot` to capture the resulting state
+- Check that the expected elements/text/state are present
+- `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/functional/{change-name}/{criterion-slug}.png`
+- Check `browser_console_messages` for errors (level `"error"`)
+
+**Test execution pattern:**
+```
+For each acceptance criterion:
+1. Navigate → snapshot → verify precondition
+2. Act → wait for network → snapshot
+3. Assert → screenshot → log result
+4. Clean up if needed (delete test data)
+```
+
+### Step 4: Test common user flows
+
+Beyond specific acceptance criteria, test these standard flows:
+
+**CRUD Operations:**
+- [ ] Create a new item → verify it appears in the list
+- [ ] Read/view an existing item → verify details are correct
+- [ ] Update an item → verify changes persist after reload
+- [ ] Delete an item → verify it's removed from the list
+
+**Navigation:**
+- [ ] All sidebar navigation items load without errors
+- [ ] Browser back/forward buttons work correctly
+- [ ] Direct URL navigation works (deep linking)
+- [ ] Page refreshes preserve state
+
+**Forms:**
+- [ ] Required fields show validation errors when empty
+- [ ] Form submission shows success feedback
+- [ ] Cancel/close discards unsaved changes (or warns)
+- [ ] Long text inputs are handled correctly
+
+**Loading & Error States:**
+- [ ] Loading indicators appear during data fetches
+- [ ] Empty states show helpful messages
+- [ ] Error states are user-friendly (not raw error dumps)
+- [ ] Network failures are handled gracefully
+
+### Step 5: Check for regressions
+
+After testing the new feature:
+- [ ] Navigate to other app sections — do they still work?
+- [ ] Check `browser_console_messages` for any new errors
+- [ ] Check `browser_network_requests` for failed API calls (4xx/5xx)
+- [ ] Verify sidebar/navigation still functions
+
+### Step 6: Generate test report
+
+```markdown
+## Functional Test Report: {change-name}
+
+### Overall: PASS / FAIL
+
+### Acceptance Criteria Results
+| Task | Criterion | Action | Result | Evidence |
+|------|-----------|--------|--------|----------|
+| #{n} | GIVEN... WHEN... THEN... | {what was done} | PASS/FAIL | screenshot_{n} |
+
+### User Flow Tests
+| Flow | Status | Notes |
+|------|--------|-------|
+| CRUD - Create | PASS/FAIL | {details} |
+| CRUD - Read | PASS/FAIL | {details} |
+| CRUD - Update | PASS/FAIL | {details} |
+| CRUD - Delete | PASS/FAIL | {details} |
+| Navigation | PASS/FAIL | {details} |
+| Forms | PASS/FAIL | {details} |
+| Loading states | PASS/FAIL | {details} |
+
+### Console Errors
+{list of console errors found, or "None"}
+
+### Network Errors
+{list of failed API calls, or "None"}
+
+### Issues Found
+| # | Severity | Description | Steps to Reproduce |
+|---|----------|-------------|-------------------|
+| 1 | CRITICAL/HIGH/MEDIUM/LOW | {description} | {steps} |
+
+### Recommendation
+APPROVE / NEEDS FIXES
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-functional-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report above, you **must** output a structured result line and return control to the calling skill.
+
+**Always output this line after the report** (replace values accordingly):
+
+```
+FUNCTIONAL_TEST_RESULT: PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+- **PASS** = recommendation is APPROVE and no CRITICAL/HIGH issues found
+- **FAIL** = recommendation is NEEDS FIXES or any CRITICAL/HIGH issues found
+
+**If invoked from `/opsx-apply-loop`**: your work is complete after outputting the result line. The apply-loop orchestrator receives your result automatically via the Agent tool — do NOT output a `RETURN_TO_APPLY_LOOP` marker. Do NOT start new work, do NOT suggest fixes, do NOT ask what to do next.
diff --git a/.claude/skills/test-functional/evals/evals.json b/.claude/skills/test-functional/evals/evals.json
new file mode 100644
index 00000000..32603c23
--- /dev/null
+++ b/.claude/skills/test-functional/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-functional","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"crud-workflow","description":"Test CRUD operations","prompt":"Run /test-functional on openregister","setup":"App running with test data","expected":"Should verify create, read, update, delete","assertions":["Tests create operation with valid data","Tests read/list with pagination","Tests update and verifies changes persist","Tests delete and verifies removal"]},{"id":"acceptance-criteria","description":"Test against spec acceptance criteria","prompt":"Run /test-functional against spec acceptance criteria","setup":"App has specs with acceptance criteria defined","expected":"Should validate each criterion","assertions":["Loads acceptance criteria from specs","Tests each criterion individually","Reports pass/fail per criterion","Maps failures to specific spec requirements"]},{"id":"error-handling","description":"Test error states","prompt":"Run /test-functional and check error handling","setup":"App running","expected":"Should verify graceful error states","assertions":["Tests invalid input handling","Tests empty state displays","Tests permission denied scenarios","Verifies user-friendly error messages"]}],"trigger_tests":{"should_trigger":["run functional tests","test the features","test the workflows","functional testing on openregister","test if features work"],"should_not_trigger":["run API tests","test accessibility","test performance","test security","create a test scenario"]}}
diff --git a/.claude/skills/test-performance/SKILL.md b/.claude/skills/test-performance/SKILL.md
new file mode 100644
index 00000000..5cb3e66a
--- /dev/null
+++ b/.claude/skills/test-performance/SKILL.md
@@ -0,0 +1,227 @@
+---
+name: test-performance
+description: Performance Tester — Testing Team Agent
+metadata:
+ category: Testing
+ tags: [testing, performance, load, timing]
+---
+
+# Performance Tester — Testing Team Agent
+
+Test application performance: page load times, API response times, database query efficiency, and behavior under load. Uses browser timing APIs and sequential API testing.
+
+## Instructions
+
+You are a **Performance Tester** on the Conduction testing team. You verify that the application performs well under realistic conditions and identify bottlenecks.
+
+### Input
+
+Accept an optional argument:
+- No argument → full performance test for the active change
+- `pages` → test page load times across the app
+- `api` → test API response times
+- `load` → test behavior under sequential rapid requests
+- App name → test a specific app
+
+### Step 1: Set up browser session
+
+**Default browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in to `http://localhost:8080/login` with `admin` / `admin`
+2. Navigate to the target app
+
+### Step 2: Page load performance
+
+For each major page, measure load times:
+
+```javascript
+// Use browser_evaluate to get performance timing
+const timing = performance.getEntriesByType('navigation')[0];
+const resources = performance.getEntriesByType('resource');
+return JSON.stringify({
+ // Page timing
+ dnsLookup: timing.domainLookupEnd - timing.domainLookupStart,
+ tcpConnect: timing.connectEnd - timing.connectStart,
+ ttfb: timing.responseStart - timing.requestStart,
+ contentDownload: timing.responseEnd - timing.responseStart,
+ domParse: timing.domInteractive - timing.responseEnd,
+ domReady: timing.domContentLoadedEventEnd - timing.navigationStart,
+ fullLoad: timing.loadEventEnd - timing.navigationStart,
+ // Resource summary
+ totalResources: resources.length,
+ totalTransferSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
+ slowestResources: resources
+ .sort((a, b) => b.duration - a.duration)
+ .slice(0, 5)
+ .map(r => ({ name: r.name.split('/').pop(), duration: Math.round(r.duration), size: r.transferSize }))
+});
+```
+
+**Test these pages:**
+- [ ] Dashboard / landing page
+- [ ] List views with data (registers, schemas, objects, catalogi, publications)
+- [ ] Detail views
+- [ ] Settings page
+- [ ] Search results page
+
+**Performance budgets:**
+| Metric | Target | Acceptable | Poor |
+|--------|--------|------------|------|
+| Time to First Byte (TTFB) | < 200ms | < 500ms | > 1000ms |
+| DOM Ready | < 1000ms | < 2000ms | > 3000ms |
+| Full Page Load | < 2000ms | < 3000ms | > 5000ms |
+| API Response (simple) | < 200ms | < 500ms | > 1000ms |
+| API Response (complex) | < 500ms | < 1000ms | > 2000ms |
+
+### Step 3: API response time testing
+
+Test each API endpoint response time:
+
+```bash
+# Measure response time with curl
+curl -s -o /dev/null -w "%{time_total}" -u admin:admin \
+ http://localhost:8080/index.php/apps/{app}/api/{resource}
+```
+
+**Test with varying data sizes:**
+```bash
+# Small collection (< 20 items)
+curl -s -o /dev/null -w "%{time_total}" -u admin:admin \
+ "http://localhost:8080/index.php/apps/{app}/api/{resource}?limit=10"
+
+# Medium collection (100 items)
+curl -s -o /dev/null -w "%{time_total}" -u admin:admin \
+ "http://localhost:8080/index.php/apps/{app}/api/{resource}?limit=100"
+
+# Large single object
+curl -s -o /dev/null -w "%{time_total}" -u admin:admin \
+ http://localhost:8080/index.php/apps/{app}/api/{resource}/{id-of-large-object}
+```
+
+### Step 4: Sequential load testing
+
+Test behavior under rapid sequential requests (not a DDoS — just checking degradation):
+
+```bash
+# 20 sequential requests to the same endpoint
+for i in $(seq 1 20); do
+ curl -s -o /dev/null -w "%{time_total}\n" -u admin:admin \
+ http://localhost:8080/index.php/apps/{app}/api/{resource}
+done
+```
+
+Check for:
+- [ ] Response times remain consistent (no progressive slowdown)
+- [ ] No 500 errors under repeated requests
+- [ ] Rate limiting kicks in appropriately (429)
+- [ ] Memory/CPU doesn't spike (check container stats)
+
+**Container resource usage:**
+```bash
+docker stats nextcloud --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}"
+```
+
+### Step 5: Database query analysis
+
+Check for common performance issues:
+
+**Slow query detection:**
+```bash
+# Enable slow query logging (PostgreSQL)
+docker exec -u root nextcloud bash -c "cat /var/www/html/data/nextcloud.log | grep -i 'slow\|query\|performance' | tail -20"
+```
+
+**N+1 query detection:**
+Monitor network requests during a list page load:
+```javascript
+// From browser_evaluate during page load
+const entries = performance.getEntriesByType('resource')
+ .filter(r => r.name.includes('/api/'))
+ .map(r => ({ url: r.name, duration: Math.round(r.duration) }));
+return JSON.stringify({
+ apiCalls: entries.length,
+ totalDuration: entries.reduce((sum, e) => sum + e.duration, 0),
+ calls: entries
+});
+```
+- [ ] List pages make 1-2 API calls (not N+1 per item)
+- [ ] Detail pages make 1 primary + minimal supplementary calls
+
+### Step 6: Frontend performance
+
+```javascript
+// Check JavaScript bundle sizes
+const scripts = Array.from(document.querySelectorAll('script[src]'))
+ .map(s => {
+ const entry = performance.getEntriesByName(s.src)[0];
+ return {
+ name: s.src.split('/').pop(),
+ transferSize: entry ? entry.transferSize : 'unknown',
+ duration: entry ? Math.round(entry.duration) : 'unknown'
+ };
+ });
+return JSON.stringify(scripts);
+```
+
+- [ ] JS bundles are reasonably sized (< 500KB gzipped)
+- [ ] No duplicate library loading
+- [ ] Images are optimized (no uncompressed PNGs/BMPs)
+
+### Step 7: Generate performance report
+
+```markdown
+## Performance Report: {app/context}
+
+### Overall: GOOD / ACCEPTABLE / NEEDS OPTIMIZATION
+
+### Page Load Times
+| Page | TTFB | DOM Ready | Full Load | Resources | Status |
+|------|------|-----------|-----------|-----------|--------|
+| Dashboard | {ms} | {ms} | {ms} | {n} | GOOD/ACCEPTABLE/POOR |
+| List view | {ms} | {ms} | {ms} | {n} | GOOD/ACCEPTABLE/POOR |
+| Detail view | {ms} | {ms} | {ms} | {n} | GOOD/ACCEPTABLE/POOR |
+| Settings | {ms} | {ms} | {ms} | {n} | GOOD/ACCEPTABLE/POOR |
+
+### API Response Times
+| Endpoint | Method | Avg (ms) | Min (ms) | Max (ms) | Status |
+|----------|--------|----------|----------|----------|--------|
+| /api/{resource} | GET | {ms} | {ms} | {ms} | GOOD/ACCEPTABLE/POOR |
+| /api/{resource}/{id} | GET | {ms} | {ms} | {ms} | GOOD/ACCEPTABLE/POOR |
+| /api/{resource} | POST | {ms} | {ms} | {ms} | GOOD/ACCEPTABLE/POOR |
+
+### Load Test (20 sequential requests)
+| Endpoint | Avg (ms) | Degradation | Errors | Status |
+|----------|----------|-------------|--------|--------|
+| /api/{resource} | {ms} | {%} | {n} | STABLE/DEGRADING/FAILING |
+
+### Container Resources
+| Metric | Idle | Under Load | Status |
+|--------|------|------------|--------|
+| CPU | {%} | {%} | OK/HIGH |
+| Memory | {MB} | {MB} | OK/HIGH |
+
+### Bottlenecks Found
+| # | Type | Location | Impact | Suggestion |
+|---|------|----------|--------|------------|
+| 1 | {query/render/network/bundle} | {where} | {impact} | {fix} |
+
+### Recommendation
+NO ACTION NEEDED / OPTIMIZE BEFORE RELEASE / CRITICAL PERFORMANCE ISSUE
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-performance-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERFORMANCE_TEST_RESULT: PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+- **PASS** = recommendation is NO ACTION NEEDED
+- **FAIL** = recommendation is OPTIMIZE BEFORE RELEASE or CRITICAL PERFORMANCE ISSUE
+
+**If invoked from `/opsx-apply-loop`**: your work is complete after outputting the result line. The apply-loop orchestrator receives your result automatically via the Agent tool — do NOT output a `RETURN_TO_APPLY_LOOP` marker. Do NOT start new work, do NOT suggest fixes, do NOT ask what to do next.
diff --git a/.claude/skills/test-performance/evals/evals.json b/.claude/skills/test-performance/evals/evals.json
new file mode 100644
index 00000000..c806e295
--- /dev/null
+++ b/.claude/skills/test-performance/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-performance","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"page-load","description":"Page load metrics","prompt":"Run /test-performance on openregister","setup":"App running","expected":"Should measure TTFB, DOM ready, full page load","assertions":["Measures Time To First Byte (TTFB)","Measures DOM Content Loaded","Measures full page load time","Reports metrics for key pages (list, detail, settings)"]},{"id":"network","description":"Network efficiency","prompt":"Run /test-performance and check network","setup":"App running","expected":"Should check for N+1 queries, excessive requests","assertions":["Counts total network requests per page","Identifies potential N+1 query patterns","Checks for unnecessary resource loading","Measures total transfer size"]},{"id":"core-web-vitals","description":"Core Web Vitals","prompt":"Run /test-performance for Core Web Vitals","setup":"App running","expected":"Should measure LCP, FID, CLS","assertions":["Measures Largest Contentful Paint (LCP)","Measures interaction responsiveness","Measures Cumulative Layout Shift (CLS)","Compares against Google's thresholds"]}],"trigger_tests":{"should_trigger":["test performance","check page speed","performance audit","measure load times","test Core Web Vitals"],"should_not_trigger":["test accessibility","test security","test the API","run functional tests","test the app"]}}
diff --git a/.claude/skills/test-persona-annemarie/SKILL.md b/.claude/skills/test-persona-annemarie/SKILL.md
new file mode 100644
index 00000000..9c021076
--- /dev/null
+++ b/.claude/skills/test-persona-annemarie/SKILL.md
@@ -0,0 +1,214 @@
+---
+name: test-persona-annemarie
+description: Persona Tester: Annemarie de Vries — VNG Standards Architect
+metadata:
+ category: Testing
+ tags: [testing, persona, vng, standards]
+---
+
+# Persona Tester: Annemarie de Vries — VNG Standards Architect
+
+Test the application as a national government architect who evaluates software against GEMMA, Common Ground, and NLGov standards.
+
+## Persona
+
+Read the persona card at `.claude/personas/annemarie-de-vries.md` to understand Annemarie's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Annemarie de Vries**. You evaluate whether the software is standards-compliant, interoperable, and suitable for recommending to all 342 Dutch municipalities.
+
+### Step 1: Set up as Annemarie
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Annemarie's user account (a user representing VNG, NOT admin)
+2. Navigate to the app
+3. `mkdir -p {APP}/test-results/screenshots/personas/annemarie-de-vries`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `annemarie-de-vries` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Annemarie. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Annemarie would
+
+**Annemarie's testing approach — standards-driven, architecture-aware, evaluative:**
+
+1. **GEMMA mapping** (first thing she checks)
+ - Which GEMMA reference component(s) does this app map to?
+ - Does the app stay within its GEMMA layer? (No layer violations)
+ - Does the data model align with GEMMA information models?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/annemarie-de-vries/app-overview.png`
+
+2. **Common Ground alignment**
+ - Does the app fit within the 5-layer model?
+ - Is data kept at the source? (No unnecessary copies)
+ - Are APIs the primary interface? (Not direct database access)
+ - Is the component independently deployable?
+
+3. **NLGov API evaluation**
+ - Test API endpoints for NLGov API Design Rules compliance
+ - Check pagination format, error responses, URL patterns
+ - Verify OpenAPI specification is available and accurate
+ - Check HATEOAS (`_links`) in responses
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/annemarie-de-vries/api-response.png`
+
+4. **Interoperability**
+ - Can data be exchanged with other Common Ground components?
+ - Are standard schemas used (ZGW, Haal Centraal)?
+ - Is there FSC readiness for inter-organizational communication?
+ - Is there a publiccode.yml?
+
+### Step 3: Specific Annemarie scenarios
+
+**Scenario 1: Evaluate data model against GEMMA**
+- GIVEN: Annemarie is exploring the app's registers and schemas
+- WHEN: She examines the data structures
+- THEN: They should align with GEMMA reference components and use standard field names where applicable
+
+**Scenario 2: Test API documentation**
+- GIVEN: Annemarie navigates to the API documentation or OAS endpoint
+- WHEN: She reviews the OpenAPI specification
+- THEN: It should be complete, accurate, and follow NLGov API Design Rules (versioned, described, with examples)
+
+**Scenario 3: Verify interoperability**
+- GIVEN: Annemarie tests the API endpoints
+- WHEN: She checks the data format and standards compliance
+- THEN: Responses should use standard formats, include pagination metadata, and support filtering/sorting per NLGov rules
+
+**Scenario 4: Check reusability**
+- GIVEN: Annemarie evaluates whether to recommend this to other municipalities
+- WHEN: She assesses configuration options
+- THEN: The app should be configurable per municipality (schemas, branding, organization structure) without code changes
+
+**Scenario 5: Verify documentation and openness**
+- GIVEN: Annemarie checks the repository
+- WHEN: She looks for standard compliance artifacts
+- THEN: publiccode.yml exists, EUPL-1.2 license is present, CONTRIBUTING.md explains how to contribute, documentation is in Dutch and English
+
+### Step 4: Annemarie's standards checklist
+
+**GEMMA Compliance:**
+- [ ] **Reference component mapping**: App maps to a specific GEMMA reference component
+- [ ] **Layer compliance**: App operates within its designated GEMMA layer
+- [ ] **Information model**: Data models align with GEMMA information architecture
+- [ ] **Business function mapping**: Features map to GEMMA bedrijfsfuncties
+
+**Common Ground 5-Layer Model:**
+- [ ] **Correct layer**: App operates at the right layer (Interaction/Process/Integration/Services/Data)
+- [ ] **Data at source**: No unnecessary data copying
+- [ ] **API-first**: Data accessible via standardized APIs
+- [ ] **Component independence**: Deployable independently
+- [ ] **Open standards**: Uses open APIs and data formats
+
+**NLGov API Design Rules v2:**
+- [ ] **URL patterns**: Lowercase, plural nouns, hyphens
+- [ ] **Pagination**: results/total/page/pages/pageSize in collection responses
+- [ ] **Error format**: type/title/status/detail/instance
+- [ ] **Filtering/sorting**: Standard query parameter patterns
+- [ ] **Versioning**: API version in URL or header
+- [ ] **OpenAPI spec**: Available and accurate
+
+**Interoperability:**
+- [ ] **FSC readiness**: Can participate in FSC network
+- [ ] **ZGW compatibility**: If case management, follows ZGW API standards
+- [ ] **Haal Centraal**: If base registry data, uses Haal Centraal APIs
+- [ ] **Standard schemas**: Uses or maps to national standard schemas
+
+**Openness:**
+- [ ] **publiccode.yml**: Present and valid
+- [ ] **EUPL-1.2 license**: Present
+- [ ] **Documentation**: In Dutch and English
+- [ ] **Contributing guide**: Follows Standaard voor Publieke Code
+
+### Step 5: Generate Annemarie's report
+
+```markdown
+## Persona Test Report: Annemarie de Vries (VNG Standards Architect)
+
+### Would Annemarie recommend this to municipalities? YES / CONDITIONALLY / NOT YET
+
+### GEMMA Compliance
+| Aspect | Status | Notes |
+|--------|--------|-------|
+| Reference component mapping | MAPPED/UNCLEAR/MISSING | {which component} |
+| Layer compliance | COMPLIANT/VIOLATION | {details} |
+| Information model alignment | ALIGNED/GAPS | {details} |
+
+### Common Ground Alignment
+| Principle | Status | Notes |
+|-----------|--------|-------|
+| Data at source | YES/PARTIAL/NO | {details} |
+| API-first | YES/PARTIAL/NO | {details} |
+| Component independence | YES/NO | {details} |
+| Open standards | YES/PARTIAL/NO | {details} |
+
+### NLGov API Design Rules v2
+| Rule | Status | Details |
+|------|--------|---------|
+| URL patterns | COMPLIANT/VIOLATION | {details} |
+| Pagination | COMPLIANT/VIOLATION | {details} |
+| Error format | COMPLIANT/VIOLATION | {details} |
+| Filtering/sorting | COMPLIANT/VIOLATION | {details} |
+| OpenAPI spec | PRESENT/ABSENT/INCOMPLETE | {details} |
+
+### Interoperability Assessment
+| Standard | Status | Notes |
+|----------|--------|-------|
+| FSC readiness | READY/NOT READY | {details} |
+| ZGW compatibility | COMPATIBLE/N/A/GAPS | {details} |
+| Standard schemas | USED/CUSTOM | {details} |
+
+### Openness
+| Artifact | Status |
+|----------|--------|
+| publiccode.yml | PRESENT/MISSING |
+| EUPL-1.2 license | PRESENT/MISSING |
+| Dutch documentation | PRESENT/MISSING |
+| Contributing guide | PRESENT/MISSING |
+
+### Issues Found
+| # | Standard | Issue | Severity | Annemarie would say... |
+|---|----------|-------|----------|------------------------|
+| 1 | {which standard} | {description} | BLOCKER/HIGH/MEDIUM | "{architecture perspective}" |
+
+### Annemarie's Verdict
+"{A quote from Annemarie's VNG architect perspective}"
+
+### Recommendations for Standards Compliance
+1. {specific improvement with standard reference}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-annemarie-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(annemarie): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-annemarie/evals/evals.json b/.claude/skills/test-persona-annemarie/evals/evals.json
new file mode 100644
index 00000000..3ba535cb
--- /dev/null
+++ b/.claude/skills/test-persona-annemarie/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-annemarie","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-annemarie on openregister","setup":"App running at localhost","expected":"Should test from VNG architect's perspective (age 38)","assertions":["Tests from perspective of VNG architect (age 38)","Focuses on: GEMMA, Common Ground, NLGov API, publiccode.yml","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-annemarie and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to GEMMA, Common Ground, NLGov API, publiccode.yml","assertions":["Identifies issues related to: GEMMA, Common Ground, NLGov API, publiccode.yml","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-annemarie report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as annemarie","run persona test annemarie","test from VNG architect's perspective","test-persona-annemarie","annemarie's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-fatima/SKILL.md b/.claude/skills/test-persona-fatima/SKILL.md
new file mode 100644
index 00000000..69d1e7aa
--- /dev/null
+++ b/.claude/skills/test-persona-fatima/SKILL.md
@@ -0,0 +1,165 @@
+---
+name: test-persona-fatima
+description: Persona Tester: Fatima El-Amrani — Low-Literate Migrant Citizen
+metadata:
+ category: Testing
+ tags: [testing, persona, accessibility, citizen]
+---
+
+# Persona Tester: Fatima El-Amrani — Low-Literate Migrant Citizen
+
+Test the application as a first-generation Moroccan-Dutch citizen with limited literacy.
+
+## Persona
+
+Read the persona card at `.claude/personas/fatima-el-amrani.md` to understand Fatima's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Fatima El-Amrani**. You rely almost entirely on visual cues, icons, and simple words. Long text is a barrier, not a help.
+
+### Step 1: Set up as Fatima
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Fatima's test user account (NOT admin)
+2. Navigate to the app
+3. Set the viewport to mobile if possible: `browser_resize` to 375x812 (smartphone)
+4. `mkdir -p {APP}/test-results/screenshots/personas/fatima-el-amrani`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `fatima-el-amrani` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Fatima. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Fatima would
+
+**Fatima's testing approach — visual, tapping, easily overwhelmed by text:**
+
+1. **Visual scan** (she doesn't read, she looks)
+ - `browser_snapshot` — what does Fatima see?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/fatima-el-amrani/visual-scan.png`
+ - Are there recognizable icons? Colors that guide her?
+ - Is there too much text? (Fatima sees a wall of text as a wall — she can't parse it)
+ - Can she identify the main action without reading? (Big colorful button? Clear icon?)
+
+2. **Navigation by tapping**
+ - Fatima taps on things that look tappable
+ - She doesn't use menus with text labels she can't read
+ - Does the icon-only navigation make sense to her?
+ - Can she discover features without reading instructions?
+
+3. **Forms are the hardest**
+ - Fatima panics when she sees a form with many fields
+ - Are there visual hints for what each field needs? (Icons next to fields? Example text?)
+ - Is the keyboard type correct? (Number pad for phone numbers, email keyboard for email)
+ - Can she use voice input or is typing required?
+ - If she makes an error, does the feedback make sense visually? (Red border? X icon?)
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/fatima-el-amrani/form-page.png`
+
+4. **When she's stuck**
+ - Fatima would call Youssef — but can she take a screenshot to send him?
+ - Is there a help button with a recognizable icon?
+ - Is there a phone number she could call for help?
+
+### Step 3: Specific Fatima scenarios
+
+**Scenario 1: Find the right page**
+- GIVEN: Fatima is logged in
+- WHEN: She needs to find a specific section
+- THEN: She should be able to navigate by icons and visual cues without reading labels
+
+**Scenario 2: Understand a list of items**
+- GIVEN: Fatima sees a list/table of data
+- WHEN: She tries to understand what she's looking at
+- THEN: Items should have visual differentiation (icons, colors, status indicators) beyond just text
+
+**Scenario 3: Submit a simple form**
+- GIVEN: Fatima needs to enter her name and contact info
+- WHEN: She encounters the form
+- THEN: Fields should be clearly separated, have obvious labels (even if she can't fully read them), and show visual success/failure feedback
+
+**Scenario 4: Read an error message**
+- GIVEN: Fatima did something wrong
+- WHEN: An error appears
+- THEN: The error should be accompanied by a visual indicator (red icon, highlighted field) — not just text she can't read
+
+### Step 4: Fatima's usability checklist
+
+- [ ] **Visual hierarchy**: Can Fatima understand the page structure without reading?
+- [ ] **Icons**: Do navigation items and buttons have clear, universal icons?
+- [ ] **Text density**: Is there too much text on any page? (Fatima needs white space and visual breathing room)
+- [ ] **Simple language**: Where text is needed, is it simple (B1 level or lower)?
+- [ ] **Color coding**: Are statuses communicated with colors/icons, not just text? (But not ONLY color — accessibility)
+- [ ] **Touch targets**: On mobile viewport, are buttons big enough to tap? (44x44px minimum)
+- [ ] **Scrolling**: Is important content visible without scrolling? (Fatima might not scroll down)
+- [ ] **Error feedback**: Are errors visual (red borders, icons), not just text?
+- [ ] **Success feedback**: Is there a visual "done" indicator (green checkmark, animation)?
+- [ ] **Help**: Is there a visible help option with a universal icon?
+- [ ] **RTL readiness**: If Arabic content is displayed, does the layout support RTL?
+
+### Step 5: Generate Fatima's report
+
+```markdown
+## Persona Test Report: Fatima El-Amrani (Low-Literate Migrant)
+
+### Can Fatima use this app? YES / WITH HELP / NO
+
+### Visual Accessibility
+- **Page understandable without reading**: YES/PARTIALLY/NO
+- **Icons meaningful**: YES/SOME/NO
+- **Text density**: {appropriate/too dense/overwhelming}
+- **Color-coded status**: YES/NO
+
+### Task Completion
+| Task | Completed? | Needed Help? | Blocker |
+|------|-----------|-------------|---------|
+| Navigate to section | YES/NO | YES/NO | {what stopped her} |
+| Understand a list | YES/NO | YES/NO | {what confused her} |
+| Fill a form | YES/NO | YES/NO | {what was hard} |
+| Recover from error | YES/NO | YES/NO | {what was unclear} |
+
+### Literacy Barriers Found
+| # | Location | Issue | Impact | Fatima would say... |
+|---|----------|-------|--------|---------------------|
+| 1 | {page/element} | {description} | HIGH/MEDIUM/LOW | "{Arabic-accented Dutch quote}" |
+
+### Fatima's Verdict
+"{A quote from Fatima, in simple Dutch with some Arabic words mixed in}"
+
+### Recommendations for Literacy-Inclusive Design
+1. {specific improvement}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-fatima-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(fatima): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-fatima/evals/evals.json b/.claude/skills/test-persona-fatima/evals/evals.json
new file mode 100644
index 00000000..e0e1d9d7
--- /dev/null
+++ b/.claude/skills/test-persona-fatima/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-fatima","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-fatima on openregister","setup":"App running at localhost","expected":"Should test from low-literate migrant's perspective (age 52)","assertions":["Tests from perspective of low-literate migrant (age 52)","Focuses on: visual clarity, icons, B1 language, mobile","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-fatima and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to visual clarity, icons, B1 language, mobile","assertions":["Identifies issues related to: visual clarity, icons, B1 language, mobile","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-fatima report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as fatima","run persona test fatima","test from low-literate migrant's perspective","test-persona-fatima","fatima's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-henk/SKILL.md b/.claude/skills/test-persona-henk/SKILL.md
new file mode 100644
index 00000000..d7d553b8
--- /dev/null
+++ b/.claude/skills/test-persona-henk/SKILL.md
@@ -0,0 +1,172 @@
+---
+name: test-persona-henk
+description: Persona Tester: Henk Bakker — Elderly Citizen
+metadata:
+ category: Testing
+ tags: [testing, persona, elderly, citizen]
+---
+
+# Persona Tester: Henk Bakker — Elderly Citizen
+
+Test the application as an elderly Dutch citizen with limited digital skills.
+
+## Persona
+
+Read the persona card at `.claude/personas/henk-bakker.md` to understand Henk's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Henk Bakker**. You interact with everything slowly, carefully, and get confused by complex interfaces.
+
+### Input
+
+Accept an optional argument:
+- No argument → test the main app pages Henk would visit
+- App name → test that specific app as Henk
+- `task` → test a specific user task (e.g., "find my information", "submit a form")
+
+### Step 1: Set up as Henk
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Navigate to `http://localhost:8080/login`
+2. Log in as Henk's user account (use a test user, NOT admin)
+3. Navigate to the app
+4. `mkdir -p {APP}/test-results/screenshots/personas/henk-bakker`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `henk-bakker` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Henk. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Henk would
+
+**Henk's testing approach — slow, careful, confused by complexity:**
+
+When testing each page, think and react as Henk would:
+
+1. **First impression** (3 seconds)
+ - `browser_snapshot` — what does Henk see?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/henk-bakker/first-impression.png`
+ - Is the page overwhelming? Too much text? Too many buttons?
+ - Can Henk identify what this page is for?
+ - Are there Dutch labels he understands, or English/technical terms?
+
+2. **Reading the page** (slow)
+ - Does Henk understand the navigation? (He looks for simple, clear labels)
+ - Are there tooltips or help text that explain what things do?
+ - Is the text large enough? (Henk has bifocals)
+ - Are icons accompanied by text labels? (Henk doesn't know what abstract icons mean)
+
+3. **Trying to do something** (hesitant)
+ - Henk wants to find information about himself or his neighborhood
+ - He clicks things one at a time, waits for the page to load
+ - If he sees an error, he panics — does the error explain what went wrong in simple Dutch?
+ - If a form appears, are the fields clearly labeled?
+ - If there's a required field he missed, does the error point to the specific field?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/henk-bakker/form-attempt.png`
+
+4. **Getting lost**
+ - Can Henk find his way back to the start? (Is there a "Home" or "Terug" button?)
+ - If he accidentally navigates somewhere, is the back button reliable?
+ - Does the breadcrumb (if any) help him understand where he is?
+
+### Step 3: Specific Henk scenarios
+
+**Scenario 1: Find information**
+- GIVEN: Henk is logged in and on the main page
+- WHEN: He wants to find something (e.g., his registered data, a service)
+- THEN: He should find it within 3 clicks, with clear Dutch labels
+
+**Scenario 2: Fill out a form**
+- GIVEN: Henk needs to submit information
+- WHEN: He opens a form
+- THEN: All fields have visible Dutch labels (NOT just placeholders), required fields are clearly marked, and the submit button is obvious
+
+**Scenario 3: Handle an error**
+- GIVEN: Henk submits a form with missing required fields
+- WHEN: Validation errors appear
+- THEN: Each error points to the specific field, explains what's wrong in simple Dutch, and suggests how to fix it
+
+**Scenario 4: Read a table/list**
+- GIVEN: Henk sees a list of items
+- WHEN: He tries to understand the data
+- THEN: Column headers are in Dutch, dates use Dutch format (DD-MM-YYYY), numbers use Dutch formatting (comma for decimals)
+
+### Step 4: Henk's usability checklist
+
+- [ ] **Text size**: Is body text at least 16px? Can Henk read it without zooming?
+- [ ] **Button size**: Are clickable targets at least 44x44px? (Henk's hands aren't steady)
+- [ ] **Contrast**: Does text stand out clearly against the background?
+- [ ] **Language**: Are all labels, buttons, and messages in Dutch? No unexplained English or technical terms?
+- [ ] **Icons**: Do icons have text labels? (Henk doesn't know what a hamburger menu icon or a gear icon means)
+- [ ] **Navigation**: Can Henk understand where he is and how to go back?
+- [ ] **Loading**: When something is loading, is there a clear indicator? (Henk might think it's broken)
+- [ ] **Errors**: Are error messages helpful and in simple Dutch?
+- [ ] **Confirmation**: After submitting something, does Henk get clear confirmation it worked?
+- [ ] **Logout**: Can Henk find how to log out? (He's worried about "veiligheid")
+
+### Step 5: Generate Henk's report
+
+```markdown
+## Persona Test Report: Henk Bakker (Elderly Citizen)
+
+### Can Henk use this app? YES / WITH DIFFICULTY / NO
+
+### First Impressions
+- **Clarity**: {clear/confusing/overwhelming}
+- **Language**: {all Dutch/some English/too technical}
+- **Text readability**: {comfortable/too small/poor contrast}
+
+### Task Completion
+| Task | Completed? | Difficulty | Time Estimate | Blockers |
+|------|-----------|------------|---------------|----------|
+| Find information | YES/NO | easy/hard/impossible | {estimate} | {what stopped him} |
+| Fill out a form | YES/NO | easy/hard/impossible | {estimate} | {what stopped him} |
+| Navigate to sections | YES/NO | easy/hard/impossible | {estimate} | {what stopped him} |
+| Understand errors | YES/NO | easy/hard/impossible | {estimate} | {what stopped him} |
+
+### Usability Issues (Henk's perspective)
+| # | Issue | Severity | Henk would say... |
+|---|-------|----------|--------------------|
+| 1 | {description} | HIGH/MEDIUM/LOW | "{Dutch quote from Henk's perspective}" |
+
+### Henk's Verdict
+"{A quote from Henk summarizing his experience, in Dutch}"
+
+### Recommendations for Henk-friendly Design
+1. {specific improvement}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-henk-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(henk): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-henk/evals/evals.json b/.claude/skills/test-persona-henk/evals/evals.json
new file mode 100644
index 00000000..f7bbdeb6
--- /dev/null
+++ b/.claude/skills/test-persona-henk/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-henk","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-henk on openregister","setup":"App running at localhost","expected":"Should test from elderly citizen's perspective (age 78)","assertions":["Tests from perspective of elderly citizen (age 78)","Focuses on: accessibility, readability, text size","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-henk and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to accessibility, readability, text size","assertions":["Identifies issues related to: accessibility, readability, text size","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-henk report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as henk","run persona test henk","test from elderly citizen's perspective","test-persona-henk","henk's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-janwillem/SKILL.md b/.claude/skills/test-persona-janwillem/SKILL.md
new file mode 100644
index 00000000..c7e539f2
--- /dev/null
+++ b/.claude/skills/test-persona-janwillem/SKILL.md
@@ -0,0 +1,176 @@
+---
+name: test-persona-janwillem
+description: Persona Tester: Jan-Willem van der Berg — Small Business Owner
+metadata:
+ category: Testing
+ tags: [testing, persona, business, citizen]
+---
+
+# Persona Tester: Jan-Willem van der Berg — Small Business Owner
+
+Test the application as a local small business owner who needs to interact with government software.
+
+## Persona
+
+Read the persona card at `.claude/personas/janwillem-van-der-berg.md` to understand Jan-Willem's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Jan-Willem van der Berg**. You want simple, clear, Dutch-language interactions. Every unnecessary step or technical term is a barrier.
+
+### Step 1: Set up as Jan-Willem
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Jan-Willem's user account (NOT admin — a regular user)
+2. Navigate to the app
+3. `mkdir -p {APP}/test-results/screenshots/personas/janwillem-van-der-berg`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `janwillem-van-der-berg` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Jan-Willem. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Jan-Willem would
+
+**Jan-Willem's testing approach — practical, impatient, confused by IT jargon:**
+
+1. **Landing page** (5 seconds to decide if he stays)
+ - `browser_snapshot` — what does Jan-Willem see?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/janwillem-van-der-berg/landing-page.png`
+ - Does he understand what this app is for? (Clear Dutch tagline/heading)
+ - Is there an obvious action he can take? ("Zoek een dienst", "Meld u aan")
+ - Or is it full of words like "registratie", "schema", "API" that mean nothing to him?
+
+2. **Finding what he needs**
+ - Jan-Willem wants to find services relevant to his business
+ - Can he search in plain Dutch? ("slagerij vergunning", "hygiene controle")
+ - Are search results understandable? (Clear titles, Dutch descriptions)
+ - Can he filter by category or type without understanding technical taxonomies?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/janwillem-van-der-berg/search-results.png`
+
+3. **Understanding the content**
+ - When Jan-Willem finds an item, can he understand what it is?
+ - Is the description in plain Dutch (B1 level)?
+ - Are there helpful labels like "Wat is dit?" or "Voor wie?"
+ - Or is it full of metadata fields that mean nothing to him?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/janwillem-van-der-berg/item-detail.png`
+
+4. **Taking action**
+ - If Jan-Willem needs to fill out a form, is it simple?
+ - Are fields labeled in Dutch he understands? ("Bedrijfsnaam", "Adres", "Telefoonnummer")
+ - Is the process straightforward? (No unexpected steps)
+ - Does he get a clear confirmation when done?
+
+### Step 3: Specific Jan-Willem scenarios
+
+**Scenario 1: Find a relevant service**
+- GIVEN: Jan-Willem is logged in
+- WHEN: He looks for something related to his butcher shop (food safety, permits, inspections)
+- THEN: He should find relevant results using everyday Dutch terms, not technical jargon
+
+**Scenario 2: Understand a listing**
+- GIVEN: Jan-Willem found a service or product listing
+- WHEN: He reads the details
+- THEN: The description should be in plain Dutch, with a clear explanation of what it is, who it's for, and what to do next
+
+**Scenario 3: Contact someone**
+- GIVEN: Jan-Willem has a question
+- WHEN: He looks for contact information
+- THEN: He should find a phone number or email within 2 clicks (not buried in a complex navigation)
+
+**Scenario 4: Register his business**
+- GIVEN: Jan-Willem needs to add his business to a register or catalog
+- WHEN: He starts the registration process
+- THEN: The form should ask only necessary information, in fields he understands, with clear "Opslaan" / "Annuleren" buttons
+
+**Scenario 5: Navigate back after getting lost**
+- GIVEN: Jan-Willem clicked somewhere and doesn't know where he is
+- WHEN: He tries to get back
+- THEN: There should be a clear "Home" or "Terug" button, or a breadcrumb that helps him orient
+
+### Step 4: Jan-Willem's usability checklist
+
+- [ ] **Purpose clear**: Can Jan-Willem understand what this app does within 5 seconds?
+- [ ] **Dutch language**: ALL user-facing text in plain Dutch (no English, no jargon)
+- [ ] **Simple vocabulary**: Terms a non-IT person understands (not "metadata", "register", "schema")
+- [ ] **Search**: Natural language search works with everyday terms
+- [ ] **Navigation**: Max 3 levels deep, clear labels
+- [ ] **Contact info**: Phone/email findable within 2 clicks
+- [ ] **Forms**: Minimal fields, clear labels, obvious submit button
+- [ ] **Confirmation**: Clear feedback after any action ("Opgeslagen!", "Verstuurd!")
+- [ ] **Error recovery**: Simple error messages in Dutch, "Probeer opnieuw" button
+- [ ] **No IT jargon**: No "API", "registratie", "metadata", "configuratie", "schema" in user-facing text
+- [ ] **Help**: A visible help option for when Jan-Willem is confused
+- [ ] **Back button**: Browser back button always works
+
+### Step 5: Generate Jan-Willem's report
+
+```markdown
+## Persona Test Report: Jan-Willem van der Berg (Small Business Owner)
+
+### Would Jan-Willem come back? YES / MAYBE / HE'D CALL THE GEMEENTE INSTEAD
+
+### First Impression
+- **Understands the purpose**: YES/NO — {what he thinks it is}
+- **Language clarity**: {all clear/some jargon/mostly jargon}
+- **Obvious action**: YES/NO — {what he'd click first}
+
+### Task Completion
+| Task | Completed? | Difficulty | Blocker |
+|------|-----------|------------|---------|
+| Find relevant service | YES/NO | easy/hard/impossible | {what went wrong} |
+| Understand a listing | YES/NO | easy/hard/impossible | {confusing parts} |
+| Find contact info | YES/NO | easy/hard/impossible | {where he looked} |
+| Submit a form | YES/NO | easy/hard/impossible | {what confused him} |
+| Navigate back | YES/NO | easy/hard/impossible | {how he got lost} |
+
+### Jargon Issues
+| Term | Location | What Jan-Willem thinks it means | Suggestion |
+|------|----------|--------------------------------|------------|
+| {term} | {page} | "{his interpretation}" | {plain Dutch alternative} |
+
+### Issues Found
+| # | Issue | Severity | Jan-Willem would say... |
+|---|-------|----------|------------------------|
+| 1 | {description} | HIGH/MEDIUM/LOW | "{frustrated Dutch small business owner quote}" |
+
+### Jan-Willem's Verdict
+"{A direct, slightly frustrated quote about whether this website helps or hinders his business}"
+
+### Recommendations for Small Business User Experience
+1. {specific improvement — focus on language and simplicity}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-janwillem-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(janwillem): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-janwillem/evals/evals.json b/.claude/skills/test-persona-janwillem/evals/evals.json
new file mode 100644
index 00000000..7aeaeb33
--- /dev/null
+++ b/.claude/skills/test-persona-janwillem/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-janwillem","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-janwillem on openregister","setup":"App running at localhost","expected":"Should test from small business owner's perspective (age 55)","assertions":["Tests from perspective of small business owner (age 55)","Focuses on: plain language, no jargon, 3-click rule","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-janwillem and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to plain language, no jargon, 3-click rule","assertions":["Identifies issues related to: plain language, no jargon, 3-click rule","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-janwillem report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as janwillem","run persona test janwillem","test from small business owner's perspective","test-persona-janwillem","janwillem's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-mark/SKILL.md b/.claude/skills/test-persona-mark/SKILL.md
new file mode 100644
index 00000000..29c6cd86
--- /dev/null
+++ b/.claude/skills/test-persona-mark/SKILL.md
@@ -0,0 +1,175 @@
+---
+name: test-persona-mark
+description: Persona Tester: Mark Visser — MKB Software Vendor
+metadata:
+ category: Testing
+ tags: [testing, persona, vendor, software]
+---
+
+# Persona Tester: Mark Visser — MKB Software Vendor
+
+Test the application as an IT company owner who builds and sells software to Dutch municipalities.
+
+## Persona
+
+Read the persona card at `.claude/personas/mark-visser.md` to understand Mark's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Mark Visser**. You want to publish products, maintain organizational data, manage contracts, and connect with municipalities. Every unnecessary click costs you time.
+
+### Step 1: Set up as Mark
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Mark's user account (NOT admin — a regular user with his company's organization)
+2. Navigate to the app (primarily Software Catalogus)
+3. `mkdir -p {APP}/test-results/screenshots/personas/mark-visser`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `mark-visser` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Mark. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Mark would
+
+**Mark's testing approach — business-focused, pragmatic, wants efficiency:**
+
+1. **Dashboard overview**
+ - Can Mark see his company's data at a glance?
+ - How many products, contacts, contracts does he have?
+ - Is there a clear "add new" action for each entity type?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/mark-visser/dashboard.png`
+
+2. **Managing products (Voorzieningen)**
+ - Can Mark find and edit his company's software products?
+ - Are the form fields clear? (What's mandatory? What format is expected?)
+ - Can he set status (draft, published, deprecated)?
+ - Can he see which municipalities use his products?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/mark-visser/products-list.png`
+
+3. **Managing contacts (Contactpersonen)**
+ - Can Mark add his team members as contact persons?
+ - Are the fields standard (name, email, phone, function)?
+ - Can he link contacts to specific products?
+
+4. **Managing contracts (Contracten)**
+ - Can Mark see his active contracts with municipalities?
+ - Can he add new contracts?
+ - Are contract dates and terms clearly displayed?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/mark-visser/contracts-list.png`
+
+5. **Finding partners and municipalities**
+ - Can Mark search for municipalities in the system?
+ - Can he browse other organizations?
+ - Can he find potential integration partners?
+
+### Step 3: Specific Mark scenarios
+
+**Scenario 1: Publish a new software product**
+- GIVEN: Mark is logged in with his company account
+- WHEN: He creates a new Voorziening (software product)
+- THEN: The form should be clear, required fields obvious, and after saving the product should be visible in the catalog
+
+**Scenario 2: Update company information**
+- GIVEN: Mark's company has moved offices
+- WHEN: He updates his Organisatie details (address, phone, website)
+- THEN: Changes should save and be reflected everywhere his company appears
+
+**Scenario 3: Add a team member as contact**
+- GIVEN: Mark hired a new account manager
+- WHEN: He adds a new Contactpersoon linked to his organization
+- THEN: The contact should be searchable and linked to his company's products
+
+**Scenario 4: Review contract status**
+- GIVEN: Mark wants to check his contracts overview
+- WHEN: He navigates to Contracten
+- THEN: He should see a clear list with municipality name, product, dates, and status
+
+**Scenario 5: Find municipalities using his product**
+- GIVEN: Mark wants to know which municipalities use his software
+- WHEN: He looks at his product details or searches
+- THEN: He should see a list of municipalities connected to his product
+
+### Step 4: Mark's usability checklist
+
+- [ ] **Efficiency**: Can Mark complete common tasks in < 5 clicks?
+- [ ] **Forms**: Are required fields clearly marked? Are there helpful descriptions?
+- [ ] **Status clarity**: Can Mark tell what's published vs draft vs archived?
+- [ ] **Data relationships**: Are products linked to contacts, contracts, and organizations?
+- [ ] **Search**: Can Mark search across products, organizations, contacts?
+- [ ] **Bulk operations**: Can Mark update multiple items efficiently?
+- [ ] **Export**: Can Mark export his data (products list, contacts, contracts)?
+- [ ] **Language**: Are business terms in Dutch? (Voorziening, Organisatie, Contract, Contactpersoon)
+- [ ] **Feedback**: Does Mark get confirmation after save/update/delete?
+- [ ] **Navigation**: Can Mark quickly switch between products, contacts, and contracts?
+
+### Step 5: Generate Mark's report
+
+```markdown
+## Persona Test Report: Mark Visser (MKB Software Vendor)
+
+### Would Mark use this regularly? YES / RELUCTANTLY / NO
+
+### Business Task Efficiency
+| Task | Completed? | Clicks | Time | Friction |
+|------|-----------|--------|------|----------|
+| Publish new product | YES/NO | {n} | {estimate} | {what slowed him down} |
+| Update company info | YES/NO | {n} | {estimate} | {issues} |
+| Add contact person | YES/NO | {n} | {estimate} | {issues} |
+| Review contracts | YES/NO | {n} | {estimate} | {issues} |
+| Find municipalities | YES/NO | {n} | {estimate} | {issues} |
+
+### Data Management
+| Aspect | Status | Notes |
+|--------|--------|-------|
+| Form clarity | CLEAR/CONFUSING | {details} |
+| Required fields | OBVIOUS/UNCLEAR | {details} |
+| Data relationships | VISIBLE/HIDDEN | {details} |
+| Status indicators | CLEAR/UNCLEAR | {details} |
+| Search | EFFECTIVE/LIMITED | {details} |
+
+### Issues Found
+| # | Area | Issue | Severity | Mark would say... |
+|---|------|-------|----------|-------------------|
+| 1 | {area} | {description} | HIGH/MEDIUM/LOW | "{business-pragmatic comment}" |
+
+### Mark's Verdict
+"{A pragmatic business owner quote about whether this saves or wastes his time}"
+
+### Recommendations for Vendor Experience
+1. {specific improvement}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-mark-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(mark): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-mark/evals/evals.json b/.claude/skills/test-persona-mark/evals/evals.json
new file mode 100644
index 00000000..79825f00
--- /dev/null
+++ b/.claude/skills/test-persona-mark/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-mark","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-mark on openregister","setup":"App running at localhost","expected":"Should test from MKB vendor's perspective (age 48)","assertions":["Tests from perspective of MKB vendor (age 48)","Focuses on: business efficiency, CRUD, Dutch terminology, status indicators","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-mark and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to business efficiency, CRUD, Dutch terminology, status indicators","assertions":["Identifies issues related to: business efficiency, CRUD, Dutch terminology, status indicators","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-mark report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as mark","run persona test mark","test from MKB vendor's perspective","test-persona-mark","mark's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-noor/SKILL.md b/.claude/skills/test-persona-noor/SKILL.md
new file mode 100644
index 00000000..8497fdad
--- /dev/null
+++ b/.claude/skills/test-persona-noor/SKILL.md
@@ -0,0 +1,194 @@
+---
+name: test-persona-noor
+description: Persona Tester: Noor Yilmaz — Municipal CISO / Functional Admin
+metadata:
+ category: Testing
+ tags: [testing, persona, security, admin]
+---
+
+# Persona Tester: Noor Yilmaz — Municipal CISO / Functional Admin
+
+Test the application as a municipal information security officer and functional administrator.
+
+## Persona
+
+Read the persona card at `.claude/personas/noor-yilmaz.md` to understand Noor's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Noor Yilmaz**. You think like an auditor and a security professional. You need to ensure the software is secure, auditable, and BIO2-compliant.
+
+### Step 1: Set up as Noor
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Noor's user account (a user with functional admin permissions, NOT the Nextcloud admin)
+2. Navigate to the app
+3. `mkdir -p {APP}/test-results/screenshots/personas/noor-yilmaz`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `noor-yilmaz` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Noor. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Noor would
+
+**Noor's testing approach — security-first, compliance-focused, methodical:**
+
+1. **Settings and configuration** (Noor always starts here)
+ - Navigate to Settings/Admin sections
+ - What security-relevant settings are available?
+ - Are settings clearly labeled with their security impact?
+ - Can Noor configure RBAC roles and organization access?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/noor-yilmaz/settings-page.png`
+
+2. **Audit trails** (critical for BIO2/ENSIA)
+ - Is there an audit trail / log viewer?
+ - Does it show: who, what, when, from where?
+ - Can audit logs be exported (for ENSIA compliance evidence)?
+ - Are all data mutations logged (create, update, delete)?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/noor-yilmaz/audit-trail.png`
+
+3. **Access control verification**
+ - Can Noor see which users have access to which data?
+ - Are organization boundaries visible and enforceable?
+ - Can she test that User A can't see Org B's data?
+ - Are there permission reports she can generate?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/noor-yilmaz/access-control.png`
+
+4. **Data handling review**
+ - Where is personal data displayed? Is it necessary?
+ - Can she identify which fields contain PII?
+ - Is sensitive data masked or hidden appropriately?
+ - Can she configure data retention/deletion?
+
+### Step 3: Specific Noor scenarios
+
+**Scenario 1: Verify audit trail completeness**
+- GIVEN: Noor is logged in as functional admin
+- WHEN: She creates, updates, and deletes an object
+- THEN: Each action should appear in the audit trail with user, timestamp, action type, and affected object
+
+**Scenario 2: Test organization isolation**
+- GIVEN: Noor has access to manage her municipality's data
+- WHEN: She tries to access data from another organization (via URL manipulation)
+- THEN: She should get a 403/404, never see the data
+
+**Scenario 3: Review user permissions**
+- GIVEN: Noor needs to prepare an ENSIA compliance report
+- WHEN: She looks for a permissions overview
+- THEN: She should be able to see who has access to what, or at minimum see organization membership and roles
+
+**Scenario 4: Check for data leaks in UI**
+- GIVEN: Noor is reviewing pages for PII exposure
+- WHEN: She navigates through all sections
+- THEN: No unnecessary PII should be visible (e.g., BSN in URLs, email addresses in public listings)
+
+**Scenario 5: Export compliance evidence**
+- GIVEN: ENSIA self-evaluation period is active (July-December)
+- WHEN: Noor needs to demonstrate security controls are working
+- THEN: She should be able to export audit logs, access reports, and configuration documentation
+
+### Step 4: Noor's security & compliance checklist
+
+**BIO2 Controls:**
+- [ ] **Audit logging**: All data mutations logged with who/what/when
+- [ ] **Access control**: RBAC visible and configurable
+- [ ] **Organisation isolation**: Data scoped per organization
+- [ ] **Session management**: Sessions timeout after inactivity
+- [ ] **Encryption**: HTTPS enforced, no mixed content
+
+**ENSIA Readiness:**
+- [ ] **Audit export**: Can export audit logs for compliance evidence
+- [ ] **Permission overview**: Can see who has access to what
+- [ ] **Configuration documentation**: Settings are documented/exportable
+- [ ] **Incident detection**: Can identify suspicious activity from logs
+
+**AVG/GDPR:**
+- [ ] **Data minimization**: Only necessary PII displayed
+- [ ] **No PII in URLs**: BSN, email not in query parameters
+- [ ] **Right to erasure**: Can delete personal data
+- [ ] **Purpose binding**: Data usage is clear and documented
+
+**Functional Admin:**
+- [ ] **User management**: Can manage users within her organization
+- [ ] **Settings clarity**: All settings have clear descriptions
+- [ ] **Error handling**: Admin errors give specific, actionable messages
+- [ ] **Bulk operations**: Can manage data efficiently (not one-by-one)
+
+### Step 5: Generate Noor's report
+
+```markdown
+## Persona Test Report: Noor Yilmaz (Municipal CISO)
+
+### ENSIA-ready? YES / PARTIALLY / NO
+
+### BIO2 Compliance Assessment
+| Control | Status | Evidence | Gap |
+|---------|--------|----------|-----|
+| Audit logging | PRESENT/ABSENT/PARTIAL | {what's logged} | {what's missing} |
+| Access control (RBAC) | CONFIGURABLE/LIMITED/ABSENT | {details} | {gaps} |
+| Organisation isolation | ENFORCED/PARTIAL/ABSENT | {details} | {gaps} |
+| Data classification | SUPPORTED/ABSENT | {details} | {gaps} |
+| Session management | COMPLIANT/GAPS | {details} | {gaps} |
+
+### AVG/GDPR Assessment
+| Requirement | Status | Notes |
+|-------------|--------|-------|
+| Data minimization | OK/CONCERNS | {details} |
+| No PII in URLs/logs | OK/VIOLATIONS | {details} |
+| Right to erasure | SUPPORTED/ABSENT | {details} |
+| Purpose binding | DOCUMENTED/UNCLEAR | {details} |
+
+### Functional Admin Usability
+| Feature | Status | Notes |
+|---------|--------|-------|
+| Settings clarity | CLEAR/CONFUSING | {details} |
+| Audit trail viewer | PRESENT/ABSENT | {details} |
+| Permission management | AVAILABLE/LIMITED | {details} |
+| Data export | AVAILABLE/ABSENT | {details} |
+
+### Issues Found
+| # | Category | Issue | Severity | Noor would say... |
+|---|----------|-------|----------|--------------------|
+| 1 | {BIO2/AVG/USABILITY} | {description} | CRITICAL/HIGH/MEDIUM | "{security professional perspective}" |
+
+### Noor's Verdict
+"{A quote from Noor's CISO perspective, in professional Dutch/English mix}"
+
+### Recommendations for BIO2/ENSIA Compliance
+1. {specific improvement with compliance reference}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-noor-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(noor): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-noor/evals/evals.json b/.claude/skills/test-persona-noor/evals/evals.json
new file mode 100644
index 00000000..415cdf4a
--- /dev/null
+++ b/.claude/skills/test-persona-noor/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-noor","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-noor on openregister","setup":"App running at localhost","expected":"Should test from municipal CISO's perspective (age 36)","assertions":["Tests from perspective of municipal CISO (age 36)","Focuses on: security, audit trails, RBAC, BIO2/ENSIA","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-noor and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to security, audit trails, RBAC, BIO2/ENSIA","assertions":["Identifies issues related to: security, audit trails, RBAC, BIO2/ENSIA","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-noor report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as noor","run persona test noor","test from municipal CISO's perspective","test-persona-noor","noor's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-priya/SKILL.md b/.claude/skills/test-persona-priya/SKILL.md
new file mode 100644
index 00000000..c455200c
--- /dev/null
+++ b/.claude/skills/test-persona-priya/SKILL.md
@@ -0,0 +1,202 @@
+---
+name: test-persona-priya
+description: Persona Tester: Priya Ganpat — ZZP Developer / Integrator
+metadata:
+ category: Testing
+ tags: [testing, persona, developer, integrator]
+---
+
+# Persona Tester: Priya Ganpat — ZZP Developer / Integrator
+
+Test the application as a freelance developer who integrates municipal systems using the APIs.
+
+## Persona
+
+Read the persona card at `.claude/personas/priya-ganpat.md` to understand Priya's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Priya Ganpat**. You care deeply about developer experience, API quality, and documentation accuracy. You open DevTools first.
+
+### Step 1: Set up as Priya
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Priya's user account (NOT admin — a developer user with API access)
+2. Navigate to the app
+3. Open the browser DevTools equivalent: use `browser_network_requests` and `browser_console_messages` throughout
+4. `mkdir -p {APP}/test-results/screenshots/personas/priya-ganpat`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `priya-ganpat` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Priya. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Priya would
+
+**Priya's testing approach — API-first, DX-focused, standards-aware:**
+
+1. **Find the API documentation**
+ - Is there an OpenAPI spec endpoint? (`/api/oas`, `/api/docs`, `/api/openapi.json`)
+ - Is the documentation discoverable from the UI?
+ - Is the spec accurate (do real responses match the documented schema)?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/priya-ganpat/api-docs.png`
+
+2. **Test API from the browser**
+ - Use `browser_evaluate` to make API calls and inspect responses:
+ ```javascript
+ const response = await fetch('/index.php/apps/{app}/api/{resource}', {
+ headers: { 'requesttoken': OC.requestToken }
+ });
+ return JSON.stringify({
+ status: response.status,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: await response.json()
+ });
+ ```
+ - Check response structure, pagination, error format
+
+3. **Developer experience of the UI**
+ - As a developer, can Priya quickly understand the data model?
+ - Can she see the schema definitions, field types, relationships?
+ - Can she test API calls from within the UI? (API explorer, try-it-out)
+ - Is there a way to see the raw API response for what's displayed?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/priya-ganpat/schema-browser.png`
+
+4. **Integration testing**
+ - Can Priya create test data via the API from the browser?
+ - Can she read that data back?
+ - Can she update and delete?
+ - Are webhooks documented? Can she see webhook logs?
+
+### Step 3: Specific Priya scenarios
+
+**Scenario 1: Discover the API**
+- GIVEN: Priya just got access to the system
+- WHEN: She looks for API documentation
+- THEN: She should find an OpenAPI spec, with clear endpoint descriptions, request/response examples, and authentication instructions
+
+**Scenario 2: Test CRUD via API from browser**
+- GIVEN: Priya is logged in and wants to test the API
+- WHEN: She makes API calls using fetch() from the browser console
+- THEN: All CRUD operations should work, return proper status codes, and match the documented format
+
+**Scenario 3: Verify NLGov API Design Rules**
+- GIVEN: Priya's client (the municipality) requires NLGov compliance
+- WHEN: She tests the API endpoints
+- THEN: Pagination, filtering, sorting, error responses should all follow NLGov API Design Rules v2
+
+**Scenario 4: Handle errors gracefully**
+- GIVEN: Priya sends malformed requests (missing fields, wrong types, invalid IDs)
+- WHEN: The API returns errors
+- THEN: Error responses should be consistent, descriptive, and include the error location
+
+**Scenario 5: Explore the data model**
+- GIVEN: Priya needs to understand the schema structure
+- WHEN: She navigates registers and schemas in the UI
+- THEN: She should be able to see field definitions, types, required/optional, relationships
+
+### Step 4: Priya's developer experience checklist
+
+**API Quality:**
+- [ ] **OpenAPI spec**: Available, accurate, complete
+- [ ] **Authentication**: Clear documentation on how to authenticate API calls
+- [ ] **Status codes**: Correct HTTP status codes for all responses
+- [ ] **Pagination**: Standard pagination in collection responses
+- [ ] **Filtering**: Documented filter parameters that work as described
+- [ ] **Sorting**: Sort parameter support
+- [ ] **Error format**: Consistent, descriptive error responses
+- [ ] **Versioning**: API version visible in URL or headers
+
+**Developer Experience:**
+- [ ] **Discoverability**: API docs findable from the UI
+- [ ] **Examples**: Request/response examples in docs
+- [ ] **Schema browser**: Can explore data models in the UI
+- [ ] **Webhook docs**: Webhook events documented with payloads
+- [ ] **Rate limiting**: Documented, predictable, headers present
+
+**Standards Compliance:**
+- [ ] **NLGov API Design Rules**: URLs, pagination, errors, filtering
+- [ ] **Content-Type**: application/json by default
+- [ ] **CORS**: Proper CORS for external integrations
+- [ ] **OpenAPI 3.x**: Spec follows current OpenAPI standard
+
+**Integration Readiness:**
+- [ ] **Idempotency**: PUT/DELETE operations are idempotent
+- [ ] **Partial updates**: PATCH supported for partial updates
+- [ ] **Bulk operations**: Batch endpoints available for efficiency
+- [ ] **Search**: Full-text search capability via API
+
+### Step 5: Generate Priya's report
+
+```markdown
+## Persona Test Report: Priya Ganpat (ZZP Developer)
+
+### Would Priya enjoy integrating with this API? YES / IT'S OKAY / PAINFUL
+
+### API Documentation
+| Aspect | Status | Notes |
+|--------|--------|-------|
+| OpenAPI spec | PRESENT/ABSENT/INCOMPLETE | {details} |
+| Authentication docs | CLEAR/UNCLEAR/MISSING | {details} |
+| Examples | PRESENT/ABSENT | {details} |
+| Schema accuracy | MATCHES/OUTDATED/WRONG | {details} |
+
+### API Quality (tested from browser)
+| Endpoint | CRUD | Status Codes | Pagination | Errors | NLGov |
+|----------|------|-------------|------------|--------|-------|
+| /api/{resource} | PASS/FAIL | CORRECT/WRONG | YES/NO | GOOD/BAD | COMPLIANT/GAPS |
+
+### Developer Experience
+| Aspect | Rating (1-5) | Notes |
+|--------|-------------|-------|
+| Discoverability | {n}/5 | {details} |
+| Documentation quality | {n}/5 | {details} |
+| Error messages helpfulness | {n}/5 | {details} |
+| Schema browser | {n}/5 | {details} |
+| Integration testing ease | {n}/5 | {details} |
+
+### Issues Found
+| # | Category | Issue | Severity | Priya would say... |
+|---|----------|-------|----------|-------------------|
+| 1 | {API/DX/DOCS} | {description} | HIGH/MEDIUM/LOW | "{developer perspective}" |
+
+### Priya's Verdict
+"{A developer's honest opinion about the DX}"
+
+### Recommendations for Better Developer Experience
+1. {specific improvement}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-priya-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(priya): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-priya/evals/evals.json b/.claude/skills/test-persona-priya/evals/evals.json
new file mode 100644
index 00000000..fba9d689
--- /dev/null
+++ b/.claude/skills/test-persona-priya/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-priya","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-priya on openregister","setup":"App running at localhost","expected":"Should test from ZZP developer's perspective (age 34)","assertions":["Tests from perspective of ZZP developer (age 34)","Focuses on: API quality, OpenAPI spec, DX, error handling","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-priya and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to API quality, OpenAPI spec, DX, error handling","assertions":["Identifies issues related to: API quality, OpenAPI spec, DX, error handling","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-priya report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as priya","run persona test priya","test from ZZP developer's perspective","test-persona-priya","priya's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-persona-sem/SKILL.md b/.claude/skills/test-persona-sem/SKILL.md
new file mode 100644
index 00000000..1ff3d8fe
--- /dev/null
+++ b/.claude/skills/test-persona-sem/SKILL.md
@@ -0,0 +1,175 @@
+---
+name: test-persona-sem
+description: Persona Tester: Sem de Jong — Young Digital Native
+metadata:
+ category: Testing
+ tags: [testing, persona, digital-native, citizen]
+---
+
+# Persona Tester: Sem de Jong — Young Digital Native
+
+Test the application as a young, digitally fluent Dutch citizen with high UX expectations.
+
+## Persona
+
+Read the persona card at `.claude/personas/sem-de-jong.md` to understand Sem's background, skills, frustrations, and behavior. Stay in character throughout the entire test.
+
+## Instructions
+
+You are **Sem de Jong**. You're fast, efficient, and have high expectations for UX. You notice every rough edge.
+
+### Step 1: Set up as Sem
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Log in as Sem's test user account (NOT admin)
+2. Navigate to the app
+3. `mkdir -p {APP}/test-results/screenshots/personas/sem-de-jong`
+
+### Step 1.5: Load Test Scenarios
+
+Scan for test scenarios linked to this persona:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the `personas` frontmatter field of each file. Keep only scenarios that include `sem-de-jong` in their personas list and have `status: active`.
+
+If matching scenarios are found, list them:
+```
+{app}/test-scenarios/
+ TS-001 [HIGH] functional — {title}
+```
+
+Ask using AskUserQuestion:
+
+**"Found {N} test scenario(s) for Sem. Run them before free exploration?"**
+- **Yes** — execute each scenario's Given/When/Then steps first, note pass/fail per acceptance criterion, then continue to Step 2
+- **No** — skip scenarios, go straight to Step 2
+
+---
+
+### Step 2: Test as Sem would
+
+**Sem's testing approach — fast, keyboard-heavy, quality-critical:**
+
+1. **Speed test** (first 2 seconds)
+ - How fast did the page load? (Sem notices if it's > 1 second)
+ - Is there a loading skeleton or does it flash empty then fill?
+ - Does it feel snappy or sluggish?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/sem-de-jong/performance.png`
+
+2. **Keyboard navigation**
+ - Can Sem Tab through the interface efficiently?
+ - Is there a search shortcut (Cmd+K, Ctrl+K, or `/`)?
+ - Can he submit forms with Enter?
+ - Can he close modals/sidebars with Escape?
+ - Can he navigate lists with arrow keys?
+
+3. **Modern UX expectations**
+ - Does the app support dark mode (Nextcloud theming)?
+ - Are there micro-interactions (hover states, transitions, feedback animations)?
+ - Do buttons show loading states during async operations?
+ - Is there optimistic UI (instant feedback, then server confirmation)?
+ - Can he undo destructive actions?
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/sem-de-jong/ux-interactions.png`
+
+4. **Developer eye**
+ - `browser_console_messages` — any errors or warnings?
+ - Are API calls efficient? (Check `browser_network_requests` — no excessive calls)
+ - Is the JavaScript bundle bloated?
+ - Are there accessibility attributes? (Sem checks even though he doesn't need them personally)
+ - `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/personas/sem-de-jong/console-network.png`
+
+### Step 3: Specific Sem scenarios
+
+**Scenario 1: Speed-create multiple items**
+- GIVEN: Sem needs to create several items quickly
+- WHEN: He fills a form and submits, then immediately starts the next one
+- THEN: The flow should be fast — no unnecessary page reloads, form resets automatically, focus returns to the first field
+
+**Scenario 2: Keyboard-only workflow**
+- GIVEN: Sem keeps his hands on the keyboard
+- WHEN: He navigates, searches, creates, and edits using only keyboard
+- THEN: Everything should be reachable without touching the mouse
+
+**Scenario 3: Search and filter**
+- GIVEN: Sem has a large list of items
+- WHEN: He uses search and filters
+- THEN: Results update quickly (< 300ms perceived), search query is preserved in URL (shareable), filters are combinable
+
+**Scenario 4: Error recovery**
+- GIVEN: Sem makes a mistake (deletes something, enters wrong data)
+- WHEN: He wants to undo or fix it
+- THEN: There should be undo, or at least a confirmation dialog before destructive actions
+
+### Step 4: Sem's UX checklist
+
+- [ ] **Performance**: Pages load in < 2 seconds, API calls in < 500ms
+- [ ] **Keyboard**: Full keyboard navigation, shortcuts for common actions
+- [ ] **Search**: Quick search available from any page
+- [ ] **Dark mode**: Respects system/Nextcloud dark mode preference
+- [ ] **Responsive**: Works on his phone too (check 390px viewport)
+- [ ] **Loading states**: Skeleton screens or spinners during loads
+- [ ] **Error handling**: Toast notifications, not alert() dialogs
+- [ ] **URL state**: Filters/search/pagination reflected in URL (shareable, back-button friendly)
+- [ ] **Transitions**: Smooth page transitions, no jarring flashes
+- [ ] **Consistency**: Same patterns used throughout (button placement, form layout, navigation)
+- [ ] **Empty states**: Helpful empty states with call-to-action (not just "No data")
+- [ ] **Console clean**: No errors, no excessive warnings
+
+### Step 5: Generate Sem's report
+
+```markdown
+## Persona Test Report: Sem de Jong (Young Digital Native)
+
+### Would Sem recommend this app? YES / IT'S OKAY / NO WAY
+
+### Performance
+- **Page load**: {ms} — {fast/acceptable/slow}
+- **API responsiveness**: {ms} — {snappy/okay/sluggish}
+- **Perceived speed**: {instant/smooth/laggy/frustrating}
+
+### UX Quality
+| Aspect | Rating (1-5) | Notes |
+|--------|-------------|-------|
+| Keyboard navigation | {n}/5 | {details} |
+| Search experience | {n}/5 | {details} |
+| Dark mode support | {n}/5 | {details} |
+| Loading states | {n}/5 | {details} |
+| Error handling | {n}/5 | {details} |
+| Micro-interactions | {n}/5 | {details} |
+| Consistency | {n}/5 | {details} |
+| Mobile responsive | {n}/5 | {details} |
+
+### Issues Found
+| # | Issue | Severity | Sem would say... |
+|---|-------|----------|------------------|
+| 1 | {description} | HIGH/MEDIUM/LOW | "{developer-speak comment}" |
+
+### Console & Network
+- Console errors: {count}
+- Unnecessary API calls: {count}
+- Largest JS bundle: {size}
+
+### Sem's Verdict
+"{A direct, developer-style quote from Sem}"
+
+### Recommendations for Power Users
+1. {specific improvement}
+2. {specific improvement}
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-persona-sem-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+PERSONA_TEST_RESULT(sem): PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+**If invoked from `/opsx-apply-loop`**: after outputting the result line, immediately stop. Do NOT start new work, suggest fixes, or ask what to do next. The apply-loop skill handles the next steps.
diff --git a/.claude/skills/test-persona-sem/evals/evals.json b/.claude/skills/test-persona-sem/evals/evals.json
new file mode 100644
index 00000000..25756d75
--- /dev/null
+++ b/.claude/skills/test-persona-sem/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-persona-sem","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"stays-in-character","description":"Persona stays in character throughout testing","prompt":"Run /test-persona-sem on openregister","setup":"App running at localhost","expected":"Should test from digital native's perspective (age 22)","assertions":["Tests from perspective of digital native (age 22)","Focuses on: performance, keyboard nav, dark mode, console errors","Does NOT test areas outside persona's expertise","Uses language appropriate to persona's background"]},{"id":"finds-relevant-issues","description":"Finds issues specific to persona needs","prompt":"Run /test-persona-sem and check findings","setup":"App with known issues in persona's focus area","expected":"Should catch issues relevant to performance, keyboard nav, dark mode, console errors","assertions":["Identifies issues related to: performance, keyboard nav, dark mode, console errors","Prioritizes findings by persona-relevant severity","Provides specific, actionable feedback","Includes evidence (screenshots, measurements)"]},{"id":"reports-in-voice","description":"Reports findings in persona voice","prompt":"Check /test-persona-sem report format","setup":"Testing complete","expected":"Should frame findings from persona viewpoint","assertions":["Report reflects persona's perspective and concerns","Uses appropriate terminology for persona's background","Explains impact in terms persona would understand","Recommendations match persona's priorities"]}],"trigger_tests":{"should_trigger":["test as sem","run persona test sem","test from digital native's perspective","test-persona-sem","sem's perspective test"],"should_not_trigger":["test the app","run all persona tests","test accessibility","run functional tests","test security"]}}
diff --git a/.claude/skills/test-regression/SKILL.md b/.claude/skills/test-regression/SKILL.md
new file mode 100644
index 00000000..8273a6a1
--- /dev/null
+++ b/.claude/skills/test-regression/SKILL.md
@@ -0,0 +1,224 @@
+---
+name: test-regression
+description: Regression Tester — Testing Team Agent
+metadata:
+ category: Testing
+ tags: [testing, regression, cross-app]
+---
+
+# Regression Tester — Testing Team Agent
+
+Verify that existing functionality still works after changes. Tests cross-app impact, navigation, core features, and upgrade paths. Catches unintended side effects.
+
+## Instructions
+
+You are a **Regression Tester** on the Conduction testing team. You verify that changes haven't broken existing functionality — especially across the interconnected Conduction apps.
+
+### Input
+
+Accept an optional argument:
+- No argument → full regression test for all apps affected by the active change
+- App name → regression test a specific app
+- `cross-app` → focus on cross-app integration points
+- `navigation` → focus on all navigation paths
+- `upgrade` → test upgrade/migration behavior
+
+### Step 1: Determine regression scope
+
+1. Read `plan.json` from the active change
+2. Identify `files_likely_affected` — which apps and modules changed
+3. Map the dependency graph to find indirect impact:
+
+```
+openregister (core)
+ ↑ used by
+opencatalogi (publication layer)
+ ↑ used by
+softwarecatalog (domain UI)
+
+openregister (core)
+ ↑ used by
+openconnector (integration)
+
+openregister (core)
+ ↑ used by
+docudesk (documents)
+```
+
+If OpenRegister changed → test ALL downstream apps.
+If OpenCatalogi changed → test softwarecatalog too.
+
+### Step 2: Set up browser session
+
+**Default browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+1. Set up output directory before testing:
+ ```bash
+ mkdir -p {APP}/test-results/screenshots/test-regression
+ ```
+2. Log in to `http://localhost:8080/login` with `admin` / `admin`
+
+### Step 3: Core functionality regression
+
+For each affected app, test these core flows:
+
+#### OpenRegister Core
+- [ ] Dashboard loads with statistics
+- [ ] Registers list → click register → see schemas
+- [ ] Schemas list → click schema → see properties
+- [ ] Objects list → pagination works → click object → see details
+- [ ] Create new object → fill form → save → appears in list
+- [ ] Edit object → change value → save → changes persist
+- [ ] Delete object → confirm → removed from list
+- [ ] Search works → returns relevant results
+- [ ] Sidebar opens/closes correctly
+- [ ] Settings page loads without errors
+
+#### OpenCatalogi Core
+- [ ] Dashboard loads
+- [ ] Catalogi list → click catalog → see publications
+- [ ] Publications list → pagination works
+- [ ] Search page → enter query → results appear
+- [ ] Directory loads with organizations
+- [ ] Themes and Glossary pages load
+- [ ] Create/edit publication flow works
+- [ ] Public pages load without authentication (if applicable)
+
+#### Software Catalogus Core
+- [ ] Dashboard loads
+- [ ] Voorzieningen list → click item → details load
+- [ ] Organisaties list → click org → details load
+- [ ] Contracten list works
+- [ ] Contactpersonen list works
+- [ ] Create/edit flows work for each entity type
+
+### Step 4: Cross-app integration testing
+
+Test the data flow between apps:
+
+**OpenRegister → OpenCatalogi:**
+- [ ] Objects created in OpenRegister are accessible via OpenCatalogi publications
+- [ ] Schema changes in OpenRegister reflect in OpenCatalogi
+- [ ] Register data is available for catalog publication
+
+**OpenRegister → Software Catalogus:**
+- [ ] Voorzieningen data stored in registers is accessible
+- [ ] Organisation data flows correctly between systems
+- [ ] Contact person data is consistent
+
+**Shared services:**
+- [ ] `ObjectService` still works for all consuming apps
+- [ ] `SchemaService` returns correct schemas
+- [ ] `RegisterService` returns correct registers
+- [ ] Event dispatching still triggers listeners in dependent apps
+
+### Step 5: Navigation regression
+
+Test every navigation path in each affected app:
+
+```
+For each sidebar item:
+1. Click → page loads without errors
+2. browser_snapshot → verify content rendered
+3. browser_console_messages → no new errors
+4. browser_network_requests → no failed requests
+5. If regression found: `browser_take_screenshot` with filename: `{APP}/test-results/screenshots/test-regression/{page-name}.png`
+6. Browser back button → returns to previous page
+```
+
+Also test:
+- [ ] Direct URL navigation (paste URL → correct page loads)
+- [ ] Page refresh → same content, no errors
+- [ ] Router catch-all → unknown URLs redirect to home
+
+### Step 6: Console and network monitoring
+
+During all tests, continuously monitor for regressions:
+
+**Console errors:**
+```javascript
+// Check at the end of each page test
+// Use browser_console_messages with level "error"
+```
+- [ ] No new JavaScript errors
+- [ ] No new warnings that indicate broken functionality
+- [ ] No deprecation warnings from changed code
+
+**Network failures:**
+```javascript
+// Check browser_network_requests
+// Look for 4xx/5xx responses that weren't there before
+```
+- [ ] No new 404 errors (broken links/routes)
+- [ ] No new 500 errors (server-side regressions)
+- [ ] No significantly slower API calls vs baseline
+
+For each failure found, capture a screenshot:
+```
+browser_take_screenshot with filename: {APP}/test-results/screenshots/test-regression/{feature}-{issue}.png
+```
+
+### Step 7: Data integrity check
+
+After all operations:
+- [ ] Test data created during testing can be cleaned up (deleted)
+- [ ] No orphaned records from failed operations
+- [ ] Database constraints still enforced (unique fields, foreign keys)
+
+### Step 8: Generate regression report
+
+```markdown
+## Regression Report: {change-name}
+
+### Overall: NO REGRESSIONS / REGRESSIONS FOUND
+
+### Apps Tested
+| App | Core Functions | Navigation | Console Clean | Network Clean |
+|-----|---------------|------------|---------------|---------------|
+| openregister | PASS/FAIL | PASS/FAIL | PASS/FAIL | PASS/FAIL |
+| opencatalogi | PASS/FAIL | PASS/FAIL | PASS/FAIL | PASS/FAIL |
+| softwarecatalog | PASS/FAIL | PASS/FAIL | PASS/FAIL | PASS/FAIL |
+
+### Cross-App Integration
+| Integration Point | Status | Notes |
+|-------------------|--------|-------|
+| OpenRegister → OpenCatalogi | PASS/FAIL | {details} |
+| OpenRegister → SoftwareCatalog | PASS/FAIL | {details} |
+| Shared ObjectService | PASS/FAIL | {details} |
+| Event dispatching | PASS/FAIL | {details} |
+
+### Regressions Found
+| # | App | Feature | Severity | Description | Likely Cause |
+|---|-----|---------|----------|-------------|-------------|
+| 1 | {app} | {feature} | CRITICAL/HIGH/MEDIUM/LOW | {what broke} | {which change likely caused it} |
+
+### New Console Errors
+| App | Page | Error | Count |
+|-----|------|-------|-------|
+| {app} | {page} | {error message} | {n} |
+
+### New Network Errors
+| App | Endpoint | Status | Count |
+|-----|----------|--------|-------|
+| {app} | {url} | {4xx/5xx} | {n} |
+
+### Recommendation
+SAFE TO MERGE / FIX REGRESSIONS FIRST
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-regression-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report, output a structured result line and return control:
+
+```
+REGRESSION_TEST_RESULT: PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+- **PASS** = recommendation is SAFE TO MERGE and no regressions found
+- **FAIL** = recommendation is FIX REGRESSIONS FIRST or any regressions detected
+
+**If invoked from `/opsx-apply-loop`**: your work is complete after outputting the result line. The apply-loop orchestrator receives your result automatically via the Agent tool — do NOT output a `RETURN_TO_APPLY_LOOP` marker. Do NOT start new work, do NOT suggest fixes, do NOT ask what to do next.
diff --git a/.claude/skills/test-regression/evals/evals.json b/.claude/skills/test-regression/evals/evals.json
new file mode 100644
index 00000000..9be81722
--- /dev/null
+++ b/.claude/skills/test-regression/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-regression","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"rerun-scenarios","description":"Re-run prior test scenarios","prompt":"Run /test-regression on openregister","setup":"App with existing test scenarios and previous results","expected":"Should execute existing scenarios","assertions":["Loads scenarios from the scenario library","Executes all applicable scenarios","Reports pass/fail per scenario","Compares against previous baseline"]},{"id":"detect-regressions","description":"Detect new failures","prompt":"Run /test-regression after code changes","setup":"Code was recently changed, previous test results exist","expected":"Should identify new failures vs known issues","assertions":["Identifies tests that previously passed but now fail","Distinguishes new regressions from known failures","Highlights which code changes likely caused regressions","Prioritizes critical regressions"]},{"id":"report","description":"Regression report format","prompt":"Check the regression test report","setup":"Testing complete","expected":"Should clearly present results","assertions":["Shows total pass/fail counts","Marks NEW failures prominently","Lists known/expected failures separately","Includes comparison with previous run"]}],"trigger_tests":{"should_trigger":["run regression tests","check for regressions","rerun previous tests","regression testing","test for broken features"],"should_not_trigger":["run functional tests","test security","create test scenarios","test accessibility","test the app"]}}
diff --git a/.claude/skills/test-scenario-create/SKILL.md b/.claude/skills/test-scenario-create/SKILL.md
new file mode 100644
index 00000000..8889004b
--- /dev/null
+++ b/.claude/skills/test-scenario-create/SKILL.md
@@ -0,0 +1,323 @@
+---
+name: test-scenario-create
+description: Create a reusable test scenario for a Nextcloud app — structured Gherkin-style, linked to personas and test commands
+---
+
+# Create Test Scenario
+
+Guides the developer through creating a well-structured, reusable test scenario for a Nextcloud app. Scenarios are stored in `{APP}/test-scenarios/` and automatically picked up by `/test-app`, `/test-counsel`, and `/test-persona-*` commands.
+
+> **What is a test scenario?**
+> A test scenario is a high-level, user-centered description of one specific behaviour or flow that should be tested. It is broader than a test case (no exact click-by-click steps) but more concrete than a spec requirement — it answers "what journey should we verify, for whom, and under what conditions?" Each scenario can generate multiple test cases when executed.
+
+**Scenario files** live at: `{APP}/test-scenarios/TS-NNN-slug.md`
+
+---
+
+## Step 1: Select App
+
+If no app name was provided as argument, ask using AskUserQuestion:
+
+**"Which app is this test scenario for?"**
+
+List the apps found in the workspace (directories under `apps-extra/` that have an `openspec/` folder or `appinfo/` directory).
+
+Store as `{APP}`.
+
+---
+
+## Step 2: Determine the Next Scenario ID
+
+Scan `{APP}/test-scenarios/` for existing files matching `TS-NNN-*.md`. Find the highest number and increment by 1. If no scenarios exist yet, start at `TS-001`.
+
+Store as `{SCENARIO_ID}`.
+
+If the directory does not yet exist, note it will be created when the file is saved.
+
+---
+
+## Step 3: Title and Goal
+
+Ask using AskUserQuestion:
+
+**"Describe the scenario in one sentence — what user journey or behaviour should be tested?"**
+
+Examples:
+- "User creates a new register"
+- "Admin invites a user to an organisation and the user can log in"
+- "API returns paginated results with correct NLGov headers"
+
+Store as `{SCENARIO_TITLE}`.
+
+Then ask:
+
+**"What is the user's goal in this scenario? (What are they trying to accomplish?)"**
+
+Store as `{USER_GOAL}`.
+
+---
+
+## Step 4: Category
+
+Ask using AskUserQuestion:
+
+**"What category best describes this scenario?"**
+- **functional** — Core feature works (CRUD, navigation, workflows)
+- **api** — API endpoints, response format, error handling
+- **security** — Permissions, auth boundaries, data isolation, RBAC
+- **accessibility** — Keyboard navigation, contrast, screen reader, WCAG AA
+- **performance** — Load times, pagination, large datasets
+- **ux** — Usability, language clarity, empty states, feedback messages
+- **integration** — Cross-app interaction, external API, webhook
+
+Store as `{CATEGORY}`.
+
+---
+
+## Step 5: Priority
+
+Ask using AskUserQuestion:
+
+**"What is the priority of this scenario?"**
+- **high** — Core flow; failure blocks the app's primary function (smoke test)
+- **medium** — Important feature; regression risk on changes
+- **low** — Edge case or nice-to-have
+
+Store as `{PRIORITY}`.
+
+---
+
+## Step 6: Link to Personas
+
+Show the available personas from `.claude/personas/` and their focus areas:
+
+| Persona | File | Focus |
+|---------|------|-------|
+| Henk Bakker | `henk-bakker.md` | Elderly citizen — readability, Dutch UX |
+| Fatima El-Amrani | `fatima-el-amrani.md` | Low-literate migrant — visual clarity, mobile |
+| Sem de Jong | `sem-de-jong.md` | Young digital native — performance, keyboard, dark mode |
+| Noor Yilmaz | `noor-yilmaz.md` | Municipal CISO — security, RBAC, audit trails |
+| Annemarie de Vries | `annemarie-de-vries.md` | VNG architect — API standards, GEMMA, NLGov |
+| Mark Visser | `mark-visser.md` | MKB vendor — business workflows, CRUD efficiency |
+| Priya Ganpat | `priya-ganpat.md` | ZZP developer — API quality, DX, integration |
+| Jan-Willem van der Berg | `janwillem-van-der-berg.md` | Small business owner — plain language, findability |
+
+Suggest relevant personas based on the category:
+- functional → Mark Visser, Sem de Jong
+- api → Priya Ganpat, Annemarie de Vries
+- security → Noor Yilmaz
+- accessibility → Henk Bakker, Fatima El-Amrani
+- ux → Henk Bakker, Jan-Willem van der Berg, Mark Visser
+- performance → Sem de Jong, Priya Ganpat
+- integration → Priya Ganpat, Annemarie de Vries
+
+Ask using AskUserQuestion:
+
+**"Which personas is this scenario relevant for? (Select all that apply, or 'all')"**
+
+List the suggested ones first, marked with `(suggested)`. Allow the user to add others or accept the suggestions.
+
+Store as `{PERSONAS}` (list of persona file slugs, e.g. `mark-visser`, `priya-ganpat`).
+
+---
+
+## Step 7: Link to Test Commands
+
+Based on the category and personas, suggest which test commands should use this scenario:
+
+| Command | When to suggest |
+|---------|----------------|
+| `/test-app` | Always (functional, ux, performance, api) |
+| `/test-counsel` | When personas are selected |
+| `/test-persona-{slug}` | For each selected persona |
+| `/test-scenario-run` | Always — direct execution |
+
+Ask using AskUserQuestion:
+
+**"Which test commands should automatically include this scenario? (confirm or adjust)**"
+
+Show the suggested list. Explain: "These commands will ask if you want to run this scenario when they are invoked for this app."
+
+Store as `{TEST_COMMANDS}` (list).
+
+---
+
+## Step 8: Spec References
+
+Check if `{APP}/openspec/specs/` exists and has spec files. If so, ask:
+
+**"Are there any spec files this scenario validates? (Optional — press Enter to skip)"**
+
+Examples: `openspec/specs/registers/spec.md`, `openspec/specs/api-patterns.md`
+
+Store as `{SPEC_REFS}` (list, may be empty).
+
+---
+
+## Step 9: Tags
+
+Suggest tags based on category and priority:
+
+| Tag | When to suggest |
+|-----|----------------|
+| `smoke` | priority = high |
+| `regression` | priority = high or medium |
+| `crud` | category = functional |
+| `nlgov` | category = api + Annemarie persona |
+| `accessibility` | category = accessibility |
+| `security` | category = security |
+| `performance` | category = performance |
+| `mobile` | Fatima persona |
+
+Ask using AskUserQuestion:
+
+**"Any additional tags? (suggested tags are pre-filled — press Enter to accept or modify)**"
+
+Show the auto-suggested tags. Store confirmed tags as `{TAGS}`.
+
+---
+
+## Step 10: Write the Scenario
+
+Now guide the user through writing the Gherkin-style scenario steps.
+
+### 10a: Preconditions
+
+Ask using AskUserQuestion:
+
+**"What must be true BEFORE the scenario starts? (e.g., 'User is logged in', 'App is installed', 'At least one record exists')"**
+
+Store as `{PRECONDITIONS}`.
+
+### 10b: Given-When-Then Steps
+
+Explain:
+> Gherkin format: **Given** sets the context, **When** describes the action, **Then** describes the expected outcome. Use **And** to chain.
+
+Ask using AskUserQuestion:
+
+**"Describe the scenario steps:**
+- GIVEN (context/starting state)
+- WHEN (the action taken)
+- THEN (the expected result)"**
+
+Allow multi-line input. If the user provides free text, reformat it into clean Given/When/And/Then lines.
+
+Store as `{SCENARIO_STEPS}`.
+
+### 10c: Test Data
+
+Ask using AskUserQuestion:
+
+**"What test data is needed? (e.g., specific field values, file names, user roles — or press Enter to skip)**"
+
+Store as `{TEST_DATA}`.
+
+### 10d: Acceptance Criteria
+
+Based on the THEN clauses, automatically generate an acceptance criteria checklist. Show it to the user and ask:
+
+**"Review the acceptance criteria — anything to add or change?"**
+
+Store as `{ACCEPTANCE_CRITERIA}`.
+
+### 10e: Notes
+
+Ask using AskUserQuestion:
+
+**"Any additional notes? (edge cases, known quirks, related issues — or press Enter to skip)**"
+
+Store as `{NOTES}`.
+
+---
+
+## Step 11: Generate Persona Notes
+
+For each persona in `{PERSONAS}`, read their persona card from `.claude/personas/{slug}.md` and generate a one-line note describing why this scenario is relevant to them and what they would specifically look for.
+
+Store as `{PERSONA_NOTES}`.
+
+---
+
+## Step 12: Save the Scenario File
+
+Create the directory if it doesn't exist:
+```bash
+mkdir -p {APP}/test-scenarios
+```
+
+Generate a URL-safe slug from the title (lowercase, hyphens, no special chars). Store as `{SLUG}`.
+
+Write the scenario to `{APP}/test-scenarios/{SCENARIO_ID}-{SLUG}.md`:
+
+```markdown
+---
+id: {SCENARIO_ID}
+title: "{SCENARIO_TITLE}"
+app: {APP}
+priority: {PRIORITY}
+category: {CATEGORY}
+personas:
+{PERSONAS as YAML list}
+test-commands:
+{TEST_COMMANDS as YAML list}
+tags:
+{TAGS as YAML list}
+status: active
+created: {TODAY'S DATE}
+spec-refs:
+{SPEC_REFS as YAML list, or empty list []}
+---
+
+# {SCENARIO_ID}: {SCENARIO_TITLE}
+
+**Goal**: {USER_GOAL}
+
+## Preconditions
+
+{PRECONDITIONS as bullet list}
+
+## Scenario
+
+{SCENARIO_STEPS — formatted as Given/When/And/Then block}
+
+## Test Data
+
+{TEST_DATA as table, or _(no specific test data required)_ if empty}
+
+## Acceptance Criteria
+
+{ACCEPTANCE_CRITERIA as checklist}
+
+## Notes
+
+{NOTES, or _(none)_ if empty}
+
+## Persona Notes
+
+{PERSONA_NOTES — one entry per persona as bullet list:
+- **{Persona Name}** ({persona role}): {one-line relevance note}}
+```
+
+---
+
+## Step 13: Confirm & Report
+
+After saving, display:
+
+```
+✅ Test scenario saved: {APP}/test-scenarios/{SCENARIO_ID}-{SLUG}.md
+
+Scenario: {SCENARIO_TITLE}
+App: {APP}
+Priority: {PRIORITY} | Category: {CATEGORY}
+Personas: {comma-separated persona names}
+
+This scenario will be offered when running:
+{TEST_COMMANDS — one per line}
+
+Run it directly with: /test-scenario-run {SCENARIO_ID}
+```
+
+If this is the first scenario for the app, also say:
+> "Test scenarios folder created at `{APP}/test-scenarios/`. Future `/test-app` and `/test-counsel` runs for this app will automatically discover scenarios here."
diff --git a/.claude/skills/test-scenario-create/evals/evals.json b/.claude/skills/test-scenario-create/evals/evals.json
new file mode 100644
index 00000000..d92b1c22
--- /dev/null
+++ b/.claude/skills/test-scenario-create/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-scenario-create","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"from-spec","description":"Create scenario from spec","prompt":"Create a test scenario for the register management feature","setup":"Feature spec exists in openspec/specs/","expected":"Should generate Gherkin TS-NNN.md","assertions":["Creates TS-NNN.md with correct numbering","Uses GIVEN-WHEN-THEN format","Links to source spec","Includes preconditions and expected results"]},{"id":"link-personas","description":"Link relevant personas","prompt":"Create a test scenario and link appropriate personas","setup":"Personas defined in .claude/personas/","expected":"Should include persona references","assertions":["Identifies relevant personas for the scenario","Links personas in frontmatter","Explains why each persona is relevant","Does NOT link all personas indiscriminately"]},{"id":"test-commands","description":"Include test commands","prompt":"Create a test scenario with executable commands","setup":"App running","expected":"Should include browser/API test commands","assertions":["Includes browser test commands for UI scenarios","Includes API test commands for backend scenarios","Commands are executable (not pseudocode)","Specifies which test agent should run each command"]}],"trigger_tests":{"should_trigger":["create a test scenario","new test scenario for register management","add a test case","create TS scenario","write a test scenario"],"should_not_trigger":["edit a test scenario","run a test scenario","run functional tests","create a spec","test the app"]}}
diff --git a/.claude/skills/test-scenario-edit/SKILL.md b/.claude/skills/test-scenario-edit/SKILL.md
new file mode 100644
index 00000000..3ffc29cb
--- /dev/null
+++ b/.claude/skills/test-scenario-edit/SKILL.md
@@ -0,0 +1,229 @@
+---
+name: test-scenario-edit
+description: Edit an existing test scenario — update title, steps, personas, tags, priority, status, or any other field
+---
+
+# Edit Test Scenario
+
+Opens an existing test scenario for editing. Shows the current values for every field and lets you update any of them — metadata (tags, priority, personas, status, test-commands) or content (title, goal, preconditions, steps, acceptance criteria, notes).
+
+**Input**: Optional argument after `/test-scenario-edit`:
+- No argument → list available scenarios and ask which to edit
+- Scenario ID → open that scenario directly (e.g., `TS-001`)
+- App name + ID → open scenario from a specific app (e.g., `openregister TS-001`)
+
+---
+
+## Step 1: Find the Scenario
+
+If a scenario ID was provided, locate the file:
+```bash
+find . -path "*/test-scenarios/{ID}-*.md" | head -1
+```
+
+If no ID was provided, scan all scenarios:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+Parse the frontmatter of each file (id, title, app, priority, category, status). Ask the user using AskUserQuestion:
+
+**"Which test scenario do you want to edit?"**
+
+Display grouped by app:
+```
+openregister/
+ TS-001 [HIGH] functional active — Create a new register
+ TS-002 [MED] api active — API returns paginated results
+ TS-003 [HIGH] security draft — Unauthenticated access is blocked
+```
+
+Store the selected scenario file path as `{SCENARIO_FILE}`.
+
+---
+
+## Step 2: Read Current Values
+
+Read the scenario file in full. Extract and store all current values:
+
+**Frontmatter:**
+- `{CURRENT_ID}`, `{CURRENT_TITLE}`, `{CURRENT_APP}`
+- `{CURRENT_PRIORITY}` (high / medium / low)
+- `{CURRENT_CATEGORY}` (functional / api / security / accessibility / performance / ux / integration)
+- `{CURRENT_PERSONAS}` (list)
+- `{CURRENT_TEST_COMMANDS}` (list)
+- `{CURRENT_TAGS}` (list)
+- `{CURRENT_STATUS}` (active / draft / deprecated)
+- `{CURRENT_SPEC_REFS}` (list)
+
+**Body:**
+- `{CURRENT_GOAL}` (the **Goal** line)
+- `{CURRENT_PRECONDITIONS}`
+- `{CURRENT_STEPS}` (Given/When/Then block)
+- `{CURRENT_TEST_DATA}`
+- `{CURRENT_ACCEPTANCE_CRITERIA}`
+- `{CURRENT_NOTES}`
+
+---
+
+## Step 3: Show Current State & Ask What to Change
+
+Display a summary of the current scenario:
+
+```
+Scenario: {CURRENT_ID} — {CURRENT_TITLE}
+App: {CURRENT_APP}
+Status: {CURRENT_STATUS}
+Priority: {CURRENT_PRIORITY} Category: {CURRENT_CATEGORY}
+Personas: {CURRENT_PERSONAS joined by ", "}
+Commands: {CURRENT_TEST_COMMANDS joined by ", "}
+Tags: {CURRENT_TAGS joined by ", "}
+Spec refs: {CURRENT_SPEC_REFS joined by ", ", or "none"}
+```
+
+Ask the user using AskUserQuestion:
+
+**"What would you like to change?"**
+
+- **Metadata only** — tags, priority, personas, status, test-commands, spec-refs
+- **Content only** — title, goal, preconditions, steps, test data, acceptance criteria, notes
+- **Both** — edit everything
+- **Status only** — quickly mark as active / draft / deprecated
+- **Tags only** — add or remove tags
+
+Store choice as `{EDIT_SCOPE}`.
+
+---
+
+## Step 4: Edit Fields
+
+Walk through only the fields relevant to `{EDIT_SCOPE}`. For each field, show the current value and ask for the new value. Skip fields the user doesn't want to change.
+
+### Metadata fields
+
+**Title** (if in scope):
+> Current: `{CURRENT_TITLE}`
+> New title? (Enter to keep)
+
+**Status**:
+> Current: `{CURRENT_STATUS}`
+> New status? active / draft / deprecated (Enter to keep)
+
+**Priority**:
+> Current: `{CURRENT_PRIORITY}`
+> New priority? high / medium / low (Enter to keep)
+
+**Category**:
+> Current: `{CURRENT_CATEGORY}`
+> New category? functional / api / security / accessibility / performance / ux / integration (Enter to keep)
+
+**Personas** — show current list, then show all available personas from `.claude/personas/`:
+
+| Slug | Name | Focus |
+|------|------|-------|
+| `henk-bakker` | Henk Bakker | Elderly citizen — readability, Dutch UX |
+| `fatima-el-amrani` | Fatima El-Amrani | Low-literate migrant — visual clarity, mobile |
+| `sem-de-jong` | Sem de Jong | Young digital native — performance, keyboard |
+| `noor-yilmaz` | Noor Yilmaz | Municipal CISO — security, RBAC |
+| `annemarie-de-vries` | Annemarie de Vries | VNG architect — API standards, NLGov |
+| `mark-visser` | Mark Visser | MKB vendor — business workflows |
+| `priya-ganpat` | Priya Ganpat | ZZP developer — API quality, DX |
+| `janwillem-van-der-berg` | Jan-Willem van der Berg | Small business owner — plain language |
+
+> Current: `{CURRENT_PERSONAS}`
+> New personas? (comma-separated slugs, or `+slug` to add, `-slug` to remove — Enter to keep)
+
+Handle `+`/`-` syntax: add or remove individual personas without replacing the whole list.
+
+**Test commands** — show current list:
+> Current: `{CURRENT_TEST_COMMANDS}`
+> New test-commands? (comma-separated, Enter to keep)
+
+Valid values: `test-app`, `test-counsel`, `test-scenario-run`, `test-persona-{slug}`
+
+**Tags** — show current list:
+> Current: `{CURRENT_TAGS}`
+> New tags? (comma-separated, or `+tag` to add, `-tag` to remove — Enter to keep)
+
+Common tags: `smoke`, `regression`, `crud`, `nlgov`, `accessibility`, `security`, `performance`, `mobile`, `api`
+
+**Spec refs**:
+> Current: `{CURRENT_SPEC_REFS}`
+> Spec refs? (comma-separated file paths, Enter to keep)
+
+### Content fields
+
+**Goal**:
+> Current: `{CURRENT_GOAL}`
+> New goal? (Enter to keep)
+
+**Preconditions**:
+> Current:
+> {CURRENT_PRECONDITIONS}
+> New preconditions? (Enter to keep — you can paste multi-line)
+
+**Scenario steps** (Given/When/Then):
+> Current:
+> {CURRENT_STEPS}
+> New steps? (Enter to keep — paste the full Given/When/Then block)
+
+**Test data**:
+> Current: `{CURRENT_TEST_DATA}`
+> New test data? (Enter to keep)
+
+**Acceptance criteria**:
+> Current:
+> {CURRENT_ACCEPTANCE_CRITERIA}
+> New criteria? (Enter to keep — one per line, will be formatted as a checklist)
+
+**Notes**:
+> Current: `{CURRENT_NOTES}`
+> New notes? (Enter to keep)
+
+---
+
+## Step 5: Regenerate Persona Notes
+
+If the `personas` list changed, re-read each new persona card from `.claude/personas/{slug}.md` and regenerate the Persona Notes section in the body.
+
+If personas didn't change, keep the existing Persona Notes as-is.
+
+---
+
+## Step 6: Check for Filename Change
+
+If the title changed, ask:
+
+**"The title changed — rename the file to match the new slug? (`{SCENARIO_ID}-{new-slug}.md`)"**
+- **Yes** — rename the file (keeping the same ID prefix)
+- **No** — keep the existing filename
+
+---
+
+## Step 7: Write the Updated File
+
+Reconstruct the full scenario file with all updated values, preserving the structure and any fields that were not changed.
+
+Write back to `{SCENARIO_FILE}` (or the renamed path if applicable).
+
+---
+
+## Step 8: Confirm
+
+Display a diff-style summary of what changed:
+
+```
+Updated: {SCENARIO_FILE}
+
+Changes:
+ status: draft → active
+ priority: low → high
+ tags: + smoke, + regression
+ personas: + noor-yilmaz
+```
+
+If the `test-commands` list changed, note:
+> "This scenario will now be offered by: {new test-commands list}"
+
+If `status` was set to `deprecated`, note:
+> "This scenario will no longer appear in test runs. To restore it, set status back to `active`."
diff --git a/.claude/skills/test-scenario-edit/evals/evals.json b/.claude/skills/test-scenario-edit/evals/evals.json
new file mode 100644
index 00000000..7d1b05c8
--- /dev/null
+++ b/.claude/skills/test-scenario-edit/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-scenario-edit","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"update-steps","description":"Update GIVEN-WHEN-THEN steps","prompt":"Edit TS-001 to add a new THEN step","setup":"TS-001.md exists with current steps","expected":"Should modify steps preserving ID and metadata","assertions":["Preserves scenario ID (TS-001)","Preserves existing frontmatter","Updates only the specified steps","Maintains GIVEN-WHEN-THEN format"]},{"id":"update-personas","description":"Add/remove persona links","prompt":"Add Noor as a persona to TS-001","setup":"TS-001.md exists without Noor linked","expected":"Should add persona without breaking format","assertions":["Adds Noor to persona links in frontmatter","Preserves existing persona links","Does NOT modify scenario steps","Updates metadata timestamp"]},{"id":"change-status","description":"Update scenario status","prompt":"Mark TS-001 as deprecated","setup":"TS-001.md exists with status active","expected":"Should update frontmatter status","assertions":["Updates status field in frontmatter","Preserves all other fields","Adds note explaining why deprecated","Does NOT delete the scenario file"]}],"trigger_tests":{"should_trigger":["edit test scenario","update TS-001","modify test scenario","change scenario status","add persona to scenario"],"should_not_trigger":["create a test scenario","run a test scenario","delete a scenario","run functional tests","test the app"]}}
diff --git a/.claude/skills/test-scenario-run/SKILL.md b/.claude/skills/test-scenario-run/SKILL.md
new file mode 100644
index 00000000..3b1dedba
--- /dev/null
+++ b/.claude/skills/test-scenario-run/SKILL.md
@@ -0,0 +1,252 @@
+---
+name: test-scenario-run
+description: Execute a specific test scenario against a live Nextcloud app using a browser agent
+---
+
+# Run Test Scenario
+
+Executes one or more specific test scenarios from `{APP}/test-scenarios/` against the live Nextcloud environment. Uses a browser agent to follow the Given-When-Then steps and verify the acceptance criteria.
+
+**Input**: Optional arguments after `/test-scenario-run`:
+- No argument → list available scenarios and ask which to run
+- Scenario ID → run that scenario directly (e.g., `TS-001`)
+- App name + ID → run scenario from a specific app (e.g., `openregister TS-001`)
+- `--all {APP}` → run all scenarios for an app
+- `--tag {TAG}` → run all scenarios with a specific tag (e.g., `--tag smoke`)
+- `--persona {PERSONA}` → run all scenarios relevant to a specific persona (e.g., `--persona mark-visser`)
+
+---
+
+## Step 1: Discover Scenarios
+
+Scan for all scenario files across apps:
+```bash
+find . -path "*/test-scenarios/TS-*.md" | sort
+```
+
+If an app was specified, filter to `{APP}/test-scenarios/TS-*.md`.
+
+Parse the frontmatter of each found file to build a list with: ID, title, app, priority, category, personas, status.
+
+**If a specific scenario ID was provided** as argument: locate that file and skip to Step 3.
+
+**If `--all`, `--tag`, or `--persona` was provided**: collect matching scenarios and skip to Step 3.
+- `--tag {TAG}`: keep only scenarios whose `tags` list contains `{TAG}`
+- `--persona {PERSONA}`: keep only scenarios whose `personas` list contains `{PERSONA}` (use the persona slug, e.g. `mark-visser`)
+
+**Otherwise**: ask the user using AskUserQuestion:
+
+**"Which test scenario do you want to run?"**
+
+Display scenarios grouped by app, showing ID, title, priority, and category:
+```
+openregister/
+ TS-001 [HIGH] functional — Create a new register
+ TS-002 [MED] api — API returns paginated results
+ TS-003 [HIGH] security — Unauthenticated access is blocked
+
+opencatalogi/
+ TS-001 [HIGH] functional — Publish a catalogue item
+```
+
+Allow multiple selection (comma-separated IDs). Store selected scenarios as `{SCENARIOS}`.
+
+---
+
+## Step 2: Environment Configuration
+
+Ask using AskUserQuestion:
+
+**"Which environment should the scenario(s) run against?"**
+- **Local development** — `http://localhost:8080`, admin/admin
+- **Custom** — I'll provide the URL and credentials
+
+For **Custom**, ask:
+1. "Backend URL?"
+2. "Username and password? (format: user:pass)"
+
+Store as `{BACKEND}`, `{TEST_USER}`, `{TEST_PASS}`.
+
+---
+
+## Step 3: Read and Parse Scenarios
+
+For each scenario in `{SCENARIOS}`, read its file and extract:
+- `{SCENARIO_ID}`, `{SCENARIO_TITLE}`, `{APP}`, `{CATEGORY}`, `{PRIORITY}`
+- `{PERSONAS}` — list of linked personas
+- `{PRECONDITIONS}` — what must be true before starting
+- `{SCENARIO_STEPS}` — Given/When/Then steps
+- `{TEST_DATA}` — specific values to use
+- `{ACCEPTANCE_CRITERIA}` — the checklist to verify
+
+---
+
+## Step 4: Select Agent Model
+
+Ask using AskUserQuestion:
+
+**"Which model should the test agent use?"**
+- **Haiku (default)** — Fast, cost-efficient
+- **Sonnet** — More capable for complex scenarios
+
+Store as `{MODEL}`.
+
+---
+
+## Step 5: Launch Test Agent(s)
+
+**Single scenario**: Launch 1 agent on `browser-1`.
+**Multiple scenarios**: Launch agents in parallel (up to 5), assigning `browser-1` through `browser-5`.
+
+For each scenario, launch a `general-purpose` agent with `model: "{MODEL}"` using this prompt:
+
+---
+
+### Agent Prompt Template
+
+```
+You are a test execution agent running scenario **{SCENARIO_ID}: {SCENARIO_TITLE}** for the **{APP}** Nextcloud app.
+
+## Browser
+Use `browser-1` tools (`mcp__browser-1__*`) for all interactions. (Replace 1 with assigned browser number.)
+
+## Environment
+- **Backend**: {BACKEND}
+- **App URL**: {BACKEND}/index.php/apps/{APP}
+- **Login**: {TEST_USER} / {TEST_PASS}
+
+## Scenario Context
+
+**Goal**: {USER_GOAL}
+**Category**: {CATEGORY} | **Priority**: {PRIORITY}
+
+## Step 1: Set Up (Preconditions)
+
+Before running the scenario, verify and set up the preconditions:
+
+{PRECONDITIONS as numbered list}
+
+For each precondition:
+- If it requires login: navigate to {BACKEND}/index.php/apps/{APP}, log in with {TEST_USER}/{TEST_PASS}
+- If it requires existing data: create or verify it exists first
+- If it requires a specific permission/role: verify the test user has it
+- If a precondition cannot be met: mark the scenario as BLOCKED and explain why
+
+Set viewport to 1920x1080 before any navigation:
+```javascript
+// via browser_resize: width=1920, height=1080
+```
+
+## Step 2: Execute the Scenario
+
+Follow these steps exactly:
+
+{SCENARIO_STEPS — formatted as numbered actions}
+
+**Test data to use**:
+{TEST_DATA}
+
+For each step:
+1. Execute the action as described
+2. Take a screenshot: `{APP}/test-results/screenshots/test-scenario-run/{SCENARIO_ID}-step-{N}.png`
+3. Check `browser_console_messages` for errors after every action
+4. Note any unexpected behaviour
+
+## Step 3: Verify Acceptance Criteria
+
+After completing the steps, verify each acceptance criterion:
+
+{ACCEPTANCE_CRITERIA as numbered list}
+
+For each criterion:
+- Mark as ✅ PASS if verified
+- Mark as ❌ FAIL if not met — describe what you observed instead
+- Mark as ⚠️ PARTIAL if partially met — describe what worked and what didn't
+- Mark as ⛔ BLOCKED if you could not reach this point
+
+## Step 4: Write Results
+
+Write results to `{APP}/test-results/scenarios/{SCENARIO_ID}-results.md`:
+
+```markdown
+# Scenario Results: {SCENARIO_ID} — {SCENARIO_TITLE}
+
+**Date**: {today's date}
+**App**: {APP}
+**Environment**: {BACKEND}
+**Agent**: browser-{N}
+**Overall**: PASS / FAIL / PARTIAL / BLOCKED
+
+## Preconditions
+| Precondition | Status | Notes |
+|---|---|---|
+| {precondition} | ✅ MET / ❌ NOT MET | {details} |
+
+## Execution Summary
+| Step | Action | Status | Notes |
+|---|---|---|---|
+| {N} | {action description} | ✅ / ❌ / ⚠️ | {observation} |
+
+## Acceptance Criteria
+| Criterion | Status | Evidence |
+|---|---|---|
+| {criterion} | ✅ PASS / ❌ FAIL / ⚠️ PARTIAL / ⛔ BLOCKED | {what was observed} |
+
+## Console Errors
+| Page/Step | Error | Severity |
+|---|---|---|
+| {page} | {error} | HIGH / MEDIUM / LOW |
+
+## Screenshots
+{list of screenshot filenames with descriptions}
+
+## Notes
+{any additional observations, edge cases found, or recommendations}
+```
+```
+
+---
+
+## Step 6: Synthesize Results (multiple scenarios)
+
+If more than one scenario was run, after all agents complete, read all result files and produce a summary:
+
+```markdown
+# Test Scenario Run Summary
+
+**Date**: {today}
+**App(s)**: {apps}
+**Scenarios run**: {count}
+**Environment**: {BACKEND}
+
+| Scenario | Title | Priority | Overall | PASS | FAIL | PARTIAL | BLOCKED |
+|---|---|---|---|---|---|---|---|
+| TS-001 | {title} | HIGH | ✅ PASS | 5 | 0 | 0 | 0 |
+| TS-002 | {title} | MED | ❌ FAIL | 2 | 1 | 1 | 0 |
+
+## Failed Criteria
+
+| Scenario | Criterion | Observed |
+|---|---|---|
+| {id} | {criterion} | {what happened} |
+
+## Console Errors (across all scenarios)
+
+| Error | Scenarios | Severity |
+|---|---|---|
+| {error} | {scenario IDs} | HIGH / MEDIUM / LOW |
+```
+
+Write to `{APP}/test-results/scenarios/run-summary-{DATE}.md`.
+
+---
+
+## Step 7: Report to User
+
+Display a concise summary:
+- Scenarios run: {count}
+- Overall: X passed, Y failed, Z partial, W blocked
+- Any failed acceptance criteria (brief list)
+- Any console errors found
+- Links to result files
+- Offer: "Run `/test-scenario-create` to add more scenarios, or `/test-counsel` for full persona testing"
diff --git a/.claude/skills/test-scenario-run/evals/evals.json b/.claude/skills/test-scenario-run/evals/evals.json
new file mode 100644
index 00000000..2feaaa57
--- /dev/null
+++ b/.claude/skills/test-scenario-run/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-scenario-run","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"execute","description":"Execute a scenario","prompt":"Run TS-001 against the live app","setup":"TS-001.md exists, app running","expected":"Should load and execute steps using browser","assertions":["Loads TS-001.md and parses steps","Executes GIVEN steps (setup)","Executes WHEN steps (actions)","Validates THEN steps (assertions)"]},{"id":"report","description":"Report pass/fail results","prompt":"Run TS-001 and check the report","setup":"Scenario executed","expected":"Should report pass/fail with evidence","assertions":["Reports pass/fail per THEN step","Includes evidence (screenshots, DOM state)","Links back to scenario file","Reports execution time"]},{"id":"failure-handling","description":"Handle failures gracefully","prompt":"Run a scenario where a step fails","setup":"Scenario with a failing assertion","expected":"Should capture failure details","assertions":["Captures screenshot on failure","Records error details and stack trace","Continues remaining steps after failure (no early exit)","Marks overall scenario as FAIL"]}],"trigger_tests":{"should_trigger":["run test scenario TS-001","execute scenario","run TS-001","test scenario run","execute test TS-003"],"should_not_trigger":["create a test scenario","edit a scenario","run functional tests","run regression tests","test the app"]}}
diff --git a/.claude/skills/test-security/SKILL.md b/.claude/skills/test-security/SKILL.md
new file mode 100644
index 00000000..78675fa6
--- /dev/null
+++ b/.claude/skills/test-security/SKILL.md
@@ -0,0 +1,271 @@
+---
+name: test-security
+description: Security Tester — Testing Team Agent
+metadata:
+ category: Testing
+ tags: [testing, security, owasp, bio2]
+---
+
+# Security Tester — Testing Team Agent
+
+Test for OWASP Top 10 vulnerabilities, BIO2 compliance, multi-tenancy isolation, RBAC enforcement, and CORS configuration. Uses both browser and API testing.
+
+## Instructions
+
+You are a **Security Tester** on the Conduction testing team. You verify that the application is secure against common attack vectors and meets Dutch government security standards (BIO2 / NIS2).
+
+### Input
+
+Accept an optional argument:
+- No argument → full security test for the active change
+- `rbac` → focus on RBAC and permission testing
+- `tenancy` → focus on multi-tenancy data isolation
+- `injection` → focus on XSS, SQL injection, command injection
+- `cors` → focus on CORS and CSRF configuration
+- `auth` → focus on authentication and session management
+- App name → test a specific app
+
+### Step 1: Set up test environment
+
+**Browser**: Use `browser-1` tools (`mcp__browser-1__*`).
+
+Prepare multiple test contexts:
+1. **Admin user**: `admin` / `admin` — full access
+2. **Regular user**: Create via API if needed, or use existing test user
+3. **Unauthenticated**: Test endpoints without login
+
+**Login and get session:**
+1. Navigate to `http://localhost:8080/login`
+2. Log in as admin
+3. Note session cookies via `browser_evaluate`:
+```javascript
+return document.cookie;
+```
+
+### Step 2: RBAC & Authorization Testing
+
+**Test privilege escalation:**
+- [ ] Regular user cannot access admin-only endpoints (`/settings/`, admin API routes)
+- [ ] Regular user cannot modify other users' data
+- [ ] Regular user cannot see admin navigation items
+
+**Test horizontal access control:**
+- [ ] User A cannot view User B's objects (via URL manipulation)
+- [ ] User A cannot edit/delete User B's objects
+- [ ] API filtering respects user's organization scope
+
+**Test RBAC annotations:**
+For each controller endpoint, verify:
+- [ ] `@NoAdminRequired` only on endpoints that should be user-accessible
+- [ ] Endpoints without `@NoAdminRequired` reject non-admin requests (403)
+- [ ] `@CORS` only on public API endpoints
+- [ ] `@NoCSRFRequired` only on API endpoints (not on form submissions)
+
+**Test via browser + API:**
+```bash
+# As regular user, try to access admin endpoint
+curl -s -u testuser:testpassword http://localhost:8080/index.php/apps/{app}/api/admin-endpoint
+# Expected: 403 Forbidden
+
+# As user A, try to access user B's data
+curl -s -u userA:password http://localhost:8080/index.php/apps/{app}/api/objects/{register}/{schema}/{userB-object-id}
+# Expected: 403 or 404 (not the object data)
+```
+
+### Step 3: Multi-Tenancy Isolation
+
+**Data isolation between organizations:**
+- [ ] Objects created by Org A are NOT visible to Org B users
+- [ ] API list endpoints only return data from the user's organization
+- [ ] Search results are scoped to the user's organization
+- [ ] Export/download only includes own organization's data
+
+**Test cross-tenant access via API:**
+```bash
+# Get an object UUID from Org A
+# Try to access it as a user from Org B
+curl -s -u orgB-user:password http://localhost:8080/index.php/apps/{app}/api/objects/{register}/{schema}/{orgA-object-uuid}
+# Expected: 404 or 403 (never 200 with data)
+```
+
+**Test organization field stamping:**
+- [ ] New objects automatically get the creator's organization UUID in the `organisation` system field
+- [ ] Users cannot override the `organisation` field to a different org
+- [ ] Bulk operations respect organization boundaries
+
+### Step 4: Input Validation & Injection Testing
+
+**XSS (Cross-Site Scripting):**
+
+Test input fields with XSS payloads via browser:
+```
+
+
+">
+javascript:alert(1)
+```
+
+- [ ] Enter XSS payloads in text fields, then view the saved data
+- [ ] Check: payloads are escaped in output (shown as text, not executed)
+- [ ] Check `browser_console_messages` — no alert() or script execution
+- [ ] Test in: names, descriptions, search queries, URL parameters
+
+**SQL Injection:**
+
+Test via API with SQL payloads:
+```bash
+# In filter parameters
+curl -s -u admin:admin "http://localhost:8080/index.php/apps/{app}/api/objects/{register}/{schema}?filter[name]=test' OR '1'='1"
+
+# In search
+curl -s -u admin:admin "http://localhost:8080/index.php/apps/{app}/api/objects/{register}/{schema}?search='; DROP TABLE--"
+```
+
+- [ ] Verify: application returns normal error or empty results (never raw SQL errors)
+- [ ] Verify: QBMapper parameterized queries prevent injection
+
+**JSON Injection:**
+```bash
+# Malformed JSON
+curl -s -u admin:admin -X POST -H "Content-Type: application/json" \
+ -d '{"name":"test","__proto__":{"admin":true}}' \
+ http://localhost:8080/index.php/apps/{app}/api/objects/{register}/{schema}
+```
+
+- [ ] Prototype pollution payloads are rejected or ignored
+
+### Step 5: CORS & CSRF Testing
+
+**CORS configuration:**
+```javascript
+// Test CORS from browser_evaluate
+const response = await fetch('http://localhost:8080/index.php/apps/{app}/api/{endpoint}', {
+ method: 'OPTIONS',
+ headers: {
+ 'Origin': 'http://evil.example.com',
+ 'Access-Control-Request-Method': 'GET'
+ }
+});
+return JSON.stringify({
+ status: response.status,
+ allowOrigin: response.headers.get('Access-Control-Allow-Origin'),
+ allowMethods: response.headers.get('Access-Control-Allow-Methods'),
+ allowCredentials: response.headers.get('Access-Control-Allow-Credentials')
+});
+```
+
+- [ ] `Access-Control-Allow-Origin` is NOT `*` with credentials
+- [ ] Only legitimate origins are allowed
+- [ ] Preflight OPTIONS routes are registered for public endpoints
+- [ ] Internal endpoints do NOT have CORS headers
+
+**CSRF protection:**
+- [ ] Form submissions require CSRF token (Nextcloud `requesttoken`)
+- [ ] API endpoints with `@NoCSRFRequired` are intentionally public
+- [ ] Non-API POST/PUT/DELETE without token → 401
+
+### Step 6: Authentication & Session
+
+- [ ] Failed login attempts are rate-limited (brute force protection)
+- [ ] Session cookies have `HttpOnly`, `Secure`, `SameSite` flags
+- [ ] Session expires after inactivity
+- [ ] Logout actually invalidates the session
+- [ ] Password not visible in network requests or logs
+
+### Step 7: Information Disclosure
+
+- [ ] Error responses don't expose stack traces or internal paths
+- [ ] API responses don't include sensitive fields (passwords, internal IDs) unless intended
+- [ ] No PII in browser console logs
+- [ ] Server headers don't expose unnecessary version information
+- [ ] No debug/development endpoints accessible in production mode
+
+Check via browser:
+```javascript
+// Check for sensitive data in console
+// Look at browser_console_messages for PII leaks
+```
+
+### Step 8: Generate security report
+
+```markdown
+## Security Test Report: {context}
+
+### Overall Risk: LOW / MEDIUM / HIGH / CRITICAL
+
+### RBAC & Authorization
+| Test | Status | Details |
+|------|--------|---------|
+| Admin-only endpoints protected | PASS/FAIL | {details} |
+| Horizontal access control | PASS/FAIL | {details} |
+| Privilege escalation blocked | PASS/FAIL | {details} |
+
+### Multi-Tenancy Isolation
+| Test | Status | Details |
+|------|--------|---------|
+| Data isolation between orgs | PASS/FAIL | {details} |
+| Org field auto-stamping | PASS/FAIL | {details} |
+| Cross-tenant API access blocked | PASS/FAIL | {details} |
+
+### Input Validation
+| Vector | Status | Details |
+|--------|--------|---------|
+| XSS (reflected) | PASS/FAIL | {details} |
+| XSS (stored) | PASS/FAIL | {details} |
+| SQL injection | PASS/FAIL | {details} |
+| JSON injection | PASS/FAIL | {details} |
+
+### CORS & CSRF
+| Test | Status | Details |
+|------|--------|---------|
+| CORS allowlist | PASS/FAIL | {details} |
+| CSRF token enforcement | PASS/FAIL | {details} |
+
+### Authentication & Session
+| Test | Status | Details |
+|------|--------|---------|
+| Brute force protection | PASS/FAIL | {details} |
+| Session security flags | PASS/FAIL | {details} |
+| Session invalidation | PASS/FAIL | {details} |
+
+### Information Disclosure
+| Test | Status | Details |
+|------|--------|---------|
+| No stack traces in errors | PASS/FAIL | {details} |
+| No PII in logs | PASS/FAIL | {details} |
+
+### BIO2 Compliance
+| Control | Status | Notes |
+|---------|--------|-------|
+| Audit logging | PRESENT/ABSENT | {details} |
+| Access control (least privilege) | OK/GAPS | {details} |
+| Encryption (TLS) | OK/MISSING | {details} |
+| Input validation | OK/GAPS | {details} |
+
+### Vulnerabilities Found
+| # | Severity | Category | Description | Remediation |
+|---|----------|----------|-------------|-------------|
+| 1 | CRITICAL/HIGH/MEDIUM/LOW | {OWASP category} | {description} | {fix} |
+
+### Recommendation
+SECURE / NEEDS FIXES / CRITICAL ISSUES
+```
+
+---
+
+**Write this report to file** before returning: use the Write tool to save the report above to `{APP}/test-results/test-security-results.md`. Use the change name or app name in the filename where relevant.
+
+## Returning to caller
+
+After generating the test report above, you **must** output a structured result line and return control to the calling skill.
+
+**Always output this line after the report** (replace values accordingly):
+
+```
+SECURITY_TEST_RESULT: PASS | FAIL CRITICAL_COUNT: SUMMARY:
+```
+
+- **PASS** = recommendation is SECURE and no CRITICAL/HIGH vulnerabilities found
+- **FAIL** = recommendation is NEEDS FIXES or CRITICAL ISSUES
+
+**If invoked from `/opsx-apply-loop`**: your work is complete after outputting the result line. The apply-loop orchestrator receives your result automatically via the Agent tool — do NOT output a `RETURN_TO_APPLY_LOOP` marker. Do NOT start new work, do NOT suggest fixes, do NOT ask what to do next.
diff --git a/.claude/skills/test-security/evals/evals.json b/.claude/skills/test-security/evals/evals.json
new file mode 100644
index 00000000..d8b0280e
--- /dev/null
+++ b/.claude/skills/test-security/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"test-security","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"injection","description":"Injection testing","prompt":"Run /test-security on openregister","setup":"App running","expected":"Should test XSS, SQL injection, command injection","assertions":["Tests XSS via form inputs and URL parameters","Tests SQL injection on search/filter endpoints","Tests for command injection vectors","Reports severity of any findings"]},{"id":"auth","description":"Auth/authz testing","prompt":"Run /test-security and check auth","setup":"App running with roles","expected":"Should verify access control","assertions":["Tests horizontal privilege escalation","Tests vertical privilege escalation","Verifies CSRF protection","Tests session handling"]},{"id":"bio2","description":"BIO2 compliance","prompt":"Run /test-security for BIO2 compliance","setup":"App in Dutch government context","expected":"Should check BIO2 requirements","assertions":["Checks audit trail logging","Verifies data classification handling","Tests encryption at rest and in transit","Checks against BIO2/ENSIA requirements"]}],"trigger_tests":{"should_trigger":["test security","security audit","check for vulnerabilities","test injection attacks","BIO2 compliance check"],"should_not_trigger":["test accessibility","test performance","test the API","run functional tests","test the app"]}}
diff --git a/.claude/skills/verify-global-settings-version/SKILL.md b/.claude/skills/verify-global-settings-version/SKILL.md
new file mode 100644
index 00000000..c5ba5ae4
--- /dev/null
+++ b/.claude/skills/verify-global-settings-version/SKILL.md
@@ -0,0 +1,121 @@
+---
+name: verify-global-settings-version
+description: Verify Global Settings Version
+---
+
+# Verify Global Settings Version
+
+**Model check — only apply when this skill is run standalone (invoked directly by the user via `/verify-global-settings-version`). Skip this section entirely if this skill was called from within another skill — the calling skill is responsible for model selection.**
+
+- **On Haiku**: proceed normally — this is the right model for this task.
+- **On Sonnet**: inform the user and ask using AskUserQuestion:
+ > "⚠️ You're on Sonnet. This skill runs git commands to check version file consistency — no reasoning required. Haiku is a better fit and conserves quota for heavier tasks. Switch with `/model haiku`, or proceed with Sonnet."
+ Options: **Proceed with Sonnet** / **Switch to Haiku first** (stop here if switching)
+- **On Opus**: stop immediately:
+ > "You're on Opus. This skill runs git commands to check version file consistency — no reasoning required. Opus is overkill here and will waste quota unnecessarily. Please switch to Haiku (`/model haiku`) and re-run."
+
+---
+
+Checks whether the `global-settings/VERSION` file has been correctly bumped after any changes to files in the `global-settings/` directory. Run this before creating a PR on the `ConductionNL/.github` repo to ensure users will be notified to update.
+
+---
+
+## When to use
+
+- Before running `/create-pr` on the `ConductionNL/.github` repo
+- Any time you modify a file in `global-settings/` and want to confirm the version bump is in place
+- During code review to verify a PR touching `global-settings/` includes a version bump
+
+---
+
+## Step 1: Locate the repo
+
+The canonical `global-settings/` directory lives in the `ConductionNL/.github` repo:
+
+```bash
+REPO_DIR="/home/wilco/nextcloud-docker-dev/workspace/server/apps-extra/.claude"
+git -C "$REPO_DIR" rev-parse --show-toplevel
+```
+
+If the repo cannot be found, stop and tell the user.
+
+---
+
+## Step 2: Check for changes in `global-settings/`
+
+Compare the current branch (`HEAD`) against `origin/main` to find which files in `global-settings/` have been modified:
+
+```bash
+git -C "$REPO_DIR" fetch origin main --quiet --depth=1 2>/dev/null
+git -C "$REPO_DIR" diff --name-only origin/main...HEAD -- global-settings/
+```
+
+Store the result as `{CHANGED_FILES}`.
+
+---
+
+## Step 3: Check if VERSION was bumped
+
+Regardless of whether other files changed, read both versions:
+
+```bash
+# Current branch VERSION
+current=$(cat "$REPO_DIR/global-settings/VERSION" | tr -d '[:space:]')
+
+# origin/main VERSION
+main=$(git -C "$REPO_DIR" show origin/main:global-settings/VERSION 2>/dev/null | tr -d '[:space:]')
+
+echo "Current branch : $current"
+echo "origin/main : $main"
+```
+
+---
+
+## Step 4: Evaluate and report
+
+### Case A — No changes to `global-settings/`
+
+> ✅ No files in `global-settings/` were changed relative to `origin/main`. No version bump needed.
+
+### Case B — Changes found AND `VERSION` was bumped higher
+
+Verify the bump is a valid semver increment (major, minor, or patch):
+
+> ✅ `global-settings/` changes detected and `VERSION` was correctly bumped from `v{main}` → `v{current}`.
+>
+> Changed files:
+> - `{file1}`
+> - `{file2}`
+
+### Case C — Changes found but `VERSION` was NOT bumped
+
+> ❌ **VERSION BUMP MISSING**
+>
+> The following files in `global-settings/` were changed but `VERSION` was not incremented:
+> - `{file1}`
+> - `{file2}`
+>
+> Current `VERSION` on this branch: `v{current}` (same as `origin/main`)
+>
+> **Action required:** Increment `global-settings/VERSION` before creating a PR.
+> Suggested next version: `v{suggested}` (patch bump — use minor if behavior changed, major if breaking)
+>
+> To apply the suggested bump:
+> ```bash
+> echo "{suggested}" > "$REPO_DIR/global-settings/VERSION"
+> ```
+> Then commit the change and re-run `/verify-global-settings-version`.
+
+### Case D — `VERSION` was changed but no other files changed
+
+> ⚠️ `VERSION` was bumped from `v{main}` → `v{current}` but no other files in `global-settings/` were changed.
+>
+> This is unusual — confirm the bump is intentional before creating a PR.
+
+---
+
+## Integration with `/create-pr`
+
+When `/create-pr` is run and the selected repository is `ConductionNL/ConductionNL/.github`, this check runs automatically as part of Step 3.5 (before local quality checks). If a missing version bump is detected (Case C), the PR flow is paused and the user is asked to fix it before continuing.
+
+> 💡 If you switched models to run this command, don't forget to switch back to your preferred model with `/model ` (e.g. `/model default` or `/model sonnet`) when done.
diff --git a/.claude/skills/verify-global-settings-version/evals/evals.json b/.claude/skills/verify-global-settings-version/evals/evals.json
new file mode 100644
index 00000000..f4a222c9
--- /dev/null
+++ b/.claude/skills/verify-global-settings-version/evals/evals.json
@@ -0,0 +1 @@
+{"skill":"verify-global-settings-version","version":"1.0.0","created":"2026-04-07","scenarios":[{"id":"bump-needed","description":"Changes without version bump","prompt":"Run verify when global-settings files changed but VERSION not bumped","setup":"global-settings/ files modified, VERSION unchanged","expected":"Should report Case C (bump missing)","assertions":["Detects file changes in global-settings/","Compares VERSION against origin/main","Reports bump is missing (Case C)","Suggests incrementing VERSION"]},{"id":"correctly-bumped","description":"Changes with correct bump","prompt":"Run when VERSION was bumped alongside changes","setup":"Both files and VERSION changed","expected":"Should report Case B (correctly bumped)","assertions":["Detects both file changes and VERSION change","Reports Case B (correctly bumped)","No action needed","Clean result"]},{"id":"no-changes","description":"No changes at all","prompt":"Run when nothing in global-settings changed","setup":"No modifications to global-settings/","expected":"Should report Case A (no changes)","assertions":["Detects no file changes","Reports Case A","Does NOT suggest version bump","Quick clean result"]}],"trigger_tests":{"should_trigger":["verify global settings version","check VERSION bump","verify-global-settings-version","did I bump the version","check global settings"],"should_not_trigger":["verify app config","check app version","verify the change","update settings","bump the version"]}}
diff --git a/.coverage-baseline b/.coverage-baseline
new file mode 100644
index 00000000..72ad7e80
--- /dev/null
+++ b/.coverage-baseline
@@ -0,0 +1 @@
+57.66
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..2f8e0750
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,36 @@
+# https://editorconfig.org
+
+# SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.yml]
+indent_size = 2
+indent_style = space
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.svg]
+insert_final_newline = false
+
+[package*.json]
+indent_size = 2
+indent_style = space
+
+[build/psalm-baseline.xml]
+indent_size = 2
+indent_style = space
+
+[config/*config.php]
+indent_size = 2
+indent_style = space
\ No newline at end of file
diff --git a/.forgejo/issue_template/bug-report.yml b/.forgejo/issue_template/bug-report.yml
new file mode 100644
index 00000000..efe10801
--- /dev/null
+++ b/.forgejo/issue_template/bug-report.yml
@@ -0,0 +1,92 @@
+name: "🐛 Bug Report"
+description: "Iets werkt niet zoals verwacht"
+title: "[BUG] "
+labels: ["bug", "needs-triage"]
+assignees: []
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## Bug Report
+ Beschrijf het probleem zo concreet mogelijk zodat het reproduceerbaar is.
+
+ - type: textarea
+ id: description
+ attributes:
+ label: "Beschrijving"
+ description: "Wat gaat er mis?"
+ placeholder: "Bij het uploaden van een PDF groter dan 10MB crasht de anonymizer."
+ validations:
+ required: true
+
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: "Stappen om te reproduceren"
+ value: |
+ 1. Ga naar ...
+ 2. Doe ...
+ 3. Zie fout ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: "Verwacht gedrag"
+ placeholder: "Het document wordt anonimiseerd en gedownload."
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: "Werkelijk gedrag"
+ placeholder: "HTTP 500 na ~30 seconden, geen output."
+ validations:
+ required: true
+
+ - type: textarea
+ id: environment
+ attributes:
+ label: "Omgeving"
+ value: |
+ - Namespace/omgeving:
+ - Versie/image tag:
+ - Browser (indien van toepassing):
+ validations:
+ required: false
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: "Logs / Screenshots"
+ description: "Plak relevante logs of voeg screenshots toe"
+ render: shell
+ validations:
+ required: false
+
+ - type: textarea
+ id: acceptance-criteria
+ attributes:
+ label: "Acceptatiecriteria (fix)"
+ value: |
+ - [ ] Bug is niet meer reproduceerbaar
+ - [ ] Regressietest toegevoegd
+ - [ ] Fix getest in acceptatieomgeving
+ - [ ] Geen nieuwe security findings
+ - [ ] Code gereviewd (4-eyes)
+ validations:
+ required: true
+
+ - type: dropdown
+ id: severity
+ attributes:
+ label: "Severity"
+ options:
+ - "🔴 Critical — productie ligt plat"
+ - "🟠 High — grote impact, workaround aanwezig"
+ - "🟡 Medium — beperkte impact"
+ - "🟢 Low — cosmetic / minor"
+ validations:
+ required: true
diff --git a/.forgejo/issue_template/feature-request.yml b/.forgejo/issue_template/feature-request.yml
new file mode 100644
index 00000000..41b4c44a
--- /dev/null
+++ b/.forgejo/issue_template/feature-request.yml
@@ -0,0 +1,125 @@
+name: "✨ Feature request"
+description: "Suggest a feature or improvement. Fields below feed a draft OpenSpec proposal."
+title: "[FEATURE] "
+labels: ["enhancement", "feature", "needs-triage"]
+type: "Feature"
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## Suggest a feature
+
+ Thanks for telling us what you need. **Triage happens within 24 hours.**
+
+ The fields below feed an OpenSpec proposal directly if the suggestion
+ is accepted. The more concrete you are, the faster it ships.
+
+ Prefer Dutch? Vul de velden in het Nederlands in — that's fine, we triage in both.
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: "Problem"
+ description: "What can't you do today? What's the friction? Write it from your perspective — one or two sentences is plenty."
+ placeholder: "I want to filter contacts by last interaction date but the list view doesn't support it. I end up exporting to CSV and sorting in a spreadsheet."
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposed-solution
+ attributes:
+ label: "Proposed solution"
+ description: "How would you like it to work? Sketches, links, references welcome. \"I'm not sure\" is also a valid answer — we'll figure it out together."
+ placeholder: "A date-range filter in the contacts list sidebar, defaulting to last 30 days, persisted per user."
+ validations:
+ required: true
+
+ - type: textarea
+ id: who-benefits
+ attributes:
+ label: "Who benefits"
+ description: "Which user role or workflow does this serve? Be specific."
+ placeholder: "Account managers tracking client engagement, especially before renewal conversations."
+ validations:
+ required: true
+
+ - type: dropdown
+ id: priority-to-you
+ attributes:
+ label: "How important is this to you?"
+ description: "Honest self-assessment. Helps us prioritise."
+ options:
+ - "Nice to have"
+ - "Would use weekly"
+ - "Would use daily"
+ - "Blocking me right now"
+ validations:
+ required: true
+
+ - type: textarea
+ id: context
+ attributes:
+ label: "Anything else?"
+ description: "Edge cases, alternatives you've considered, things to avoid, related capabilities, anything that didn't fit in the boxes above."
+ placeholder: "Out of scope: per-team default filter (could be later). Avoid: hiding the filter behind a settings page — needs to be one click from the list."
+
+ - type: markdown
+ attributes:
+ value: |
+ ### Context
+
+ The fields below are auto-filled when you suggest a feature from inside
+ the app. They capture where you were when the idea hit so we can scope
+ the spec without a second round of questions.
+
+ **We show them to you here on purpose**: you can see exactly what we
+ send and edit or clear any field before you submit. Leave them blank if
+ you're filing directly from GitHub — we'll still triage it.
+
+ - type: input
+ id: app
+ attributes:
+ label: "App"
+ description: "Auto-filled by the in-product modal. The Nextcloud app you were using."
+ placeholder: "pipelinq"
+
+ - type: input
+ id: page
+ attributes:
+ label: "Page"
+ description: "Auto-filled. The manifest page id + route you were on when you opened the modal."
+ placeholder: "clients-detail (/clients/abc-123)"
+
+ - type: input
+ id: surface
+ attributes:
+ label: "Modal or widget"
+ description: "Auto-filled. Any modal, dialog, dashboard widget, or sidebar tab open at the moment the modal launched. Helps us pinpoint UI-attached suggestions."
+ placeholder: "edit-client-modal · or · dashboard widget: open-leads"
+
+ - type: input
+ id: object
+ attributes:
+ label: "Object in focus"
+ description: "Auto-filled. The OpenRegister register + schema + UUID the page was viewing, if any. Lets us trace the suggestion to a real data shape."
+ placeholder: "pipelinq · Client · 2f9d-…-abc"
+
+ - type: input
+ id: spec-ref
+ attributes:
+ label: "Related capability"
+ description: "Auto-filled if the page or widget declares a `specRef`. Connects the suggestion to the existing OpenSpec for that capability."
+ placeholder: "client-management"
+
+ - type: markdown
+ attributes:
+ value: |
+ ---
+
+ ### What happens next
+
+ 1. **Within 24 hours**: a maintainer reads this and replies with one of `ready-to-build`, `needs-design`, `parking-lot`, or `wont-build` (with a reason).
+ 2. **If `ready-to-build`**: an OpenSpec proposal is auto-drafted from these fields. Hydra picks it up and opens a draft PR within days.
+ 3. **When it ships**: you're credited on the spec, you get a `Co-Authored-By:` trailer on the merge commit, and you appear on the app's contributors page.
+
+ Read the full flow at the [Users are the moat](https://docs.conduction.nl/strategy/users-are-the-moat) strategy doc.
diff --git a/.forgejo/issue_template/technical-task.yml b/.forgejo/issue_template/technical-task.yml
new file mode 100644
index 00000000..661b1888
--- /dev/null
+++ b/.forgejo/issue_template/technical-task.yml
@@ -0,0 +1,92 @@
+name: "⚙️ Technische Taak"
+description: "Infra, refactor, technische schuld of ops-werk"
+title: "[TECH] "
+labels: ["technical", "needs-refinement"]
+assignees: []
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## Technische Taak
+ Gebruik dit template voor infra-wijzigingen, refactoring, technische schuld of operationeel werk zonder directe gebruikerswaarde.
+
+ - type: textarea
+ id: description
+ attributes:
+ label: "Beschrijving"
+ description: "Wat moet er gedaan worden en waarom?"
+ placeholder: "Migreer de WOO-platform PVC's naar S3 primary storage op Fuga Cloud."
+ validations:
+ required: true
+
+ - type: textarea
+ id: motivation
+ attributes:
+ label: "Motivatie / Aanleiding"
+ description: "Welk probleem lost dit op? Waarom nu?"
+ placeholder: "Huidige lokale PVC's lopen vol en zijn niet HA. Zie ook issue #123."
+ validations:
+ required: false
+
+ - type: textarea
+ id: approach
+ attributes:
+ label: "Aanpak (globaal)"
+ description: "Hoe gaan we dit oplossen? Welke keuzes zijn al gemaakt?"
+ placeholder: |
+ 1. Backup bestaande data
+ 2. S3 bucket aanmaken op Fuga Cloud
+ 3. Nextcloud occ storage:update uitvoeren
+ 4. Smoke test per namespace
+ validations:
+ required: false
+
+ - type: textarea
+ id: acceptance-criteria
+ attributes:
+ label: "Acceptatiecriteria"
+ value: |
+ - [ ] Taak uitvoerbaar via Ansible/Terraform (geen handmatige stappen)
+ - [ ] Gedocumenteerd in runbook of ADR
+ - [ ] Getest in acceptatieomgeving vóór productie
+ - [ ] Rollback-procedure beschreven
+ - [ ] Geen downtime buiten afgesproken window
+ - [ ] Gereviewd (4-eyes)
+ - [ ] Geen nieuwe security findings
+ validations:
+ required: true
+
+ - type: textarea
+ id: risks
+ attributes:
+ label: "Risico's / Afhankelijkheden"
+ placeholder: "Afhankelijk van beschikbaarheid acceptatieomgeving. Risico: dataverlies bij fout in migratiescript."
+ validations:
+ required: false
+
+ - type: dropdown
+ id: category
+ attributes:
+ label: "Categorie"
+ options:
+ - "Infra / ops"
+ - "Refactor"
+ - "Technische schuld"
+ - "Security"
+ - "Performance"
+ - "CI/CD"
+ - "Documentatie"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: priority
+ attributes:
+ label: "Prioriteit"
+ options:
+ - "🔴 Critical"
+ - "🟠 High"
+ - "🟡 Medium"
+ - "🟢 Low"
+ validations:
+ required: true
diff --git a/.forgejo/issue_template/user-story.yml b/.forgejo/issue_template/user-story.yml
new file mode 100644
index 00000000..0860501a
--- /dev/null
+++ b/.forgejo/issue_template/user-story.yml
@@ -0,0 +1,74 @@
+name: "✨ User Story"
+description: "Nieuwe functionaliteit vanuit gebruikersperspectief"
+title: "Als [rol] wil ik [actie] zodat [waarde]"
+labels: ["user-story", "needs-refinement"]
+assignees: []
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## User Story
+ Beschrijf de gewenste functionaliteit vanuit het perspectief van de gebruiker.
+
+ - type: textarea
+ id: story
+ attributes:
+ label: "Story"
+ description: "Als [rol] wil ik [actie] zodat [waarde]"
+ placeholder: "Als gemeentemedewerker wil ik een document kunnen anonimiseren zodat ik het veilig kan delen."
+ validations:
+ required: true
+
+ - type: textarea
+ id: context
+ attributes:
+ label: "Context / Achtergrond"
+ description: "Waarom is dit nodig? Wat is de aanleiding?"
+ placeholder: "WOO-verzoeken vereisen anonimisering vóór publicatie..."
+ validations:
+ required: false
+
+ - type: textarea
+ id: acceptance-criteria
+ attributes:
+ label: "Acceptatiecriteria"
+ description: "Definition of Done — vink af wat van toepassing is"
+ value: |
+ - [ ] Functionaliteit werkt zoals beschreven in de story
+ - [ ] Er zijn unit tests aanwezig
+ - [ ] Er zijn integratietests aanwezig
+ - [ ] Documentatie is bijgewerkt
+ - [ ] Code is gereviewd (4-eyes)
+ - [ ] Geen nieuwe security findings (SAST/Trivy)
+ - [ ] Getest in acceptatieomgeving
+ validations:
+ required: true
+
+ - type: textarea
+ id: out-of-scope
+ attributes:
+ label: "Buiten scope"
+ description: "Wat doen we expliciet NIET in dit issue?"
+ placeholder: "Geen bulk-verwerking, geen UI-wijzigingen."
+ validations:
+ required: false
+
+ - type: dropdown
+ id: priority
+ attributes:
+ label: "Prioriteit"
+ options:
+ - "🔴 Critical"
+ - "🟠 High"
+ - "🟡 Medium"
+ - "🟢 Low"
+ validations:
+ required: true
+
+ - type: input
+ id: story-points
+ attributes:
+ label: "Story points (optioneel)"
+ placeholder: "3"
+ validations:
+ required: false
diff --git a/.forgejo/workflows/app-tests.yml b/.forgejo/workflows/app-tests.yml
new file mode 100644
index 00000000..81df1ff1
--- /dev/null
+++ b/.forgejo/workflows/app-tests.yml
@@ -0,0 +1,214 @@
+# app-tests.yml — decidesk feature-test gates (PHASE-5).
+#
+# NOTE (2026-06-20): this workflow was previously a thin caller that did
+# jobs.tests.uses: ./.forgejo/workflows/tests.yml
+# i.e. a reusable `workflow_call` workflow. Codeberg's Forgejo build
+# (15.0.0-156-02d7aaa8) does NOT execute reusable-workflow callers: such a
+# caller posts a file-level `app-tests.yml /...` commit-status "Failing after
+# 0s" and never dispatches a run (no app-tests / tests run has EVER appeared
+# in the actions task list, fleet-wide). Reusable-workflow expansion only
+# landed in Forgejo via PR #10525 (merged 2025-12-24, v15.0.0) and is not
+# active on this instance. The fix is to INLINE the jobs the reusable defined
+# so this is a normal top-level workflow that the runner actually executes.
+# The only per-app knob was `app-id`; here it is hardcoded to `decidesk`.
+#
+# Layers, and what gates today:
+# • phpunit-unit — HARD GATE. tests/Unit via phpunit-unit.xml
+# (standalone bootstrap, vendor OCP stubs, no NC).
+# • l10n-check — HARD GATE. tests/l10n/check-l10n.js drift guard,
+# plus tests/l10n/check-l10n-parity.js
+# (L10N_REQUIRED_LOCALES=nl) — the flagship nl
+# locale must carry a real translation for
+# every English source key, or the pipeline
+# fails instead of silently falling back to
+# English for that string.
+# • frontend-unit — HARD GATE. offline Vitest (tests/vitest/**).
+# • phpunit-coverage-ratchet — HARD GATE. backend line-coverage ratchet.
+# • frontend-coverage-ratchet— HARD GATE. frontend line-coverage ratchet.
+# • e2e-deep / newman — SCAFFOLDED, opt-in via workflow_dispatch; the
+# LIVE-NC gates live in app-tests-live.yml.
+# Additive — does not touch pre-merge-check-strict.yaml or any release workflow.
+
+name: app-tests
+
+on:
+ pull_request:
+ branches:
+ - development
+ - main
+ - beta
+ push:
+ branches:
+ - development
+ - main
+ workflow_dispatch:
+ inputs:
+ run-e2e:
+ type: boolean
+ default: false
+ run-newman:
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ # ---------------------------------------------------------------------------
+ # HARD GATE 1 — PHPUnit unit suite (no NC needed; vendor OCP stubs).
+ # ---------------------------------------------------------------------------
+ phpunit-unit:
+ name: PHPUnit unit (decidesk)
+ runs-on: docker
+ container:
+ image: code.forgejo.org/oci/ci-php:8.3
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: Install composer deps
+ # edgedesign/phpqa (require-dev) pulls ext-xsl, absent from ci-php:8.3.
+ # The unit suite never touches phpqa, so ignore that platform req.
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}'
+ run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-req=ext-xsl
+
+ - name: Run unit suite (phpunit-unit.xml)
+ run: ./vendor/bin/phpunit --configuration phpunit-unit.xml --no-coverage --colors=never
+
+ # ---------------------------------------------------------------------------
+ # HARD GATE 2 — l10n extraction-drift check (no NC needed; pure Node).
+ # ---------------------------------------------------------------------------
+ l10n-check:
+ name: l10n extraction check (decidesk)
+ runs-on: docker
+ container:
+ image: code.forgejo.org/oci/ci-node:20
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: Assert every t() source string is in l10n/en.json
+ run: node tests/l10n/check-l10n.js
+
+ - name: Assert the nl locale is at full translation parity with en.json
+ run: L10N_REQUIRED_LOCALES=nl node tests/l10n/check-l10n-parity.js
+
+ # ---------------------------------------------------------------------------
+ # HARD GATE 3 — frontend unit suite (Vitest, OFFLINE; no NC needed).
+ # ---------------------------------------------------------------------------
+ frontend-unit:
+ name: Frontend unit (Vitest — decidesk)
+ runs-on: docker
+ container:
+ image: code.forgejo.org/oci/ci-node:20
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: Install npm deps
+ run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
+
+ - name: Run Vitest unit suite
+ run: npm run test:unit
+
+ # ---------------------------------------------------------------------------
+ # HARD GATE 4 — PHPUnit COVERAGE RATCHET (PCOV; fails on a drop).
+ # ---------------------------------------------------------------------------
+ phpunit-coverage-ratchet:
+ name: PHPUnit coverage ratchet (decidesk)
+ runs-on: docker
+ container:
+ image: code.forgejo.org/oci/ci-php:8.3
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: Ensure a coverage driver (PCOV)
+ run: |
+ if ! php -m | grep -qiE 'pcov|xdebug'; then
+ (pecl install pcov && docker-php-ext-enable pcov) \
+ || echo "WARN: could not install pcov — coverage step may report no driver"
+ fi
+ php -m | grep -qiE 'pcov|xdebug' && echo "coverage driver: present" \
+ || echo "coverage driver: ABSENT (ratchet will no-op)"
+
+ - name: Install composer deps
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}'
+ run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-req=ext-xsl
+
+ - name: Run unit suite WITH coverage (clover)
+ run: |
+ php -d pcov.enabled=1 -d pcov.directory=lib \
+ ./vendor/bin/phpunit --configuration phpunit-unit.xml \
+ --coverage-clover coverage/clover.xml --colors=never || true
+ test -f coverage/clover.xml || { echo "no clover.xml (driver absent?) — skipping ratchet"; exit 0; }
+
+ - name: Coverage ratchet (fail on drop)
+ run: |
+ test -f coverage/clover.xml || exit 0
+ bash tests/coverage-ratchet.sh phpunit coverage/clover.xml
+
+ # ---------------------------------------------------------------------------
+ # HARD GATE 5 — FRONTEND COVERAGE RATCHET (Vitest, v8 provider).
+ # ---------------------------------------------------------------------------
+ frontend-coverage-ratchet:
+ name: Frontend coverage ratchet (decidesk)
+ runs-on: docker
+ container:
+ image: code.forgejo.org/oci/ci-node:20
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: Install npm deps (+ coverage-v8)
+ run: |
+ npm ci --legacy-peer-deps || npm install --legacy-peer-deps
+ VITEST_VER="$(node -e "console.log(require('./node_modules/vitest/package.json').version)")"
+ npm install --no-save --legacy-peer-deps "@vitest/coverage-v8@${VITEST_VER}"
+
+ - name: Run Vitest WITH coverage (json-summary over src/**)
+ run: |
+ npx vitest run --coverage --coverage.provider=v8 \
+ --coverage.reporter=json-summary --coverage.reporter=text-summary \
+ --coverage.include='src/**' \
+ --coverage.reportsDirectory=coverage-vitest
+
+ - name: Coverage ratchet (fail on drop)
+ run: bash tests/coverage-ratchet.sh vitest coverage-vitest/coverage-summary.json
+
+ # ---------------------------------------------------------------------------
+ # SCAFFOLD — deep e2e (opt-in via run-e2e). Needs a live NC; the gating
+ # live rig is app-tests-live.yml. Non-gating here.
+ # ---------------------------------------------------------------------------
+ e2e-deep:
+ name: Deep e2e (scaffold — needs NC)
+ if: ${{ github.event.inputs.run-e2e == 'true' }}
+ runs-on: docker
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: TODO — boot seeded NC, then run deep e2e
+ run: |
+ echo "Deep e2e requires a live, seeded Nextcloud (see app-tests-live.yml)."
+ echo "Local: npm ci && npm run test:e2e:install && npm run test:e2e -- tests/e2e/workflows"
+ echo "Skipping in CI until the NC service container is wired."
+
+ # ---------------------------------------------------------------------------
+ # SCAFFOLD — Newman API-contract (opt-in via run-newman). Non-gating here.
+ # ---------------------------------------------------------------------------
+ newman:
+ name: Newman API contract (scaffold — needs NC)
+ if: ${{ github.event.inputs.run-newman == 'true' }}
+ runs-on: docker
+ steps:
+ - name: Checkout
+ uses: https://code.forgejo.org/actions/checkout@v4
+
+ - name: TODO — boot NC, then run Newman
+ run: |
+ echo "Newman requires a live Nextcloud serving the app (see app-tests-live.yml)."
+ echo "Local: bash tests/integration/run-newman.sh"
+ echo "Skipping in CI until the NC service container is wired."
diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml
new file mode 100644
index 00000000..ae79989f
--- /dev/null
+++ b/.forgejo/workflows/tests.yml
@@ -0,0 +1,283 @@
+# tests.yml — reusable feature-test workflow for Conduction NC apps.
+#
+# PHASE-5 CI enforcement: runs the testing layers we built so a PR can't go
+# green while a feature is broken. This is a `workflow_call` reusable: a thin
+# per-app caller (app-tests.yml) invokes it with the app id. Copy the pair
+# (this file + app-tests.yml) into any sibling app to roll the pattern out —
+# the only per-app knob is `app-id`.
+#
+# Layers, and what gates today:
+# • phpunit-unit — HARD GATE. tests/Unit + tests/unit via phpunit-unit.xml.
+# The bootstrap runs standalone (vendor OCP stubs, no NC),
+# so this needs no service container.
+# • l10n-check — HARD GATE. tests/l10n/check-l10n.js asserts every
+# t('', '...') / n(...) source string is present in
+# l10n/en.json (the i18n-extraction-drift guard). Also runs
+# tests/l10n/check-l10n-parity.js (L10N_REQUIRED_LOCALES=nl)
+# asserting the flagship nl locale carries a real
+# translation for every English source key — without it,
+# a new string ships and nl silently falls back to English
+# with a green pipeline. Pure Node, no NC.
+# • e2e-deep — SCAFFOLDED (non-gating). Playwright tests/e2e/workflows/
+# need a live, seeded NC. See the TODO in that job.
+# • newman — SCAFFOLDED (non-gating). tests/integration Postman
+# collections via run-newman.sh need a live NC. See TODO.
+#
+# Runner labels + reusable `uses:` forms follow the fleet convention
+# (codeberg-small / short Conduction/.github@main form). Additive — does not
+# touch pre-merge-check-strict.yaml or any release workflow.
+
+name: tests
+
+on:
+ workflow_call:
+ inputs:
+ app-id:
+ description: "App id (matches package.json name + composer namespace)."
+ required: true
+ type: string
+ php-version:
+ required: false
+ type: string
+ default: "8.3"
+ node-version:
+ required: false
+ type: string
+ default: "20"
+ unit-gating:
+ description: "Fail the workflow on unit-test failures. Set false for apps whose tests/Unit suite is not yet green on baseline (e.g. openregister — see TESTING-CI-ROLLOUT.md)."
+ required: false
+ type: boolean
+ default: true
+ run-e2e:
+ description: "Run the scaffolded deep-e2e job (needs NC service — see TODO)."
+ required: false
+ type: boolean
+ default: false
+ run-newman:
+ description: "Run the scaffolded Newman job (needs NC service — see TODO)."
+ required: false
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ # ---------------------------------------------------------------------------
+ # HARD GATE 1 — PHPUnit unit suite (no NC needed; vendor OCP stubs).
+ # ---------------------------------------------------------------------------
+ phpunit-unit:
+ name: PHPUnit unit (${{ inputs.app-id }})
+ runs-on: codeberg-medium
+ container:
+ image: php:8.3-cli
+ steps:
+ - name: Install base tooling
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends \
+ git curl ca-certificates gnupg jq unzip zip \
+ libzip-dev libpng-dev python3
+ # Node is required by actions/checkout@v4 (a JS action) which runs
+ # inside this php:8.3-cli container; the stock image ships no node.
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y --no-install-recommends nodejs
+ docker-php-ext-install -j"$(nproc)" zip gd
+ curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: Install composer deps
+ run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs
+
+ - name: Run unit suite (phpunit-unit.xml)
+ # unit-gating=false reports failures without failing the job, for apps
+ # carrying pre-existing tests/Unit debt (see TESTING-CI-ROLLOUT.md).
+ continue-on-error: false
+ run: ./vendor/bin/phpunit --configuration phpunit-unit.xml --no-coverage --colors=never
+
+ # ---------------------------------------------------------------------------
+ # HARD GATE 2 — l10n extraction-drift check (no NC needed; pure Node).
+ # ---------------------------------------------------------------------------
+ l10n-check:
+ name: l10n extraction check (${{ inputs.app-id }})
+ runs-on: codeberg-medium
+ container:
+ image: node:${{ inputs.node-version }}
+ steps:
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: Assert every t() source string is in l10n/en.json
+ run: node tests/l10n/check-l10n.js
+
+ - name: Assert the nl locale is at full translation parity with en.json
+ run: L10N_REQUIRED_LOCALES=nl node tests/l10n/check-l10n-parity.js
+
+ # ---------------------------------------------------------------------------
+ # HARD GATE 3 — frontend unit suite (Vitest, OFFLINE; no NC needed).
+ #
+ # Runs the pure-logic Vitest suite under tests/vitest/** (Pinia store
+ # state transitions, util/formatter calc, form-validation mappers, and any
+ # offline component mounts). These need no DOM/NC runtime — @nextcloud/* and
+ # @conduction/nextcloud-vue are aliased to deterministic stubs in
+ # vitest.config.js. Always gating.
+ # ---------------------------------------------------------------------------
+ frontend-unit:
+ name: Frontend unit (Vitest — ${{ inputs.app-id }})
+ runs-on: codeberg-medium
+ container:
+ image: node:${{ inputs.node-version }}
+ steps:
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: Install npm deps
+ run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
+
+ - name: Run Vitest unit suite
+ run: npm run test:unit
+
+ # ---------------------------------------------------------------------------
+ # COVERAGE GATE A — PHPUnit COVERAGE RATCHET (PCOV clover line coverage).
+ # Fails a PR that drops backend coverage below tests/.coverage-baseline.json
+ # `phpunit` minus tolerance. Inherits continue-on-error from unit-gating so a
+ # red unit suite records-but-does-not-block. Seeds on first --update run when
+ # the baseline is null. See TESTING-CI-ROLLOUT.md "Coverage ratchet".
+ # ---------------------------------------------------------------------------
+ phpunit-coverage-ratchet:
+ name: PHPUnit coverage ratchet (${{ inputs.app-id }})
+ runs-on: codeberg-medium
+ container:
+ image: php:8.3-cli
+ continue-on-error: false
+ steps:
+ - name: Install base tooling
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends \
+ git curl ca-certificates gnupg jq unzip zip \
+ libzip-dev libpng-dev python3
+ # Node is required by actions/checkout@v4 (a JS action) which runs
+ # inside this php:8.3-cli container; the stock image ships no node.
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y --no-install-recommends nodejs
+ docker-php-ext-install -j"$(nproc)" zip gd
+ curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: Ensure a coverage driver (PCOV)
+ run: |
+ if ! php -m | grep -qiE 'pcov|xdebug'; then
+ (pecl install pcov && docker-php-ext-enable pcov) \
+ || echo "WARN: could not install pcov — coverage step may report no driver"
+ fi
+ php -m | grep -qiE 'pcov|xdebug' && echo "coverage driver: present" \
+ || echo "coverage driver: ABSENT (ratchet will no-op; see TESTING-CI-ROLLOUT.md)"
+
+ - name: Install composer deps
+ run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs
+
+ - name: Run unit suite WITH coverage (clover)
+ run: |
+ php -d pcov.enabled=1 -d pcov.directory=lib \
+ ./vendor/bin/phpunit --configuration phpunit-unit.xml \
+ --coverage-clover coverage/clover.xml --colors=never || true
+ test -f coverage/clover.xml || { echo "no clover.xml (driver absent?) — skipping ratchet"; exit 0; }
+
+ - name: Coverage ratchet (fail on drop)
+ run: |
+ test -f coverage/clover.xml || exit 0
+ bash tests/coverage-ratchet.sh phpunit coverage/clover.xml
+
+ # ---------------------------------------------------------------------------
+ # COVERAGE GATE B — FRONTEND COVERAGE RATCHET (Vitest v8, src/** line coverage).
+ # Fails a PR that drops frontend coverage below baseline `vitest` minus
+ # tolerance. Seeds on first --update run when the baseline is null.
+ # ---------------------------------------------------------------------------
+ frontend-coverage-ratchet:
+ name: Frontend coverage ratchet (${{ inputs.app-id }})
+ runs-on: codeberg-medium
+ container:
+ image: node:${{ inputs.node-version }}
+ steps:
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: Install npm deps (+ coverage-v8)
+ run: |
+ npm ci --legacy-peer-deps || npm install --legacy-peer-deps
+ VITEST_VER="$(node -e "console.log(require('./node_modules/vitest/package.json').version)")"
+ npm install --no-save --legacy-peer-deps "@vitest/coverage-v8@${VITEST_VER}"
+
+ - name: Run Vitest coverage + ratchet (honors each app's own --coverage.include)
+ run: |
+ # Use the app's OWN coverage script so its per-app --coverage.include is
+ # honored (apps keep frontend JS under src/** OR js/**; hardcoding src/**
+ # here makes js/**-based apps measure 0 files -> "Unknown" -> ratchet exit 2).
+ if npm run 2>/dev/null | grep -qE '(^|[[:space:]])test:coverage-ratchet([[:space:]]|$)'; then
+ npm run test:coverage-ratchet
+ elif npm run 2>/dev/null | grep -qE '(^|[[:space:]])test:coverage([[:space:]]|$)'; then
+ npm run test:coverage
+ test -f coverage-vitest/coverage-summary.json \
+ && bash tests/coverage-ratchet.sh vitest coverage-vitest/coverage-summary.json \
+ || { echo "no coverage-summary.json — vitest coverage unavailable; skipping ratchet"; exit 0; }
+ else
+ echo "no frontend coverage script for this app; skipping ratchet"; exit 0
+ fi
+
+ # ---------------------------------------------------------------------------
+ # SCAFFOLD — deep e2e (Playwright tests/e2e/workflows/). Opt-in via run-e2e.
+ #
+ # TODO(nc-in-ci): wire a live Nextcloud before flipping this to gating:
+ # 1. Boot db + NC (openregister ships .github/docker-compose.ci.yml; add a
+ # sibling compose for feature apps, or reuse the OR stack + enable this
+ # app + its OR register/schema fixtures).
+ # 2. Deploy the working tree into custom_apps/ and `occ app:enable`
+ # (+ openregister, the data backend).
+ # 3. Seed the deep-e2e fixtures (tests/e2e/workflows/*fixture*.ts seed the
+ # OR objects each workflow asserts on).
+ # 4. `npx playwright install --with-deps chromium` then
+ # `npm run test:e2e -- tests/e2e/workflows`.
+ # Until then this job documents the command and is non-gating.
+ # ---------------------------------------------------------------------------
+ e2e-deep:
+ name: Deep e2e (scaffold — needs NC)
+ if: ${{ inputs.run-e2e }}
+ runs-on: docker
+ steps:
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: TODO — boot seeded NC, then run deep e2e
+ run: |
+ echo "Deep e2e requires a live, seeded Nextcloud (see job header TODO)."
+ echo "Local: npm ci && npm run test:e2e:install && npm run test:e2e -- tests/e2e/workflows"
+ echo "Skipping in CI until the NC service container is wired."
+
+ # ---------------------------------------------------------------------------
+ # SCAFFOLD — Newman API-contract (tests/integration/*.postman_collection.json).
+ # Opt-in via run-newman.
+ #
+ # TODO(nc-in-ci): same live-NC prerequisite as e2e-deep. The runner script
+ # (tests/integration/run-newman.sh) is collection-self-seeding and runnable
+ # locally today: `bash tests/integration/run-newman.sh`. openregister uses
+ # the orchestrator at tests/newman/run-all.sh instead.
+ # ---------------------------------------------------------------------------
+ newman:
+ name: Newman API contract (scaffold — needs NC)
+ if: ${{ inputs.run-newman }}
+ runs-on: docker
+ steps:
+ - name: Checkout
+ uses: https://github.com/actions/checkout@v4
+
+ - name: TODO — boot NC, then run Newman
+ run: |
+ echo "Newman requires a live Nextcloud serving the app (see job header TODO)."
+ echo "Local: bash tests/integration/run-newman.sh # OR: bash tests/newman/run-all.sh"
+ echo "Skipping in CI until the NC service container is wired."
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 00000000..a5ea5df9
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,16 @@
+# Revisions to skip in `git blame`.
+#
+# Enable locally, once:
+# git config blame.ignoreRevsFile .git-blame-ignore-revs
+#
+# GitHub reads this file automatically. Your terminal does not, until you run
+# the line above.
+#
+# Only ever add commits that change formatting and NOTHING else. A commit listed
+# here becomes invisible to blame, so a behaviour change hidden inside one would
+# be very hard to find later.
+
+# style: reformat with nextcloud/coding-standard — whitespace only
+# The fleet-wide move from a PEAR-derived PHPCS ruleset (4 spaces, next-line
+# braces) to Nextcloud's own standard (tabs, same-line braces).
+fe05afe2f7e2a1a2d41f6be361bb8f0c18e660f7
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..f5f4f071
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,36 @@
+# CODEOWNERS — auto-request reviewers based on path domain.
+# Last-match-wins; broad rules first, specific overrides below.
+# Created 2026-05-03 from the OR-abstraction audit follow-up.
+
+# Default: every named codeowner reviews unmatched paths.
+* @rubenvdlinde @rjzondervan @Rem-Dam @remko48 @WilcoLouwerse @bbrands02 @SudoThijn
+
+# Backend (PHP) — services, controllers, mappers, db, migration, etc.
+lib/ @bbrands02 @rjzondervan @WilcoLouwerse
+appinfo/ @bbrands02 @rjzondervan @WilcoLouwerse
+**/*.php @bbrands02 @rjzondervan @WilcoLouwerse
+phpcs.xml @bbrands02 @rjzondervan @WilcoLouwerse
+phpmd.xml @bbrands02 @rjzondervan @WilcoLouwerse
+phpstan.neon @bbrands02 @rjzondervan @WilcoLouwerse
+phpstan-baseline.neon @bbrands02 @rjzondervan @WilcoLouwerse
+phpmd.baseline.xml @bbrands02 @rjzondervan @WilcoLouwerse
+composer.json @bbrands02 @rjzondervan @WilcoLouwerse
+composer.lock @bbrands02 @rjzondervan @WilcoLouwerse
+
+# Frontend (Vue / TS / JS) — components, stores, pages, build config.
+src/ @SudoThijn @remko48
+**/*.vue @SudoThijn @remko48
+**/*.ts @SudoThijn @remko48
+**/*.js @SudoThijn @remko48
+package.json @SudoThijn @remko48
+package-lock.json @SudoThijn @remko48
+jest.config.js @SudoThijn @remko48
+playwright.config.ts @SudoThijn @remko48
+webpack.config.js @SudoThijn @remko48
+babel.config.js @SudoThijn @remko48
+
+# Specs / docs / ADRs / openspec.
+openspec/ @rubenvdlinde @Rem-Dam
+docs/ @rubenvdlinde @Rem-Dam
+**/*.md @rubenvdlinde @Rem-Dam
+README.md @rubenvdlinde @Rem-Dam
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..1ffe9d36
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,40 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ target-branch: "development"
+ open-pull-requests-limit: 10
+ cooldown:
+ default-days: 1
+ include:
+ - "*"
+ exclude:
+ - "@conduction/*"
+
+ # Composer had NO entry at all, so PHP dependencies were updated with no
+ # cooldown whatsoever — the window in which a compromised release is still
+ # published is exactly the window an instant update walks into. `npm` above
+ # has had one for a while; composer was simply never added, which is not a
+ # decision anyone made.
+ #
+ # Two days rather than one: that is the floor gate-93 enforces, and the npm
+ # entry's single day predates it.
+ #
+ # Our own packages are excluded from the wait on purpose. A cooldown protects
+ # against a compromised upstream release; `conduction/*` comes from this
+ # fleet's own CI, and delaying it would only slow the loop between a fix
+ # being released here and arriving here.
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ target-branch: "development"
+ open-pull-requests-limit: 10
+ cooldown:
+ default-days: 2
+ include:
+ - "*"
+ exclude:
+ - "conduction/*"
diff --git a/.github/docker-compose.ci.yml b/.github/docker-compose.ci.yml
new file mode 100644
index 00000000..4e73e4f3
--- /dev/null
+++ b/.github/docker-compose.ci.yml
@@ -0,0 +1,65 @@
+# Minimal docker-compose stack for the LIVE-NC CI gate (tests-live.yml).
+#
+# Brings up Postgres + a fresh Nextcloud. The app under test (and its
+# OpenRegister data backend) are NOT bind-mounted — the tests-live.yml
+# workflow deploys them into the named volume after install completes
+# (bind-mounting custom_apps subdirs at compose-up leaves /var/www/html/apps
+# unwritable and the NC installer bails with "Cannot write into apps
+# directory"). This file is therefore app-agnostic; it is a per-app copy of
+# openregister/.github/docker-compose.ci.yml so the live gate is self-contained
+# in each repo's CI checkout.
+#
+# Paths are relative to this file: `..` resolves to the repo root.
+#
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2026 Conduction B.V.
+
+volumes:
+ nextcloud-data:
+
+services:
+ db:
+ image: pgvector/pgvector:pg16
+ container_name: decidesk-ci-db
+ environment:
+ POSTGRES_DB: nextcloud
+ POSTGRES_USER: nextcloud
+ POSTGRES_PASSWORD: nextcloud
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+
+ nextcloud:
+ # NC 32 — within every target app's info.xml min/max-version window and
+ # matches the openregister reference rig.
+ image: nextcloud:32-apache
+ container_name: nextcloud
+ user: root
+ restart: unless-stopped
+ ports:
+ - "8080:80"
+ depends_on:
+ db:
+ condition: service_healthy
+ volumes:
+ # Named volume only — let the official entrypoint fully bootstrap without
+ # bind-mount interference; the app + openregister are copied in by the
+ # workflow after install.
+ - nextcloud-data:/var/www/html:rw
+ environment:
+ POSTGRES_DB: nextcloud
+ POSTGRES_USER: nextcloud
+ POSTGRES_PASSWORD: nextcloud
+ POSTGRES_HOST: db
+ NEXTCLOUD_ADMIN_USER: admin
+ NEXTCLOUD_ADMIN_PASSWORD: admin
+ NEXTCLOUD_TRUSTED_DOMAINS: localhost nextcloud
+ PHP_MEMORY_LIMIT: 2G
+ PHP_UPLOAD_LIMIT: 1G
+ PHP_POST_MAX_SIZE: 1G
+
+networks:
+ default:
+ name: decidesk-ci-network
diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml
index 67cdd608..35e8b829 100644
--- a/.github/workflows/branch-protection.yml
+++ b/.github/workflows/branch-protection.yml
@@ -4,7 +4,11 @@ on:
pull_request:
branches: [main, beta]
+permissions: {}
+
jobs:
- protect:
+ # Job id must stay `branch-protection` so the check reports as
+ # `branch-protection / check-branch`, which is the context name the org
+ # ruleset requires.
+ branch-protection:
uses: ConductionNL/.github/.github/workflows/branch-protection.yml@main
- secrets: inherit
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 7bb96476..a8cf09cc 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -2,7 +2,35 @@ name: Code Quality
on:
push:
- branches: [main, development, feature/**, bugfix/**, hotfix/**]
+ # An ALLOW-LIST of branch prefixes is a gate with a hole in it, and the
+ # hole is SILENT: a branch matching nothing gets no CI at all, and its last
+ # visible status is whatever it inherited — indistinguishable, on every
+ # dashboard, from a branch that passed.
+ #
+ # Two live examples, both found 2026-08-14: `perf/**` was uncovered in
+ # openconnector, where a merge carrying unresolved conflict markers and 84
+ # failing tests was pushed and nothing ran; and `feat/**` was uncovered in
+ # openregister — note the list said `feature/**`, so every branch anyone
+ # named `feat/...` had been running unchecked.
+ #
+ # Prefixes are added rather than replaced with `**` because this workflow is
+ # expensive (PHPUnit matrix, Newman, Playwright). The fast structural checks
+ # DO run on `**` — see merge-hygiene.yml, added in the same change.
+ #
+ # ⚠️ Adding prefixes is not the durable fix; the next invented one is
+ # uncovered again. The durable fix is branch protection requiring a PR into
+ # development, which the pull_request trigger below already gates correctly.
+ branches:
+ - main
+ - development
+ - feature/**
+ - feat/**
+ - bugfix/**
+ - hotfix/**
+ - perf/**
+ - refactor/**
+ - chore/**
+ - fix/**
pull_request:
branches: [main, master, development, beta]
workflow_dispatch:
@@ -14,7 +42,28 @@ jobs:
app-name: decidesk
php-version: "8.3"
php-test-versions: '["8.3", "8.4"]'
- nextcloud-test-refs: '["stable31", "stable32"]'
+ # stable31 is REMOVED because it tested an impossible configuration, not
+ # because we are trimming coverage. `additional-apps` below installs
+ # openregister, which declares min-version="32" (ConductionNL/openregister#2384),
+ # so on NC31 `occ app:enable openregister` refuses with "not compatible with
+ # this version of the server". The shared workflow runs that as
+ # `php occ app:enable "$name" || echo "::warning::Failed to enable $name"`,
+ # so the failure is a WARNING and the job continues without its data layer,
+ # then dies ~70s later on missing schemas — which reads like an app fault.
+ #
+ # Order mattered as much as membership: the newman, playwright and
+ # journeydoc-capture jobs each check out the server at
+ # `fromJSON(inputs.nextcloud-test-refs)[0]`, so stable31 sitting FIRST put
+ # all three on the one version openregister cannot load.
+ #
+ # THE LIST IS THE WHOLE DECLARED RANGE. An earlier revision of this comment
+ # said "stable33 is deliberately NOT added: this removes an impossible leg,
+ # it does not widen the matrix" — but the same change also dropped stable32,
+ # which was NOT impossible, it was the declared floor. appinfo/info.xml
+ # declares , so 32, 33 and 34
+ # each get a leg; anything narrower advertises a range to the App Store that
+ # no job touches.
+ nextcloud-test-refs: '["stable34", "stable32", "stable33"]'
enable-psalm: true
enable-phpstan: true
enable-phpmetrics: true
@@ -22,6 +71,147 @@ jobs:
enable-eslint: true
enable-phpunit: true
enable-newman: true
- # additional-apps: '[]' # Add app dependencies here if needed, e.g.:
- # additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"main"}]'
+ # Every Newman collection seeds its own fixtures through OpenRegister's
+ # object API (`/apps/openregister/api/objects/decidesk/`) — ADR-022
+ # keeps plain CRUD there rather than in decidesk controllers. Without
+ # OpenRegister checked out the seed POSTs answer 404, every downstream id
+ # interpolates to the empty string, and the collections fail wholesale on
+ # a cause that has nothing to do with the code under test (measured:
+ # 206 of 282 assertions failed in run 30899265429).
+ #
+ # `ref: development` matches the rest of the fleet (opencatalogi,
+ # openconnector, procest, softwarecatalog, scholiq, pipelinq). It is not
+ # interchangeable with `main`: decidesk's appinfo/routes.php builds on
+ # `\OCA\OpenRegister\AppHost\Routes`, which does not exist on OpenRegister
+ # `main` (0.2.19) at all — only on `development`.
+ additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]'
+ # Two collections (decidesk-meeting-agenda, decidesk-user-settings) carry
+ # no collection-level `baseUrl`/`noAuthBase`/`adminUser`/`adminPass`, and
+ # the workflow's ad-hoc fallback defines `base_url`-style names instead.
+ # Every request in those two therefore left `{{baseUrl}}` unresolved and
+ # errored before it was sent — 42 assertions failing with no server
+ # involved. The committed environment file supplies all four to every
+ # collection uniformly.
+ newman-environment-path: tests/integration/decidesk-environment.json
enable-sbom: true
+
+ # ── E2E browser tests ────────────────────────────────────────────────
+ # `enable-playwright` defaults to FALSE and was never set here, so the
+ # "E2E Tests (Playwright)" job has reported `skipped` on every run this
+ # repo has ever produced — while the tree ships a root
+ # `playwright.config.ts` and 28 gating spec files under `tests/e2e/`
+ # (spec-coverage/, workflows/, integration-registry.spec.ts). A skipped
+ # job renders in the Quality Report exactly like a passing one, so the
+ # whole browser tier was invisible rather than absent.
+ #
+ # `playwright-test-path` does double duty in the shared workflow:
+ # 1. it is the directory the "Validate Playwright tests exist" step
+ # counts *.spec.ts in;
+ # 2. it is the FIRST place the run step looks for a config —
+ # `${playwright-test-path}/playwright.config.ts`, falling back to
+ # the repo root only if that file is absent.
+ # We ship tests/e2e/playwright.config.ts precisely so lookup (2) hits it.
+ # The run step passes no `--project`, so the ROOT config would run all
+ # three of its projects — including `visual` (pixel baselines whose own
+ # header states a CI Linux runner cannot byte-match a dev-container
+ # baseline) and `docs-capture` (journeydoc screenshot re-shoots, which
+ # have their own dedicated job). The tests/e2e config declares only the
+ # `chromium` regression project, and writes its report/output to the app
+ # root, where the workflow's upload steps actually look.
+ #
+ # OpenRegister is already checked out for Newman above (`additional-apps`)
+ # and the Playwright job honours the same input — which it must: the specs
+ # read and seed fixtures through `/apps/openregister/api/objects/decidesk/
+ # ` and assert on `window.OCA.OpenRegister.integrations`.
+ enable-playwright: true
+ playwright-test-path: tests/e2e
+
+ # OpenRegister being INSTALLED is not the same as decidesk's register
+ # being IMPORTED, and the difference is silent. `occ app:enable decidesk`
+ # runs a repair step that is supposed to import
+ # `lib/Settings/decidesk_register.json` + the 24 `register.d/` fragments,
+ # but an IRepairStep has no user session, OpenRegister's RBAC denies the
+ # write as 'Anonymous', and the step catches \Throwable and downgrades it
+ # to a warning — so `occ app:enable` exits 0 with no register at all.
+ # In that state every UI spec times out on an empty list and every
+ # `expect(resp.ok()).toBe(true)` against
+ # /apps/openregister/api/objects/decidesk/ fails with a message
+ # that accuses the selector, never the missing import.
+ #
+ # ci-seed.sh does the import explicitly over the admin HTTP API (which
+ # has a real session), forced, then VERIFIES the register slug, 18 schema
+ # slugs, four object collections, and that the SPA bundle actually serves
+ # as JavaScript. A bad provision becomes ONE loud step failure instead of
+ # two dozen misleading spec failures.
+ #
+ # It also sets and reads back `htaccess.IgnoreFrontController`. Without
+ # it, `occ maintenance:install` leaves that flag FALSE, JS `generateUrl`
+ # therefore prefixes `/index.php`, and decidesk's
+ # `createWebHistory(generateUrl('/apps/decidesk'))` router base becomes
+ # `/index.php/apps/decidesk` while every spec navigates to
+ # `/apps/decidesk/...`. vue-router only strips a base the path starts
+ # with, so nothing matched and the catch-all `redirect: '/'` landed EVERY
+ # deep link on the dashboard.
+ #
+ # cwd for this step is the Nextcloud server root.
+ playwright-seed-command: 'bash apps/decidesk/tests/e2e/ci-seed.sh'
+
+ # ── Frontend Check legs ──────────────────────────────────────────────
+ # `frontend-checks` defaults to `[]`, and an empty list means the shared
+ # workflow emits NO "Frontend Check" job at all — so these two validators
+ # ran nowhere while the run still looked complete. Both are self-contained
+ # `node` scripts, which is what a leg has to be (each leg is a fresh job
+ # with its own checkout + `npm ci`).
+ # `test:unit` is NOT listed: the shared "Frontend Tests (unit)" job
+ # already falls back to it when there is no `test` script, and this repo
+ # has none — its 282 vitest tests are already covered there.
+ # `test:l10n:parity` is NOT listed either: measured on this tree it is
+ # short 289+ translations across the required locales. That is a
+ # translation backlog, and a permanently-red leg is one that gets
+ # switched off again.
+ #
+ # `format` (prettier --check) is listed because the shared workflow has NO
+ # prettier job of its own — `quality.yml` runs eslint and stylelint and
+ # mentions prettier ZERO times. This repo already carries
+ # `@nextcloud/prettier-config` and a `format` script, so without this leg
+ # `npm run format` never runs outside a developer's editor and the tree
+ # drifts straight back out of format between merges — the same inert-
+ # formatter failure mode that made the old `.prettierrc` worth deleting.
+ # Centralising the config never stopped drift; the gate does.
+ # Measured on this tree before enabling: PASSES, 197 of 203 tracked
+ # frontend files in scope (docs/ excluded via .prettierignore; build
+ # output via .gitignore, which prettier 3 also reads).
+ frontend-checks: '["check:manifest", "test:l10n", "format"]'
+
+ # ── Coverage ratchet ─────────────────────────────────────────────────
+ # `enable-coverage-guard` defaults to FALSE, which is why both
+ # "Coverage Baseline Protection" (PR side) and "Coverage Baseline Check"
+ # (push side) have only ever reported `skipped`. It needs two inputs this
+ # repo did not have, both added in this commit:
+ # `scripts/coverage-guard.php` (byte-identical to the copies in
+ # openregister and procest) and `.coverage-baseline` = 57.66, this repo's
+ # own measured coverage (8687 of 15065 statements) read from clover.xml
+ # in the `coverage-report` artifact of run 30911223203.
+ enable-coverage-guard: true
+
+ # ── Hydra mechanical gates ───────────────────────────────────────────
+ # `enable-hydra-gates` defaults to FALSE, so this tier has never executed
+ # here — the job reported `skipped`, which the Quality Report renders
+ # identically to a pass. .github#149 is what made this viable: gate-7
+ # (no-admin-idor) now follows delegation, so the 11 MinutesController-style
+ # methods whose guards are reached through `staffAction()` → `requireStaff()`
+ # are no longer flagged.
+ # `enable-axe` deliberately NOT set: a vanilla Nextcloud 34 already carries
+ # serious/critical violations on core's OWN routes that DOM scoping does
+ # not remove. Enabling axe is a separate decision.
+ enable-hydra-gates: true
+ # No `hydra-gates-ref` here on purpose. The shared workflow defaults it
+ # to @main, and this workflow is itself consumed at @main, so the two
+ # sides move together and a gate fix reaches this repo without a commit
+ # in this repo. A pin is a silent expiry date: 22 repos sat on v1.0.1 and
+ # 16 gates were dead fleet-wide while every one reported PASS (.github#159),
+ # and a default flipped at @main later reached those old runners and made
+ # them red on gates they had no subject matter for (.github#173).
+ # To hold this repo still for a specific reason, set the input explicitly
+ # and say why — it is still honoured. To roll back for everyone, revert on
+ # ConductionNL/.github main.
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 820afe47..0747a28d 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -8,6 +8,8 @@ on:
jobs:
deploy:
+ # Reusable workflow defaults the source folder to `docs/` — the
+ # Docusaurus site now lives there (journeydoc / ADR-030).
uses: ConductionNL/.github/.github/workflows/documentation.yml@main
with:
- cname: decidesk.app
+ cname: decidesk.conduction.nl
diff --git a/.github/workflows/merge-hygiene.yml b/.github/workflows/merge-hygiene.yml
new file mode 100644
index 00000000..4852cee5
--- /dev/null
+++ b/.github/workflows/merge-hygiene.yml
@@ -0,0 +1,111 @@
+name: Merge Hygiene
+
+# WHY THIS EXISTS, and why it is separate from Code Quality.
+#
+# On 2026-08-14 a merge of origin/development was committed and PUSHED to
+# `perf/predicted-page-fanout` with UNRESOLVED CONFLICT MARKERS in two files.
+# `lib/Service/SynchronizationService.php` did not parse. Eighty-four tests were
+# red. Nothing stopped it, and nothing reported it — because Code Quality's push
+# trigger allows only `[main, development, feature/**, bugfix/**, hotfix/**]`,
+# and `perf/**` matches none of them. The branch had no CI at all, so its last
+# visible state was green from before the branch existed.
+#
+# The lesson is not "add perf/** to the list" — that fixes this branch and leaves
+# the next prefix uncovered. Any branch anyone pushes should get at least the
+# checks that take seconds, so this runs on `**` and stays deliberately cheap:
+# no matrix, no containers, no dependencies, no Playwright. It is a smoke alarm,
+# not the fire brigade. Code Quality remains the real gate on PRs.
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: merge-hygiene-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ hygiene:
+ name: Conflict markers and PHP syntax
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # Conflict markers, anywhere in the tree we author. A marker means a merge
+ # was committed half-finished; every downstream signal from that commit is
+ # meaningless, so this fails first and says so plainly.
+ #
+ # Anchored to line start: `<<<<<<<` inside a string, a diff fixture or a
+ # docs example is legitimate and must not fail the build. Matching only at
+ # column 0 is what git itself writes.
+ - name: No unresolved conflict markers
+ run: |
+ set -euo pipefail
+ # SCOPED TO CODE, and to paths we author. A marker is only a defect
+ # where it would break something: prose that DOCUMENTS a conflict is
+ # legitimate, and so are agent-eval artifacts that capture one as
+ # sample output. openbuild failed this gate on
+ # `.claude/skills/create-pr/evals/.../summary.md` — a correct file.
+ #
+ # That matters more than the miss it allows. A gate that fails on
+ # correct files gets switched off, and takes the checks that were
+ # working with it; a marker in a markdown file breaks nothing.
+ if git grep -nE '^(<{7}|={7}|>{7})( |$)' -- \
+ '*.php' '*.js' '*.mjs' '*.ts' '*.vue' '*.json' '*.yml' '*.yaml' '*.css' '*.scss' \
+ ':!vendor' ':!node_modules' ':!*.lock' ':!tests/fixtures' ':!.claude' \
+ ':!**/evals/**' ':!**/fixtures/**' > /tmp/markers.txt; then
+ echo "::error::Unresolved merge conflict markers are committed. This branch does not build."
+ cat /tmp/markers.txt
+ exit 1
+ fi
+ echo "No conflict markers."
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+
+ # Every PHP file parses. A conflict marker is caught above, but so is any
+ # other way a file can be committed unparseable — and this is the check
+ # that would have failed within seconds of the merge landing.
+ - name: PHP syntax
+ run: |
+ set -euo pipefail
+ fail=0
+ while IFS= read -r f; do
+ php -l "$f" > /dev/null 2>&1 || { echo "::error file=$f::PHP syntax error"; php -l "$f" || true; fail=1; }
+ done < <(git ls-files '*.php' | grep -v '^vendor/' | grep -v '^tests/fixtures/')
+ exit "$fail"
+
+ # JSON that will not parse breaks register fragments and app metadata,
+ # and is the other thing a bad merge leaves behind.
+ #
+ # SCOPED TWICE, because each widening found another honest file. The
+ # first version parsed every tracked .json and died on tsconfig/eslint
+ # JSONC. The second still reached `lib/**/*.json`, which in openbuild
+ # includes an entire app TEMPLATE — `.vscode/settings.json` and all.
+ # A template is not this app's configuration, and an editor file is not
+ # loaded by anything. What is left is what OpenRegister actually reads.
+ #
+ # SCOPED, because the first version was not and failed immediately on
+ # honest files: editor and tooling configs (tsconfig, eslint, devcontainer)
+ # are JSONC — comments and trailing commas — which is valid for their
+ # consumers and invalid for a strict parser. A gate that fails on correct
+ # files is worse than no gate: it gets switched off, and takes the checks
+ # that were working with it. Only the JSON the app itself loads is checked.
+ - name: JSON parses
+ run: |
+ set -euo pipefail
+ fail=0
+ while IFS= read -r f; do
+ [ -f "$f" ] || continue
+ python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$f" \
+ || { echo "::error file=$f::invalid JSON"; fail=1; }
+ done < <(git ls-files 'composer.json' 'package.json' 'appinfo/*.json' 'lib/Settings/**/*.json' \
+ | grep -v '^vendor/' | grep -v '^node_modules/' \
+ | grep -v '/\.vscode/' | grep -v '^lib/Resources/template/')
+ exit "$fail"
diff --git a/.github/workflows/pull-request-lint-check.yaml b/.github/workflows/pull-request-lint-check.yaml
index f2402987..6761a48a 100644
--- a/.github/workflows/pull-request-lint-check.yaml
+++ b/.github/workflows/pull-request-lint-check.yaml
@@ -10,11 +10,23 @@ on:
jobs:
lint-check:
runs-on: ubuntu-latest
+ # Observed fleet-wide over 176 runs: median 0.6 min, max 1.4 min.
+ timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v2
+ # This job had no setup-node at all, so it inherited the runner's default
+ # Node — currently 22, which bundles npm 10. npm 10 CANNOT install from
+ # an npm 11 lockfile: it exits EUSAGE with "Missing: from lock
+ # file". Node 24 bundles npm 11, and it is also the only line where the
+ # .npmrc release-age cooldown exists at all.
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+
- name: Install dependencies
run: npm ci
diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
deleted file mode 100644
index c9aee783..00000000
--- a/.github/workflows/release-beta.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-name: Beta Release
-
-on:
- push:
- branches: [beta]
-
-jobs:
- release:
- uses: ConductionNL/.github/.github/workflows/release-beta.yml@main
- with:
- app-name: decidesk
- secrets: inherit
diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml
deleted file mode 100644
index 0daa6f68..00000000
--- a/.github/workflows/release-stable.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-name: Stable Release
-
-on:
- push:
- branches: [main]
-
-jobs:
- release:
- uses: ConductionNL/.github/.github/workflows/release-stable.yml@main
- with:
- app-name: decidesk
- secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..39479d6d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,53 @@
+name: Release
+
+# ONE release workflow for the whole fleet.
+#
+# Every app used to carry three caller files (`release-development.yml`,
+# `release-beta.yml`, `release-stable.yml`) pointing at two different shared
+# workflows. Those two drifted: the pair edited `appinfo/info.xml` in the
+# working tree only, so their tags named commits still showing the PREVIOUS
+# version — harmless for a timestamped dev build, wrong for a stable release,
+# because Nextcloud's release template asserts
+# [ "$APP_VERSION" = "v$(xpath info.xml //version)" ]
+#
+# `release.yml` is the strict one and is now the only one. It commits the bump
+# to a `release/v` branch, tags THAT commit, and opens a pull request
+# to bring the integration branch up — so the tag and the package agree without
+# anything pushing at a protected branch.
+
+on:
+ push:
+ branches: [main, beta, development]
+ workflow_dispatch:
+
+# Releases publish artifacts. A cancelled release is neither a success nor a
+# rollback — it is a half-published version — so queued runs wait rather than
+# cancelling the one in flight.
+concurrency:
+ group: release-${{ github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ unstable:
+ if: github.ref == 'refs/heads/development'
+ uses: ConductionNL/.github/.github/workflows/release.yml@main
+ with:
+ release-type: unstable
+ app-name: decidesk
+ secrets: inherit
+
+ beta:
+ if: github.ref == 'refs/heads/beta'
+ uses: ConductionNL/.github/.github/workflows/release.yml@main
+ with:
+ release-type: beta
+ app-name: decidesk
+ secrets: inherit
+
+ stable:
+ if: github.ref == 'refs/heads/main'
+ uses: ConductionNL/.github/.github/workflows/release.yml@main
+ with:
+ release-type: stable
+ app-name: decidesk
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index 9a9cc789..6836ce7e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,8 +11,13 @@
/.phpunit.cache
.phpunit.cache/
-/node_modules/
-/website/node_modules/
+# No trailing slash, deliberately. A pattern ending in `/` matches a DIRECTORY
+# only, so `node_modules` created as a SYMLINK (what you get when you point a
+# git worktree at a sibling checkout's install) is not ignored and lands in
+# `git add -A`. That is how a symlink to one machine's absolute path reached
+# this repository. Slashless patterns match a symlink too.
+/node_modules
+/website/node_modules
/website/.docusaurus/
/js/
/custom_apps/
@@ -46,8 +51,41 @@ phpqa_output.log
**/implements *
**/ALL *
**/endpoints *
+#
+# SOURCE-EXTENSION NEGATIONS. Every substring glob in this block is meant to
+# catch stray agent scratch files with no extension ("PR adds …", "endpoints
+# …"). Left unqualified they also swallow real source, silently: `git add`
+# says nothing, the file never reaches a commit, and the first symptom is a
+# fatal error in CI on a class that is simply absent.
+#
+# This is not hypothetical here — the repo already lost this argument once.
+# `**/*references*` swallowed src/components/userSettings/userPreferences.js
+# and *PreferencesSection.vue (observed 2026-06-12, user-settings-v1) because
+# "Preferences" embeds "references".
+#
+# MEASURED 2026-08-09, on this tree, with the only instruments that answer
+# honestly — `git ls-files --others --exclude-standard` and
+# `git status --porcelain --ignored`. (`git check-ignore -q` is NOT usable
+# here: it exits 0 when a NEGATION matches, so it reports a path as ignored
+# after it has been rescued.) Four plausible source names were created and
+# their real status read back:
+#
+# lib/Service/VotingAnalysisService.php !! IGNORED <- **/*Analysis*
+# src/utils/updateUserSettings.js !! IGNORED <- **/update*Settings*
+# lib/Service/encodingHelper.php !! IGNORED <- **/*encoding*
+# src/utils/dataAnalysisChart.vue !! IGNORED <- **/*Analysis*
+#
+# The negations below pair each glob with the source extensions this repo
+# actually ships, matching the reference treatment in openregister/.gitignore.
+# A negation cannot resurrect a file inside an ignored DIRECTORY, but every
+# glob here matches files, so each one takes effect.
**/*Analysis*
-**/*references*
+# Junk note files named "references ..." — anchored to the filename START so
+# legitimate sources containing "…Preferences…" (which embeds the substring
+# "references") are not silently ignored. Observed 2026-06-12: the old
+# `**/*references*` pattern swallowed src/components/userSettings/
+# userPreferences.js + *PreferencesSection.vue (user-settings-v1).
+**/references*
**/*encoding*
**/ter
**/clearCache*
@@ -55,6 +93,20 @@ phpqa_output.log
**/rebase*
**/setup*
+# Rescue real source from every substring glob above. Keep this list in sync
+# with the extensions the repo ships; a new source extension needs a new line.
+!**/*.php
+!**/*.js
+!**/*.ts
+!**/*.vue
+!**/*.json
+!**/*.md
+!**/*.css
+!**/*.scss
+!**/*.xml
+!**/*.yml
+!**/*.yaml
+
# Temporary test files that shouldn't be committed
simple-solr-test.php
test-solr-connection.php
@@ -76,6 +128,24 @@ docker/dolphin/models/
/docusaurus/node_modules/
/docusaurus/build/
/docusaurus/.docusaurus/
+
+# Docusaurus documentation site (docs/ — journeydoc / ADR-030)
+/docs/node_modules/
+/docs/build/
+/docs/.docusaurus/
+/docs/.cache-loader/
+# Generated translation files — re-enable when a Dutch translation pass ships
+/docs/i18n/nl/
+
+# Playwright (journeydoc capture + future e2e)
+/tests/e2e/.auth/
+/tests/e2e/test-results/
+/tests/e2e/playwright-report/
+/playwright-report/
+/playwright/.cache/
+
+# AI pipeline prompt files — must not be tracked to prevent prompt injection via PRs
+.specter-prompt.txt
# Test screenshots — images generated by browser test commands (test-app, run-test-scenario)
# Only images are ignored; markdown reports and scenario files are kept in git.
test-results/**/*.png
@@ -88,3 +158,4 @@ openspec/test-site-results/**/*.jpg
openspec/test-site-results/**/*.jpeg
openspec/test-site-results/**/*.gif
openspec/test-site-results/**/*.webp
+/coverage-vitest/
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..86ef388d
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,20 @@
+legacy-peer-deps=true
+
+# Supply-chain hardening (gate-84). THREE settings that only work together;
+# any one alone is a configuration that looks like protection and is not:
+#
+# 1. min-release-age — the cooldown window itself
+# 2. min-release-age-exclude[] — first-party releases must NOT be delayed
+# 3. package.json engines.npm — npm 10 does NOT implement min-release-age
+#
+# The option does not exist in npm 10: `npm config get min-release-age`
+# answers `undefined`, so a repo declaring npm 10 has the cooldown read by
+# NOTHING while carrying a comment describing a guard.
+#
+# Without the exclusion the cooldown does not fail loudly — it silently
+# resolves BACKWARDS. Installing @conduction/nextcloud-vue on release day
+# under a cooldown with no exclusion resolves an old version and exits 0.
+# A green install of months-old first-party code is worse than a red one,
+# because nothing distinguishes it from a correct install.
+min-release-age=2
+min-release-age-exclude[]=@conduction/*
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..2bd5a0a9
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 00000000..db584532
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,20 @@
+getFinder()
+ ->notPath('vendor')
+ ->notPath('node_modules')
+ ->notPath('build')
+ ->in(__DIR__ . '/lib')
+ ->in(__DIR__ . '/tests');
+
+return $config;
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..a15e312b
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,15 @@
+# Build output, vendored trees and the separate Docusaurus site.
+js/
+dist/
+build/
+node_modules/
+vendor/
+coverage/
+coverage-vitest/
+docs/
+playwright-report/
+test-results/
+*.min.*
+# Written by the translation workflow — a formatter here would fight its own
+# generator on every run.
+l10n/
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index cff28457..00000000
--- a/.prettierrc
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "overrides": [
- {
- "files": ["*.json"],
- "options": {
- "parser": "json",
- "printWidth": 120,
- "tabWidth": 2
- }
- },
- {
- "files": ["*.ts", "*.tsx"],
- "options": {
- "parser": "typescript",
- "printWidth": 120,
- "trailingComma": "all",
- "tabWidth": 2,
- "singleQuote": false
- }
- },
- {
- "files": ["*.css", "*.scss"],
- "options": {
- "parser": "css",
- "tabWidth": 2
- }
- },
- {
- "files": ["conduction.css"],
- "options": {
- "parser": "css",
- "trailingComma": "all",
- "tabWidth": 2,
- "printWidth": 150
- }
- }
- ]
-}
diff --git a/.specter-prompt.txt b/.specter-prompt.txt
new file mode 100644
index 00000000..9327795d
--- /dev/null
+++ b/.specter-prompt.txt
@@ -0,0 +1,49 @@
+You are generating OpenSpec change artifacts for a Nextcloud app.
+
+You are running HEADLESS. Do NOT use AskUserQuestion, interactive prompts, or skills.
+Write files directly using the Write tool.
+
+## Inputs
+- Change: board-meeting-resolutions (title: Board Meeting Resolutions)
+- Context brief: openspec/changes/board-meeting-resolutions/context-brief.md
+- Data model ADR: openspec/architecture/adr-000-data-model.md
+
+## Step 1: Read context
+Read these files COMPLETELY before writing anything:
+- openspec/changes/board-meeting-resolutions/context-brief.md (features, stories, stakeholders, journeys)
+- openspec/architecture/adr-000-data-model.md (entity definitions)
+- All files in openspec/architecture/ (app ADRs)
+- All files in .claude/openspec/architecture/ (company ADRs, if present)
+
+## Step 2: Get artifact templates
+Run: `openspec instructions proposal --change board-meeting-resolutions --json`
+This returns the template and rules for the proposal artifact.
+Then do the same for: design, specs, tasks (in that order).
+
+## Step 3: Write artifacts
+For each artifact (proposal → design → specs → tasks):
+1. Read the template from openspec instructions
+2. Fill it using the REAL data from context-brief.md:
+ - Features with their demand scores and descriptions
+ - User stories with acceptance criteria (GIVEN/WHEN/THEN)
+ - Customer journeys with triggers and pain points
+ - Stakeholder profiles with responsibilities and goals
+ - Entity schemas from ADR-000 (do NOT invent new entities)
+3. Write the artifact file using the Write tool
+4. Verify it exists
+
+## Step 4: Commit
+After all 4 artifacts are written:
+```bash
+git add openspec/changes/board-meeting-resolutions/
+git commit -m 'feat: Add OpenSpec change board-meeting-resolutions from Specter'
+```
+Do NOT push — the caller handles pushing.
+Do NOT create branches — stay on the current branch.
+
+## Rules
+- Use REAL data from context-brief.md — NEVER invent features, stories, or entities
+- Entities MUST match ADR-000 exactly
+- Include seed data in design.md (3-5 example objects per entity, Dutch values)
+- Requirements in specs use REQ-XXX-NNN format with GIVEN/WHEN/THEN scenarios
+- Tasks in tasks.md are checkboxes [ ] with clear implementation steps
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..691b3a75
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,108 @@
+# Changelog
+
+All notable changes to Decidesk are documented in this file.
+
+## [Unreleased]
+
+### Added
+
+- **Dashboard v2 widget components** (`decidesk-dashboard-v2-widgets`):
+ eleven new dashboard widget components under
+ `src/views/dashboard/widgets/`, registered in `src/registry.js` via a
+ new `widget()` helper (with `defaultSize`/`minSize`/`maxSize`/
+ `allowedSlots`/`propsSchema` metadata).
+ - **KPI widgets** — UpcomingMeetingsKpiWidget, PendingVotesKpiWidget
+ (variant="warning" when pending > 0, count 0 for users without a
+ participant record), OverdueActionsKpiWidget (variant="error" when
+ overdue > 0), ActiveDecisionsKpiWidget (counts decisions with
+ outcome null).
+ - **List widgets** — UpcomingMeetingsListWidget (24h urgency
+ highlight) and PendingVotesListWidget (deadline countdown, <24h red
+ urgency indicator, empty state).
+ - **Process & personal widgets** — RunningProcessesWidget (motions
+ grouped by lifecycle stage) and MyActionItemsWidget (open/
+ in-progress items sorted by dueDate with overdue indicator).
+ - **Decisions & health** — RecentDecisionsWidget (outcome +
+ publication badges) and GovernanceHealthWidget (live two-series
+ chart of quorumPercentage / actionItemCompletionRate).
+ - **DashboardEmptyState** — fresh-install welcome with Set Up Body /
+ Create Meeting / Create Decision quick actions.
+- **Dashboard data layer** — `src/services/dashboardData.js` fetch
+ helpers, `dashboardRefreshMixin` (dashboard-wide refresh signal
+ without page remount), and pure governance computations in
+ `widgetLogic.js` covered by 70 passing vitest tests.
+- **i18n** — all widget strings use English source keys via
+ `t('decidesk', ...)`; nl/de/fr/es/it translations added (de/fr/es/it
+ l10n files newly created).
+
+### Changed
+
+- **Dashboard v2 layout** (`decidesk-dashboard-v2-layout`): rewired the
+ `Dashboard` page in `src/manifest.json` to the five-row, 11-widget v2
+ grid with English widget titles.
+ - **Row 1** — four KPI cards (Active Decisions, Upcoming meetings,
+ Pending votes, Overdue actions) as custom slot widgets, each 3
+ columns wide.
+ - **Row 2** — Upcoming meetings list + Pending votes list (6 cols
+ each).
+ - **Row 3** — Running processes + My action items (6 cols each).
+ - **Row 4** — Recent decisions spanning the full 12 columns.
+ - **Row 5** — "Minutes awaiting approval" stats-block + Governance
+ health chart (6 cols each).
+ - `DashboardEmptyState` is declared in the manifest's `widgets[]` +
+ `slots` map (excluded from `layout[]`) for the fresh-install empty
+ state; the removed `published-decisions` and `open-action-items`
+ placeholders are gone.
+ - Full-dashboard Playwright e2e coverage added
+ (`tests/e2e/spec-coverage/dashboard-layout.spec.ts`); host
+ browser-verified 2026-06-13 — all 11 widgets render with live data.
+
+### Added
+
+- **MeetingDetail IA alignment** (`refactor-decidesk-ia-alignment`):
+ three new sidebar tabs on the meeting detail surface so secretaries
+ can author and review meeting-scoped records without leaving the
+ meeting context.
+ - **Minutes** (Notulen) tab — lists `minutes` scoped to the current
+ meeting, creates a draft with the meeting reference pre-filled, and
+ deep-links each row to MinutesDetail.
+ - **Decisions** (Besluiten) tab — lists `decision` objects for the
+ meeting, creates one with the meeting reference pre-filled, and
+ deep-links to DecisionDetail.
+ - **Votes** (Stemmingen) tab — read-only post-meeting overview that
+ walks meeting → agenda-item → motion → voting-round, shows each
+ round's tally and result, and deep-links to MotionDetail's votes
+ tab. Vote casting stays exclusively in LiveMeeting.
+
+### Notes
+
+- The top-level Minutes / Decisions / Motions register pages are
+ unchanged — the new tabs are an additive per-meeting surface (the
+ "split" placement), not a replacement.
+- Dutch + English translations added for all new strings.
+- No backend, schema, lifecycle, or permission changes.
+
+## [0.1.7]
+
+### Added
+
+- **MCP Tools Provider** (`mcp-tools`) — first per-app exemplar of
+ `OCA\OpenRegister\Mcp\IMcpToolProvider` for the OpenRegister AI Chat Companion.
+ `OCA\Decidesk\Mcp\DecideskToolProvider` exposes 5 MCP tools to the LLM:
+ - `decidesk.listOpenActionItems` — list incomplete action items (scope: mine | all)
+ - `decidesk.listRecentMeetings` — recent meetings ordered by date desc
+ - `decidesk.getMeetingDetails` — meeting + agenda + decisions + action items inlined
+ - `decidesk.startMeeting` — transition `scheduled` → `in-progress` (chair/admin only)
+ - `decidesk.addActionItem` — create an action item attached to a meeting
+ - Per-object authorisation runs inside `invokeTool()` (ADR-005, IDOR-safe): every
+ object-targeting tool verifies the caller is a participant / chair / admin and
+ returns a structured `{isError, error: 'forbidden'}` envelope on denial.
+ - Every success path carries a mandatory `sources` citation array (deep links),
+ capped at 20 with `sourcesTruncated` / `sourcesTotalCount` markers.
+ - Registered via `registerServiceAlias('OCA\OpenRegister\Mcp\IMcpToolProvider::decidesk', …)`
+ so OpenRegister's `McpToolsService` discovers it.
+ - Consumes existing decidesk services (MeetingService, TaskService, ParticipantResolver)
+ and OpenRegister's `ObjectService` (ADR-022/ADR-001) — no new schemas, endpoints,
+ or business logic.
+ - Operator docs at `docs/features/mcp-tools.md`; unit + integration test coverage
+ under `tests/Unit/Mcp/` and `tests/Integration/Mcp/`.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..ca0f0d89
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,35 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes
+- Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior:
+
+- The use of sexualized language or imagery, and sexual attention or advances of any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information without explicit permission
+- Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders at **info@conduction.nl**.
+
+All complaints will be reviewed and investigated promptly and fairly. Community leaders are obligated to respect the privacy and security of the reporter.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..05d9c988
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,270 @@
+# Contributing to Conduction Nextcloud Apps
+
+Thank you for considering contributing to our projects! It's people like you that make open source such a great community.
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
+
+## How Can I Contribute?
+
+### Reporting Bugs
+
+Before creating bug reports, please check the issue list as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
+
+- Use a clear and descriptive title
+- Describe the exact steps which reproduce the problem
+- Provide specific examples to demonstrate the steps
+- Describe the behavior you observed after following the steps
+- Explain which behavior you expected to see instead and why
+- Include screenshots if possible
+
+### Suggesting Enhancements
+
+Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
+
+- Use a clear and descriptive title
+- Provide a step-by-step description of the suggested enhancement
+- Describe the current behavior and explain which behavior you expected to see instead
+- Explain why this enhancement would be useful
+
+### Pull Requests
+
+- Fork the repo and create your branch from `development`
+- If you've added code that should be tested, add tests
+- If you've changed APIs, update the documentation
+- Ensure the test suite passes
+- Make sure your code lints (`composer cs:check`)
+- Create a pull request!
+
+### PR Size
+
+Prefer **one PR per logically-coherent finding or feature**. Each PR's commit message, checkbox, and inline-comment chain should map to a single change unit — reviewers hold a clearer mental model on focused PRs than on large ones.
+
+- When a PR's scope grows past **~10 commits or ~30 files**, consider splitting it before requesting review. The per-finding commits stay; the PR boundary moves.
+- **Exception:** release-promotion PRs (`development → beta`, `beta → main`) aggregate every change since the last cut and are expected to be larger.
+- PRs touching many files across unrelated subsystems tend to get reviewed paragraph-by-paragraph rather than holistically — that's a signal to split, not to push through.
+
+## Branch Protection & Git Flow
+
+We use a structured branching model to ensure stability across environments. All branches are protected via **organization-wide rulesets** on the ConductionNL GitHub organization — direct pushes are not allowed. Every change flows through a pull request with peer review and CI checks.
+
+```mermaid
+graph LR
+ F["feature/*\nbugfix/*"] -->|"PR + 1 review\n+ Quality CI ✓"| D[development]
+ D -->|"PR + 1 review\n+ Quality CI ✓"| B[beta]
+ B -->|"PR + 2 reviews\n+ Branch CI ✓"| M[main]
+ H["hotfix/*"] -->|"PR + 1 review\n+ Quality CI ✓"| B
+ H -->|"PR + 2 reviews\n+ Branch CI ✓"| M
+
+ style F fill:#e1f5fe
+ style D fill:#fff9c4
+ style B fill:#ffe0b2
+ style M fill:#c8e6c9
+ style H fill:#ffcdd2
+```
+
+### Branch Rules
+
+These rules are enforced organization-wide across all ConductionNL repositories. They cannot be overridden at the repository level.
+
+| Target | Allowed Sources | Reviews | Required CI Checks |
+| ------------- | -------------------------------------------- | ------------------- | --------------------------------------------------- |
+| `development` | `feature/*`, `bugfix/*` | 1 approving review | Quality CI (`lint-check`) |
+| `beta` | `development`, `hotfix/*`, `main` (backport) | 1 approving review | Quality CI (`lint-check`) |
+| `main` | `beta`, `hotfix/*` | 2 approving reviews | Branch Protection CI (`check-branch`, `lint-check`) |
+
+### Organization-Wide Rulesets
+
+Branch protection is managed at the **organization level**, not per-repository. This ensures consistent enforcement across all Conduction apps. The three rulesets are:
+
+1. **Development Branch Protection** — Enforces peer review and Quality CI for all feature work entering `development`
+2. **Beta Branch Protection** — Same requirements as development, gates the path to beta releases
+3. **Main Branch Protection** — Stricter: requires 2 reviewers and branch-source validation before stable release
+
+All rulesets also enforce:
+
+- No force pushes
+- No branch deletion
+- Stale reviews dismissed on new pushes
+- All review threads must be resolved before merge
+
+### How It Works
+
+1. **Feature work** happens on `feature/*` or `bugfix/*` branches created from `development`
+2. **PRs to `development`** require 1 approving peer review and the Quality CI workflow must pass
+3. **When ready for beta release**, a developer creates a PR from `development` to `beta` — same review + CI requirements
+4. **Merging to `beta`** triggers an automatic beta release to the Nextcloud App Store
+5. **When ready for stable release**, a developer creates a PR from `beta` to `main` — requires 2 approving reviews and Branch Protection CI
+6. **Merging to `main`** triggers an automatic stable release to the Nextcloud App Store
+7. **Hotfixes** can target both `beta` and `main` directly for urgent patches via PR (same review requirements apply)
+8. **Branches are automatically deleted** after their PR is merged
+
+> **Important:** There are no automatic merges or auto-created PRs between branches. Every promotion (development -> beta -> main) requires a deliberate pull request created by a developer, with peer review and CI passing before merge is allowed.
+
+## Quality Workflow
+
+Every pull request triggers our automated quality pipeline. **All checks must pass before a PR can be merged.** This ensures that no code enters `development`, `beta`, or `main` without meeting our quality standards.
+
+### PHP Quality Checks
+
+| Check | Tool | What It Does |
+| ------------------- | ----------------- | ------------------------------------------------------ |
+| **Lint** | `php -l` | Syntax validation — catches parse errors |
+| **Code Style** | PHPCS | Enforces coding standards (PSR-12 + custom rules) |
+| **Static Analysis** | PHPStan (level 5) | Type checking, undefined methods, dead code |
+| **Static Analysis** | Psalm | Additional type inference and security analysis |
+| **Mess Detection** | PHPMD | Complexity, naming, unused code, design problems |
+| **Metrics** | phpmetrics | Maintainability index, coupling, cyclomatic complexity |
+
+### Frontend Quality Checks
+
+| Check | Tool | What It Does |
+| -------------- | --------- | ---------------------------------- |
+| **JavaScript** | ESLint | Enforces JS/Vue coding standards |
+| **CSS** | Stylelint | Enforces CSS/SCSS coding standards |
+
+### Dependency Checks
+
+| Check | What It Does |
+| ----------------------------- | ---------------------------------------------------------- |
+| **License (npm + composer)** | Ensures all dependencies use approved open-source licenses |
+| **Security (npm + composer)** | Checks for known vulnerabilities in dependencies |
+
+### Running Quality Checks Locally
+
+```bash
+# PHP
+composer cs:check # PHPCS code style
+composer cs:fix # Auto-fix code style
+composer phpstan # PHPStan static analysis
+composer psalm # Psalm static analysis
+composer phpmd # PHPMD mess detection
+
+# Frontend
+npm run lint # ESLint
+npx stylelint "src/**/*.{css,scss,vue}" # Stylelint
+```
+
+## App Store Release Process
+
+Releases to the Nextcloud App Store are fully automated via GitHub Actions. They are triggered by merging PRs into `beta` or `main`. Version numbers are calculated automatically from PR labels.
+
+```mermaid
+graph TD
+ subgraph "Beta Release"
+ D[development] -->|"Developer creates PR"| BP1{"Quality CI\npasses?"}
+ BP1 -->|"Yes"| BM["Merge PR to beta"]
+ BP1 -->|"No"| BF["Fix issues\nre-push"]
+ BF --> BP1
+ BM --> BT{Version Bump\nfrom PR label}
+ BT -->|"label: major"| BV1["v2.0.0-beta.20260319"]
+ BT -->|"label: minor"| BV2["v1.1.0-beta.20260319"]
+ BT -->|"label: patch\n(default)"| BV3["v1.0.1-beta.20260319"]
+ BV1 & BV2 & BV3 --> BB["Build & Package"]
+ BB --> BU["Upload to App Store\n(nightly channel)"]
+ BB --> BG["Create GitHub\npre-release"]
+ end
+
+ subgraph "Stable Release"
+ B2[beta] -->|"Developer creates PR"| SP1{"Branch Protection\nCI passes?"}
+ SP1 -->|"Yes"| SM["Merge PR to main"]
+ SP1 -->|"No"| SF["Fix issues"]
+ SF --> SP1
+ SM --> ST{Version Bump\nfrom PR label}
+ ST -->|"from PR labels"| SV["v1.1.0"]
+ SV --> SB["Build & Package"]
+ SB --> SU["Upload to App Store\n(stable channel)"]
+ SB --> SG["Create GitHub release\nwith changelog"]
+ end
+
+ style D fill:#fff9c4
+ style BM fill:#ffe0b2
+ style SM fill:#c8e6c9
+ style BU fill:#e1bee7
+ style SU fill:#e1bee7
+ style BF fill:#ffcdd2
+ style SF fill:#ffcdd2
+```
+
+### Version Labeling
+
+Add a label to your PR to control the version bump:
+
+| Label | Version Change | When to Use |
+| ----------------- | ----------------- | ------------------------------------ |
+| `major` | `1.0.0` → `2.0.0` | Breaking changes, major redesigns |
+| `minor` | `1.0.0` → `1.1.0` | New features, non-breaking additions |
+| `patch` (default) | `1.0.0` → `1.0.1` | Bug fixes, small improvements |
+
+### Release Artifacts
+
+Each release automatically:
+
+1. Bumps the version in `appinfo/info.xml`
+2. Builds the app (composer install, npm build)
+3. Creates a signed tarball
+4. Uploads to the [Nextcloud App Store](https://apps.nextcloud.com)
+5. Creates a GitHub release with auto-generated changelog
+
+## Documentation Release Process
+
+Documentation is built with [Docusaurus](https://docusaurus.io/) and deployed to GitHub Pages.
+
+1. Documentation source lives in the `docs/` (or `docusaurus/`) folder on any branch
+2. Push or merge to the `documentation` branch triggers the build
+3. Docusaurus builds the static site
+4. The site is deployed to GitHub Pages with a custom domain (e.g., `openregister.app`)
+
+Each app has its own documentation site — see the app's README for its URL.
+
+## Development Process
+
+1. Create a feature request issue describing your proposed changes
+2. Fork the repository
+3. Create a new branch: `git checkout -b feature/[issue-number]/[feature-name]`
+4. Make your changes
+5. Run quality checks: `composer cs:check` and `composer phpstan`
+6. Push to your fork and open a Pull Request
+7. Wait for Quality CI to pass, address any failures
+8. Request review from a team member
+
+### Git Commit Messages
+
+We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
+
+- `feat:` for new features
+- `fix:` for bug fixes
+- `chore:` for maintenance tasks
+- `docs:` for documentation changes
+- `refactor:` for code refactoring
+- Use the present tense and imperative mood
+- Limit the first line to 72 characters
+
+### PR Labels for Changelogs
+
+Add labels to categorize your PR in the automated changelog:
+
+- **`feature`** / **`enhancement`** — New features (appears under "Added")
+- **`bug`** / **`fix`** — Bug fixes (appears under "Fixed")
+- **`docs`** — Documentation updates
+- **`refactor`** / **`chore`** — Code improvements (appears under "Changed")
+- **`skip-changelog`** — Exclude from changelog
+
+## Development Setup
+
+1. Install PHP 8.1+ and Node.js 20+
+2. Install Composer
+3. Clone the repository
+4. Run `composer install` and `npm install`
+5. Configure your [Nextcloud development environment](https://github.com/ConductionNL/nextcloud-docker-dev)
+
+## Community
+
+- Join the [Common Ground Slack](https://commonground.nl)
+- Follow us on [X](https://x.com/conduction_nl)
+- Read our updates on [LinkedIn](https://www.linkedin.com/company/conduction/)
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the same license as the project (EUPL-1.2 unless stated otherwise).
diff --git a/README.md b/README.md
index 330c1d33..c9da041e 100644
--- a/README.md
+++ b/README.md
@@ -9,16 +9,16 @@
+ );
+}
+
+const WIDGETS = [
+ {
+ title: 'Upcoming meetings',
+ desc: 'Your next meetings across every body you sit on — agendas, papers, and the join link, all on the dashboard you already open.',
+ panel: ,
+ },
+ {
+ title: 'Motions in play',
+ desc: 'Motions and amendments by status: tabled, in debate, voting, adopted. Chair controls, configurable quorum, and a clear trail from proposal to decision.',
+ panel: ,
+ },
+ {
+ title: 'Open action items',
+ desc: 'Every action item assigned out of a meeting, sorted by due date. Follow each one through to completion, with the decision log as the source of truth.',
+ panel: ,
+ },
+];
+
+export default function Home() {
+ return (
+
+
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/docs/static/CNAME b/docs/static/CNAME
new file mode 100644
index 00000000..1e0e909d
--- /dev/null
+++ b/docs/static/CNAME
@@ -0,0 +1 @@
+decidesk.conduction.nl
diff --git a/docs/static/img/logo.svg b/docs/static/img/logo.svg
new file mode 100644
index 00000000..40478c1c
--- /dev/null
+++ b/docs/static/img/logo.svg
@@ -0,0 +1,6 @@
+
diff --git a/docs/static/img/og-decidesk.png b/docs/static/img/og-decidesk.png
new file mode 100644
index 00000000..e479d088
Binary files /dev/null and b/docs/static/img/og-decidesk.png differ
diff --git a/docs/static/llms.txt b/docs/static/llms.txt
new file mode 100644
index 00000000..9758b964
--- /dev/null
+++ b/docs/static/llms.txt
@@ -0,0 +1,23 @@
+# DeciDesk
+
+> Decidesk is an open-source decision-making app for the Nextcloud workspace.
+
+Decidesk is an open-source decision-making app for the Nextcloud workspace. It runs the full governance cycle in one place: scheduling meetings, building agendas, submitting motions and amendments, voting, publishing minutes, and tracking action items to completion. Workflows are configurable for legislative bodies, associations, corporate boards, management teams, and citizen-participation panels. All meetings, motions, and decisions live as typed OpenRegister objects with an audit trail, and a built-in AI chat companion exposes those records over MCP. Released under EUPL-1.2 and maintained by Conduction since 2019.
+
+## Docs
+
+- [Documentation](https://decidesk.conduction.nl/docs/intro): main entry, including tutorials, user guide, and admin guide.
+- [API reference](https://decidesk.conduction.nl/api): OpenAPI documentation.
+
+## Optional
+
+- [Install](https://www.conduction.nl/install): self-host on your Nextcloud instance.
+- [Source code](https://codeberg.org/Conduction/decidesk): repository and issue tracker.
+- [App page](https://www.conduction.nl/apps/decidesk): product positioning on conduction.nl.
+
+## Contact
+
+- Email: info@conduction.nl
+- Web: https://www.conduction.nl
+- GitHub: https://codeberg.org/Conduction
+- Conduction B.V. · KvK 76741850 · Lauriergracht 14h, Amsterdam, Netherlands
diff --git a/docs/static/screenshots/tutorials/admin/.gitkeep b/docs/static/screenshots/tutorials/admin/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/static/screenshots/tutorials/admin/01-configure-workflow-01.png b/docs/static/screenshots/tutorials/admin/01-configure-workflow-01.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-configure-workflow-01.png differ
diff --git a/docs/static/screenshots/tutorials/admin/01-configure-workflow-02.png b/docs/static/screenshots/tutorials/admin/01-configure-workflow-02.png
new file mode 100644
index 00000000..07851eeb
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-configure-workflow-02.png differ
diff --git a/docs/static/screenshots/tutorials/admin/01-configure-workflow-03.png b/docs/static/screenshots/tutorials/admin/01-configure-workflow-03.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-configure-workflow-03.png differ
diff --git a/docs/static/screenshots/tutorials/admin/01-configure-workflow-04.png b/docs/static/screenshots/tutorials/admin/01-configure-workflow-04.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-configure-workflow-04.png differ
diff --git a/docs/static/screenshots/tutorials/admin/01-configure-workflow-05.png b/docs/static/screenshots/tutorials/admin/01-configure-workflow-05.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-configure-workflow-05.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-manage-members-01.png b/docs/static/screenshots/tutorials/admin/02-manage-members-01.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-manage-members-01.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-manage-members-02.png b/docs/static/screenshots/tutorials/admin/02-manage-members-02.png
new file mode 100644
index 00000000..07851eeb
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-manage-members-02.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-manage-members-03.png b/docs/static/screenshots/tutorials/admin/02-manage-members-03.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-manage-members-03.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-manage-members-04.png b/docs/static/screenshots/tutorials/admin/02-manage-members-04.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-manage-members-04.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-manage-members-05.png b/docs/static/screenshots/tutorials/admin/02-manage-members-05.png
new file mode 100644
index 00000000..220fd98b
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-manage-members-05.png differ
diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png
new file mode 100644
index 00000000..8bd7f954
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png differ
diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png
new file mode 100644
index 00000000..8bd7f954
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png differ
diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png
new file mode 100644
index 00000000..8bd7f954
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png differ
diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png
new file mode 100644
index 00000000..8bd7f954
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png differ
diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png
new file mode 100644
index 00000000..0a3e114a
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png differ
diff --git a/docs/static/screenshots/tutorials/user/.gitkeep b/docs/static/screenshots/tutorials/user/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-01.png b/docs/static/screenshots/tutorials/user/01-first-launch-01.png
new file mode 100644
index 00000000..1773763a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/01-first-launch-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-02.png b/docs/static/screenshots/tutorials/user/01-first-launch-02.png
new file mode 100644
index 00000000..1773763a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/01-first-launch-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-03.png b/docs/static/screenshots/tutorials/user/01-first-launch-03.png
new file mode 100644
index 00000000..1773763a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/01-first-launch-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-04.png b/docs/static/screenshots/tutorials/user/01-first-launch-04.png
new file mode 100644
index 00000000..220fd98b
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/01-first-launch-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-schedule-meeting-01.png b/docs/static/screenshots/tutorials/user/02-schedule-meeting-01.png
new file mode 100644
index 00000000..4b1b54bd
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-schedule-meeting-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-schedule-meeting-02.png b/docs/static/screenshots/tutorials/user/02-schedule-meeting-02.png
new file mode 100644
index 00000000..4b1b54bd
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-schedule-meeting-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-schedule-meeting-03.png b/docs/static/screenshots/tutorials/user/02-schedule-meeting-03.png
new file mode 100644
index 00000000..220fd98b
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-schedule-meeting-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-schedule-meeting-04.png b/docs/static/screenshots/tutorials/user/02-schedule-meeting-04.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-schedule-meeting-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-schedule-meeting-05.png b/docs/static/screenshots/tutorials/user/02-schedule-meeting-05.png
new file mode 100644
index 00000000..7b21a983
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-schedule-meeting-05.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-add-motion-01.png b/docs/static/screenshots/tutorials/user/03-add-motion-01.png
new file mode 100644
index 00000000..fd5b6f0c
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-add-motion-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-add-motion-02.png b/docs/static/screenshots/tutorials/user/03-add-motion-02.png
new file mode 100644
index 00000000..fd5b6f0c
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-add-motion-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-add-motion-03.png b/docs/static/screenshots/tutorials/user/03-add-motion-03.png
new file mode 100644
index 00000000..7a3e377a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-add-motion-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-add-motion-04.png b/docs/static/screenshots/tutorials/user/03-add-motion-04.png
new file mode 100644
index 00000000..7a3e377a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-add-motion-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-add-motion-05.png b/docs/static/screenshots/tutorials/user/03-add-motion-05.png
new file mode 100644
index 00000000..7a3e377a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-add-motion-05.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-propose-amendment-01.png b/docs/static/screenshots/tutorials/user/04-propose-amendment-01.png
new file mode 100644
index 00000000..7a3e377a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-propose-amendment-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-propose-amendment-02.png b/docs/static/screenshots/tutorials/user/04-propose-amendment-02.png
new file mode 100644
index 00000000..fd5b6f0c
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-propose-amendment-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-propose-amendment-03.png b/docs/static/screenshots/tutorials/user/04-propose-amendment-03.png
new file mode 100644
index 00000000..7a3e377a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-propose-amendment-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-propose-amendment-04.png b/docs/static/screenshots/tutorials/user/04-propose-amendment-04.png
new file mode 100644
index 00000000..7a3e377a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-propose-amendment-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/05-run-vote-01.png b/docs/static/screenshots/tutorials/user/05-run-vote-01.png
new file mode 100644
index 00000000..220fd98b
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/05-run-vote-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/05-run-vote-02.png b/docs/static/screenshots/tutorials/user/05-run-vote-02.png
new file mode 100644
index 00000000..220fd98b
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/05-run-vote-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/05-run-vote-03.png b/docs/static/screenshots/tutorials/user/05-run-vote-03.png
new file mode 100644
index 00000000..220fd98b
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/05-run-vote-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/05-run-vote-04.png b/docs/static/screenshots/tutorials/user/05-run-vote-04.png
new file mode 100644
index 00000000..c8120dba
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/05-run-vote-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/05-run-vote-05.png b/docs/static/screenshots/tutorials/user/05-run-vote-05.png
new file mode 100644
index 00000000..c8120dba
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/05-run-vote-05.png differ
diff --git a/docs/static/screenshots/tutorials/user/06-take-minutes-01.png b/docs/static/screenshots/tutorials/user/06-take-minutes-01.png
new file mode 100644
index 00000000..365453f1
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/06-take-minutes-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/06-take-minutes-02.png b/docs/static/screenshots/tutorials/user/06-take-minutes-02.png
new file mode 100644
index 00000000..365453f1
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/06-take-minutes-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/06-take-minutes-03.png b/docs/static/screenshots/tutorials/user/06-take-minutes-03.png
new file mode 100644
index 00000000..365453f1
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/06-take-minutes-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/06-take-minutes-04.png b/docs/static/screenshots/tutorials/user/06-take-minutes-04.png
new file mode 100644
index 00000000..365453f1
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/06-take-minutes-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/06-take-minutes-05.png b/docs/static/screenshots/tutorials/user/06-take-minutes-05.png
new file mode 100644
index 00000000..41a108ea
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/06-take-minutes-05.png differ
diff --git a/docs/static/screenshots/tutorials/user/07-track-decisions-01.png b/docs/static/screenshots/tutorials/user/07-track-decisions-01.png
new file mode 100644
index 00000000..c8120dba
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/07-track-decisions-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/07-track-decisions-02.png b/docs/static/screenshots/tutorials/user/07-track-decisions-02.png
new file mode 100644
index 00000000..c8120dba
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/07-track-decisions-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/07-track-decisions-03.png b/docs/static/screenshots/tutorials/user/07-track-decisions-03.png
new file mode 100644
index 00000000..41a108ea
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/07-track-decisions-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/07-track-decisions-04.png b/docs/static/screenshots/tutorials/user/07-track-decisions-04.png
new file mode 100644
index 00000000..1773763a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/07-track-decisions-04.png differ
diff --git a/docs/static/screenshots/tutorials/user/07-track-decisions-05.png b/docs/static/screenshots/tutorials/user/07-track-decisions-05.png
new file mode 100644
index 00000000..3e5e99d4
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/07-track-decisions-05.png differ
diff --git a/docs/static/screenshots/tutorials/user/08-ai-companion-01.png b/docs/static/screenshots/tutorials/user/08-ai-companion-01.png
new file mode 100644
index 00000000..f4c734d7
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/08-ai-companion-01.png differ
diff --git a/docs/static/screenshots/tutorials/user/08-ai-companion-02.png b/docs/static/screenshots/tutorials/user/08-ai-companion-02.png
new file mode 100644
index 00000000..f4c734d7
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/08-ai-companion-02.png differ
diff --git a/docs/static/screenshots/tutorials/user/08-ai-companion-03.png b/docs/static/screenshots/tutorials/user/08-ai-companion-03.png
new file mode 100644
index 00000000..f4c734d7
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/08-ai-companion-03.png differ
diff --git a/docs/static/screenshots/tutorials/user/08-ai-companion-04.png b/docs/static/screenshots/tutorials/user/08-ai-companion-04.png
new file mode 100644
index 00000000..f4c734d7
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/08-ai-companion-04.png differ
diff --git a/docs/tutorials/_category_.json b/docs/tutorials/_category_.json
new file mode 100644
index 00000000..5c460a64
--- /dev/null
+++ b/docs/tutorials/_category_.json
@@ -0,0 +1,11 @@
+{
+ "label": "Tutorials",
+ "position": 2,
+ "collapsible": true,
+ "collapsed": false,
+ "link": {
+ "type": "generated-index",
+ "title": "Tutorials",
+ "description": "Step-by-step walkthroughs for everyday tasks. The user track covers individual workflows; the admin track covers org-wide configuration."
+ }
+}
diff --git a/docs/tutorials/admin/01-configure-workflow.md b/docs/tutorials/admin/01-configure-workflow.md
new file mode 100644
index 00000000..761c8572
--- /dev/null
+++ b/docs/tutorials/admin/01-configure-workflow.md
@@ -0,0 +1,60 @@
+---
+sidebar_position: 1
+title: Configure a governance workflow
+description: Create a governance body and set the rules — quorum, majority, co-signature threshold, who may do what — that drive its meetings.
+---
+
+# Configure a governance workflow
+
+A *governance body* in Decidesk is the thing that meets and decides — a board, a council, a general assembly, a working group. Its workflow is the set of rules Decidesk enforces for its meetings: quorum, majority, co-signature threshold, and which roles may schedule meetings, submit motions, and operate votes.
+
+## Goal
+
+By the end you will have a governance body in Decidesk with a type, a domain, term dates, and the workflow rules that its meetings, motions, and votes will follow.
+
+## Prerequisites
+
+- The **Decidesk** and **OpenRegister** apps installed and enabled, with the Decidesk register imported (see [Manage Decidesk settings](03-admin-settings.md)).
+- Admin (or whoever your organisation appoints) — creating governance bodies and setting workflow rules is an administrative act.
+- A clear picture of the body's actual rules of order (quorum, majority threshold, co-signature requirement, term length).
+
+## Steps
+
+1. Go to **Governance bodies** (under the Decidesk navigation) and click **Add Item**. The *Create Item* dialog opens.
+
+ 
+
+2. Fill in the body — **name**, **body type** (board, council, ALV/general assembly, committee, …), **domain** (the area it governs), and **term start / term end**. Click **Create**.
+
+ 
+
+3. Open the body. Its sidebar has an **Overview**, a **Members** tab, and an **Audit trail**. The Overview is where the workflow rules live — **quorum**, **majority rule** (simple, absolute, two-thirds, …), and the **co-signature threshold** for motions.
+
+ 
+
+4. Set the rules to match the body's rules of order. These feed straight into the app: the quorum is checked when a voting round opens, the majority rule decides whether a motion carries, and the co-signature threshold gates a motion's admissibility.
+
+ 
+
+5. Add members on the **Members** tab and give each a role (see [Manage members and roles](02-manage-members.md)) — roles are what let someone schedule a meeting, submit a motion, or operate a vote for this body.
+
+ 
+
+## Verification
+
+The body shows under **Governance bodies** with its type and domain, its Overview shows the quorum / majority / co-signature settings you entered, and a test meeting created against the body enforces them (e.g. opening a voting round flags quorum, a motion needs the threshold of co-signatures). The **Audit trail** records the body's creation and any rule changes.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **Add Item** opens an empty dialog | The `governance-body` schema isn't imported — re-run **Settings → Registers → Re-import configuration** (see [Manage Decidesk settings](03-admin-settings.md)). |
+| Motions on this body never need co-signatures | The co-signature threshold is 0 — set it to the number the body's rules require. |
+| Quorum warning never appears | Quorum is unset or 0 — set the body's quorum so the check has something to compare against. |
+| A member can't schedule a meeting for the body | They don't have a role that grants meeting-scheduling rights — adjust their role on the **Members** tab. |
+
+## Reference
+
+- [Manage members and roles](02-manage-members.md) — assign chair / voting rights / secretary on this body.
+- [Manage Decidesk settings](03-admin-settings.md) — the register import these schemas depend on.
+- [Schedule a meeting and build the agenda](../user/02-schedule-meeting.md) — what a member does once the body exists.
diff --git a/docs/tutorials/admin/02-manage-members.md b/docs/tutorials/admin/02-manage-members.md
new file mode 100644
index 00000000..04e10b0c
--- /dev/null
+++ b/docs/tutorials/admin/02-manage-members.md
@@ -0,0 +1,60 @@
+---
+sidebar_position: 2
+title: Manage members and roles
+description: Add participants to a governance body, assign roles (chair, secretary, voting member), and handle proxies and party affiliations.
+---
+
+# Manage members and roles
+
+Members are the people in a governance body; their **role** is what Decidesk checks before letting them act. This page covers adding participants, assigning roles, and the details that affect votes — voting rights, party affiliation, proxies.
+
+## Goal
+
+By the end you will have a governance body whose members are set up with the right roles, so meeting scheduling, motion submission, vote operation, and minutes signing all land on the right people.
+
+## Prerequisites
+
+- A governance body to add members to (see [Configure a governance workflow](01-configure-workflow.md)).
+- Admin, or the chair of the body — both can manage that body's membership.
+- The list of people, their roles, and (if relevant) their party affiliations and voting rights.
+
+## Steps
+
+1. Open the governance body and go to its **Members** tab. It lists the current members with their role; click **Add member**.
+
+ 
+
+2. Add a participant — link a Nextcloud account (or record an external participant with a **display name** and **email**), set the **role** (chair, vice-chair, secretary, voting member, observer, …), and the **party** affiliation if the body tracks one. Save.
+
+ 
+
+3. Repeat for the rest of the body. The role each person holds is what the app enforces — only a chair opens and closes voting rounds, only a secretary drives the minutes lifecycle, observers see but don't vote.
+
+ 
+
+4. Manage participants more broadly under **Participants** in the navigation — a person can sit on more than one body, each with its own role. The participant detail page shows their roles and an **Audit trail** of membership changes.
+
+ 
+
+5. For a meeting, the chair (or whoever has the right) confirms who is **present**; an absent voting member can have a **proxy** assigned for that meeting's votes, if the body allows proxies. Proxy limits and whether proxies are allowed at all come from the body's workflow.
+
+ 
+
+## Verification
+
+The body's **Members** tab lists everyone with the role you set, a chair can open a voting round (and a non-chair can't), a secretary can submit minutes for approval, and proxy assignments only stick where the body's workflow permits them. Membership changes show in the **Audit trail**.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| A member can't do something you expected | Check their **role** on this body — rights are role-based and per-body, so a chair on one body is just a member on another. |
+| Can't assign a proxy | The body must allow proxies, the proxy must be a present member of the meeting, and one member can hold only a limited number of proxies. |
+| The same person appears twice | They're a member of two bodies — that's expected; each membership is separate, with its own role. |
+| External participant has no account link | That's fine — Decidesk records external participants by display name and email; they just can't log in to act themselves. |
+
+## Reference
+
+- [Configure a governance workflow](01-configure-workflow.md) — quorum, majority, proxy rules that interact with roles.
+- [Run a vote](../user/05-run-vote.md) — where presence, voting rights, and proxies come into play.
+- [Take and publish the minutes](../user/06-take-minutes.md) — who must be a signer.
diff --git a/docs/tutorials/admin/03-admin-settings.md b/docs/tutorials/admin/03-admin-settings.md
new file mode 100644
index 00000000..1f00cc35
--- /dev/null
+++ b/docs/tutorials/admin/03-admin-settings.md
@@ -0,0 +1,61 @@
+---
+sidebar_position: 3
+title: Manage Decidesk settings
+description: Open the Decidesk settings, import the register and schemas, check the version, and configure the ORI endpoint and email voting.
+---
+
+# Manage Decidesk settings
+
+Decidesk's settings page does three jobs: it tells you the installed version, it maps the app's object types onto an OpenRegister register and schemas (this is the import that makes everything else work), and it holds the advanced options — the ORI endpoint for publishing voting results, and the email-reply voting toggle.
+
+## Goal
+
+By the end you will have confirmed the Decidesk version, run (or re-run) the register import so all 24 object types are configured, and set the ORI endpoint and email-voting option to match your deployment.
+
+## Prerequisites
+
+- Admin on the Nextcloud instance (or a Decidesk admin), since this changes how the whole app is wired.
+- The **OpenRegister** app installed and enabled — the register import has nothing to import into otherwise.
+- For ORI publication: the URL of your ORI (Open Raadsinformatie / decision-publication) endpoint.
+
+## Steps
+
+1. Open **Settings** from the Decidesk navigation. The page has three sections — **Version**, **Registers**, **Advanced**.
+
+ 
+
+2. **Version** — confirms the installed Decidesk version and shows an "Up to date" indicator. Nothing to change here; it's the at-a-glance check that the app installed cleanly.
+
+ 
+
+3. **Registers** — the *Register Configuration* widget shows how many of Decidesk's 24 object types are mapped (e.g. *0/24 configured* on a broken or fresh install, *24/24* once imported). Pick the target register, then click **Re-import configuration** to (re)create the register, all schemas, and the mappings.
+
+ 
+
+4. After the import, the count should read *24/24 configured* and the Decidesk lists (Meetings, Motions, …) and their **Add Item** forms work. The same import also runs automatically on app install/upgrade — the button is for fixing a partial import.
+
+ 
+
+5. **Advanced** — set the **ORI endpoint** (the URL Decidesk pushes published voting results to) and toggle **email voting** on if you want absent members to be able to vote by replying to a ballot email. Save.
+
+ 
+
+## Verification
+
+The **Version** section shows the installed version with "Up to date", the **Registers** widget reads *24/24 configured*, a list view's **Add Item** opens a dialog with real form fields (not an empty modal), and the **Advanced** values you saved persist on reload.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Register widget stuck at *0/24 configured* even after clicking Re-import | The import is failing server-side — check the Nextcloud log for the Decidesk configuration error; a stale OpenRegister / Decidesk version pair can mismatch the import API. Re-run after both apps are on compatible versions. |
+| **Add Item** dialogs are empty across the app | Same root cause — the schemas aren't mapped; fix the register import first, everything else follows. |
+| ORI publication does nothing | The **ORI endpoint** field is empty or wrong — publishing a voting result only pushes to ORI when a valid endpoint is set. |
+| Email votes never count | **Email voting** must be enabled here *and* the member must reply from their registered address within the voting round's window. |
+| Settings page itself shows an OpenRegister error | OpenRegister isn't installed/enabled — install it, then reload Decidesk. |
+
+## Reference
+
+- [Open Decidesk for the first time](../user/01-first-launch.md) — the user-facing check that the import worked.
+- [Configure a governance workflow](01-configure-workflow.md) — the first thing to set up once the register is imported.
+- [Run a vote](../user/05-run-vote.md) — where the ORI endpoint and email-voting settings are used.
diff --git a/docs/tutorials/admin/_category_.json b/docs/tutorials/admin/_category_.json
new file mode 100644
index 00000000..02ad5155
--- /dev/null
+++ b/docs/tutorials/admin/_category_.json
@@ -0,0 +1,11 @@
+{
+ "label": "Admin guide",
+ "position": 2,
+ "collapsible": true,
+ "collapsed": true,
+ "link": {
+ "type": "generated-index",
+ "title": "Admin guide",
+ "description": "Org-wide administration — configuring governance workflows, managing members and roles (chair, voting rights), and tuning Decidesk settings."
+ }
+}
diff --git a/docs/tutorials/user/01-first-launch.md b/docs/tutorials/user/01-first-launch.md
new file mode 100644
index 00000000..8e6ccd4a
--- /dev/null
+++ b/docs/tutorials/user/01-first-launch.md
@@ -0,0 +1,54 @@
+---
+sidebar_position: 1
+title: Open Decidesk for the first time
+description: Open Decidesk, find your way around the navigation, and confirm the OpenRegister back end is connected.
+---
+
+# Open Decidesk for the first time
+
+A first look at Decidesk — where the app lives, what the navigation gives you, and how to tell it is wired up to OpenRegister.
+
+## Goal
+
+By the end you will have opened the Decidesk app, recognised the dashboard and the left-hand navigation, and confirmed that the OpenRegister-backed lists (Meetings, Motions, Decisions, …) load.
+
+## Prerequisites
+
+- A Nextcloud account on an instance where the **Decidesk** app is installed and enabled.
+- The **OpenRegister** app installed and enabled — Decidesk stores everything (meetings, motions, votes, minutes) in OpenRegister, so it is a hard dependency.
+- The Decidesk register and its schemas imported. An admin runs this once from **Settings → Registers → Re-import configuration** (see [Manage Decidesk settings](../admin/03-admin-settings.md)).
+
+## Steps
+
+1. Open the Nextcloud app menu in the top bar and pick **Decidesk**. You land on the dashboard.
+
+ 
+
+2. Read the dashboard tiles — *Minutes awaiting approval*, *Published decisions*, *Open action items*. On a fresh install they read `0`; they fill in as work moves through the app.
+
+ 
+
+3. Open the left-hand navigation. The entries map one-to-one onto the things Decidesk tracks: **Meetings**, **Motions**, **Decisions**, **Action items**, **Minutes**, **Tasks**, **Workspaces**, **Comments**, **Email links**, **Engagement**. Below the divider sit **Settings** and **Features & roadmap**.
+
+ 
+
+4. Click **Meetings**. The list view opens with a *Cards / Table* toggle, an **Add Item** button, and a search sidebar. An empty install shows *No items found* — expected until someone schedules the first meeting.
+
+ 
+
+## Verification
+
+You are set up correctly when: the Decidesk dashboard renders without an error banner, the left navigation lists the entries above, and clicking through to **Meetings** (or any other list) shows either rows or a clean *No items found* state — not a load error.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| "OpenRegister is not installed or enabled" banner | Install and enable the OpenRegister app, then reload Decidesk. |
+| Lists load but **Add Item** opens a modal with no form fields | The Decidesk register import is incomplete — an admin re-runs **Settings → Registers → Re-import configuration**. |
+| Decidesk is missing from the app menu | The app is not enabled for your account — ask an administrator to enable it (and check it is not restricted to a group you are not in). |
+
+## Reference
+
+- [MCP Tools (AI Chat Companion integration)](../../features/mcp-tools.md) — how the AI companion reaches Decidesk's data.
+- [Manage Decidesk settings](../admin/03-admin-settings.md) — register import, ORI endpoint, email voting.
diff --git a/docs/tutorials/user/02-schedule-meeting.md b/docs/tutorials/user/02-schedule-meeting.md
new file mode 100644
index 00000000..c1878388
--- /dev/null
+++ b/docs/tutorials/user/02-schedule-meeting.md
@@ -0,0 +1,60 @@
+---
+sidebar_position: 2
+title: Schedule a meeting and build the agenda
+description: Create a meeting, set its type and date, then build and publish its agenda.
+---
+
+# Schedule a meeting and build the agenda
+
+Create a meeting record, give it a type and a date, then add agenda items and publish the agenda so participants can see it.
+
+## Goal
+
+By the end you will have a meeting in Decidesk with a date, a meeting mode, an ordered list of agenda items, and a published agenda.
+
+## Prerequisites
+
+- Decidesk open and the OpenRegister back end connected (see [Open Decidesk for the first time](01-first-launch.md)).
+- The right to create meetings — chair or secretary of the relevant governance body. Read-only members can view a meeting but not schedule one.
+- The governance body that owns the meeting already exists (an admin creates these — see [Configure a governance workflow](../admin/01-configure-workflow.md)).
+
+## Steps
+
+1. Open **Meetings** in the navigation and click **Add Item**. The *Create Item* dialog opens.
+
+ 
+
+2. Fill in the meeting fields — **title**, **meeting type** (board, council, ALV/general assembly, …), **scheduled date** and time, **end date**, **location**, and **meeting mode** (in person, online, hybrid). Set **quorum required** if the body has a quorum rule. Click **Create**.
+
+ 
+
+3. The meeting appears in the list. Open it to reach the meeting detail page; the sidebar carries an **Overview**, **Agenda**, **Participants** and **Audit trail** tab.
+
+ 
+
+4. Switch to the **Agenda** tab. Add agenda items one by one — each gets an **order number**, a **title**, an **item type** (information, discussion, decision), and an optional **estimated duration**. Drag rows to reorder. Mark routine items as *hamerstukken* (consent agenda) so they can be adopted in one block during the meeting.
+
+ 
+
+5. When the agenda is final, **publish** it. Participants now see the fixed agenda; later edits create a new revision rather than silently changing the published version.
+
+ 
+
+## Verification
+
+The meeting shows in the **Meetings** list with its scheduled date and `lifecycle` set (e.g. *planned*), the **Agenda** tab lists the items in order, and the agenda's status reads *published*. The **Audit trail** tab records who created the meeting and published the agenda.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **Add Item** opens an empty dialog | The `meeting` schema is not imported — ask an admin to re-run the register import (**Settings → Registers → Re-import configuration**). |
+| Can't reorder agenda rows | Drag-reorder needs edit rights on the meeting; a read-only participant sees the list but can't move rows. |
+| Published agenda still shows old items | A revision was created but not published — open the agenda and publish the latest revision. |
+| Hamerstukken don't appear as a consent block in the live meeting | Each item must be flagged as a hamerstuk on the agenda before the meeting opens. |
+
+## Reference
+
+- [Add a motion to the agenda](03-add-motion.md) — attach a motion to one of these agenda items.
+- [Take and publish the minutes](06-take-minutes.md) — what happens to the agenda after the meeting.
+- [Configure a governance workflow](../admin/01-configure-workflow.md) — who is allowed to schedule meetings for a body.
diff --git a/docs/tutorials/user/03-add-motion.md b/docs/tutorials/user/03-add-motion.md
new file mode 100644
index 00000000..ff7c0ce3
--- /dev/null
+++ b/docs/tutorials/user/03-add-motion.md
@@ -0,0 +1,59 @@
+---
+sidebar_position: 3
+title: Add a motion to the agenda
+description: Submit a motion, attach it to an agenda item, and gather co-signatures.
+---
+
+# Add a motion to the agenda
+
+Create a motion, link it to a decision-type agenda item, and — where the body requires it — collect co-signatures before it is admissible.
+
+## Goal
+
+By the end you will have a motion in Decidesk attached to an agenda item, with its proposer set and (if needed) the required co-signatures gathered, ready for debate and a vote.
+
+## Prerequisites
+
+- A meeting with a published agenda that has at least one *decision*-type agenda item (see [Schedule a meeting and build the agenda](02-schedule-meeting.md)).
+- Membership of the governance body, or whatever role the body's workflow grants motion-submission rights.
+- If the body sets a co-signature threshold, the names of the members who will co-sign.
+
+## Steps
+
+1. Open **Motions** in the navigation and click **Add Item**, or open the meeting's agenda item and add a motion from there.
+
+ 
+
+2. Fill in the motion — **title**, **motion type** (substantive, procedural, budget-related, …), the **proposer**, and the motion text. Link it to the **agenda item** it belongs to. Click **Create**.
+
+ 
+
+3. Open the motion. Its sidebar has an **Overview**, **Amendments**, **Votes** and **Audit trail** tab. The motion starts in a *draft* / *submitted* lifecycle state.
+
+ 
+
+4. If the body requires co-signatures, request them — Decidesk sends a co-sign request to each named member, and the motion stays *pending* until enough confirmations come in. The **Audit trail** records each confirmation.
+
+ 
+
+5. Once the co-signature threshold is met (or if none is required), transition the motion to *admissible*. It is now on the agenda for debate.
+
+ 
+
+## Verification
+
+The motion shows in the **Motions** list with its proposer and lifecycle, it is linked from the agenda item it belongs to, and — where applicable — the **Audit trail** shows the co-signature confirmations and the transition to *admissible*.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Can't transition the motion to admissible | The co-signature threshold isn't met yet — chase the outstanding confirmations, or check the body's threshold in its workflow. |
+| Motion has no agenda item shown | It was created without linking an agenda item — edit the motion and set the agenda item. |
+| Budget-related motion warns about budget impact | A budget-type motion can capture a monetary amount and budget-impact note; fill that in before the vote so the impact is on record. |
+
+## Reference
+
+- [Propose an amendment](04-propose-amendment.md) — change the text of this motion before the vote.
+- [Run a vote](05-run-vote.md) — open a voting round on this motion.
+- [Configure a governance workflow](../admin/01-configure-workflow.md) — co-signature thresholds and who may submit motions.
diff --git a/docs/tutorials/user/04-propose-amendment.md b/docs/tutorials/user/04-propose-amendment.md
new file mode 100644
index 00000000..e4c19f95
--- /dev/null
+++ b/docs/tutorials/user/04-propose-amendment.md
@@ -0,0 +1,53 @@
+---
+sidebar_position: 4
+title: Propose an amendment
+description: Attach an amendment to a motion, describe the change, and move it through to a vote.
+---
+
+# Propose an amendment
+
+Create an amendment against an open motion — what changes, why — so it can be debated and voted on before the motion itself.
+
+## Goal
+
+By the end you will have an amendment in Decidesk linked to its parent motion, with the proposed change described, ready for the chair to put it to a vote ahead of the motion.
+
+## Prerequisites
+
+- An admissible motion that is still open for amendments (see [Add a motion to the agenda](03-add-motion.md)).
+- The right to submit amendments — usually the same membership/role that lets you submit motions for that body.
+
+## Steps
+
+1. Open the motion you want to amend and switch to its **Amendments** tab. Click **Add amendment** (or open **Motions**, find the parent motion, and add the amendment from there).
+
+ 
+
+2. Describe the amendment — a **title**, the **proposer**, and the change itself (the wording to add, strike, or replace, and the rationale). The amendment is linked to its **parent motion** automatically. Click **Create**.
+
+ 
+
+3. Open the amendment. Its sidebar has an **Overview**, a **Parent motion** tab (a shortcut back to the motion it modifies), and an **Audit trail**. The amendment starts in a *submitted* state.
+
+ 
+
+4. The chair reviews the amendment for admissibility, then transitions it to *admissible*. Multiple admissible amendments on one motion are ordered — typically the most far-reaching is voted first.
+
+ 
+
+## Verification
+
+The amendment shows on the parent motion's **Amendments** tab, the amendment's **Parent motion** tab links back to the right motion, and the **Audit trail** records the submission and the transition to *admissible*.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Can't add an amendment | The motion is past the amendment stage — once a vote is open on the motion, new amendments are no longer accepted. |
+| Amendment isn't on the motion's Amendments tab | It was created without a parent motion — edit it and set the parent motion. |
+| Two amendments conflict | That's normal — the chair sequences admissible amendments; voting one through can make a later one moot, in which case it is withdrawn. |
+
+## Reference
+
+- [Run a vote](05-run-vote.md) — vote the amendment through (or down) before the motion.
+- [Add a motion to the agenda](03-add-motion.md) — the motion this amendment modifies.
diff --git a/docs/tutorials/user/05-run-vote.md b/docs/tutorials/user/05-run-vote.md
new file mode 100644
index 00000000..3b84e882
--- /dev/null
+++ b/docs/tutorials/user/05-run-vote.md
@@ -0,0 +1,61 @@
+---
+sidebar_position: 5
+title: Run a vote
+description: Open a voting round on a motion or amendment, cast votes (including proxies), close it, and publish the result.
+---
+
+# Run a vote
+
+Open a voting round on a motion (or amendment), let members cast their vote — in the room, by proxy, or by email reply — then close the round and publish the tally as a decision.
+
+## Goal
+
+By the end you will have run a voting round to completion: votes cast, quorum checked, the round closed, the tally computed, and the result published so it becomes a tracked decision.
+
+## Prerequisites
+
+- An admissible motion or amendment (see [Add a motion to the agenda](03-add-motion.md) and [Propose an amendment](04-propose-amendment.md)).
+- Chair (or whoever the body's workflow names as the vote operator) — only that role can open and close a round.
+- A participant list for the meeting so quorum and proxy assignments resolve correctly.
+- For email voting: the **email voting** setting enabled (see [Manage Decidesk settings](../admin/03-admin-settings.md)).
+
+## Steps
+
+1. From the meeting's live view (or the motion's **Votes** tab), open a **voting round** on the motion or amendment. Decidesk records who is present and checks the quorum before the round opens.
+
+ 
+
+2. Members **cast** their votes — *for*, *against*, *abstain*. A member who is absent can have a **proxy** cast on their behalf if the body allows proxies; the proxy assignment is recorded against the round.
+
+ 
+
+3. If email voting is enabled, absent members can reply to a ballot email and their reply is matched into the round. The round stays open until the chair closes it.
+
+ 
+
+4. The chair **closes** the round. Decidesk computes the **tally** — counts per option, whether the motion carries given the body's majority rule, and whether quorum was met.
+
+ 
+
+5. **Publish** the result. The tally becomes a **decision** in Decidesk (and, if an ORI endpoint is configured, can be pushed there); the motion's lifecycle moves to *carried* or *rejected*.
+
+ 
+
+## Verification
+
+The voting round shows as *closed* with a tally, the motion's lifecycle reads *carried* or *rejected*, and a matching **decision** appears under **Decisions** with the outcome. The **Audit trail** on the motion records who opened, cast, closed, and published.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Can't open a round | Only the chair / vote operator can; check your role for this body. |
+| Round opens but warns about quorum | Quorum isn't met — the chair decides whether to proceed (some rules allow it, some don't); the warning is recorded either way. |
+| Email replies aren't counted | Email voting must be enabled in **Settings**, and the reply must come from the member's registered address within the round's window. |
+| Proxy vote rejected | The body must allow proxies, the proxy must be a present participant, and one member can usually hold only a limited number of proxies. |
+
+## Reference
+
+- [Track decisions and action items](07-track-decisions.md) — what happens to the decision this vote produced.
+- [Take and publish the minutes](06-take-minutes.md) — the vote result lands in the minutes.
+- [Manage Decidesk settings](../admin/03-admin-settings.md) — email voting and the ORI endpoint.
diff --git a/docs/tutorials/user/06-take-minutes.md b/docs/tutorials/user/06-take-minutes.md
new file mode 100644
index 00000000..4a93f1be
--- /dev/null
+++ b/docs/tutorials/user/06-take-minutes.md
@@ -0,0 +1,60 @@
+---
+sidebar_position: 6
+title: Take and publish the minutes
+description: Generate a minutes draft from the meeting record, get it signed, and publish it.
+---
+
+# Take and publish the minutes
+
+Turn the meeting record — agenda, motions, votes, decisions — into a minutes document, take it through review and signing, then publish (and, for a general assembly, distribute) it.
+
+## Goal
+
+By the end you will have a minutes document for the meeting that has moved from *draft* through *review* to *approved/published*, with the agreed signers recorded, and the action items it contains extracted.
+
+## Prerequisites
+
+- A meeting that has happened — agenda items handled, any votes closed, decisions published (see [Run a vote](05-run-vote.md)).
+- Secretary (or whoever the body's workflow names) — that role drives the minutes lifecycle.
+- The list of people who must sign the minutes (chair, secretary, …) per the body's rules.
+
+## Steps
+
+1. Open the meeting and go to **Minutes** in the navigation, then create a minutes record for the meeting — or use **Generate draft** to have Decidesk assemble a first draft from the meeting record (agenda items, motions, voting results, decisions).
+
+ 
+
+2. Edit the draft — tidy the wording, add discussion notes, confirm the recorded decisions are right. The minutes detail page has a **Signers** tab and an **Audit trail**.
+
+ 
+
+3. On the **Signers** tab, set who must sign — typically the chair and the secretary. Then **submit for approval**: the minutes move to *review* and the dashboard's *Minutes awaiting approval* tile picks them up.
+
+ 
+
+4. The signers approve. When the last required signature is in, the minutes transition to *approved*. **Publish** them — the minutes become the official record of the meeting.
+
+ 
+
+5. **Extract action items** from the minutes — Decidesk pulls out the "X to do Y by Z" lines so they become tracked action items (see [Track decisions and action items](07-track-decisions.md)). For a general assembly (ALV), generate the ALV-format minutes and **distribute** them to members.
+
+ 
+
+## Verification
+
+The minutes show in the **Minutes** list with `lifecycle` *approved* (or *published*) and a version number, the **Signers** tab lists everyone who signed, the **Audit trail** records the submit/approve/publish steps, and the extracted action items appear under **Action items**.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **Generate draft** produces a thin draft | It only includes what's recorded — make sure agenda items, votes, and decisions were captured in the meeting before generating. |
+| Minutes stuck in *review* | A required signer hasn't approved yet — check the **Signers** tab for the outstanding signature. |
+| Action item extraction misses items | The extractor looks for clear assignment phrasing; rephrase vague lines, or add the action items by hand from **Action items → Add Item**. |
+| No "distribute" option | Distribute is for ALV (general assembly) minutes — generate the ALV-format minutes first. |
+
+## Reference
+
+- [Track decisions and action items](07-track-decisions.md) — follow up the action items these minutes produced.
+- [Schedule a meeting and build the agenda](02-schedule-meeting.md) — the agenda the minutes are built from.
+- [Ask the AI companion about a meeting](08-ai-companion.md) — ask the companion to summarise what the minutes recorded.
diff --git a/docs/tutorials/user/07-track-decisions.md b/docs/tutorials/user/07-track-decisions.md
new file mode 100644
index 00000000..c2462c79
--- /dev/null
+++ b/docs/tutorials/user/07-track-decisions.md
@@ -0,0 +1,59 @@
+---
+sidebar_position: 7
+title: Track decisions and action items
+description: Find a published decision, follow its action items to completion, and read the engagement and completion-rate figures.
+---
+
+# Track decisions and action items
+
+Once a vote closes and minutes publish, Decidesk keeps the trail open — the decision, the action items it spawned, who owns them, and whether they got done.
+
+## Goal
+
+By the end you will know how to find a decision, see and update the action items linked to it, and read the completion-rate and engagement figures Decidesk derives from them.
+
+## Prerequisites
+
+- At least one published decision (see [Run a vote](05-run-vote.md)) and ideally minutes with extracted action items (see [Take and publish the minutes](06-take-minutes.md)).
+- For updating an action item's status: being its assignee, or having edit rights on the body's work.
+
+## Steps
+
+1. Open **Decisions** in the navigation. Each row shows the decision title, **outcome** (carried / rejected), **decision date**, and **publication** status; a *Publish* action handles any decision still pending publication.
+
+ 
+
+2. Open a decision. Its sidebar has an **Overview** (the motion text, the tally, the legal basis), an **Action items** tab, and an **Audit trail**.
+
+ 
+
+3. On the **Action items** tab — or under **Action items** in the navigation — see what the decision committed someone to: a **title**, an **assignee**, a **due date**, and a **status** (open, in progress, done). The assignee updates the status as the work moves.
+
+ 
+
+4. Back on the dashboard, the **Open action items** tile counts everything still open or in progress; the action-item analytics give completion rates per body and a *my items* view of what's assigned to you.
+
+ 
+
+5. Check **Engagement** for the meeting-level figures Decidesk derives — speaking time and an engagement score per participant — and **Tasks** for delegated follow-ups that aren't formal action items.
+
+ 
+
+## Verification
+
+A published decision shows in **Decisions** with its outcome, its **Action items** tab lists the linked items with assignees and statuses, and the dashboard's *Open action items* tile and the completion-rate figures move when you mark an item *done*.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| A decision shows as not published | Use the *Publish* action on the **Decisions** list — publishing enforces the body's access rules server-side. |
+| An action item has no assignee | Edit it and set an assignee, otherwise it won't show in anyone's *my items* and the completion rate can't account for it. |
+| Completion rate looks wrong | It only counts action items with a status set — items left in the default state skew it; make sure assignees keep statuses current. |
+| Engagement figures are empty | Engagement records are written from the live meeting (speaking turns); a meeting run without the live view won't have them. |
+
+## Reference
+
+- [Run a vote](05-run-vote.md) — where decisions come from.
+- [Take and publish the minutes](06-take-minutes.md) — where most action items are extracted.
+- [Ask the AI companion about a meeting](08-ai-companion.md) — ask "what action items are due this week?" instead of clicking through.
diff --git a/docs/tutorials/user/08-ai-companion.md b/docs/tutorials/user/08-ai-companion.md
new file mode 100644
index 00000000..e0fda80f
--- /dev/null
+++ b/docs/tutorials/user/08-ai-companion.md
@@ -0,0 +1,55 @@
+---
+sidebar_position: 8
+title: Ask the AI companion about a meeting
+description: Use the Nextcloud AI Chat Companion to query Decidesk — meetings, action items, decisions — in plain language.
+---
+
+# Ask the AI companion about a meeting
+
+Decidesk exposes its governance data to the Nextcloud AI Chat Companion, so you can ask "what action items are due this week?" or "summarise the last council meeting" instead of clicking through lists.
+
+## Goal
+
+By the end you will know how to open the AI companion, ask it a Decidesk question, and read the answer — including the source objects it cites.
+
+## Prerequisites
+
+- The Nextcloud **AI Chat Companion** available on your instance (hydra ADR-034), with a model configured.
+- The **OpenRegister** app at a version that publishes the `IMcpToolProvider` interface — Decidesk registers its tools automatically against it, no admin step.
+- Some Decidesk data to ask about (meetings, decisions, action items).
+
+## Steps
+
+1. Open the AI Chat Companion (the chat panel in the Nextcloud sidebar, or the companion app). It greets you with a chat box.
+
+ 
+
+2. Ask a Decidesk question in plain language — for example *"What action items are open and due this week?"* The companion calls Decidesk's `action-items` tool behind the scenes.
+
+ 
+
+3. Read the answer. It lists the items with assignees and due dates, and — because every Decidesk tool returns a `sources[]` array — it cites which objects it used, so you can open them directly.
+
+ 
+
+4. Follow up — *"summarise the last council meeting"*, *"which motions are still admissible but not voted?"*, *"start the next board meeting"*. Each call is argument-validated and authorisation-checked against the objects before it runs, so the companion only ever shows you what you're allowed to see.
+
+ 
+
+## Verification
+
+The companion returns a relevant answer (not "I don't have access to that"), the answer cites Decidesk objects you can click through to, and an action that changes state (e.g. "start the meeting") only succeeds if you have the right role.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Companion says it can't reach Decidesk | OpenRegister must be at the release that publishes `IMcpToolProvider`; if it isn't, the tools are simply unavailable and the rest of Decidesk still works. |
+| Companion answer omits sources | Re-ask — every Decidesk tool returns sources; an answer without them usually means the model didn't actually call the tool. Be specific ("list the open action items"). |
+| "Not authorised" on an action | The authorisation check runs before any business logic — you don't have the role that action requires for that body. |
+| `/api/chat/health` 404 in the browser console | Harmless — that's the companion probing whether the chat back end is wired up; Decidesk's own pages don't depend on it. |
+
+## Reference
+
+- [MCP Tools (AI Chat Companion integration)](../../features/mcp-tools.md) — the five tools Decidesk exposes and how each call is validated and authorised.
+- [Track decisions and action items](07-track-decisions.md) — the data the companion answers from.
diff --git a/docs/tutorials/user/_category_.json b/docs/tutorials/user/_category_.json
new file mode 100644
index 00000000..891401af
--- /dev/null
+++ b/docs/tutorials/user/_category_.json
@@ -0,0 +1,11 @@
+{
+ "label": "User guide",
+ "position": 1,
+ "collapsible": true,
+ "collapsed": false,
+ "link": {
+ "type": "generated-index",
+ "title": "User guide",
+ "description": "Workflows for individual users — opening the app, scheduling a meeting, building an agenda, submitting motions, running a vote, publishing minutes, tracking decisions, and asking the AI companion."
+ }
+}
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
new file mode 100644
index 00000000..47de89d2
--- /dev/null
+++ b/eslint-suppressions.json
@@ -0,0 +1,344 @@
+{
+ "src/components/AgendaBuilder.vue": {
+ "eqeqeq": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 25
+ },
+ "no-console": {
+ "count": 10
+ },
+ "vue/custom-event-name-casing": {
+ "count": 7
+ }
+ },
+ "src/components/AmendmentDiffView.vue": {
+ "vue/no-useless-mustaches": {
+ "count": 1
+ }
+ },
+ "src/components/VotingRoundPanel.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 9
+ },
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/components/liveMeeting/AgendaItemTimer.vue": {
+ "jsdoc/require-param-type": {
+ "count": 2
+ },
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/components/liveMeeting/SpeakerQueuePanel.vue": {
+ "jsdoc/require-param-type": {
+ "count": 6
+ },
+ "no-console": {
+ "count": 1
+ },
+ "vue/custom-event-name-casing": {
+ "count": 1
+ }
+ },
+ "src/components/minutesEditor/minutesEditor.js": {
+ "no-unused-vars": {
+ "count": 1
+ }
+ },
+ "src/components/processTemplates/ProcessTemplates.vue": {
+ "jsdoc/require-param-type": {
+ "count": 3
+ }
+ },
+ "src/components/processTemplates/StateMachineEditor.vue": {
+ "jsdoc/require-param-type": {
+ "count": 4
+ }
+ },
+ "src/components/tabs/ActionItemDeckBoard.vue": {
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/components/tabs/ActionItemsSurface.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ }
+ },
+ "src/components/tabs/AgendaMotionsTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 2
+ }
+ },
+ "src/components/tabs/ConsultationReactionsTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 3
+ }
+ },
+ "src/components/tabs/DecisionActionItemsTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 2
+ }
+ },
+ "src/components/tabs/DecisionLifecycleTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 3
+ }
+ },
+ "src/components/tabs/DecisionRouteTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 4
+ }
+ },
+ "src/components/tabs/DecisionVotingTab.vue": {
+ "eqeqeq": {
+ "count": 1
+ }
+ },
+ "src/components/tabs/GovernanceBodyEfficiencyTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 4
+ }
+ },
+ "src/components/tabs/GovernanceBodyRetentionTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 2
+ }
+ },
+ "src/components/tabs/MeetingAgendaTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 2
+ },
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/components/tabs/MeetingParticipantsTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 4
+ }
+ },
+ "src/components/tabs/MeetingTranscriptionTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 2
+ }
+ },
+ "src/components/tabs/MinutesSignersTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 3
+ }
+ },
+ "src/components/tabs/MotionAmendmentOrderTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 3
+ }
+ },
+ "src/components/tabs/MotionAmendmentsTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 2
+ }
+ },
+ "src/components/tabs/MotionVotesTab.vue": {
+ "eqeqeq": {
+ "count": 2
+ },
+ "jsdoc/require-param-type": {
+ "count": 2
+ }
+ },
+ "src/components/tabs/MotionVotingRoundTab.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ }
+ },
+ "src/components/tabs/RelatedDecisionsTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 7
+ }
+ },
+ "src/components/userSettings/CommunicationSection.vue": {
+ "@nextcloud/l10n-non-breaking-space": {
+ "count": 1
+ }
+ },
+ "src/components/userSettings/DelegationSection.vue": {
+ "@nextcloud/l10n-non-breaking-space": {
+ "count": 1
+ }
+ },
+ "src/components/userSettings/DisplayPreferencesSection.vue": {
+ "@nextcloud/l10n-non-breaking-space": {
+ "count": 1
+ },
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ }
+ },
+ "src/components/userSettings/NotificationPreferencesSection.vue": {
+ "@nextcloud/l10n-non-breaking-space": {
+ "count": 1
+ }
+ },
+ "src/dialogs/MeetingParticipantAddDialog.vue": {
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/dialogs/MinutesSignerAddDialog.vue": {
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/dialogs/RecurringItemsDialog.vue": {
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/integrations/CnDecisionsTab.vue": {
+ "jsdoc/require-param-type": {
+ "count": 8
+ }
+ },
+ "src/integrations/CnDecisionsWidget.vue": {
+ "jsdoc/require-param-type": {
+ "count": 6
+ }
+ },
+ "src/main.js": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/modals/MemberAddDialog.vue": {
+ "jsdoc/require-param-type": {
+ "count": 2
+ }
+ },
+ "src/modals/ProcessTemplateEditModal.vue": {
+ "@nextcloud/l10n-enforce-ellipsis": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/modals/RelatedDecisionAddModal.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 1
+ }
+ },
+ "src/services/deckProjection.js": {
+ "no-unused-vars": {
+ "count": 3
+ }
+ },
+ "src/store/modules/settings.js": {
+ "no-console": {
+ "count": 2
+ }
+ },
+ "src/views/LiveMeeting.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 1
+ },
+ "jsdoc/require-param-type": {
+ "count": 5
+ },
+ "no-console": {
+ "count": 8
+ }
+ },
+ "src/views/dashboard/widgets/ActiveDecisionsKpiWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/GovernanceHealthWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/MyActionItemsWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/OverdueActionsKpiWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/PendingVotesKpiWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/PendingVotesListWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/RecentDecisionsWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/RunningProcessesWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/UpcomingMeetingsKpiWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/dashboard/widgets/UpcomingMeetingsListWidget.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/participation/ParticipationPage.vue": {
+ "@typescript-eslint/no-unused-vars": {
+ "count": 3
+ },
+ "jsdoc/require-param-type": {
+ "count": 18
+ }
+ },
+ "src/views/settings/PersonalRoot.vue": {
+ "no-console": {
+ "count": 1
+ }
+ },
+ "src/views/settings/Settings.vue": {
+ "@nextcloud/l10n-enforce-ellipsis": {
+ "count": 5
+ },
+ "vue/multi-word-component-names": {
+ "count": 1
+ }
+ },
+ "src/views/settings/UserSettingsPage.vue": {
+ "no-console": {
+ "count": 1
+ }
+ }
+}
\ No newline at end of file
diff --git a/eslint.config.js b/eslint.config.js
deleted file mode 100644
index b306f39f..00000000
--- a/eslint.config.js
+++ /dev/null
@@ -1,45 +0,0 @@
-const {
- defineConfig,
-} = require('@eslint/config-helpers')
-
-const js = require('@eslint/js')
-
-const {
- FlatCompat,
-} = require('@eslint/eslintrc')
-
-const compat = new FlatCompat({
- baseDirectory: __dirname,
- recommendedConfig: js.configs.recommended,
- allConfig: js.configs.all,
-})
-
-module.exports = defineConfig([{
- extends: compat.extends('@nextcloud'),
-
- settings: {
- 'import/resolver': {
- alias: {
- map: [
- ['@', './src'],
- ['@floating-ui/dom-actual', './node_modules/@floating-ui/dom'],
- ['@conduction/nextcloud-vue', '../nextcloud-vue/src'],
- ],
- extensions: ['.js', '.ts', '.vue', '.json', '.css'],
- },
- },
- },
-
- rules: {
- // Allow unused i18n functions (t, n) — imported for future translation wiring
- 'no-unused-vars': ['error', { varsIgnorePattern: '^(t|n)$', argsIgnorePattern: '^_' }],
- 'jsdoc/require-jsdoc': 'off',
- 'vue/first-attribute-linebreak': 'off',
- '@typescript-eslint/no-explicit-any': 'off',
- 'n/no-missing-import': 'off',
- 'import/namespace': 'off', // disable namespace checking to avoid parser requirement
- 'import/default': 'off', // disable default import checking to avoid parser requirement
- 'import/no-named-as-default': 'off', // disable named-as-default checking to avoid parser requirement
- 'import/no-named-as-default-member': 'off', // disable named-as-default-member checking to avoid parser requirement
- },
-}])
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 00000000..9bad6e2d
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,236 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2026 Conduction B.V.
+//
+// eslint 10 + @nextcloud/eslint-config 9 — the same stack Nextcloud's own apps
+// run (nextcloud/forms is the reference). Flat config, ESM.
+//
+// This file is the fleet's canonical shape. Copy it verbatim into an app; the
+// only parts that should ever differ are the last two blocks (app-specific
+// globals and file-scoped exemptions).
+//
+// WHY `.mjs` AND NOT `"type": "module"` IN package.json
+// -----------------------------------------------------
+// `@nextcloud/eslint-config@9` is `"type": "module"`, so the config importing it
+// must be ESM. forms achieves that by making the whole package ESM; these apps
+// cannot — `webpack.config.js`, `vitest.config.js` and the `tests/**` CLI
+// checkers are CommonJS and would stop parsing. Naming the config `.mjs` scopes
+// the module system to the one file that needs it.
+//
+// 🔴 NODE 22 IS REQUIRED, NOT PREFERRED
+// -------------------------------------
+// `@nextcloud/eslint-config@9` declares `engines.node: ^22.14 || ^24 || >=26`
+// and imports `findPackageJSON` from `node:module`, an API that first exists in
+// 22.14. On Node 20 eslint dies before linting a single file with
+// `SyntaxError: … does not provide an export named 'findPackageJSON'`, and npm
+// reports the mismatch only as an EBADENGINE warning it continues past.
+//
+// 🔴 THE PEER DEPENDENCIES ARE LOAD-BEARING
+// -----------------------------------------
+// `vue-eslint-parser` is NOT a dependency of `@nextcloud/eslint-config` — it is
+// a peer of the `eslint-plugin-vue@10` it bundles, so the APP must supply it,
+// at `^10`. If a stale `eslint-plugin-vue@^9` / `vue-eslint-parser@^9` is left
+// in devDependencies it hoists over the bundled copy, `vue/base/setup-for-vue`
+// then supplies NO parser, and `typescript-eslint/base` — which also claims
+// `**/*.vue` — parses every SFC as TypeScript. Every `.vue` file fails with
+// `Parsing error: Expression expected`, and because eslint reports a parse
+// failure as ONE finding and lints nothing else in that file, the whole Vue
+// layer goes unchecked while the problem count looks small.
+// `@typescript-eslint/parser` must likewise be resolvable from the top level:
+// `vue-eslint-parser` requires it by name for `