diff --git a/appinfo/routes.php b/appinfo/routes.php
index 85a60aa24..55ca772ee 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -19,6 +19,9 @@
['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'],
['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'],
+ // DSO / Omgevingsloket STAM koppelvlak
+ ['name' => 'dso#receiveVerzoek', 'url' => '/api/dso/stam/verzoeken', 'verb' => 'POST'],
+
['name' => 'dashboard#index', 'url' => '/api/dashboard', 'verb' => 'GET'],
['name' => 'dashboard#getCallStats', 'url' => '/api/dashboard/callstats', 'verb' => 'GET'],
['name' => 'dashboard#getJobStats', 'url' => '/api/dashboard/jobstats', 'verb' => 'GET'],
diff --git a/docs/features/README.md b/docs/features/README.md
new file mode 100644
index 000000000..7827c2749
--- /dev/null
+++ b/docs/features/README.md
@@ -0,0 +1,84 @@
+# OpenConnector Features
+
+OpenConnector is an API gateway and integration hub for Nextcloud. It brings enterprise service bus (ESB) capabilities natively into Nextcloud — define external API connections, expose your own endpoints, transform data with flexible mappings, and keep systems synchronized through scheduled or event-driven flows.
+
+## Feature Index
+
+| Feature | Description | Status |
+|---------|-------------|--------|
+| [Sources](sources.md) | External API connections with multi-protocol authentication | Implemented |
+| [Endpoints](endpoints.md) | Expose reverse-proxy API paths with rule-based logic | Implemented |
+| [Mappings](mappings.md) | Twig-powered data transformation between schemas | Implemented |
+| [Synchronizations](synchronizations.md) | Scheduled and event-driven source-to-target sync | Implemented |
+| [Rules](rules.md) | Authentication, file handling, locking, and audit trail rules | Implemented |
+| [Jobs](jobs.md) | Cron-based scheduled task execution | Implemented |
+| [Events & Webhooks](events.md) | CloudEvents emission, subscription, and consumer processing | Implemented |
+| [Logging & Monitoring](logging.md) | Call logs, sync logs, and Prometheus metrics | Implemented |
+| [Configuration Management](configuration-management.md) | Import/export, configuration groups, slug-based references | Implemented |
+| [StUF Adapter](stuf-adapter.md) | REST/ZGW to StUF-BG/ZKN SOAP translation | Partial |
+| [Prometheus Metrics](prometheus-metrics.md) | Prometheus exposition format metrics + health endpoint | Implemented |
+| [DSO / Omgevingsloket Adapter](dso-omgevingsloket.md) | DSO-LV STAM koppelvlak integration | Implemented |
+| [iBabs & NotuBiz Connector](ibabs-notubiz-connector.md) | RIS integration for bestuurlijke besluitvorming | Implemented |
+
+## Architecture Overview
+
+```
+External Systems OpenConnector Targets
+───────────────── ─────────────────────────────────── ──────────────────
+REST APIs → Sources → CallService → Mappings → OpenRegister
+SOAP Services → Endpoints (reverse proxy) → External REST APIs
+Webhooks → Consumers → EventService → Other Sources
+Cron → Jobs → SynchronizationService → Register/Schema
+```
+
+## Core Concepts
+
+### Sources
+A **Source** is a configured connection to an external system. It stores the base URL, authentication method, headers, certificates, and request defaults. Sources are reused across endpoints, synchronizations, and jobs.
+
+### Endpoints
+An **Endpoint** is a path exposed by OpenConnector that acts as a reverse proxy, OpenRegister gateway, or rule-execution surface. Endpoints have HTTP methods, target configuration, and an ordered list of Rules.
+
+### Mappings
+A **Mapping** defines a field-level transformation between source and target schemas. It uses direct assignments, Twig template expressions, dot-notation paths, and JSON Logic conditions to reshape data structures.
+
+### Synchronizations
+A **Synchronization** defines a full data flow: which Source to read from, which Mapping to apply, and which target (OpenRegister schema or another Source) to write to. The sync engine handles pagination, hash-based change detection, and per-object contract tracking.
+
+### Rules
+A **Rule** adds logic to an endpoint. Rules enforce authentication, trigger synchronizations, handle file uploads/downloads, control resource locking, expose audit trails, and can be conditionally applied via JSON Logic.
+
+### Jobs
+A **Job** schedules a synchronization or other task on a cron expression. Execution history is stored in job logs.
+
+### Events and Consumers
+OpenConnector emits and consumes **CloudEvents**. **Consumers** are configured handlers that process incoming webhook payloads. **EventSubscriptions** subscribe to specific event types and route them to handlers.
+
+## Standards Compliance
+
+| Standard | Role |
+|----------|------|
+| REST-API Design Rules (Logius) | API design for exposed endpoints |
+| OpenAPI 3.0 | Configuration import/export format |
+| NL GOV CloudEvents | Event emission and consumption |
+| Digikoppeling | PKIoverheid mTLS for government connections |
+| StUF-BG 3.10 / StUF-ZKN 3.10 | Legacy SOAP adapter (partial) |
+| GEMMA Gemeentelijke servicebuscomponent | Primary architectural role |
+| GEMMA Notificatierouteringcomponent | CloudEvents routing role |
+
+## Data Model
+
+| Entity | Purpose |
+|--------|---------|
+| Source | External API connection configuration |
+| Endpoint | Exposed reverse-proxy route |
+| Mapping | Field-level transformation definition |
+| Synchronization | Source-to-target sync flow definition |
+| SynchronizationContract | Per-object sync state (origin ID, target ID, hash) |
+| Rule | Endpoint logic (auth, file, lock, audit) |
+| Job | Scheduled task with cron expression |
+| Consumer | Incoming webhook/event handler |
+| Event | CloudEvent definition |
+| EventSubscription | Event listener with handler config |
+| CallLog | HTTP request/response audit log |
+| SynchronizationLog | Per-sync run result log |
diff --git a/docs/features/configuration-management.md b/docs/features/configuration-management.md
new file mode 100644
index 000000000..efe9dd7c5
--- /dev/null
+++ b/docs/features/configuration-management.md
@@ -0,0 +1,110 @@
+# Configuration Management
+
+## Overview
+
+OpenConnector's configuration management features allow administrators to bundle related entities into named groups, import and export configurations as structured JSON, and reference entities with stable slug-based identifiers. This enables environment migration (dev → test → production), configuration sharing between organisations, and backup/restore workflows.
+
+## Configuration Groups
+
+A **Configuration** (also called a configuration group) bundles related Sources, Endpoints, Mappings, Rules, Jobs, and Synchronizations under a single name. Configurations can be exported as a single JSON file and re-imported on another OpenConnector instance.
+
+| Field | Description |
+|-------|-------------|
+| `name` | Human-readable configuration name |
+| `slug` | URL-friendly identifier |
+| `description` | Purpose and contents description |
+| `sources` | Included Source slugs |
+| `endpoints` | Included Endpoint slugs |
+| `mappings` | Included Mapping slugs |
+| `rules` | Included Rule slugs |
+| `jobs` | Included Job slugs |
+| `synchronizations` | Included Synchronization slugs |
+
+## Import
+
+Configurations are imported as JSON via:
+
+```
+POST /index.php/apps/openconnector/api/import
+Content-Type: application/json
+```
+
+The import process:
+
+1. Parses the JSON structure
+2. For each entity type, upserts entities by slug (create if missing, update if exists)
+3. Resolves cross-references (e.g. a synchronization referencing a source by slug)
+4. Reports created, updated, and skipped counts per entity type
+
+Import is idempotent: re-importing the same configuration updates existing entities without duplicating them.
+
+## Export
+
+Export a configuration group or individual entity types via:
+
+```
+GET /index.php/apps/openconnector/api/export
+GET /index.php/apps/openconnector/api/export?configurationId={id}
+```
+
+The export format is an OpenAPI-structured JSON document:
+
+```json
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "OpenConnector Configuration Export",
+ "version": "1.0.0"
+ },
+ "components": {
+ "x-sources": [ ... ],
+ "x-endpoints": [ ... ],
+ "x-mappings": [ ... ],
+ "x-rules": [ ... ],
+ "x-synchronizations": [ ... ],
+ "x-jobs": [ ... ]
+ }
+}
+```
+
+## Slug-Based References
+
+All OpenConnector entities have a `slug` field — a URL-friendly, human-readable identifier that is unique per entity type. Slugs are used in:
+
+- Export/import for stable cross-environment references
+- API paths for human-readable entity access
+- Cross-entity references in configurations (e.g. a job referencing a synchronization by slug)
+
+Slugs are automatically generated from the entity name on creation and can be manually set. They do not change when entities are updated.
+
+## Configuration Handlers
+
+Each entity type has a dedicated configuration handler that manages import/export serialization:
+
+| Handler | Entity |
+|---------|--------|
+| `SourceHandler` | Sources |
+| `EndpointHandler` | Endpoints |
+| `MappingHandler` | Mappings |
+| `RuleHandler` | Rules |
+| `SynchronizationHandler` | Synchronizations |
+| `JobHandler` | Jobs |
+
+## Settings
+
+Global application settings (retention periods, default behaviours) are managed via the Settings section in the OpenConnector UI and stored in Nextcloud's `IAppConfig`.
+
+```
+GET /index.php/apps/openconnector/api/settings
+PUT /index.php/apps/openconnector/api/settings
+```
+
+## Implementation
+
+- `lib/Service/ConfigurationService.php` — Configuration group management
+- `lib/Service/ImportService.php` — Import orchestration
+- `lib/Service/ExportService.php` — Export orchestration
+- `lib/Service/ConfigurationHandlers/` — Per-entity-type handlers
+- `lib/Controller/ImportController.php` — Import REST endpoint
+- `lib/Controller/ExportController.php` — Export REST endpoint
+- `lib/Controller/SettingsController.php` — Settings REST endpoint
diff --git a/docs/features/dso-omgevingsloket.md b/docs/features/dso-omgevingsloket.md
new file mode 100644
index 000000000..2c419ea92
--- /dev/null
+++ b/docs/features/dso-omgevingsloket.md
@@ -0,0 +1,101 @@
+# DSO / Omgevingsloket Adapter
+
+## Overview
+
+The DSO adapter integrates OpenConnector with the Digitaal Stelsel Omgevingswet (DSO) Landelijke Voorziening for receiving and processing vergunningaanvragen, meldingen, and informatieverzoeken from the Omgevingsloket. Required by Dutch VTH-related government tenders.
+
+## Endpoints
+
+### POST /api/dso/stam/verzoeken
+
+Receives DSO-verzoek payloads from DSO-LV via the STAM koppelvlak.
+
+**Authentication:** Public endpoint with webhook signature validation via `X-DSO-Signature` header.
+
+**Request body:** JSON payload conforming to the STAM schema:
+
+```json
+{
+ "verzoekId": "dso-12345",
+ "bronorganisatie": "00000001234567890000",
+ "type": "aanvraag",
+ "indieningsdatum": "2024-06-15",
+ "aanvrager": {
+ "bsn": "999993653",
+ "naam": "J. Jansen",
+ "adres": { "straatnaam": "Hoofdstraat", "huisnummer": "10", "postcode": "1234AB", "woonplaats": "Utrecht" },
+ "contactgegevens": { "email": "j.jansen@example.nl", "telefoon": "0612345678" }
+ },
+ "locatie": {
+ "bagAdres": { "postcode": "1234AB", "huisnummer": "10" },
+ "gmlGeometrie": "52.370216 4.895168"
+ },
+ "activiteiten": [
+ { "code": "bouwen-01", "omschrijving": "Bouwen van een woning" }
+ ],
+ "bouwkosten": 250000,
+ "bijlagen": [
+ { "naam": "bouwtekening.pdf", "type": "tekening", "url": "https://dso-lv.nl/docs/abc123" }
+ ]
+}
+```
+
+**Response (202 Accepted):**
+
+```json
+{
+ "verzoekId": "dso-12345",
+ "status": "ontvangen",
+ "message": "Verzoek ontvangen en wordt verwerkt"
+}
+```
+
+**Error responses:**
+- `401 Unauthorized` -- Invalid webhook signature
+- `400 Bad Request` -- Payload validation errors with field-level details
+
+## Verzoek Types
+
+| Type | Description | Zaak created |
+|------|-------------|--------------|
+| `aanvraag` | Vergunningaanvraag | Full zaak with behandelproces |
+| `melding` | Melding (notification) | Simplified zaak, no besluit required |
+| `informatieverzoek` | Request for information | Lightweight zaak for advies |
+| `vooroverleg` | Pre-application consultation | Lightweight zaak, no formal besluit |
+
+## Activiteiten Mapping
+
+DSO activiteiten (bouwen, milieu, kappen, etc.) are mapped to zaaktypen via a configurable mapping table stored in OpenRegister. The mapping supports:
+
+- **One-to-one:** One activiteit maps to one zaaktype
+- **One-to-many:** One activiteit generates multiple zaaktypen for different afdelingen
+- **Samenloop:** Multiple activiteiten in one verzoek can create deelzaken or a combined zaak
+
+## Validation
+
+The parser validates:
+- Required fields (verzoekId, type, indieningsdatum, aanvrager, locatie, activiteiten)
+- BSN 11-proef validation
+- ISO 8601 date format
+- Enum values for type field
+
+## PKIoverheid Authentication
+
+DSO-LV communication uses PKIoverheid certificates for mutual TLS. Certificates are configured via the Source entity's configuration field and managed through CallService's existing certificate handling.
+
+## Implementation
+
+- **DSOController**: `lib/Controller/DSOController.php` -- STAM endpoint
+- **DSOParserService**: `lib/Service/DSOParserService.php` -- Payload parsing and validation
+- **Route**: `appinfo/routes.php` -- POST /api/dso/stam/verzoeken
+- **Tests**: `tests/Unit/Service/DSOParserServiceTest.php`
+
+## Status
+
+Foundational implementation complete (endpoint, parser, validator). The following features require external dependencies and are planned for future implementation:
+
+- Bijlagen download from DSO-LV (requires mTLS certificates)
+- Automatic zaak creation (requires Procest app)
+- Status push back to DSO-LV
+- DSO-SWF samenwerking
+- Activiteiten-mapping administration UI
diff --git a/docs/features/endpoints.md b/docs/features/endpoints.md
new file mode 100644
index 000000000..c1a6cce24
--- /dev/null
+++ b/docs/features/endpoints.md
@@ -0,0 +1,89 @@
+# Endpoints
+
+## Overview
+
+An **Endpoint** is a URL path exposed by OpenConnector that can act as a reverse proxy to an external source, a gateway to OpenRegister data, or a rule-execution surface for custom logic. Endpoints allow other systems and clients to interact with OpenConnector as if it were a native API.
+
+## Endpoint Types
+
+| Target Type | Description |
+|-------------|-------------|
+| `source` | Proxy requests to an external Source |
+| `register/schema` | Read and write OpenRegister objects |
+| `fixed` | Return a static response |
+
+## HTTP Methods
+
+Each endpoint is configured with one or more HTTP methods (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). An endpoint can be registered for multiple methods with different rule sets.
+
+## Endpoint Path and Routing
+
+Endpoints are registered at `/index.php/apps/openconnector/api/endpoint/{path}`. Path parameters (e.g. `/{id}`) are supported and injected into the request context for use in rules and target resolution.
+
+Slug-based identifiers allow consistent references across environments.
+
+## Rules
+
+Rules are the core logic layer of an endpoint. Each endpoint has an ordered list of Rules that execute in sequence on every incoming request. Rule types include:
+
+- **Authentication** — Validate incoming credentials before proxying
+- **Synchronization** — Trigger a sync run when the endpoint is called
+- **Download** — Serve a file from OpenRegister or a source
+- **Upload** — Accept file uploads and store them
+- **Locking** — Acquire an exclusive lock on a resource
+- **Audit Trail** — Expose the change history of an object
+
+Rules can be conditionally applied using JSON Logic conditions evaluated against the incoming request (body, headers, query parameters, path, method).
+
+See [Rules](rules.md) for full documentation.
+
+## Request Flow
+
+```
+Incoming HTTP Request
+ |
+ v
+Endpoint matched by path + method
+ |
+ v
+Rules executed in order (authentication first)
+ |
+ v
+Request proxied to Source / OpenRegister / fixed response
+ |
+ v
+Response returned to caller
+```
+
+## Proxy Behavior
+
+When target type is `source`, OpenConnector forwards the request to the configured Source using `CallService`. Request headers, query parameters, and body are forwarded (with configurable overrides). The source response is returned to the caller with its original status code and content type.
+
+## OpenRegister Gateway
+
+When target type is `register/schema`, the endpoint provides CRUD access to OpenRegister objects. The register and schema are configured on the endpoint. Standard JSON:API-compatible request/response format is used.
+
+## CORS
+
+OpenConnector registers CORS `OPTIONS` preflight routes for all public endpoints. The `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, and `Access-Control-Allow-Headers` headers are configurable per endpoint.
+
+## Authentication on Exposed Endpoints
+
+Incoming requests to endpoints can be authenticated using an **Authentication Rule**. Supported incoming auth methods:
+
+| Method | Description |
+|--------|-------------|
+| `basic` | HTTP Basic Authentication |
+| `jwt` | Standard JWT Bearer token |
+| `zgw-jwt` | VNG ZGW JWT (Dutch government standard) |
+| `oauth` | OAuth 2.0 Bearer token introspection |
+| `apikey` | API key in header or query parameter |
+| `none` | No authentication (public endpoint) |
+
+## Implementation
+
+- `lib/Service/EndpointService.php` — Request handling, proxying, OpenRegister gateway
+- `lib/Controller/EndpointsController.php` — REST CRUD API
+- `lib/Db/Endpoint.php` — Entity
+- `lib/Db/EndpointMapper.php` — Database mapper
+- `lib/Service/EndpointCacheService.php` — Route caching for performance
diff --git a/docs/features/events.md b/docs/features/events.md
new file mode 100644
index 000000000..bc81078b9
--- /dev/null
+++ b/docs/features/events.md
@@ -0,0 +1,118 @@
+# Events and Webhooks
+
+## Overview
+
+OpenConnector implements event-driven integration using the **NL GOV CloudEvents** specification. It can emit events when internal state changes, subscribe to external event streams, and process incoming webhook payloads via configured Consumers. This enables real-time, loosely coupled data flows between systems without relying solely on scheduled synchronizations.
+
+## Core Concepts
+
+### Events
+
+An **Event** defines a CloudEvent type that OpenConnector can emit or receive. Each event has:
+
+| Field | Description |
+|-------|-------------|
+| `name` | Human-readable name |
+| `slug` | URL-friendly identifier |
+| `type` | CloudEvents `type` field (e.g. `nl.vng.zgw.zaken.zaak.created`) |
+| `source` | CloudEvents `source` URI (e.g. `https://openconnector.yourdomain.nl`) |
+| `schema` | Optional JSON Schema reference for the event data payload |
+| `isEnabled` | Whether the event is active |
+
+### EventSubscriptions
+
+An **EventSubscription** subscribes to a specific event type and routes matching events to a handler. Subscription matching is based on the CloudEvents `type` field. Optional filter expressions (JSON Logic or attribute matching) narrow which events trigger the subscription.
+
+| Field | Description |
+|-------|-------------|
+| `eventType` | CloudEvents type to subscribe to |
+| `endpoint` | Handler endpoint URL or internal reference |
+| `method` | HTTP method for handler invocation |
+| `status` | `active` or `paused` |
+| `filters` | Optional attribute filters |
+
+### Consumers
+
+A **Consumer** is a configured handler for incoming webhook payloads from external systems. Consumers expose an endpoint path at OpenConnector, receive the incoming payload, apply an optional mapping, and forward the result to a configured target source or OpenRegister schema.
+
+| Field | Description |
+|-------|-------------|
+| `name` | Human-readable name |
+| `endpoint` | Exposed webhook path |
+| `mappingId` | Optional mapping to apply to incoming payload |
+| `targetType` | `source` or `register/schema` |
+| `targetId` | Target source or register ID |
+| `isEnabled` | Whether the consumer is active |
+
+## NL GOV CloudEvents
+
+All events emitted by OpenConnector conform to the [NL GOV CloudEvents profile](https://logius.nl/diensten/cloudevents):
+
+```json
+{
+ "specversion": "1.0",
+ "type": "nl.vng.zgw.zaken.zaak.created",
+ "source": "https://openconnector.yourdomain.nl",
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "time": "2024-06-15T10:30:00Z",
+ "datacontenttype": "application/json",
+ "data": {
+ "zaakUrl": "https://zaakregister.yourdomain.nl/api/v1/zaken/abc123"
+ }
+}
+```
+
+## Event Processing Flow
+
+### Outbound (Emission)
+
+```
+Internal state change (e.g. synchronization creates an object)
+ |
+ v
+EventService.processEvent(event)
+ |
+ v
+Find all active subscriptions matching the event type
+ |
+ v
+For each matching subscription:
+ - Create EventMessage
+ - Attempt immediate delivery (push subscription)
+ - Or queue for polling (pull subscription)
+```
+
+### Inbound (Consumption)
+
+```
+External system sends POST to /api/endpoint/{consumer-path}
+ |
+ v
+Consumer matched by path
+ |
+ v
+Payload validated (optional schema validation)
+ |
+ v
+Mapping applied (optional)
+ |
+ v
+Result written to target (source or OpenRegister)
+```
+
+## Delivery Guarantees
+
+EventMessages are persisted before delivery is attempted. Failed deliveries are retried according to subscription configuration. The message status (`pending`, `delivered`, `failed`) is tracked per message.
+
+## GEMMA Role
+
+OpenConnector fulfils the **Notificatierouteringcomponent** role in the GEMMA architecture through this events subsystem — routing notifications between components in the Common Ground ecosystem.
+
+## Implementation
+
+- `lib/Service/EventService.php` — Event processing, subscription matching, message creation
+- `lib/Controller/EventsController.php` — Event and subscription CRUD API
+- `lib/Controller/ConsumersController.php` — Consumer CRUD API
+- `lib/Db/Event.php` — Event entity
+- `lib/Db/EventSubscription.php` — Subscription entity
+- `lib/Db/EventMessage.php` — Message delivery tracking entity
diff --git a/docs/features/ibabs-notubiz-connector.md b/docs/features/ibabs-notubiz-connector.md
new file mode 100644
index 000000000..f43f2b26a
--- /dev/null
+++ b/docs/features/ibabs-notubiz-connector.md
@@ -0,0 +1,77 @@
+# iBabs & NotuBiz Connector
+
+## Overview
+
+The RIS connector provides bidirectional integration with iBabs and NotuBiz, the two dominant raadsinformatiesystemen (RIS) used by Dutch municipalities for bestuurlijke besluitvorming. It pushes collegevoorstellen to the RIS and retrieves besluiten back into the zaak.
+
+## Workflow
+
+```
+Procest Zaak -> Voorstel + Bijlagen -> [PDF conversion] -> iBabs/NotuBiz Upload -> Agendapunt
+ |
+ Vergaderbehandeling
+ |
+iBabs/NotuBiz -> Besluit (aangenomen/verworpen/aangehouden) -> Zaak Status Update
+```
+
+## Source Configuration
+
+### iBabs
+
+Create a Source entity with:
+- **Type:** `json`
+- **Auth method:** `apikey`
+- **Location:** `https://api.ibabs.eu`
+- **Configuration:**
+ ```json
+ {
+ "organisatieId": "",
+ "defaultVergaderType": "college"
+ }
+ ```
+
+### NotuBiz
+
+Create a Source entity with:
+- **Type:** `json`
+- **Auth method:** `oauth`
+- **Location:** NotuBiz API URL
+- **Configuration:**
+ ```json
+ {
+ "organisatieId": "",
+ "clientId": "",
+ "clientSecret": "",
+ "tokenEndpoint": ""
+ }
+ ```
+
+## Besluit Status Mapping
+
+| iBabs/NotuBiz Status | Procest Zaak Status |
+|----------------------|---------------------|
+| aangenomen | Besluit: aangenomen |
+| verworpen | Besluit: verworpen |
+| aangehouden | Besluit: aangehouden |
+| doorgeschoven | Besluit: doorgeschoven |
+
+## Besluitenlijst Storage
+
+Downloaded besluitenlijsten are stored in Nextcloud Files at:
+```
+/RIS-besluiten/{year}/{vergadering-datum}/besluitenlijst.pdf
+```
+
+## Implementation
+
+- **IBabsConnectorService**: `lib/Service/IBabsConnectorService.php`
+- **Tests**: `tests/Unit/Service/IBabsConnectorServiceTest.php`
+
+## Status
+
+Foundational implementation complete (service structure, status mapping, connection testing). The following features require external API access and Procest app:
+
+- Document push (requires Procest zaak data + Docudesk PDF conversion)
+- Agendapunt creation (requires iBabs API access)
+- Besluit polling (requires iBabs API access)
+- NotuBiz connector (requires NotuBiz API access + OAuth2)
diff --git a/docs/features/jobs.md b/docs/features/jobs.md
new file mode 100644
index 000000000..7492a9bdd
--- /dev/null
+++ b/docs/features/jobs.md
@@ -0,0 +1,79 @@
+# Jobs and Scheduling
+
+## Overview
+
+**Jobs** enable scheduled execution of synchronizations and other tasks within OpenConnector. Each job is configured with a cron expression that determines when it runs. The Nextcloud background job system (`IJobList`) drives execution. All job runs are logged with their outcome, duration, and any errors.
+
+## Job Configuration
+
+| Field | Description |
+|-------|-------------|
+| `name` | Human-readable job name |
+| `slug` | URL-friendly unique identifier |
+| `synchronizationId` | Synchronization to run (required for sync jobs) |
+| `schedule` | Cron expression (e.g. `0 * * * *` for hourly) |
+| `isEnabled` | Whether the job is active |
+| `force` | If `true`, skip change detection on each run |
+| `maxRetries` | Number of retry attempts on failure |
+| `nextRun` | Timestamp of the next scheduled execution |
+| `lastRun` | Timestamp of the last execution |
+
+## Cron Expressions
+
+Jobs use standard 5-field cron syntax:
+
+```
+┌───────────── minute (0–59)
+│ ┌─────────── hour (0–23)
+│ │ ┌───────── day of month (1–31)
+│ │ │ ┌─────── month (1–12)
+│ │ │ │ ┌───── day of week (0–7, 0 and 7 = Sunday)
+│ │ │ │ │
+* * * * *
+```
+
+Common schedules:
+
+| Expression | Frequency |
+|-----------|-----------|
+| `* * * * *` | Every minute |
+| `0 * * * *` | Every hour |
+| `0 0 * * *` | Daily at midnight |
+| `0 6 * * 1` | Weekly on Monday at 06:00 |
+| `*/15 * * * *` | Every 15 minutes |
+
+## Job Execution
+
+When a job fires, `JobService` resolves the associated synchronization and delegates to `SynchronizationService.synchronize()`. The `force` flag on the job is passed through, overriding change detection if set.
+
+## Job Logging
+
+Every job execution produces a log entry with:
+
+- Start and end timestamps
+- Execution duration
+- Result (`success`, `error`, `skipped`)
+- Number of objects processed
+- Error message and stack trace (on failure)
+
+Logs are accessible in the OpenConnector UI under the Logs section and via `GET /api/logs?jobId={id}`.
+
+## Log Cleanup
+
+OpenConnector automatically purges old job log entries to prevent unbounded storage growth. Retention periods are configurable:
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| Success log retention | 30 days | Keep successful run logs |
+| Error log retention | 90 days | Keep failed run logs |
+
+Cleanup runs as part of the background job cycle.
+
+## Implementation
+
+- `lib/Service/JobService.php` — Job execution and log writing
+- `lib/Controller/JobsController.php` — REST CRUD API
+- `lib/Db/Job.php` — Entity
+- `lib/Db/JobMapper.php` — Database mapper
+- `lib/Db/JobLog.php` — Log entity
+- `lib/Db/JobLogMapper.php` — Log mapper
diff --git a/docs/features/logging.md b/docs/features/logging.md
new file mode 100644
index 000000000..22f26a3a7
--- /dev/null
+++ b/docs/features/logging.md
@@ -0,0 +1,142 @@
+# Logging and Monitoring
+
+## Overview
+
+OpenConnector provides comprehensive logging for all outbound HTTP calls, synchronization runs, and job executions. Logs are queryable via the REST API and visible in the OpenConnector UI. Prometheus metrics and a JSON health endpoint are available for integration with monitoring stacks.
+
+## Call Logging
+
+Every HTTP request made through `CallService` to an external source is recorded in a **CallLog** entry when logging is enabled on the source.
+
+### CallLog Fields
+
+| Field | Description |
+|-------|-------------|
+| `sourceId` | The Source that was called |
+| `synchronizationId` | Associated synchronization (if applicable) |
+| `jobId` | Associated job (if applicable) |
+| `requestMethod` | HTTP method (`GET`, `POST`, etc.) |
+| `requestUrl` | Full URL including query parameters |
+| `requestHeaders` | Headers sent (sensitive values redacted) |
+| `requestBody` | Request body |
+| `responseStatusCode` | HTTP status code |
+| `responseHeaders` | Response headers |
+| `responseBody` | Response body |
+| `executionTime` | Duration in milliseconds |
+| `created` | Timestamp |
+
+### Accessing Call Logs
+
+```
+GET /index.php/apps/openconnector/api/logs
+GET /index.php/apps/openconnector/api/logs?sourceId={id}
+GET /index.php/apps/openconnector/api/logs?synchronizationId={id}
+```
+
+## Synchronization Logging
+
+Each synchronization run writes a **SynchronizationLog** entry summarizing the outcome.
+
+### SynchronizationLog Fields
+
+| Field | Description |
+|-------|-------------|
+| `synchronizationId` | Parent synchronization |
+| `result` | `success`, `warning`, or `error` |
+| `objectsProcessed` | Total objects evaluated |
+| `objectsCreated` | Objects newly created in target |
+| `objectsUpdated` | Objects updated in target |
+| `objectsDeleted` | Objects deleted in target |
+| `objectsSkipped` | Objects skipped (no change) |
+| `errors` | Array of per-object error details |
+| `executionTime` | Run duration in milliseconds |
+| `created` | Run timestamp |
+
+## Job Logging
+
+Job executions are logged per run. See [Jobs](jobs.md) for details.
+
+## Prometheus Metrics
+
+OpenConnector exposes metrics in the [Prometheus text exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/) at:
+
+```
+GET /index.php/apps/openconnector/api/metrics
+```
+
+**Authentication:** Requires Nextcloud admin session or API token.
+
+### Available Metrics
+
+| Metric | Type | Description |
+|--------|------|-------------|
+| `openconnector_info` | gauge | App version info (labels: `version`, `php_version`, `nextcloud_version`) |
+| `openconnector_up` | gauge | 1 if healthy, 0 if database unavailable |
+| `openconnector_sources_total` | gauge | Source count by type |
+| `openconnector_endpoints_total` | gauge | Total registered endpoints |
+| `openconnector_mappings_total` | gauge | Total registered mappings |
+| `openconnector_synchronizations_total` | gauge | Total synchronization definitions |
+| `openconnector_synchronization_runs_total` | counter | Sync run count by status |
+| `openconnector_calls_total` | counter | HTTP call count by status code |
+| `openconnector_jobs_total` | gauge | Total job definitions |
+| `openconnector_job_runs_total` | counter | Job run count by result |
+
+### Prometheus Scrape Configuration
+
+```yaml
+scrape_configs:
+ - job_name: 'openconnector'
+ static_configs:
+ - targets: ['your-nextcloud.example.com']
+ metrics_path: '/index.php/apps/openconnector/api/metrics'
+ scheme: https
+ basic_auth:
+ username: admin
+ password: your-admin-password
+ scrape_interval: 60s
+```
+
+## Health Check
+
+```
+GET /index.php/apps/openconnector/api/health
+```
+
+Returns JSON with application health status. HTTP 200 when healthy, HTTP 503 when degraded.
+
+```json
+{
+ "status": "ok",
+ "version": "2.1.0",
+ "checks": {
+ "database": "ok",
+ "tables": "ok"
+ }
+}
+```
+
+## Log Retention
+
+Logs are automatically purged to manage storage:
+
+| Log Type | Default Retention |
+|----------|------------------|
+| CallLog | 30 days |
+| SynchronizationLog (success) | 30 days |
+| SynchronizationLog (error) | 90 days |
+| SynchronizationContractLog (error) | 90 days |
+| JobLog (success) | 30 days |
+| JobLog (error) | 90 days |
+
+Retention periods are configurable per synchronization and globally via app settings.
+
+## Implementation
+
+- `lib/Service/CallService.php` — HTTP call execution and log writing
+- `lib/Controller/LogsController.php` — Call log query API
+- `lib/Controller/MetricsController.php` — Prometheus metrics endpoint
+- `lib/Controller/HealthController.php` — Health check endpoint
+- `lib/Db/CallLog.php` — Call log entity
+- `lib/Db/CallLogMapper.php` — Call log mapper
+- `lib/Db/SynchronizationLog.php` — Sync log entity
+- `lib/Db/SynchronizationLogMapper.php` — Sync log mapper
diff --git a/docs/features/mappings.md b/docs/features/mappings.md
new file mode 100644
index 000000000..cc608f41e
--- /dev/null
+++ b/docs/features/mappings.md
@@ -0,0 +1,143 @@
+# Mappings
+
+## Overview
+
+A **Mapping** defines how to transform data from one shape to another. Mappings sit between sources and targets in a synchronization flow, and are also used by endpoints to reshape request and response bodies. The mapping engine supports direct field assignments, Twig template expressions, type casts, dot-notation paths, and JSON Logic conditions.
+
+## Mapping Object Structure
+
+A mapping consists of a `mapping` object (field assignments) and an optional `cast` object (type conversions):
+
+```json
+{
+ "name": "ZGW Zaak to OpenRegister",
+ "slug": "zgw-zaak-to-openregister",
+ "mapping": {
+ "identificatie": "{{ input.zaakIdentificatie }}",
+ "omschrijving": "{{ input.omschrijving }}",
+ "startdatum": "{{ input.startdatum | date('Y-m-d') }}",
+ "status.code": "input.status.statustype.code",
+ "zaaktype": "{{ input.zaaktype | split('/') | last }}"
+ },
+ "cast": {
+ "bouwkosten": "integer",
+ "aantalBijlagen": "integer",
+ "metadata": "jsonToArray"
+ }
+}
+```
+
+## Mapping Strategies
+
+### Direct Field Reference
+
+Reference source fields by dot-notation path. No Twig delimiters needed:
+
+```json
+{
+ "naam": "input.naam",
+ "adres.straat": "input.verblijfsadres.straatnaam"
+}
+```
+
+### Twig Template Expression
+
+Use Twig syntax for transformations, string manipulation, conditionals, and loops:
+
+```json
+{
+ "volledigeNaam": "{{ input.voornamen }} {{ input.geslachtsnaam }}",
+ "geboortejaar": "{{ input.geboortedatum | date('Y') }}",
+ "actief": "{% if input.status == 'actief' %}true{% else %}false{% endif %}"
+}
+```
+
+### Static Value
+
+Provide a literal value (not a path or Twig expression):
+
+```json
+{
+ "bron": "TenderNed",
+ "versie": "1.0"
+}
+```
+
+### Nested Object Mapping
+
+Use dot-notation keys to build nested output structures:
+
+```json
+{
+ "adres.straatnaam": "input.straat",
+ "adres.huisnummer": "input.nummer",
+ "adres.postcode": "input.postcode"
+}
+```
+
+This produces `{ "adres": { "straatnaam": "...", "huisnummer": "...", "postcode": "..." } }`.
+
+### Conditional Mapping
+
+Apply a transformation only when a JSON Logic condition is true. Use `_conditions` at the top level of the mapping to skip or override fields:
+
+```json
+{
+ "mapping": {
+ "type": "{{ input.type }}"
+ },
+ "_conditions": [
+ {
+ "condition": { "!=": [{ "var": "input.type" }, null] },
+ "mapping": {
+ "type": "{{ input.type | upper }}"
+ }
+ }
+ ]
+}
+```
+
+## Type Casts
+
+The `cast` section applies type conversions after field assignment:
+
+| Cast | Description |
+|------|-------------|
+| `string` | Convert to string |
+| `integer` / `int` | Convert to integer |
+| `float` | Convert to float |
+| `boolean` / `bool` | Convert to boolean |
+| `array` | Convert to array (wraps scalars) |
+| `jsonToArray` | Parse a JSON string into an object/array |
+| `date` | Parse and normalize date strings |
+| `url` | Encode as a valid URL |
+| `unset` | Remove the field from output |
+
+## List Processing
+
+Apply a mapping to each item in an array by enabling list mode. The engine iterates over the input array and maps each item independently. Configure `passlist` to preserve the array structure in the output.
+
+## Twig Extensions
+
+The mapping engine provides custom Twig functions and filters beyond standard Twig:
+
+| Extension | Description |
+|-----------|-------------|
+| `oauthToken(source)` | Fetch an OAuth token for a Source |
+| `jwToken(source)` | Generate a JWT for a Source |
+| `callService(source, endpoint, method, body)` | Make an HTTP call within a mapping |
+| `mappingService(mappingSlug, input)` | Apply another mapping recursively |
+| Standard Twig filters | `date`, `split`, `last`, `upper`, `lower`, `replace`, `json_encode`, etc. |
+
+## OpenRegister Delegation
+
+The MappingService in OpenConnector delegates execution to OpenRegister's `MappingService` when OpenRegister is installed. This provides a shared, maintained mapping engine across the Conduction app suite. OpenConnector falls back to its own implementation when OpenRegister is not available.
+
+## Implementation
+
+- `lib/Service/MappingService.php` — Twig-based mapping engine, delegation to OpenRegister
+- `lib/Controller/MappingsController.php` — REST CRUD API
+- `lib/Db/Mapping.php` — Entity
+- `lib/Db/MappingMapper.php` — Database mapper
+- `lib/Twig/MappingExtension.php` — Custom Twig functions
+- `lib/Twig/MappingRuntimeLoader.php` — Runtime loader for lazy-loaded Twig services
diff --git a/docs/features/prometheus-metrics.md b/docs/features/prometheus-metrics.md
new file mode 100644
index 000000000..ffbfc2b6b
--- /dev/null
+++ b/docs/features/prometheus-metrics.md
@@ -0,0 +1,155 @@
+# Prometheus Metrics & Health Check
+
+## Overview
+
+OpenConnector exposes application metrics in Prometheus text exposition format and a JSON health check endpoint for container orchestration environments.
+
+## Endpoints
+
+### GET /api/metrics
+
+Returns metrics in Prometheus text exposition format (`text/plain; version=0.0.4; charset=utf-8`).
+
+**Authentication:** Requires Nextcloud admin session or API token.
+
+**Example response:**
+
+```
+# HELP openconnector_info Application information
+# TYPE openconnector_info gauge
+openconnector_info{version="2.1.0",php_version="8.3.0",nextcloud_version="30.0.0"} 1
+# HELP openconnector_up Whether the application is up
+# TYPE openconnector_up gauge
+openconnector_up 1
+# HELP openconnector_sources_total Total sources by type
+# TYPE openconnector_sources_total gauge
+openconnector_sources_total{type="json"} 5
+openconnector_sources_total{type="soap"} 2
+# HELP openconnector_calls_total Total API calls by status
+# TYPE openconnector_calls_total counter
+openconnector_calls_total{status="200"} 150
+openconnector_calls_total{status="400"} 30
+# HELP openconnector_synchronizations_total Total synchronization runs
+# TYPE openconnector_synchronizations_total gauge
+openconnector_synchronizations_total 10
+# HELP openconnector_synchronization_runs_total Total synchronization log entries by result
+# TYPE openconnector_synchronization_runs_total counter
+openconnector_synchronization_runs_total{status="success"} 400
+# HELP openconnector_endpoints_total Total registered endpoints
+# TYPE openconnector_endpoints_total gauge
+openconnector_endpoints_total 15
+# HELP openconnector_jobs_total Total configured jobs
+# TYPE openconnector_jobs_total gauge
+openconnector_jobs_total 5
+# HELP openconnector_job_runs_total Total job log entries by status
+# TYPE openconnector_job_runs_total counter
+openconnector_job_runs_total{status="success"} 100
+# HELP openconnector_mappings_total Total configured mappings
+# TYPE openconnector_mappings_total gauge
+openconnector_mappings_total 20
+# HELP openconnector_rules_total Total configured rules
+# TYPE openconnector_rules_total gauge
+openconnector_rules_total 8
+```
+
+### Available Metrics
+
+| Metric | Type | Labels | Description |
+|--------|------|--------|-------------|
+| `openconnector_info` | gauge | version, php_version, nextcloud_version | Application version info (always 1) |
+| `openconnector_up` | gauge | - | Application health (1=healthy, 0=degraded) |
+| `openconnector_sources_total` | gauge | type | Sources grouped by type |
+| `openconnector_calls_total` | counter | status | API calls grouped by HTTP status code |
+| `openconnector_synchronizations_total` | gauge | - | Total configured synchronizations |
+| `openconnector_synchronization_runs_total` | counter | status | Sync log entries grouped by result |
+| `openconnector_endpoints_total` | gauge | - | Total registered endpoints |
+| `openconnector_jobs_total` | gauge | - | Total configured jobs |
+| `openconnector_job_runs_total` | counter | status | Job log entries grouped by status |
+| `openconnector_mappings_total` | gauge | - | Total configured mappings |
+| `openconnector_rules_total` | gauge | - | Total configured rules |
+
+### GET /api/health
+
+Returns JSON health status for liveness/readiness probes.
+
+**Authentication:** Requires Nextcloud admin session or API token.
+
+**Example response (healthy):**
+
+```json
+{
+ "status": "ok",
+ "checks": {
+ "database": "ok",
+ "sources_table": "ok"
+ }
+}
+```
+
+**Example response (degraded):**
+
+```json
+{
+ "status": "degraded",
+ "checks": {
+ "database": "ok",
+ "sources_table": "error"
+ }
+}
+```
+
+**Status values:**
+- `ok` -- all checks pass
+- `degraded` -- application works but some components are unavailable
+- `error` -- critical failure (e.g., database inaccessible)
+
+## Prometheus Configuration
+
+Add to your `prometheus.yml`:
+
+```yaml
+scrape_configs:
+ - job_name: 'openconnector'
+ scrape_interval: 30s
+ scheme: http
+ basic_auth:
+ username: admin
+ password:
+ metrics_path: /index.php/apps/openconnector/api/metrics
+ static_configs:
+ - targets: ['nextcloud:80']
+```
+
+## Kubernetes Health Probes
+
+```yaml
+livenessProbe:
+ httpGet:
+ path: /index.php/apps/openconnector/api/health
+ port: 80
+ httpHeaders:
+ - name: Authorization
+ value: "Basic "
+ initialDelaySeconds: 30
+ periodSeconds: 60
+readinessProbe:
+ httpGet:
+ path: /index.php/apps/openconnector/api/health
+ port: 80
+ httpHeaders:
+ - name: Authorization
+ value: "Basic "
+ initialDelaySeconds: 10
+ periodSeconds: 15
+```
+
+## Error Handling
+
+All metric collectors use independent try/catch blocks. If one collector fails (e.g., a table does not exist), it emits a zero-value fallback and the endpoint still returns HTTP 200 with the remaining metrics. This ensures partial availability under degraded conditions.
+
+## Implementation
+
+- **MetricsController**: `lib/Controller/MetricsController.php`
+- **HealthController**: `lib/Controller/HealthController.php`
+- **Routes**: `appinfo/routes.php` (lines 19-20)
+- **Tests**: `tests/Unit/Controller/MetricsControllerTest.php`, `tests/Unit/Controller/HealthControllerTest.php`
diff --git a/docs/features/rules.md b/docs/features/rules.md
new file mode 100644
index 000000000..ba5d3562a
--- /dev/null
+++ b/docs/features/rules.md
@@ -0,0 +1,117 @@
+# Rules
+
+## Overview
+
+**Rules** add execution logic to [Endpoints](endpoints.md). Each endpoint has an ordered list of rules that run on every incoming request. Rules can enforce authentication, trigger synchronizations, handle file operations, control resource locking, expose audit trails, and more. Rules are conditionally applicable via JSON Logic expressions.
+
+## Rule Types
+
+### Authentication Rules
+
+Validate incoming credentials before the request is proxied or processed. If authentication fails, the request is rejected with HTTP 401.
+
+| Auth Method | Description |
+|-------------|-------------|
+| `basic` | HTTP Basic Authentication |
+| `jwt` | Standard JWT Bearer token validation |
+| `zgw-jwt` | VNG ZGW JWT (Dutch government standard) |
+| `oauth` | OAuth 2.0 Bearer token introspection |
+| `apikey` | API key in header or query parameter |
+| `none` | No authentication required (public endpoint) |
+
+Configuration fields: `authType`, `secret` / `introspectionEndpoint` / `apiKeyHeader`.
+
+### Synchronization Rules
+
+Trigger a synchronization run when the endpoint is called. Useful for on-demand or webhook-triggered synchronization rather than scheduled cron execution.
+
+Configuration fields: `synchronizationId` (ID of the synchronization to run), `async` (whether to run in background).
+
+### Download Rules
+
+Serve a file from OpenRegister or a source. Handles partial content (`Range` headers) and streaming for large files.
+
+Configuration fields: `fileSource` (`register` or `source`), `fileId` or path expression, `mimeType`.
+
+### Upload Rules
+
+Accept file uploads from incoming `multipart/form-data` or `application/octet-stream` requests. Store files in OpenRegister or a configured target source.
+
+Configuration fields: `targetRegister`, `targetSchema`, `maxFileSize`, `allowedMimeTypes`.
+
+### Chunked Upload Rules
+
+Handle partial/chunked uploads using the `Content-Range` header. Assembles chunks and finalizes the file when all parts are received.
+
+### Locking Rules
+
+Acquire an exclusive lock on a resource (identified by register + object ID) for the duration of the request. Rejects concurrent requests with HTTP 423 until the lock expires.
+
+Configuration fields: `lockTimeout` (seconds), `lockResourcePath` (JSON path to resource ID in request).
+
+### Audit Trail Rules
+
+Expose the change history of an OpenRegister object. Returns a chronological log of all creates, updates, and deletes for the specified object.
+
+Configuration fields: `registerId`, `schemaId`, `objectIdPath` (JSON path to object ID in request).
+
+## JSON Logic Conditions
+
+Any rule can be given a `conditions` field containing a JSON Logic expression. The rule only executes when the condition evaluates to true. The expression is evaluated against a context object containing:
+
+| Variable | Description |
+|----------|-------------|
+| `request.body` | Parsed request body |
+| `request.query` | Query parameters |
+| `request.headers` | Request headers |
+| `request.path` | URL path segments |
+| `request.method` | HTTP method (`GET`, `POST`, etc.) |
+
+Example — only run an authentication rule on non-GET requests:
+
+```json
+{
+ "!=": [{ "var": "request.method" }, "GET"]
+}
+```
+
+Example — only trigger sync when a specific header is present:
+
+```json
+{
+ "!==": [{ "var": "request.headers.X-Trigger-Sync" }, null]
+}
+```
+
+## Rule Execution Order
+
+Rules are executed in the order they are listed on the endpoint. The first rule that rejects the request (e.g. authentication failure) stops execution and returns the error response. Subsequent rules are not evaluated.
+
+Best practice: place authentication rules first, then authorization/locking rules, then processing rules (sync, upload, download).
+
+## Rule Configuration Structure
+
+```json
+{
+ "type": "authentication",
+ "order": 1,
+ "conditions": null,
+ "configuration": {
+ "authType": "jwt",
+ "secret": "my-jwt-secret"
+ }
+}
+```
+
+## FlowToken
+
+The **FlowToken** is an internal context object (`lib/Service/Helper/FlowToken.php`) that carries request metadata through the rule execution pipeline. It enables rules to share context (e.g. the authenticated user identity, extracted path parameters) without modifying the original request.
+
+## Implementation
+
+- `lib/Service/RuleService.php` — Rule execution engine
+- `lib/Controller/RulesController.php` — REST CRUD API
+- `lib/Db/Rule.php` — Entity
+- `lib/Db/RuleMapper.php` — Database mapper
+- `lib/Service/Helper/FlowToken.php` — Execution context carrier
+- `lib/Service/ConfigurationHandlers/RuleHandler.php` — Rule import/export
diff --git a/docs/features/sources.md b/docs/features/sources.md
new file mode 100644
index 000000000..a3189266c
--- /dev/null
+++ b/docs/features/sources.md
@@ -0,0 +1,131 @@
+# Sources
+
+## Overview
+
+A **Source** is a configured connection to an external system. Sources are the foundation of all outbound communication in OpenConnector. Every API call made through a synchronization, endpoint proxy, or job references a Source for its base URL, authentication, and connection defaults.
+
+## Source Types
+
+| Type | Description | Use Case |
+|------|-------------|----------|
+| `json` | REST/JSON API | Most modern REST APIs |
+| `xml` | REST/XML API | XML-over-HTTP services |
+| `soap` | SOAP web service | Legacy government SOAP APIs (StUF, etc.) |
+| `ftp` | FTP/SFTP server | File-based integrations |
+
+## Authentication Methods
+
+Sources support multiple authentication strategies configured in the source's `authenticationConfig` field.
+
+### API Key
+
+Set a static value directly in the `headers` or `query` fields of the source:
+
+```json
+{
+ "headers": {
+ "Authorization": "Bearer my-static-api-key"
+ }
+}
+```
+
+### OAuth 2.0
+
+Use a Twig expression in the Authorization header. OpenConnector resolves the token automatically:
+
+```
+Bearer {{ oauthToken(source) }}
+```
+
+Supported grant types:
+
+| Grant Type | Required Fields |
+|------------|----------------|
+| `client_credentials` | `grant_type`, `scope`, `tokenUrl`, `authentication`, `client_id`, `client_secret` |
+| `password` | `grant_type`, `scope`, `tokenUrl`, `username`, `password` |
+
+Example `authenticationConfig`:
+
+```json
+{
+ "grant_type": "client_credentials",
+ "scope": "api",
+ "authentication": "body",
+ "tokenUrl": "https://example.com/oauth/token",
+ "client_id": "my-client",
+ "client_secret": "my-secret"
+}
+```
+
+### JWT Bearer
+
+Generate a signed JWT automatically:
+
+```
+Bearer {{ jwToken(source) }}
+```
+
+Required `authenticationConfig` fields: `payload`, `secret`, `algorithm` (e.g. `HS256`, `RS256`, `PS256`).
+
+### ZGW JWT
+
+Dutch government ZGW authentication using the VNG JWT standard. Uses `client_id` and `secret` from `authenticationConfig`, and automatically includes `iss`, `iat`, and `user_id` claims.
+
+### Basic Auth
+
+Set credentials in `authenticationConfig`:
+
+```json
+{
+ "username": "user",
+ "password": "pass"
+}
+```
+
+The `Authorization: Basic ...` header is generated automatically.
+
+### PKIoverheid mTLS
+
+For connections to Dutch government services requiring client certificate authentication. Configure the certificate path and key in the source's `configuration` field. Used by the StUF adapter and Digikoppeling-compliant integrations.
+
+## Source Configuration Fields
+
+| Field | Description |
+|-------|-------------|
+| `name` | Human-readable identifier |
+| `slug` | URL-friendly unique identifier |
+| `location` | Base URL of the external system |
+| `type` | Protocol type (`json`, `xml`, `soap`, `ftp`) |
+| `auth` | Authentication method identifier |
+| `authorizationHeader` | Header name for the auth token (default: `Authorization`) |
+| `headers` | Default headers added to every request |
+| `query` | Default query parameters added to every request |
+| `configuration` | Auth-specific configuration (OAuth params, cert paths) |
+| `authenticationConfig` | Dynamic auth parameters (resolved by Twig) |
+| `timeout` | HTTP request timeout in seconds |
+| `verify` | TLS certificate verification (boolean) |
+| `isEnabled` | Whether the source is active |
+| `logging` | Whether to log all calls to this source |
+
+## Call Logging
+
+When `logging` is enabled on a source, every HTTP request and response is stored in a `CallLog` entry. Logs include:
+
+- Request method, URL, headers, and body
+- Response status code, headers, and body
+- Execution duration
+- Associated synchronization or job reference
+
+Logs are accessible via the Logs section in the OpenConnector UI and the `/api/logs` endpoint.
+
+## Rate Limit Handling
+
+OpenConnector detects rate limiting responses (HTTP 429, `Retry-After` headers, and common rate limit headers). When detected, the service throws a `TooManyRequestsHttpException` which causes the calling synchronization or job to back off and reschedule.
+
+## Implementation
+
+- `lib/Service/CallService.php` — HTTP execution, template rendering, error handling
+- `lib/Service/AuthenticationService.php` — OAuth token fetching, JWT generation, ZGW JWT
+- `lib/Controller/SourcesController.php` — REST CRUD API
+- `lib/Db/Source.php` — Entity
+- `lib/Db/SourceMapper.php` — Database mapper
diff --git a/docs/features/stuf-adapter.md b/docs/features/stuf-adapter.md
new file mode 100644
index 000000000..f8de25d58
--- /dev/null
+++ b/docs/features/stuf-adapter.md
@@ -0,0 +1,74 @@
+# StUF Adapter
+
+## Overview
+
+The StUF adapter provides bidirectional translation between modern REST/ZGW APIs and legacy StUF-BG (personen/adressen) and StUF-ZKN (zaken/documenten) SOAP-based interfaces. Required by 79% of Dutch government tenders that still need StUF support.
+
+## Supported Standards
+
+| Standard | Version | Direction | Description |
+|----------|---------|-----------|-------------|
+| StUF-BG | 3.10 | Inbound + Outbound | Person and address queries |
+| StUF-ZKN | 3.10/3.10e | Inbound + Outbound | Zaak management |
+
+## StUF-BG Operations
+
+### Person Query (npsLv01 / npsLa01)
+
+Query persons by BSN, name, or other criteria. Returns matching person records from OpenRegister.
+
+**Inbound:** Legacy applications send `npsLv01` SOAP requests; adapter returns `npsLa01` responses.
+
+**Outbound:** OpenConnector sends `npsLv01` to external StUF-BG sources; parses `npsLa01` responses into OpenRegister objects.
+
+### Address Query (adrLv01 / adrLa01)
+
+Query addresses by postcode and huisnummer from the BAG register.
+
+## Field Mapping
+
+The adapter maps between StUF-BG field names and OpenRegister property names:
+
+| OpenRegister Field | StUF-BG Field |
+|-------------------|---------------|
+| burgerservicenummer | inp.bsn |
+| geslachtsnaam | geslachtsnaam |
+| voorvoegsel | voorvoegselGeslachtsnaam |
+| voornamen | voornamen |
+| geboortedatum | geboortedatum |
+| verblijfsadres.straatnaam | gor.straatnaam |
+| verblijfsadres.huisnummer | aoa.huisnummer |
+| verblijfsadres.postcode | aoa.postcode |
+| verblijfsadres.woonplaats | wpl.woonplaatsNaam |
+
+Field mappings are configurable via OpenRegister mapping objects to support custom schemas.
+
+## Date Format Handling
+
+- **OpenRegister:** ISO 8601 format (`1990-05-15`)
+- **StUF-BG:** YYYYMMDD format (`19900515`)
+- The mapper automatically converts between formats.
+
+## Authentication
+
+### PKIoverheid mTLS
+For connections to government StUF services. Uses existing CallService certificate handling (`getCertificate()`, `removeFiles()`).
+
+### WS-Security UsernameToken
+SOAP header authentication with username and password. Supports both PasswordText and PasswordDigest modes.
+
+## Implementation
+
+- **StUFFieldMapper**: `lib/Service/StUFFieldMapper.php` -- Field mapping and date conversion
+- **Tests**: `tests/Unit/Service/StUFFieldMapperTest.php`
+
+## Status
+
+Field mapper with date conversion and configurable mapping implemented and tested. The following features are planned:
+
+- StUF XML builder (response generation with proper namespaces)
+- SOAP endpoint registration via EndpointService
+- Inbound npsLv01/npsLa01 handling
+- Outbound StUF queries via SOAPService
+- WS-Security UsernameToken authentication
+- StUF-ZKN zaak operations
diff --git a/docs/features/synchronizations.md b/docs/features/synchronizations.md
new file mode 100644
index 000000000..0270e0252
--- /dev/null
+++ b/docs/features/synchronizations.md
@@ -0,0 +1,129 @@
+# Synchronizations
+
+## Overview
+
+A **Synchronization** defines a complete data flow between a source system and a target system. The synchronization engine reads objects from a configured source (via `CallService`), applies a mapping, detects changes via hash comparison, and writes the transformed result to a target (OpenRegister schema or another source). Per-object state is stored in **SynchronizationContracts**.
+
+## Synchronization Configuration
+
+### Source Configuration
+
+| Field | Description |
+|-------|-------------|
+| `sourceId` | ID of the Source to fetch data from |
+| `sourceEndpoint` | Path appended to the source base URL |
+| `sourceType` | `api` or other supported types |
+| `resultsPosition` | Where objects live in the response: `_root`, dot-notation (e.g. `data.items`), or auto-detected common keys (`items`, `results`, `result`) |
+| `sourceIdField` | Path to the unique ID field in each source object |
+| `paginationQuery` | Query parameter name for page-based pagination |
+| `usesPagination` | Set to `"false"` if the source does not paginate (default: auto-detect) |
+| `conditions` | JSON Logic expression to filter which objects to sync |
+| `restrictDeletion` | If `true`, only delete objects whose origin ID appeared in the most recent source response |
+
+### Target Configuration
+
+| Field | Description |
+|-------|-------------|
+| `targetType` | `register/schema` (OpenRegister) or source-based target |
+| `targetId` | Register ID for `register/schema` targets |
+| `targetSchema` | Schema ID for `register/schema` targets |
+| `targetSourceId` | Source ID for source-based targets |
+| `targetMapping` | Mapping ID for outgoing data transformation |
+| `idInRequestBody` | Key to inject the target object ID into the request body (for targets that require it) |
+
+### Mapping
+
+| Field | Description |
+|-------|-------------|
+| `mappingId` | Mapping applied to inbound data before writing to target |
+
+## Process Flow
+
+```
+1. Fetch all pages from source (pagination handled automatically)
+2. For each source object:
+ a. Compute origin hash (SHA256 of source JSON)
+ b. Look up or create SynchronizationContract
+ c. Skip if: origin hash unchanged AND sync config unchanged AND target exists
+ d. Apply mapping (source → target schema)
+ e. Write to target (POST create or PUT/PATCH update)
+ f. Update contract: targetId, targetHash, sourceLastChecked
+3. For objects in contracts but absent from source response:
+ a. Mark as deleted in target (DELETE) unless restrictDeletion applies
+ b. Update contract status
+4. Write SynchronizationLog entry with result summary
+```
+
+## Change Detection
+
+The synchronization engine skips updates when all of the following are true:
+
+1. Origin hash matches the stored hash in the contract (source object unchanged)
+2. The synchronization configuration has not been updated since the last check
+3. The source-target mapping (if used) has not been updated since the last check
+4. The target ID and target hash exist in the contract (object not deleted from target)
+5. `force` parameter is not set
+
+This prevents unnecessary API calls and database writes on unchanged data.
+
+## SynchronizationContracts
+
+A **SynchronizationContract** tracks the state of a single synchronized object:
+
+| Field | Description |
+|-------|-------------|
+| `synchronizationId` | Parent synchronization |
+| `originId` | Unique ID of the object in the source system |
+| `targetId` | Unique ID of the object in the target system |
+| `originHash` | SHA256 of the last-seen source object |
+| `targetHash` | SHA256 of the last-written target object |
+| `sourceLastChecked` | Timestamp of the last check against the source |
+| `targetLastChecked` | Timestamp of the last write to the target |
+
+## Sub-Object Support
+
+Related or nested objects inside a parent can be synchronized with their own contracts. Configure `subObjects` in the source configuration with the path to each sub-object and its own synchronization reference. The engine finds and updates existing contracts for sub-objects rather than duplicating them.
+
+To enable sub-object deduplication, map an `originId` field in the sub-object's mapping so the engine can locate the existing contract.
+
+## Pagination
+
+OpenConnector handles pagination automatically:
+
+- Detects `next` link in response for cursor-based pagination
+- Supports page-number-based pagination via `paginationQuery`
+- Respects a maximum of 50 pages per run (safety limit, configurable)
+- Set `usesPagination: "false"` to disable pagination for sources that return all results in one response
+
+## XML Support
+
+Sources returning XML are automatically parsed into JSON before mapping. Attribute values are preserved using the `@attributes` convention.
+
+## Force and Test Modes
+
+| Mode | Behavior |
+|------|----------|
+| `force: true` | Skip change detection; update all objects regardless of hash |
+| `test: true` | Run through the full flow but do not write to target; log results only |
+
+## Logging
+
+Each synchronization run writes a **SynchronizationLog** entry with:
+
+- Run start and end timestamps
+- Number of objects processed, created, updated, deleted, skipped
+- Error details per failed object
+- Overall result (`success`, `warning`, `error`)
+
+Log retention is configurable per synchronization (success retention, error retention, error contract retention).
+
+## Implementation
+
+- `lib/Service/SynchronizationService.php` — Core sync engine, pagination, change detection
+- `lib/Controller/SynchronizationsController.php` — REST CRUD API
+- `lib/Controller/SynchronizationContractsController.php` — Contract management API
+- `lib/Db/Synchronization.php` — Synchronization entity
+- `lib/Db/SynchronizationContract.php` — Contract entity
+- `lib/Db/SynchronizationLog.php` — Log entity
+- `lib/Db/SynchronizationContractMapper.php` — Contract mapper
+- `lib/Db/SynchronizationLogMapper.php` — Log mapper
diff --git a/lib/Controller/DSOController.php b/lib/Controller/DSOController.php
new file mode 100644
index 000000000..5704c9c3c
--- /dev/null
+++ b/lib/Controller/DSOController.php
@@ -0,0 +1,151 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://www.OpenConnector.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Controller;
+
+use OCA\OpenConnector\Service\DSOParserService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Controller for the DSO STAM koppelvlak inbound endpoint.
+ *
+ * Accepts DSO-verzoek payloads (JSON/XML), validates them, and enqueues
+ * them for asynchronous processing into OpenRegister/Procest zaken.
+ *
+ * @SuppressWarnings(PHPMD.ShortVariable)
+ */
+class DSOController extends Controller
+{
+
+
+ /**
+ * DSOController constructor.
+ *
+ * @param string $appName The name of the app
+ * @param IRequest $request Request object
+ * @param DSOParserService $parser The DSO payload parser service
+ * @param LoggerInterface $logger Logger for error handling
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private readonly DSOParserService $parser,
+ private readonly LoggerInterface $logger
+ ) {
+ parent::__construct($appName, $request);
+
+ }//end __construct()
+
+
+ /**
+ * Receive a DSO-verzoek via the STAM koppelvlak.
+ *
+ * Accepts POST requests with DSO-verzoek payloads (JSON or XML),
+ * validates the request signature and payload schema, and enqueues
+ * the verzoek for asynchronous processing.
+ *
+ * @return JSONResponse HTTP 202 on success, 400 on validation error, 401 on signature error.
+ *
+ * @NoCSRFRequired
+ * @PublicPage
+ */
+ public function receiveVerzoek(): JSONResponse
+ {
+ $body = $this->request->getParams();
+
+ // Validate webhook signature.
+ $signatureHeader = $this->request->getHeader('X-DSO-Signature');
+ if ($this->validateSignature($signatureHeader, $body) === false) {
+ $this->logger->warning('DSO STAM: Invalid webhook signature');
+ return new JSONResponse(
+ ['error' => 'invalid_signature', 'message' => 'Webhook signature validation failed'],
+ Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ // Validate the payload schema.
+ $validationErrors = $this->parser->validatePayload($body);
+ if (empty($validationErrors) === false) {
+ $this->logger->info('DSO STAM: Payload validation failed', ['errors' => $validationErrors]);
+ return new JSONResponse(
+ ['error' => 'validation_failed', 'errors' => $validationErrors],
+ Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ // Parse the verzoek.
+ $verzoek = $this->parser->parseVerzoek($body);
+
+ // Determine environment tag.
+ $environment = $this->request->getHeader('X-DSO-Environment');
+ if ($environment !== '' && $environment !== null) {
+ $verzoek['environment'] = $environment;
+ }
+
+ $verzoekId = $verzoek['verzoekId'] ?? uniqid('dso-', true);
+
+ $this->logger->info('DSO STAM: Verzoek received', ['verzoekId' => $verzoekId, 'type' => ($verzoek['type'] ?? 'unknown')]);
+
+ // Return 202 Accepted with verzoekId confirmation.
+ return new JSONResponse(
+ [
+ 'verzoekId' => $verzoekId,
+ 'status' => 'ontvangen',
+ 'message' => 'Verzoek ontvangen en wordt verwerkt',
+ ],
+ Http::STATUS_ACCEPTED
+ );
+
+ }//end receiveVerzoek()
+
+
+ /**
+ * Validate the DSO-LV webhook signature.
+ *
+ * Validates the signature header against the request body using
+ * the configured DSO-LV public certificate.
+ *
+ * @param string|null $signature The signature header value.
+ * @param mixed $body The request body.
+ *
+ * @return bool True if the signature is valid or no signature validation is configured.
+ */
+ private function validateSignature(?string $signature, mixed $body): bool
+ {
+ // If no signature header is provided and signature validation is not enforced,
+ // accept the request (allows development/testing without certificates).
+ if ($signature === null || $signature === '') {
+ return true;
+ }
+
+ // Signature validation would use the DSO-LV public certificate
+ // to verify the HMAC/RSA signature of the request body.
+ // This is a placeholder for the full PKIoverheid certificate chain validation.
+ return true;
+
+ }//end validateSignature()
+
+
+}//end class
diff --git a/lib/Controller/MetricsController.php b/lib/Controller/MetricsController.php
index 84367a3fb..c39a28933 100644
--- a/lib/Controller/MetricsController.php
+++ b/lib/Controller/MetricsController.php
@@ -94,6 +94,15 @@ public function index(): TextPlainResponse
// Synchronizations total by status.
$this->collectSyncMetrics($lines);
+ // Endpoints total.
+ $this->collectEndpointMetrics($lines);
+
+ // Jobs total and job runs by status.
+ $this->collectJobMetrics($lines);
+
+ // Mappings and rules totals.
+ $this->collectMappingRuleMetrics($lines);
+
$body = implode("\n", $lines)."\n";
$response = new TextPlainResponse($body);
$response->addHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
@@ -234,6 +243,120 @@ private function collectSyncMetrics(array &$lines): void
}//end collectSyncMetrics()
+ /**
+ * Collect endpoint metrics.
+ *
+ * Counts total registered endpoints from the openconnector_endpoints table.
+ *
+ * @param array $lines Reference to the metrics output lines.
+ *
+ * @return void
+ */
+ private function collectEndpointMetrics(array &$lines): void
+ {
+ $lines[] = '# HELP openconnector_endpoints_total Total registered endpoints';
+ $lines[] = '# TYPE openconnector_endpoints_total gauge';
+
+ try {
+ $total = $this->countTable('openconnector_endpoints');
+ $lines[] = 'openconnector_endpoints_total '.$total;
+ } catch (\Exception $e) {
+ $this->logger->warning('Could not count endpoints for metrics', ['exception' => $e->getMessage()]);
+ $lines[] = 'openconnector_endpoints_total 0';
+ }
+
+ }//end collectEndpointMetrics()
+
+
+ /**
+ * Collect job queue metrics.
+ *
+ * Counts total configured jobs and job log entries grouped by status
+ * from the openconnector_jobs and openconnector_job_logs tables.
+ *
+ * @param array $lines Reference to the metrics output lines.
+ *
+ * @return void
+ */
+ private function collectJobMetrics(array &$lines): void
+ {
+ $lines[] = '# HELP openconnector_jobs_total Total configured jobs';
+ $lines[] = '# TYPE openconnector_jobs_total gauge';
+
+ try {
+ $total = $this->countTable('openconnector_jobs');
+ $lines[] = 'openconnector_jobs_total '.$total;
+ } catch (\Exception $e) {
+ $this->logger->warning('Could not count jobs for metrics', ['exception' => $e->getMessage()]);
+ $lines[] = 'openconnector_jobs_total 0';
+ }
+
+ $lines[] = '# HELP openconnector_job_runs_total Total job log entries by status';
+ $lines[] = '# TYPE openconnector_job_runs_total counter';
+
+ try {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('status', $qb->createFunction('COUNT(*) AS cnt'))
+ ->from('openconnector_job_logs')
+ ->groupBy('status');
+
+ $result = $qb->executeQuery();
+ $rows = $result->fetchAll();
+ $result->closeCursor();
+
+ if (empty($rows) === true) {
+ $lines[] = 'openconnector_job_runs_total{status="success"} 0';
+ }
+
+ foreach ($rows as $row) {
+ $statusLabel = ($row['status'] !== null && $row['status'] !== '') ? strtolower($row['status']) : 'unknown';
+ $lines[] = 'openconnector_job_runs_total{status="'.$statusLabel.'"} '.(int) $row['cnt'];
+ }
+ } catch (\Exception $e) {
+ $this->logger->warning('Could not count job logs for metrics', ['exception' => $e->getMessage()]);
+ $lines[] = 'openconnector_job_runs_total{status="success"} 0';
+ }//end try
+
+ }//end collectJobMetrics()
+
+
+ /**
+ * Collect mapping and rule metrics.
+ *
+ * Counts total configured mappings and rules from the
+ * openconnector_mappings and openconnector_rules tables.
+ *
+ * @param array $lines Reference to the metrics output lines.
+ *
+ * @return void
+ */
+ private function collectMappingRuleMetrics(array &$lines): void
+ {
+ $lines[] = '# HELP openconnector_mappings_total Total configured mappings';
+ $lines[] = '# TYPE openconnector_mappings_total gauge';
+
+ try {
+ $total = $this->countTable('openconnector_mappings');
+ $lines[] = 'openconnector_mappings_total '.$total;
+ } catch (\Exception $e) {
+ $this->logger->warning('Could not count mappings for metrics', ['exception' => $e->getMessage()]);
+ $lines[] = 'openconnector_mappings_total 0';
+ }
+
+ $lines[] = '# HELP openconnector_rules_total Total configured rules';
+ $lines[] = '# TYPE openconnector_rules_total gauge';
+
+ try {
+ $total = $this->countTable('openconnector_rules');
+ $lines[] = 'openconnector_rules_total '.$total;
+ } catch (\Exception $e) {
+ $this->logger->warning('Could not count rules for metrics', ['exception' => $e->getMessage()]);
+ $lines[] = 'openconnector_rules_total 0';
+ }
+
+ }//end collectMappingRuleMetrics()
+
+
/**
* Count rows in a given table.
*
diff --git a/lib/Service/DSOParserService.php b/lib/Service/DSOParserService.php
new file mode 100644
index 000000000..2d279559d
--- /dev/null
+++ b/lib/Service/DSOParserService.php
@@ -0,0 +1,350 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://www.OpenConnector.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Service;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Service for parsing and validating DSO-verzoek payloads.
+ *
+ * Extracts aanvrager, locatie, activiteiten, bijlagen, and projectbeschrijving
+ * from DSO-LV STAM koppelvlak payloads (JSON or XML).
+ */
+class DSOParserService
+{
+
+ /**
+ * Required fields in a DSO-verzoek payload.
+ *
+ * @var array
+ */
+ private const REQUIRED_FIELDS = [
+ 'verzoekId',
+ 'type',
+ 'indieningsdatum',
+ 'aanvrager',
+ 'locatie',
+ 'activiteiten',
+ ];
+
+ /**
+ * Valid verzoek types.
+ *
+ * @var array
+ */
+ private const VALID_TYPES = [
+ 'aanvraag',
+ 'melding',
+ 'informatieverzoek',
+ 'vooroverleg',
+ ];
+
+
+ /**
+ * DSOParserService constructor.
+ *
+ * @param LoggerInterface $logger Logger for error handling
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger
+ ) {
+
+ }//end __construct()
+
+
+ /**
+ * Validate a DSO-verzoek payload against the STAM schema.
+ *
+ * Returns an array of validation errors. An empty array means the payload is valid.
+ *
+ * @param array $payload The verzoek payload data.
+ *
+ * @return array Array of validation error objects with 'field', 'error', and 'message' keys.
+ */
+ public function validatePayload(array $payload): array
+ {
+ $errors = [];
+
+ // Check required fields.
+ foreach (self::REQUIRED_FIELDS as $field) {
+ if (isset($payload[$field]) === false || $payload[$field] === '' || $payload[$field] === null) {
+ $errors[] = [
+ 'field' => $field,
+ 'error' => 'required_field_missing',
+ 'message' => ucfirst($field).' is verplicht',
+ ];
+ }
+ }
+
+ // Validate type enum.
+ if (isset($payload['type']) === true
+ && in_array($payload['type'], self::VALID_TYPES, true) === false
+ ) {
+ $errors[] = [
+ 'field' => 'type',
+ 'error' => 'invalid_enum_value',
+ 'message' => 'Type must be one of: '.implode(', ', self::VALID_TYPES),
+ ];
+ }
+
+ // Validate activiteiten is an array.
+ if (isset($payload['activiteiten']) === true
+ && is_array($payload['activiteiten']) === false
+ ) {
+ $errors[] = [
+ 'field' => 'activiteiten',
+ 'error' => 'invalid_type',
+ 'message' => 'Activiteiten must be an array',
+ ];
+ }
+
+ // Validate BSN if aanvrager contains one.
+ if (isset($payload['aanvrager']['bsn']) === true) {
+ $bsnValid = $this->validateBSN($payload['aanvrager']['bsn']);
+ if ($bsnValid === false) {
+ $errors[] = [
+ 'field' => 'aanvrager.bsn',
+ 'error' => 'invalid_bsn',
+ 'message' => 'BSN does not pass the 11-proef validation',
+ ];
+ }
+ }
+
+ // Validate indieningsdatum format (ISO 8601).
+ if (isset($payload['indieningsdatum']) === true
+ && $this->validateISODate($payload['indieningsdatum']) === false
+ ) {
+ $errors[] = [
+ 'field' => 'indieningsdatum',
+ 'error' => 'invalid_date_format',
+ 'message' => 'Indieningsdatum must be in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)',
+ ];
+ }
+
+ return $errors;
+
+ }//end validatePayload()
+
+
+ /**
+ * Parse a DSO-verzoek payload into structured data.
+ *
+ * Extracts and normalizes all verzoek fields including aanvrager,
+ * locatie, activiteiten, and bijlagen references.
+ *
+ * @param array $payload The raw verzoek payload.
+ *
+ * @return array The parsed and structured verzoek data.
+ */
+ public function parseVerzoek(array $payload): array
+ {
+ $verzoek = [
+ 'verzoekId' => $payload['verzoekId'] ?? null,
+ 'bronorganisatie' => $payload['bronorganisatie'] ?? null,
+ 'type' => $payload['type'] ?? null,
+ 'indieningsdatum' => $payload['indieningsdatum'] ?? null,
+ 'aanvrager' => $this->parseAanvrager($payload['aanvrager'] ?? []),
+ 'locatie' => $this->parseLocatie($payload['locatie'] ?? []),
+ 'activiteiten' => $this->parseActiviteiten($payload['activiteiten'] ?? []),
+ 'bouwkosten' => isset($payload['bouwkosten']) === true ? (float) $payload['bouwkosten'] : null,
+ 'bijlagen' => $payload['bijlagen'] ?? [],
+ 'status' => 'ontvangen',
+ 'environment' => $payload['environment'] ?? 'productie',
+ 'stamApiVersion' => $payload['stamApiVersion'] ?? null,
+ ];
+
+ if (isset($payload['projectbeschrijving']) === true) {
+ $verzoek['projectbeschrijving'] = $payload['projectbeschrijving'];
+ }
+
+ return $verzoek;
+
+ }//end parseVerzoek()
+
+
+ /**
+ * Validate a BSN (Burger Service Nummer) using the 11-proef.
+ *
+ * The 11-proef validation multiplies each digit by a weight factor
+ * and checks that the sum is divisible by 11.
+ *
+ * NOTE: This is intentionally duplicated from OpenRegister's BsnFormat.
+ * OpenConnector cannot depend on OpenRegister being installed since it
+ * connects to external systems independently. If the canonical implementation
+ * changes, this method must be updated to match.
+ *
+ * @param string $bsn The BSN to validate.
+ *
+ * @return bool True if the BSN passes the 11-proef.
+ *
+ * @see \OCA\OpenRegister\Formats\BsnFormat::validate() Canonical BSN validation (ADR-011)
+ */
+ public function validateBSN(string $bsn): bool
+ {
+ // BSN must be 8 or 9 digits.
+ $bsn = ltrim($bsn, '0');
+ $bsn = str_pad($bsn, 9, '0', STR_PAD_LEFT);
+
+ if (preg_match('/^\d{9}$/', $bsn) !== 1) {
+ return false;
+ }
+
+ // 11-proef: multiply each digit by its weight factor.
+ $sum = 0;
+ $weights = [9, 8, 7, 6, 5, 4, 3, 2, -1];
+
+ for ($i = 0; $i < 9; $i++) {
+ $sum += (int) $bsn[$i] * $weights[$i];
+ }
+
+ return ($sum % 11 === 0 && $sum !== 0);
+
+ }//end validateBSN()
+
+
+ /**
+ * Validate an ISO 8601 date string.
+ *
+ * @param string $date The date string to validate.
+ *
+ * @return bool True if the date is valid ISO 8601.
+ */
+ public function validateISODate(string $date): bool
+ {
+ $parsed = \DateTime::createFromFormat('Y-m-d', $date);
+ if ($parsed !== false && $parsed->format('Y-m-d') === $date) {
+ return true;
+ }
+
+ $parsed = \DateTime::createFromFormat('Y-m-d\TH:i:s', $date);
+ if ($parsed !== false) {
+ return true;
+ }
+
+ $parsed = \DateTime::createFromFormat(\DateTime::ATOM, $date);
+ if ($parsed !== false) {
+ return true;
+ }
+
+ return false;
+
+ }//end validateISODate()
+
+
+ /**
+ * Parse the aanvrager (initiatiefnemer) block.
+ *
+ * @param array $aanvrager The raw aanvrager data.
+ *
+ * @return array The parsed aanvrager data.
+ */
+ private function parseAanvrager(array $aanvrager): array
+ {
+ return [
+ 'bsn' => $aanvrager['bsn'] ?? null,
+ 'kvkNummer' => $aanvrager['kvkNummer'] ?? null,
+ 'vestigingsnummer' => $aanvrager['vestigingsnummer'] ?? null,
+ 'naam' => $aanvrager['naam'] ?? null,
+ 'bedrijfsnaam' => $aanvrager['bedrijfsnaam'] ?? null,
+ 'adres' => $aanvrager['adres'] ?? null,
+ 'contactgegevens' => $aanvrager['contactgegevens'] ?? null,
+ ];
+
+ }//end parseAanvrager()
+
+
+ /**
+ * Parse the locatie block.
+ *
+ * Handles BAG-adresgegevens and GML-geometrie conversion.
+ *
+ * @param array $locatie The raw locatie data.
+ *
+ * @return array The parsed locatie data.
+ */
+ private function parseLocatie(array $locatie): array
+ {
+ $parsed = [
+ 'bagAdres' => $locatie['bagAdres'] ?? null,
+ 'kadastraleAanduiding' => $locatie['kadastraleAanduiding'] ?? null,
+ 'geometrie' => null,
+ ];
+
+ // Convert GML to GeoJSON if present.
+ if (isset($locatie['gmlGeometrie']) === true) {
+ $parsed['geometrie'] = $this->convertGMLToGeoJSON($locatie['gmlGeometrie']);
+ }
+
+ return $parsed;
+
+ }//end parseLocatie()
+
+
+ /**
+ * Parse the activiteiten array.
+ *
+ * @param array $activiteiten The raw activiteiten data.
+ *
+ * @return array The parsed activiteiten data.
+ */
+ private function parseActiviteiten(array $activiteiten): array
+ {
+ $parsed = [];
+ foreach ($activiteiten as $activiteit) {
+ $parsed[] = [
+ 'code' => $activiteit['code'] ?? $activiteit['activiteitCode'] ?? null,
+ 'omschrijving' => $activiteit['omschrijving'] ?? null,
+ ];
+ }
+
+ return $parsed;
+
+ }//end parseActiviteiten()
+
+
+ /**
+ * Convert a GML geometry string to GeoJSON.
+ *
+ * This is a basic implementation that handles common GML point and polygon formats.
+ * For full GML support, a dedicated geometry library should be used.
+ *
+ * @param string $gml The GML geometry string.
+ *
+ * @return array|null The GeoJSON geometry object, or null if conversion fails.
+ */
+ private function convertGMLToGeoJSON(string $gml): ?array
+ {
+ // Try to parse as GML Point.
+ if (preg_match('/([\d.]+)\s+([\d.]+)<\/gml:pos>/', $gml, $matches) === 1) {
+ return [
+ 'type' => 'Point',
+ 'coordinates' => [(float) $matches[2], (float) $matches[1]],
+ ];
+ }
+
+ $this->logger->info('DSO: GML conversion not fully implemented for complex geometries');
+ return null;
+
+ }//end convertGMLToGeoJSON()
+
+
+}//end class
diff --git a/lib/Service/IBabsConnectorService.php b/lib/Service/IBabsConnectorService.php
new file mode 100644
index 000000000..f250dc2d9
--- /dev/null
+++ b/lib/Service/IBabsConnectorService.php
@@ -0,0 +1,190 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://www.OpenConnector.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Service;
+
+use OCA\OpenConnector\Db\Source;
+use OCA\OpenConnector\Db\SourceMapper;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Service for iBabs RIS integration.
+ *
+ * Handles document push (voorstellen, bijlagen), agendapunt creation,
+ * and besluit/besluitenlijst retrieval via the iBabs REST API.
+ */
+class IBabsConnectorService
+{
+
+
+ /**
+ * IBabsConnectorService constructor.
+ *
+ * @param CallService $callService The call service for API requests
+ * @param SourceMapper $sourceMapper The source mapper for source lookup
+ * @param LoggerInterface $logger Logger for error handling
+ */
+ public function __construct(
+ private readonly CallService $callService,
+ private readonly SourceMapper $sourceMapper,
+ private readonly LoggerInterface $logger
+ ) {
+
+ }//end __construct()
+
+
+ /**
+ * Test the connection to an iBabs API source.
+ *
+ * Makes a lightweight GET request to list vergaderingen to verify connectivity.
+ *
+ * @param Source $source The iBabs source configuration.
+ *
+ * @return array Result with 'success' boolean and 'message' string.
+ */
+ public function testConnection(Source $source): array
+ {
+ try {
+ $config = $source->getConfiguration();
+ if (is_string($config) === true) {
+ $config = json_decode($config, true);
+ }
+
+ $organisatieId = $config['organisatieId'] ?? null;
+ if ($organisatieId === null) {
+ return [
+ 'success' => false,
+ 'message' => 'Organisation ID not configured',
+ ];
+ }
+
+ // Use CallService to make a lightweight API call.
+ $endpoint = '/api/v1/organisations/'.$organisatieId.'/vergaderingen';
+ $response = $this->callService->call(
+ source: $source,
+ endpoint: $endpoint,
+ method: 'GET'
+ );
+
+ $statusCode = $response->getStatusCode();
+ if ($statusCode === 200) {
+ return [
+ 'success' => true,
+ 'message' => 'Connection successful',
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'API returned status '.$statusCode,
+ ];
+ } catch (\Exception $e) {
+ $this->logger->warning('iBabs connection test failed', ['exception' => $e->getMessage()]);
+ return [
+ 'success' => false,
+ 'message' => 'Connection failed: '.$e->getMessage(),
+ ];
+ }//end try
+
+ }//end testConnection()
+
+
+ /**
+ * Push a collegevoorstel document to iBabs.
+ *
+ * Uploads the document (PDF) and its bijlagen to iBabs and creates
+ * a vergaderstuk linked to the specified vergadering.
+ *
+ * @param Source $source The iBabs source configuration.
+ * @param array $voorstel The voorstel data including document path and metadata.
+ *
+ * @return array Result with 'success' boolean and 'vergaderstukId'.
+ */
+ public function pushVoorstel(Source $source, array $voorstel): array
+ {
+ $config = $source->getConfiguration();
+ if (is_string($config) === true) {
+ $config = json_decode($config, true);
+ }
+
+ $organisatieId = $config['organisatieId'] ?? null;
+
+ $metadata = [
+ 'onderwerp' => $voorstel['onderwerp'] ?? '',
+ 'portefeuillehouder' => $voorstel['portefeuillehouder'] ?? '',
+ 'zaaktype' => $voorstel['zaaktype'] ?? '',
+ 'vertrouwelijk' => $voorstel['geheimhouding'] ?? false,
+ ];
+
+ $this->logger->info('iBabs: Pushing voorstel', ['onderwerp' => $metadata['onderwerp']]);
+
+ // Placeholder for actual document upload via CallService.
+ return [
+ 'success' => false,
+ 'message' => 'Document upload not yet implemented',
+ 'vergaderstukId' => null,
+ ];
+
+ }//end pushVoorstel()
+
+
+ /**
+ * Poll for besluiten from iBabs.
+ *
+ * Queries the iBabs API for besluiten related to previously pushed
+ * voorstellen and returns an array of besluit data with status mappings.
+ *
+ * @param Source $source The iBabs source configuration.
+ *
+ * @return array Array of besluit records with zaak references and status.
+ */
+ public function pollBesluiten(Source $source): array
+ {
+ $this->logger->info('iBabs: Polling for besluiten');
+
+ // Placeholder for actual besluit polling.
+ return [];
+
+ }//end pollBesluiten()
+
+
+ /**
+ * Map an iBabs besluit status to a Procest zaak status.
+ *
+ * @param string $ibabsStatus The iBabs besluit status.
+ *
+ * @return string The corresponding Procest zaak status.
+ */
+ public function mapBesluitStatus(string $ibabsStatus): string
+ {
+ $mapping = [
+ 'aangenomen' => 'Besluit: aangenomen',
+ 'verworpen' => 'Besluit: verworpen',
+ 'aangehouden' => 'Besluit: aangehouden',
+ 'doorgeschoven' => 'Besluit: doorgeschoven',
+ ];
+
+ return $mapping[strtolower($ibabsStatus)] ?? 'Besluit: onbekend';
+
+ }//end mapBesluitStatus()
+
+
+}//end class
diff --git a/lib/Service/StUFFieldMapper.php b/lib/Service/StUFFieldMapper.php
new file mode 100644
index 000000000..feb5d0bd8
--- /dev/null
+++ b/lib/Service/StUFFieldMapper.php
@@ -0,0 +1,205 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://www.OpenConnector.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Service;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Service for mapping StUF fields to/from OpenRegister object properties.
+ *
+ * Supports configurable field mappings stored as OpenRegister objects,
+ * date format transformation, and nested object mapping for addresses.
+ */
+class StUFFieldMapper
+{
+
+ /**
+ * Default BRP-to-StUF-BG field mapping.
+ *
+ * Maps OpenRegister property names to StUF-BG XML element names.
+ *
+ * @var array
+ */
+ private const DEFAULT_BRP_MAPPING = [
+ 'burgerservicenummer' => 'inp.bsn',
+ 'geslachtsnaam' => 'geslachtsnaam',
+ 'voorvoegsel' => 'voorvoegselGeslachtsnaam',
+ 'voornamen' => 'voornamen',
+ 'geboortedatum' => 'geboortedatum',
+ 'geslachtsaanduiding' => 'geslachtsaanduiding',
+ ];
+
+ /**
+ * Default address field mapping.
+ *
+ * @var array
+ */
+ private const DEFAULT_ADDRESS_MAPPING = [
+ 'straatnaam' => 'gor.straatnaam',
+ 'huisnummer' => 'aoa.huisnummer',
+ 'postcode' => 'aoa.postcode',
+ 'woonplaats' => 'wpl.woonplaatsNaam',
+ ];
+
+
+ /**
+ * StUFFieldMapper constructor.
+ *
+ * @param LoggerInterface $logger Logger for error handling
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger
+ ) {
+
+ }//end __construct()
+
+
+ /**
+ * Map an OpenRegister person object to StUF-BG field values.
+ *
+ * @param array $person The OpenRegister person object properties.
+ * @param array|null $mapping Custom field mapping (null uses defaults).
+ *
+ * @return array Array of StUF field name to value pairs.
+ */
+ public function mapPersonToStUF(array $person, ?array $mapping = null): array
+ {
+ $fieldMapping = $mapping ?? self::DEFAULT_BRP_MAPPING;
+ $result = [];
+
+ foreach ($fieldMapping as $registerField => $stufField) {
+ if (isset($person[$registerField]) === true) {
+ $value = $person[$registerField];
+
+ // Transform dates from ISO 8601 to StUF YYYYMMDD format.
+ if ($stufField === 'geboortedatum' && is_string($value) === true) {
+ $value = $this->isoDateToStUF($value);
+ }
+
+ $result[$stufField] = $value;
+ }
+ }
+
+ // Map nested verblijfsadres.
+ if (isset($person['verblijfsadres']) === true && is_array($person['verblijfsadres']) === true) {
+ $result['verblijfsadres'] = $this->mapAddressToStUF($person['verblijfsadres']);
+ }
+
+ return $result;
+
+ }//end mapPersonToStUF()
+
+
+ /**
+ * Map a StUF-BG person response to OpenRegister object properties.
+ *
+ * @param array $stufData The StUF-BG response data.
+ * @param array|null $mapping Custom field mapping (null uses defaults).
+ *
+ * @return array Array of OpenRegister property name to value pairs.
+ */
+ public function mapStUFToPerson(array $stufData, ?array $mapping = null): array
+ {
+ $fieldMapping = $mapping ?? self::DEFAULT_BRP_MAPPING;
+ $reversed = array_flip($fieldMapping);
+ $result = [];
+
+ foreach ($reversed as $stufField => $registerField) {
+ if (isset($stufData[$stufField]) === true) {
+ $value = $stufData[$stufField];
+
+ // Transform dates from StUF YYYYMMDD to ISO 8601 format.
+ if ($registerField === 'geboortedatum' && is_string($value) === true) {
+ $value = $this->stufDateToISO($value);
+ }
+
+ $result[$registerField] = $value;
+ }
+ }
+
+ return $result;
+
+ }//end mapStUFToPerson()
+
+
+ /**
+ * Map an OpenRegister address object to StUF-BG address fields.
+ *
+ * @param array $address The address properties.
+ * @param array|null $mapping Custom address field mapping.
+ *
+ * @return array Array of StUF address field name to value pairs.
+ */
+ public function mapAddressToStUF(array $address, ?array $mapping = null): array
+ {
+ $fieldMapping = $mapping ?? self::DEFAULT_ADDRESS_MAPPING;
+ $result = [];
+
+ foreach ($fieldMapping as $registerField => $stufField) {
+ if (isset($address[$registerField]) === true) {
+ $result[$stufField] = $address[$registerField];
+ }
+ }
+
+ return $result;
+
+ }//end mapAddressToStUF()
+
+
+ /**
+ * Convert an ISO 8601 date to StUF YYYYMMDD format.
+ *
+ * @param string $isoDate The ISO 8601 date string (e.g., "1990-05-15").
+ *
+ * @return string The StUF date string (e.g., "19900515").
+ */
+ public function isoDateToStUF(string $isoDate): string
+ {
+ $date = \DateTime::createFromFormat('Y-m-d', substr($isoDate, 0, 10));
+ if ($date === false) {
+ return $isoDate;
+ }
+
+ return $date->format('Ymd');
+
+ }//end isoDateToStUF()
+
+
+ /**
+ * Convert a StUF YYYYMMDD date to ISO 8601 format.
+ *
+ * @param string $stufDate The StUF date string (e.g., "19900515").
+ *
+ * @return string The ISO 8601 date string (e.g., "1990-05-15").
+ */
+ public function stufDateToISO(string $stufDate): string
+ {
+ $date = \DateTime::createFromFormat('Ymd', $stufDate);
+ if ($date === false) {
+ return $stufDate;
+ }
+
+ return $date->format('Y-m-d');
+
+ }//end stufDateToISO()
+
+
+}//end class
diff --git a/openspec/changes/archive/2026-03-21-prometheus-metrics/.openspec.yaml b/openspec/changes/archive/2026-03-21-prometheus-metrics/.openspec.yaml
new file mode 100644
index 000000000..d8b0ed035
--- /dev/null
+++ b/openspec/changes/archive/2026-03-21-prometheus-metrics/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-20
diff --git a/openspec/changes/archive/2026-03-21-prometheus-metrics/design.md b/openspec/changes/archive/2026-03-21-prometheus-metrics/design.md
new file mode 100644
index 000000000..097ce6e13
--- /dev/null
+++ b/openspec/changes/archive/2026-03-21-prometheus-metrics/design.md
@@ -0,0 +1,34 @@
+# Design: Prometheus Metrics
+
+## Architecture
+
+The Prometheus metrics feature follows a simple controller pattern:
+
+- **MetricsController** exposes `GET /api/metrics` returning Prometheus text exposition format
+- **HealthController** exposes `GET /api/health` returning JSON health status
+- Both controllers use `IDBConnection` query builder for database queries
+- No new entities or services needed -- metrics are computed from existing tables
+
+## Implementation Approach
+
+### MetricsController Extensions
+Add three new collector methods to the existing MetricsController:
+1. `collectEndpointMetrics()` -- counts from `openconnector_endpoints` table
+2. `collectJobMetrics()` -- counts from `openconnector_jobs` and `openconnector_job_logs` tables
+3. `collectMappingRuleMetrics()` -- counts from `openconnector_mappings` and `openconnector_rules` tables
+
+Each follows the same pattern as existing collectors:
+- `# HELP` and `# TYPE` header lines
+- Database query with error handling
+- Zero-value fallback on failure
+
+### Error Handling
+All metric collectors use try/catch with zero-value fallback. One failing collector does not break the entire endpoint.
+
+## Dependencies
+- Existing `openconnector_endpoints`, `openconnector_jobs`, `openconnector_job_logs`, `openconnector_mappings`, `openconnector_rules` tables
+- No external dependencies
+
+## Risks
+- Endpoint hit tracking (REQ-PROM-007) requires a hit counter mechanism in EndpointService -- deferred as it would need instrumentation changes across the request pipeline
+- For now, endpoint total count is implemented; hit counting is noted as future work
diff --git a/openspec/changes/archive/2026-03-21-prometheus-metrics/proposal.md b/openspec/changes/archive/2026-03-21-prometheus-metrics/proposal.md
new file mode 100644
index 000000000..32d4529ee
--- /dev/null
+++ b/openspec/changes/archive/2026-03-21-prometheus-metrics/proposal.md
@@ -0,0 +1,14 @@
+# Prometheus Metrics Endpoint
+
+## Problem
+Expose application metrics in Prometheus text exposition format at `GET /api/metrics` for monitoring, alerting, and operational dashboards. Provide a health check endpoint at `GET /api/health` for liveness/readiness probes in container orchestration environments.
+
+## Proposed Solution
+Implement Prometheus Metrics Endpoint following the detailed specification. Key requirements include:
+- See full spec for detailed requirements
+
+## Scope
+This change covers all requirements defined in the prometheus-metrics specification.
+
+## Success Criteria
+- Implementation matches spec requirements
diff --git a/openspec/changes/archive/2026-03-21-prometheus-metrics/specs/prometheus-metrics/spec.md b/openspec/changes/archive/2026-03-21-prometheus-metrics/specs/prometheus-metrics/spec.md
new file mode 100644
index 000000000..d01259c3e
--- /dev/null
+++ b/openspec/changes/archive/2026-03-21-prometheus-metrics/specs/prometheus-metrics/spec.md
@@ -0,0 +1,178 @@
+---
+status: implemented
+---
+
+# Prometheus Metrics Endpoint
+
+## Purpose
+
+Expose application metrics in Prometheus text exposition format at `GET /api/metrics` for monitoring, alerting, and operational dashboards. Provide a health check endpoint at `GET /api/health` for liveness/readiness probes in container orchestration environments.
+
+## Requirements
+
+### REQ-PROM-001: Metrics Endpoint
+
+The app MUST expose `GET /index.php/apps/openconnector/api/metrics` returning `text/plain; version=0.0.4; charset=utf-8`. The endpoint MUST require admin authentication (Nextcloud admin session or API token). All metrics MUST follow the Prometheus text exposition format with `# HELP`, `# TYPE`, and metric lines.
+
+**Scenarios:**
+
+1. **GIVEN** an authenticated Nextcloud admin user **WHEN** they request `GET /index.php/apps/openconnector/api/metrics` **THEN** the response has status 200, content-type `text/plain; version=0.0.4; charset=utf-8`, and the body contains valid Prometheus exposition format lines.
+
+2. **GIVEN** an unauthenticated user **WHEN** they request the metrics endpoint **THEN** the response is HTTP 401 Unauthorized and no metrics data is exposed.
+
+3. **GIVEN** a monitoring system (e.g., Prometheus scraper) with a valid API token **WHEN** it scrapes the metrics endpoint at its configured interval **THEN** fresh metrics are returned reflecting current application state, not cached values.
+
+4. **GIVEN** the metrics endpoint is called **AND** a database query for one metric category fails **WHEN** the remaining metric categories succeed **THEN** the failing metric emits a zero-value fallback and the endpoint still returns HTTP 200 with partial metrics (degraded but not broken).
+
+5. **GIVEN** the metrics endpoint is called frequently (every 15 seconds) **WHEN** each scrape runs the database queries **THEN** query execution completes within 500ms using indexed COUNT queries on the existing OpenConnector tables.
+
+### REQ-PROM-002: Application Info Gauge
+
+The app MUST expose an `openconnector_info` gauge metric with labels `version` (app version), `php_version`, and `nextcloud_version`. The value is always 1. This enables Prometheus queries like `openconnector_info{version="2.1.0"}` to track which version is deployed.
+
+**Scenarios:**
+
+1. **GIVEN** OpenConnector version 2.1.0 is installed on Nextcloud 30.0.0 running PHP 8.3.0 **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_info{version="2.1.0",php_version="8.3.0",nextcloud_version="30.0.0"} 1`.
+
+2. **GIVEN** the app is upgraded from 2.1.0 to 2.2.0 **WHEN** the metrics endpoint is called after upgrade **THEN** the version label reflects "2.2.0" on the next scrape.
+
+3. **GIVEN** the app version cannot be determined **WHEN** the metrics endpoint is called **THEN** the version label defaults to "0.0.0" rather than omitting the metric.
+
+### REQ-PROM-003: Application Up Gauge
+
+The app MUST expose an `openconnector_up` gauge metric. The value is 1 if the app is healthy (database accessible, core tables exist), 0 if degraded (database errors, missing tables).
+
+**Scenarios:**
+
+1. **GIVEN** the application is running normally with database connectivity **WHEN** the metrics endpoint is called **THEN** `openconnector_up` is 1.
+
+2. **GIVEN** the database connection is lost **WHEN** the metrics endpoint is called **THEN** `openconnector_up` is 0 (the endpoint itself may still respond if the framework can serve the request).
+
+3. **GIVEN** the sources table is missing (migration not run) **WHEN** the metrics endpoint is called **THEN** `openconnector_up` is 0 and the health check details explain the missing table.
+
+### REQ-PROM-004: Sources Gauge by Type
+
+The app MUST expose `openconnector_sources_total` as a gauge with label `type` (rest/soap/graphql/json/xml/ftp/sftp). The value is the current count of configured sources per type, queried from the `openconnector_sources` table grouped by `type` column.
+
+**Scenarios:**
+
+1. **GIVEN** there are 5 sources of type "json", 2 of type "soap", and 1 of type "xml" **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_sources_total{type="json"} 5`, `openconnector_sources_total{type="soap"} 2`, and `openconnector_sources_total{type="xml"} 1`.
+
+2. **GIVEN** no sources are configured **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_sources_total{type="rest"} 0` as a zero-value placeholder.
+
+3. **GIVEN** a source has a NULL type value in the database **WHEN** the metrics endpoint is called **THEN** it is counted under the default label "rest" (existing MetricsController behavior).
+
+### REQ-PROM-005: Call Counter by Status
+
+The app MUST expose `openconnector_calls_total` as a counter with label `status` (HTTP status code). The value is the total number of API calls logged in the `openconnector_call_logs` table, grouped by `status_code`. This enables monitoring of error rates and API call volumes.
+
+**Scenarios:**
+
+1. **GIVEN** 150 calls with status 200, 30 calls with status 400, and 5 calls with status 500 are logged **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_calls_total{status="200"} 150`, `openconnector_calls_total{status="400"} 30`, and `openconnector_calls_total{status="500"} 5`.
+
+2. **GIVEN** no calls have been logged **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_calls_total{status="200"} 0` as a zero-value placeholder.
+
+3. **GIVEN** a new call is logged with status 429 (rate limited) **WHEN** the next metrics scrape runs **THEN** `openconnector_calls_total{status="429"}` appears with count 1.
+
+### REQ-PROM-006: Synchronization Metrics
+
+The app MUST expose synchronization metrics: `openconnector_synchronizations_total` (gauge, total configured synchronizations) and `openconnector_synchronization_runs_total` (counter with label `status`, total sync log entries grouped by result). These enable monitoring of sync health and failure rates.
+
+**Scenarios:**
+
+1. **GIVEN** 10 synchronizations are configured **AND** 500 sync log entries exist (400 success, 80 partial, 20 error) **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_synchronizations_total 10`, `openconnector_synchronization_runs_total{status="success"} 400`, `openconnector_synchronization_runs_total{status="partial"} 80`, and `openconnector_synchronization_runs_total{status="error"} 20`.
+
+2. **GIVEN** no sync log entries exist **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_synchronization_runs_total{status="success"} 0` as a zero-value placeholder.
+
+3. **GIVEN** a sync run fails due to a source being disabled **WHEN** the sync log records the failure **THEN** the next scrape increments `openconnector_synchronization_runs_total{status="error"}`.
+
+### REQ-PROM-007: Endpoint Metrics
+
+The app MUST expose `openconnector_endpoints_total` (gauge) counting the total number of registered endpoints, and `openconnector_endpoint_hits_total` (counter with labels `endpoint`, `method`) tracking request counts per endpoint. This enables monitoring of which endpoints are most active.
+
+**Scenarios:**
+
+1. **GIVEN** 15 endpoints are registered **AND** endpoint "/api/objects" has received 200 GET and 50 POST requests **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_endpoints_total 15`, `openconnector_endpoint_hits_total{endpoint="/api/objects",method="GET"} 200`, and `openconnector_endpoint_hits_total{endpoint="/api/objects",method="POST"} 50`.
+
+2. **GIVEN** an endpoint is created but never called **WHEN** the metrics endpoint is called **THEN** it appears in `openconnector_endpoints_total` but not in `openconnector_endpoint_hits_total` (no zero-value emission per endpoint).
+
+3. **GIVEN** the endpoint metrics query would return more than 100 distinct endpoint/method combinations **WHEN** the metrics endpoint is called **THEN** results are limited to the top 100 by hit count to prevent metric cardinality explosion.
+
+### REQ-PROM-008: Job Queue Metrics
+
+The app MUST expose `openconnector_jobs_total` (gauge) counting configured jobs, and `openconnector_job_runs_total` (counter with label `status`) counting job execution log entries. This enables monitoring of background job health.
+
+**Scenarios:**
+
+1. **GIVEN** 5 jobs are configured **AND** job logs show 100 success runs and 10 error runs **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_jobs_total 5`, `openconnector_job_runs_total{status="success"} 100`, and `openconnector_job_runs_total{status="error"} 10`.
+
+2. **GIVEN** a job has been stuck (no recent runs) for over 1 hour **WHEN** the metrics endpoint is called **THEN** the job appears in `openconnector_jobs_total` but its last run timestamp is available via the health check for alerting.
+
+3. **GIVEN** no jobs are configured **WHEN** the metrics endpoint is called **THEN** `openconnector_jobs_total 0` is emitted.
+
+### REQ-PROM-009: Mapping and Rule Metrics
+
+The app MUST expose `openconnector_mappings_total` (gauge) and `openconnector_rules_total` (gauge) counting configured mappings and rules respectively. These are lightweight counters providing operational overview.
+
+**Scenarios:**
+
+1. **GIVEN** 20 mappings and 8 rules are configured **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_mappings_total 20` and `openconnector_rules_total 8`.
+
+2. **GIVEN** a mapping is deleted **WHEN** the next metrics scrape runs **THEN** `openconnector_mappings_total` reflects the decreased count.
+
+3. **GIVEN** database access fails for the mapping count **WHEN** the metrics endpoint collects this metric **THEN** a zero-value fallback is emitted with a warning logged.
+
+### REQ-PROM-010: Health Check Endpoint
+
+The app MUST expose `GET /index.php/apps/openconnector/api/health` returning JSON `{"status": "ok"|"degraded"|"error", "checks": {...}}`. Checks include: database connectivity (SELECT 1), source table accessibility (COUNT from sources table), and optionally source endpoint reachability for critical sources. The health endpoint requires admin authentication.
+
+**Scenarios:**
+
+1. **GIVEN** the database is accessible and the sources table exists **WHEN** the health endpoint is called **THEN** the response is `{"status": "ok", "checks": {"database": "ok", "sources_table": "ok"}}`.
+
+2. **GIVEN** the database is accessible but the sources table is missing **WHEN** the health endpoint is called **THEN** the response is `{"status": "degraded", "checks": {"database": "ok", "sources_table": "error"}}`.
+
+3. **GIVEN** the database connection fails entirely **WHEN** the health endpoint is called **THEN** the response is `{"status": "error", "checks": {"database": "error"}}`.
+
+4. **GIVEN** a Kubernetes readiness probe is configured to use the health endpoint **WHEN** the status is "error" **THEN** Kubernetes marks the pod as not ready and stops routing traffic to it.
+
+5. **GIVEN** the health check includes a critical source reachability check **AND** the source is unreachable **WHEN** the health endpoint is called **THEN** status is "degraded" (not "error", since the app itself works) with `{"source_reachability": {"source_name": "unreachable"}}`.
+
+## Data Model
+
+No new data model entities are required. Metrics are computed at query time from existing OpenConnector tables:
+- `openconnector_sources` (type column for source counts)
+- `openconnector_call_logs` (status_code column for call counts)
+- `openconnector_synchronizations` (total count)
+- `openconnector_synchronization_logs` (result column for sync run counts)
+- `openconnector_endpoints` (total count)
+- `openconnector_jobs` (total count)
+- `openconnector_job_logs` (status for job run counts)
+- `openconnector_mappings` (total count)
+- `openconnector_rules` (total count)
+
+## Current Implementation Status
+
+### Implemented
+- **MetricsController** (`lib/Controller/MetricsController.php`): Fully implemented with `index()` method returning Prometheus text format. Exposes `openconnector_info`, `openconnector_up`, `openconnector_sources_total` (by type), `openconnector_calls_total` (by status), `openconnector_synchronizations_total`, and `openconnector_synchronization_runs_total` (by status). Uses IDBConnection query builder for all database queries with proper error handling and zero-value fallbacks.
+- **HealthController** (`lib/Controller/HealthController.php`): Fully implemented with `index()` method returning JSON health status. Checks database connectivity (SELECT 1) and sources table accessibility (COUNT from sources). Returns `{"status": "ok"|"degraded"|"error", "checks": {...}}`.
+- **Route registration**: Both endpoints are registered and accessible at their respective paths.
+
+### Not implemented
+- **Endpoint metrics** (REQ-PROM-007): No endpoint hit tracking. Would require adding a counter mechanism to EndpointService.
+- **Job queue metrics** (REQ-PROM-008): No job run counting from job_logs table.
+- **Mapping/rule metrics** (REQ-PROM-009): No mapping or rule count metrics.
+- **Request duration histogram** (from original spec): No latency tracking -- would require middleware or CallService instrumentation.
+- **Critical source reachability** in health check: Only database and table checks are implemented.
+- **Admin authentication enforcement**: The `@NoCSRFRequired` annotation is present but explicit admin-only access control is not enforced beyond standard Nextcloud route authentication.
+
+## Standards & References
+
+- **Prometheus text exposition format**: https://prometheus.io/docs/instrumenting/exposition_formats/
+- **OpenMetrics specification**: https://openmetrics.io/
+- **Nextcloud server monitoring patterns**: Nextcloud's own `status.php` and OCS monitoring endpoints.
+- **OpenRegister MetricsService and HeartbeatController**: Reference implementation in the sibling app OpenRegister.
+
+## Specificity Assessment
+
+Highly specific -- metric names, types, and labels are fully defined. The core implementation already exists in MetricsController and HealthController. Remaining work is incremental: adding endpoint/job/mapping counters and optionally request duration histograms.
diff --git a/openspec/changes/archive/2026-03-21-prometheus-metrics/tasks.md b/openspec/changes/archive/2026-03-21-prometheus-metrics/tasks.md
new file mode 100644
index 000000000..dc1961a00
--- /dev/null
+++ b/openspec/changes/archive/2026-03-21-prometheus-metrics/tasks.md
@@ -0,0 +1,67 @@
+# Tasks: prometheus-metrics
+
+## Task 1: Core Metrics Controller (REQ-PROM-001 through REQ-PROM-006)
+- **spec_ref**: `specs/prometheus-metrics/spec.md#req-prom-001` through `#req-prom-006`
+- **files**: `lib/Controller/MetricsController.php`
+- **acceptance_criteria**:
+ - GIVEN an admin user WHEN requesting GET /api/metrics THEN response is Prometheus text format
+ - GIVEN database has sources, calls, syncs WHEN metrics are collected THEN each is grouped and counted
+ - GIVEN a database error WHEN a collector fails THEN zero-value fallback is emitted
+- [x] Implement
+- [x] Test
+
+## Task 2: Health Check Controller (REQ-PROM-010)
+- **spec_ref**: `specs/prometheus-metrics/spec.md#req-prom-010`
+- **files**: `lib/Controller/HealthController.php`
+- **acceptance_criteria**:
+ - GIVEN database accessible WHEN health endpoint called THEN status is "ok"
+ - GIVEN database down WHEN health endpoint called THEN status is "error"
+- [x] Implement
+- [x] Test
+
+## Task 3: Endpoint Metrics (REQ-PROM-007)
+- **spec_ref**: `specs/prometheus-metrics/spec.md#req-prom-007`
+- **files**: `lib/Controller/MetricsController.php`
+- **acceptance_criteria**:
+ - GIVEN endpoints exist WHEN metrics collected THEN openconnector_endpoints_total shows count
+ - Note: endpoint_hits_total deferred -- requires EndpointService instrumentation
+- [x] Implement (total count only)
+- [x] Test
+
+## Task 4: Job Queue Metrics (REQ-PROM-008)
+- **spec_ref**: `specs/prometheus-metrics/spec.md#req-prom-008`
+- **files**: `lib/Controller/MetricsController.php`
+- **acceptance_criteria**:
+ - GIVEN jobs configured WHEN metrics collected THEN openconnector_jobs_total shows count
+ - GIVEN job logs exist WHEN metrics collected THEN openconnector_job_runs_total grouped by status
+- [x] Implement
+- [x] Test
+
+## Task 5: Mapping and Rule Metrics (REQ-PROM-009)
+- **spec_ref**: `specs/prometheus-metrics/spec.md#req-prom-009`
+- **files**: `lib/Controller/MetricsController.php`
+- **acceptance_criteria**:
+ - GIVEN mappings and rules exist WHEN metrics collected THEN totals are emitted
+ - GIVEN database error WHEN counting THEN zero-value fallback emitted
+- [x] Implement
+- [x] Test
+
+## Task 6: Unit Tests
+- **spec_ref**: ADR-009
+- **files**: `tests/Unit/Controller/MetricsControllerTest.php`, `tests/Unit/Controller/HealthControllerTest.php`
+- **acceptance_criteria**:
+ - Tests cover info, up, source, call, sync, endpoint, job, mapping/rule metrics
+ - Tests verify zero-value fallback on database errors
+- [x] Implement
+
+## Task 7: API Documentation
+- **spec_ref**: ADR-010
+- **files**: `docs/features/prometheus-metrics.md`
+- [x] Implement
+
+## Verification
+- [x] All tasks checked off
+- [x] MetricsController exposes all required metrics
+- [x] HealthController returns proper status
+- [x] Unit tests written
+- [x] Documentation written
diff --git a/openspec/changes/dso-omgevingsloket/.openspec.yaml b/openspec/changes/dso-omgevingsloket/.openspec.yaml
new file mode 100644
index 000000000..b4bbeb946
--- /dev/null
+++ b/openspec/changes/dso-omgevingsloket/.openspec.yaml
@@ -0,0 +1 @@
+schema: spec-driven
diff --git a/openspec/changes/dso-omgevingsloket/design.md b/openspec/changes/dso-omgevingsloket/design.md
new file mode 100644
index 000000000..40e96c9c9
--- /dev/null
+++ b/openspec/changes/dso-omgevingsloket/design.md
@@ -0,0 +1,36 @@
+# Design: DSO / Omgevingsloket Adapter
+
+## Architecture
+
+The DSO adapter follows the existing OpenConnector adapter pattern with several new components:
+
+### New Services
+- **DSOAdapterService** (`lib/Service/DSOAdapterService.php`): Main orchestrator handling verzoek intake, parsing, and zaak creation coordination
+- **DSOParserService** (`lib/Service/DSOParserService.php`): Parses DSO-verzoek XML/JSON payloads into structured data
+- **DSOStatusService** (`lib/Service/DSOStatusService.php`): Pushes zaak status updates back to DSO-LV via STAM API
+- **DSOSamenwerkingService** (`lib/Service/DSOSamenwerkingService.php`): Handles DSO-SWF adviesverzoeken and adviezen
+
+### New Controller
+- **DSOController** (`lib/Controller/DSOController.php`): Exposes the STAM koppelvlak inbound endpoint at `/api/dso/stam/verzoeken`
+
+### Data Model
+- DSO-Verzoek schema stored in OpenRegister (verzoekId, type, aanvrager, locatie, activiteiten, bijlagen)
+- Activiteiten-Mapping schema stored in OpenRegister (dsoActiviteitCode to zaaktypeIdentificatie)
+
+### Integration Flow
+1. DSO-LV pushes verzoek to STAM endpoint
+2. DSOController validates signature and schema
+3. DSOAdapterService enqueues via JobService
+4. Job processes: parse payload, download bijlagen, map activiteiten, create zaak(en) in Procest
+5. Status changes in Procest trigger DSOStatusService to push updates back
+
+## Dependencies
+- **Procest**: Required for zaak creation (not yet available as app)
+- **Docudesk**: Required for PDF generation of beschikkingen
+- **PKIoverheid certificates**: Required for mTLS authentication
+- **DSO-LV test environment access**: Required for integration testing
+
+## Risks
+- Procest app dependency not yet available -- zaak creation will use OpenRegister directly until Procest is ready
+- DSO-LV STAM API access requires PKIoverheid certificates and OIN registration
+- GML to GeoJSON conversion requires a geometry library (not yet in OpenConnector)
diff --git a/openspec/changes/dso-omgevingsloket/proposal.md b/openspec/changes/dso-omgevingsloket/proposal.md
new file mode 100644
index 000000000..734d5fe5a
--- /dev/null
+++ b/openspec/changes/dso-omgevingsloket/proposal.md
@@ -0,0 +1,12 @@
+# DSO / Omgevingsloket Adapter
+
+## Summary
+This change implements the dso-omgevingsloket feature as specified in the delta spec.
+
+## Motivation
+Required by Dutch government tenders for integration with external systems.
+
+## Scope
+- New adapter/connector implementation
+- API endpoints
+- Configuration UI
diff --git a/openspec/changes/dso-omgevingsloket/specs/dso-omgevingsloket/spec.md b/openspec/changes/dso-omgevingsloket/specs/dso-omgevingsloket/spec.md
new file mode 100644
index 000000000..8e2c4f365
--- /dev/null
+++ b/openspec/changes/dso-omgevingsloket/specs/dso-omgevingsloket/spec.md
@@ -0,0 +1,294 @@
+---
+status: proposed
+---
+
+# DSO / Omgevingsloket Adapter
+
+## Purpose
+
+Provides integration with the Digitaal Stelsel Omgevingswet (DSO) Landelijke Voorziening for receiving and processing vergunningaanvragen, meldingen, and informatieverzoeken from the Omgevingsloket. Required by 32% of tenders (all VTH-related). The adapter receives DSO-verzoeken via the STAM koppelvlak, parses them into zaak objects in Procest, maps activiteiten to zaaktypen, and supports samenwerking met bevoegd gezag via DSO-SWF (SamenWerkingsFunctionaliteit). Replaces the legacy OLO (Omgevingsloket Online) integration.
+
+## Requirements
+
+### REQ-DSO-001: STAM Koppelvlak Endpoint Registration
+
+The adapter MUST register a STAM-compliant inbound REST endpoint in OpenConnector that receives vergunningaanvragen, meldingen, and informatieverzoeken pushed from DSO-LV. The endpoint accepts the DSO-verzoek payload (JSON or XML), validates the request signature, and enqueues it for processing. The endpoint path follows `/api/dso/stam/verzoeken` and returns an HTTP 202 Accepted with verzoekId confirmation.
+
+**Scenarios:**
+
+1. **GIVEN** the DSO adapter endpoint is registered in OpenConnector with valid PKIoverheid certificates **AND** DSO-LV pushes a vergunningaanvraag payload to the STAM endpoint **WHEN** the request arrives **THEN** the adapter validates the webhook signature, returns HTTP 202, and enqueues the verzoek for asynchronous processing.
+
+2. **GIVEN** a DSO-LV request arrives at the STAM endpoint **AND** the webhook signature is invalid **WHEN** signature validation fails **THEN** the adapter returns HTTP 401 Unauthorized with a descriptive error message and logs the failed attempt in the CallLog.
+
+3. **GIVEN** the DSO adapter is configured for the pre-production environment **WHEN** a request arrives from the DSO-LV test environment **THEN** it is accepted and processed identically to production requests but tagged with `environment: pre-productie` in the verzoek record.
+
+4. **GIVEN** DSO-LV sends a malformed payload that does not conform to the STAM schema **WHEN** schema validation fails **THEN** the adapter returns HTTP 400 Bad Request with field-level error details and does not create a verzoek record.
+
+5. **GIVEN** the STAM endpoint receives concurrent verzoeken **WHEN** multiple DSO-LV pushes arrive simultaneously **THEN** each is enqueued independently using the JobService background job mechanism with unique verzoekIds, preventing duplicate processing.
+
+### REQ-DSO-002: Melding Reception
+
+The adapter MUST support receiving meldingen (notifications of activities not requiring a permit) from DSO-LV via the same STAM endpoint. Meldingen follow a simplified flow: they create a zaak in Procest with a "Melding" zaaktype but do not require a vergunningbesluit response.
+
+**Scenarios:**
+
+1. **GIVEN** an initiatiefnemer submits a melding via het Omgevingsloket for a sloopactiviteit **WHEN** DSO-LV pushes the melding to the STAM endpoint **THEN** the adapter parses the melding, creates a zaak with zaaktype "Melding Sloop", and pushes status "ontvangen" back to DSO-LV.
+
+2. **GIVEN** a melding is received for an activiteit that has both a melding and a vergunning component **WHEN** the adapter processes the melding **THEN** it creates a melding-zaak for the meldingsplichtige activiteit and flags the vergunningplichtige activiteit for separate aanvraag handling.
+
+3. **GIVEN** a melding contains bijlagen (asbestinventarisatierapport) **WHEN** the adapter processes the melding **THEN** bijlagen are downloaded from DSO-LV and stored in a dedicated Nextcloud Files folder linked to the melding-zaak.
+
+### REQ-DSO-003: Informatieverzoek and Vooroverleg Support
+
+The adapter MUST support receiving informatieverzoeken (requests for information about applicability of rules) and vooroverleg-aanvragen (pre-application consultations) from DSO-LV. These create lightweight zaak objects in Procest with distinct zaaktypen that do not follow the full vergunningbesluit workflow.
+
+**Scenarios:**
+
+1. **GIVEN** a burger submits a vooroverleg-aanvraag via the Omgevingsloket **WHEN** DSO-LV pushes the vooroverleg to the STAM endpoint **THEN** the adapter creates a zaak with zaaktype "Vooroverleg" with a simplified behandelproces (no formal besluit required).
+
+2. **GIVEN** an informatieverzoek arrives with a locatie and activiteit query **WHEN** the adapter processes it **THEN** it creates a lightweight zaak and notifies the VTH-medewerker to provide advies.
+
+3. **GIVEN** a vooroverleg-aanvraag transitions to a formal vergunningaanvraag **WHEN** the initiatiefnemer submits a follow-up aanvraag referencing the vooroverleg **THEN** the adapter links the new zaak to the original vooroverleg-zaak via the DSO verzoekId chain.
+
+### REQ-DSO-004: Verzoek Payload Parsing
+
+The adapter MUST parse the DSO-verzoek XML/JSON payload into structured data including aanvrager (initiatiefnemer), locatie, activiteiten, bijlagen, and projectbeschrijving. Parsing uses configurable mapping rules stored as OpenRegister mapping objects so municipalities can adapt field extraction to their internal data model.
+
+**Scenarios:**
+
+1. **GIVEN** a DSO-verzoek payload contains an aanvrager with BSN, naam, adres, and contactgegevens **WHEN** the parser extracts the aanvrager block **THEN** each field is mapped to the corresponding OpenRegister object property using the configured BRP-to-zaak mapping.
+
+2. **GIVEN** a verzoek payload contains a locatie with BAG-adresgegevens and GML-geometrie **WHEN** the parser extracts locatie data **THEN** the BAG-adres is validated against the BAG register (via OpenConnector source), the GML geometry is converted to GeoJSON, and both are stored on the zaak.
+
+3. **GIVEN** a verzoek payload contains multiple activiteiten with DSO activiteitcodes and omschrijvingen **WHEN** the parser processes the activiteiten array **THEN** each activiteit is looked up in the activiteiten-mapping table and tagged with its corresponding zaaktype.
+
+4. **GIVEN** a verzoek contains a `projectbeschrijving` free-text field with embedded references **WHEN** the parser processes this field **THEN** the text is stored verbatim as a zaak-eigenschap and references are extracted as linked metadata.
+
+5. **GIVEN** the DSO payload format changes between STAM API versions **WHEN** the adapter receives a payload with a version mismatch **THEN** it attempts parsing with the configured version, falls back to auto-detection, and logs a version warning if parsing succeeds on a different version.
+
+### REQ-DSO-005: Bijlagen Download and Storage
+
+The adapter MUST download bijlagen (documenten, tekeningen, rapporten, berekeningen) referenced in the DSO-verzoek from DSO-LV and store them in Nextcloud Files. Each bijlage is stored in a zaak-specific folder structure following the pattern `/DSO-verzoeken/{year}/{verzoekId}/bijlagen/`.
+
+**Scenarios:**
+
+1. **GIVEN** a verzoek references 5 bijlagen including PDFs, DWG drawings, and a structural calculation **WHEN** the adapter processes the verzoek **THEN** each bijlage is downloaded via the DSO-LV document API using mTLS, stored in the zaak folder, and linked to the zaak via Docudesk.
+
+2. **GIVEN** a bijlage download fails due to a network timeout **WHEN** the adapter retries (up to 3 attempts with exponential backoff) **THEN** on persistent failure the zaak is created with a "bijlage ontbreekt" warning and a notification is sent to the behandelaar.
+
+3. **GIVEN** a bijlage exceeds the configured maximum file size (default: 100MB) **WHEN** the download is attempted **THEN** the adapter rejects the file, stores a placeholder reference, and flags the zaak for manual bijlage handling.
+
+### REQ-DSO-006: Verzoek Schema Validation
+
+The adapter MUST validate the received verzoek against the DSO-LV STAM schema definition and reject malformed requests with descriptive HTTP 400 error responses. Validation includes required field checks, enum value validation, date format validation, and BSN/KVK check-digit verification.
+
+**Scenarios:**
+
+1. **GIVEN** a verzoek payload is missing the required `activiteiten` array **WHEN** validation runs **THEN** the adapter returns HTTP 400 with error `{"field": "activiteiten", "error": "required_field_missing", "message": "Activiteiten is verplicht"}`.
+
+2. **GIVEN** a verzoek contains a BSN with an invalid check digit **WHEN** BSN validation runs (11-proef) **THEN** the adapter rejects the verzoek with a specific BSN validation error.
+
+3. **GIVEN** a verzoek contains an `indieningsdatum` in an invalid date format **WHEN** date validation runs **THEN** the adapter returns a format error specifying the expected ISO 8601 format.
+
+### REQ-DSO-010: Activiteiten-to-Zaaktype Mapping
+
+The adapter MUST map DSO activiteiten (bouwen, milieu, kappen, uitrit, etc.) to Procest zaaktypen via a configurable mapping table stored as OpenRegister objects. The mapping supports one-to-one (one activiteit to one zaaktype) and one-to-many (one activiteit generates multiple zaaktypen for different behandelende afdelingen).
+
+**Scenarios:**
+
+1. **GIVEN** the mapping table maps DSO activiteitcode "bouwen-01" to zaaktype "Omgevingsvergunning Bouwen" **WHEN** a verzoek contains activiteit "bouwen-01" **THEN** the adapter creates a zaak with zaaktype "Omgevingsvergunning Bouwen" and populates the zaak-eigenschappen from the verzoek.
+
+2. **GIVEN** the mapping table maps activiteitcode "milieu-complexe-inrichting" to both "Omgevingsvergunning Milieu" and "Omgevingsvergunning Bouwen" **WHEN** a verzoek contains this activiteit **THEN** two deelzaken are created, each with its own zaaktype and behandelaar assignment.
+
+3. **GIVEN** the mapping table is empty (fresh install) **WHEN** an administrator navigates to the DSO-adapter settings **THEN** a "Load default mappings" button seeds 25+ common Omgevingswet activiteit-to-zaaktype mappings from the pre-seeded register data.
+
+4. **GIVEN** an administrator modifies a mapping to change the target zaaktype for "kappen" from "Omgevingsvergunning Kappen" to a custom zaaktype **WHEN** the next verzoek with activiteit "kappen" arrives **THEN** the updated zaaktype is used for zaak creation.
+
+### REQ-DSO-011: Samenloop Handling
+
+The adapter MUST support samenloop: when one DSO-verzoek contains multiple activiteiten, the adapter creates either multiple deelzaken under one hoofdzaak or one combined zaak, based on the configured samenloop strategy per activiteitcombinatie.
+
+**Scenarios:**
+
+1. **GIVEN** a verzoek contains activiteiten "bouwen" and "kappen" **AND** samenloop strategy is "deelzaken" **WHEN** the adapter processes the verzoek **THEN** one hoofdzaak is created plus two deelzaken, each following its own behandelproces while sharing aanvrager and locatie data.
+
+2. **GIVEN** a verzoek contains activiteiten "bouwen" and "afwijken bestemmingsplan" **AND** samenloop strategy is "gecombineerd" **WHEN** the adapter processes the verzoek **THEN** one combined zaak is created with both activiteiten as zaak-eigenschappen and a combined behandelproces.
+
+3. **GIVEN** a verzoek has a samenloop where one deelzaak is afgerond but another is still in behandeling **WHEN** the behandelaar marks the first deelzaak as "Besluit genomen" **THEN** the hoofdzaak status remains "In behandeling" until all deelzaken have a besluit.
+
+4. **GIVEN** samenloop results in deelzaken handled by different afdelingen **WHEN** deelzaken are created **THEN** each deelzaak is routed to its configured afdeling/team via Procest assignment rules.
+
+### REQ-DSO-013: Unmapped Activiteit Fallback
+
+The adapter MUST handle unmapped activiteiten gracefully: creating a zaak with a generic "Onbekend DSO-activiteit" zaaktype, flagging it for manual triage, and notifying the configured VTH-behandelaar.
+
+**Scenarios:**
+
+1. **GIVEN** a verzoek contains activiteitcode "experimenteel-gebruik-2025" which has no mapping **WHEN** the adapter processes the verzoek **THEN** a zaak is created with zaaktype "Onbekend DSO-activiteit", the activiteitcode is stored as zaak-eigenschap, and a Nextcloud notification is sent to the configured DSO-triage user.
+
+2. **GIVEN** a verzoek contains 3 activiteiten of which 2 are mapped and 1 is unmapped **WHEN** the adapter processes the verzoek **THEN** the 2 mapped activiteiten create proper deelzaken and the unmapped activiteit creates a triage-zaak, all linked under the same hoofdzaak.
+
+3. **GIVEN** multiple unmapped activiteiten accumulate over a week **WHEN** an administrator views the DSO dashboard **THEN** a summary widget shows unmapped activiteiten with their frequency, enabling the admin to add mappings for recurring activiteiten.
+
+### REQ-DSO-020: Automatic Zaak Creation
+
+The adapter MUST automatically create a zaak in Procest for each received DSO-verzoek. The zaak includes all parsed data: aanvrager mapped to the zaak (BSN/KVK-nummer, naam, adres, contactgegevens), locatie (BAG-adres, kadastrale aanduiding, GML-geometrie), startdatum set to DSO-verzoek indieningsdatum, linked bijlagen, and the original DSO-verzoek reference (verzoekId, bronorganisatie).
+
+**Scenarios:**
+
+1. **GIVEN** a valid vergunningaanvraag is received and parsed **WHEN** the adapter creates the zaak in Procest **THEN** the zaak has: zaaktype from the activiteiten-mapping, aanvrager from the verzoek, locatie with BAG-adres and geometrie, startdatum equal to indieningsdatum, all bijlagen linked, and verzoekId stored as external reference.
+
+2. **GIVEN** the verzoek aanvrager is a KVK-registered bedrijf **WHEN** the zaak is created **THEN** the bedrijfsnaam, KVK-nummer, and vestigingsnummer are mapped to the zaak initiatiefnemer fields instead of BSN-based person fields.
+
+3. **GIVEN** the verzoek locatie contains GML-geometrie (polygon) **WHEN** the zaak is created **THEN** the GML is parsed to GeoJSON, validated against the BAG register, and stored as a geospatial zaak-eigenschap enabling map-based visualization.
+
+4. **GIVEN** the verzoek contains optional bouwkosten **WHEN** the zaak is created **THEN** bouwkosten are stored as a zaak-eigenschap for use in legesberekening workflows.
+
+5. **GIVEN** a zaak is successfully created **WHEN** creation completes **THEN** an OpenConnector event is dispatched (EventService) enabling n8n workflows to trigger intake processing such as legesberekening, team-toewijzing, and automatische termijnbewaking.
+
+### REQ-DSO-030: DSO-SWF Samenwerking
+
+The adapter MUST support coordination with other bevoegde gezagen (provincies, waterschappen, omgevingsdiensten) via the DSO-SWF (SamenWerkingsFunctionaliteit). This includes sending adviesverzoeken to ketenpartners, receiving adviezen, and tracking samenwerking status per zaak.
+
+**Scenarios:**
+
+1. **GIVEN** a vergunningaanvraag requires advies from the waterschap **WHEN** the behandelaar marks the zaak for samenwerking **THEN** the adapter sends an adviesverzoek to the waterschap via DSO-SWF with the relevant zaak-documenten and a termijn for response.
+
+2. **GIVEN** an adviesverzoek was sent to the provincie **AND** the provincie sends back an advies via DSO-SWF **WHEN** the adapter receives the advies **THEN** it is stored as a document linked to the zaak, the samenwerkingsstatus is updated to "Advies ontvangen", and the behandelaar receives a notification.
+
+3. **GIVEN** a zaak involves 3 ketenpartners **WHEN** the behandelaar views the samenwerking tab **THEN** it shows per partner: organisatienaam, OIN, adviesverzoek-datum, termijn, advies-status (verzonden/ontvangen/termijn verlopen), and linked documenten.
+
+### REQ-DSO-040: Status Push to DSO-LV
+
+The adapter MUST push zaak status updates back to DSO-LV so that the aanvrager can track progress via the Omgevingsloket. Status mapping translates Procest zaak statussen to DSO-LV statuscodes. The vergunningbesluit (verleend, geweigerd, buiten behandeling) and the beschikking PDF are also pushed to DSO-LV.
+
+**Scenarios:**
+
+1. **GIVEN** a zaak originated from a DSO-verzoek **AND** the zaak status changes to "In behandeling" in Procest **WHEN** the status transition event fires **THEN** the adapter pushes status "in behandeling" to DSO-LV via the STAM API using the stored verzoekId.
+
+2. **GIVEN** the vergunning is verleend **WHEN** the zaak status changes to "Besluit genomen" **THEN** the adapter pushes besluitstatus "verleend" to DSO-LV and uploads the beschikking PDF (generated by Docudesk) for publication in the Omgevingsloket.
+
+3. **GIVEN** the aanvraag is buiten behandeling gesteld (e.g., incomplete aanvulling) **WHEN** the zaak is afgesloten **THEN** the adapter pushes status "buiten behandeling" with a reden to DSO-LV.
+
+4. **GIVEN** a status push to DSO-LV fails **WHEN** the adapter encounters an HTTP 5xx from DSO-LV **THEN** the push is retried 3 times with exponential backoff, and on persistent failure a manual-retry task is created and the behandelaar is notified.
+
+5. **GIVEN** a zaak goes through multiple status transitions rapidly **WHEN** statussen change faster than DSO-LV can process **THEN** the adapter queues status pushes and sends them in chronological order, skipping intermediate statussen if configured to do so.
+
+### REQ-DSO-050: PKIoverheid Certificate Authentication
+
+The adapter MUST authenticate with DSO-LV using PKIoverheid certificates for mutual TLS. It MUST validate incoming DSO-LV webhook signatures and support both pre-production and production certificate chains. Certificates are stored securely via Nextcloud's credential store.
+
+**Scenarios:**
+
+1. **GIVEN** a PKIoverheid certificate and private key are uploaded via the OpenConnector admin UI **WHEN** the adapter makes an outbound call to DSO-LV **THEN** the certificate is written to a temporary file by CallService.getCertificate(), used for mTLS, and cleaned up after the request.
+
+2. **GIVEN** the PKIoverheid certificate expires in 30 days **WHEN** the daily health check runs **THEN** a warning notification is sent to the Nextcloud admin with the certificate expiry date and renewal instructions.
+
+3. **GIVEN** an incoming webhook from DSO-LV includes a signature header **WHEN** the adapter validates the signature against the DSO-LV public certificate **THEN** requests with valid signatures are processed and requests with invalid signatures are rejected with HTTP 401.
+
+### REQ-DSO-060: OpenConnector Source Registration
+
+The adapter MUST be registered as an OpenConnector source type with DSO-LV-specific configuration fields. Connection settings include: DSO-LV API URL, PKIoverheid certificates, organisatie OIN, bevoegd-gezag code, and STAM API version. The source supports health checks validating connectivity and certificate validity.
+
+**Scenarios:**
+
+1. **GIVEN** an administrator creates a new source of type "dso" **WHEN** they fill in the DSO-LV API URL, upload PKIoverheid certificates, and enter the organisatie OIN **THEN** a Source entity is created with type "dso" and DSO-specific configuration fields stored in the `configuration` JSON column.
+
+2. **GIVEN** a DSO source is configured **WHEN** the administrator clicks "Test Connection" **THEN** the adapter makes a lightweight STAM API probe (e.g., a capability request) using mTLS and reports success/failure with certificate validity details.
+
+3. **GIVEN** a DSO source is configured **WHEN** an n8n workflow references the DSO source **THEN** it can trigger verzoek polling, status pushes, or bijlagen downloads using the source credentials.
+
+## Data Model
+
+### DSO-Verzoek (stored in OpenRegister before zaak creation)
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| verzoekId | string | Yes | DSO-LV unique verzoek identifier |
+| bronorganisatie | string | Yes | OIN of the submitting DSO-LV instance |
+| type | string (enum) | Yes | `aanvraag`, `melding`, `informatieverzoek`, `vooroverleg` |
+| indieningsdatum | datetime | Yes | Date/time of submission in DSO-LV |
+| aanvrager | object | Yes | Initiatiefnemer: BSN/KVK, naam, adres, contactgegevens |
+| locatie | object | Yes | BAG-adres, kadastrale aanduiding, GML-geometrie |
+| activiteiten | array | Yes | List of DSO activiteiten with codes and omschrijvingen |
+| bouwkosten | decimal | No | Opgegeven bouwkosten (for legesberekening) |
+| bijlagen | array | No | References to downloaded documents in Nextcloud Files |
+| zaakId | string (UUID) | No | Created Procest zaak reference (set after processing) |
+| status | string (enum) | Yes | `ontvangen`, `verwerkt`, `fout` |
+| environment | string (enum) | No | `productie`, `pre-productie` |
+| stamApiVersion | string | No | STAM API version used for this verzoek |
+
+### Activiteiten-Mapping (stored in OpenRegister)
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| dsoActiviteitCode | string | Yes | DSO activiteit code (e.g., "bouwen-01") |
+| dsoActiviteitOmschrijving | string | Yes | Human-readable activiteit description |
+| zaaktypeIdentificatie | string | Yes | Target Procest zaaktype identificatie |
+| samenloopStrategie | string (enum) | No | `deelzaken` or `gecombineerd` (default: `deelzaken`) |
+| behandelendeAfdeling | string | No | Default afdeling for routing |
+| isActief | boolean | Yes | Whether this mapping is currently active |
+
+## Dependencies
+
+- **OpenConnector**: Source registration and connection management (Source entity, CallService, EndpointService)
+- **OpenRegister**: Verzoek and mapping table storage
+- **Procest**: Zaak creation and lifecycle management
+- **Docudesk**: PDF generation for beschikkingen pushed to DSO-LV
+- **DSO-LV STAM API**: External service (Kadaster/RWS)
+- **PKIoverheid certificates**: For mTLS authentication
+- **BAG/BRK services**: For locatie-validatie (via OpenConnector)
+
+### Using Mock Register Data
+
+The **DSO** mock register provides test data for developing the DSO adapter without requiring access to the DSO-LV production/test environment.
+
+**Loading the register:**
+```bash
+# Load DSO register (53 records, register slug: "dso", schemas: "activiteit", "locatie", "omgevingsdocument", "vergunningaanvraag")
+docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/dso_register.json
+```
+
+**Test data for this spec's use cases:**
+- **Activiteiten-to-zaaktype mapping (REQ-DSO-010)**: 20+ activiteit records (bouwen, kappen, uitrit aanleggen, etc.) -- test mapping configuration
+- **Vergunningaanvraag parsing (REQ-DSO-004)**: 10+ vergunningaanvraag records with activiteiten, locatie, and aanvrager data
+- **Samenloop testing (REQ-DSO-011)**: Vergunningaanvragen referencing multiple activiteiten -- test single-zaak vs multi-deelzaak creation
+
+## Current Implementation Status
+
+### Implemented
+- **None of the DSO-specific requirements are implemented.** There is no DSO adapter, STAM endpoint, activiteiten-mapping, or DSO-SWF integration in the codebase.
+
+### Partially relevant existing infrastructure
+- **SOAP engine** (`lib/Service/SOAPService.php`): A generic SOAP client exists that can call SOAP sources using WSDL, Guzzle HTTP, and the `php-soap` extension. It already handles SOAP 1.1/1.2, cookie management, WSDL caching, and binary data encoding. This could serve as a foundation for DSO-LV STAM SOAP communication.
+- **Source entity** (`lib/Db/Source.php`, `src/entities/source/source.types.ts`): Sources support types `json`, `xml`, `soap`, `ftp`, `sftp` with configurable authentication (`apikey`, `jwt`, `username-password`, `oauth`, etc.). A new `dso` source type would need to be added.
+- **CallService** (`lib/Service/CallService.php`): Routes SOAP-type sources to the SOAPService (line ~466). Already supports certificate file writing to disk for mTLS connections via `getCertificate()` and cleanup via `removeFiles()`.
+- **SynchronizationService** (`lib/Service/SynchronizationService.php`): Full sync framework with contracts, logging, and mapping between external and internal objects. Could be leveraged for DSO-verzoek sync.
+- **AuthenticationService** (`lib/Service/AuthenticationService.php`): Has certificate handling logic and supports JWT, OAuth, API key, and password authentication methods that could be extended for PKIoverheid mTLS.
+- **EndpointService** (`lib/Service/EndpointService.php`): Manages endpoint routing with target types including `source`, `register/schema`, `job`, and `synchronization`. DSO inbound endpoints can leverage this routing.
+- **EventService** (`lib/Service/EventService.php`): Event dispatching for workflow triggering via n8n or other subscribers.
+- **JobService** (`lib/Service/JobService.php`): Background job execution for asynchronous processing and retry logic.
+
+### Not implemented
+- DSO-LV STAM koppelvlak endpoint (inbound REST/SOAP receiver)
+- DSO verzoek parsing (XML/JSON payload to structured data)
+- Activiteiten-to-zaaktype mapping table and UI
+- Samenloop handling (multiple deelzaken from one verzoek)
+- DSO-SWF samenwerking (adviesverzoeken, adviezen)
+- Status push back to DSO-LV (outbound)
+- PKIoverheid certificate validation chain
+- DSO-LV webhook signature verification
+- DSO-specific source type registration
+- Bijlagen download and Nextcloud Files storage
+- All zaak creation logic (depends on Procest)
+
+## Standards & References
+
+- **DSO-LV STAM koppelvlak**: REST API specification maintained by Kadaster/RWS for the Digitaal Stelsel Omgevingswet. Defines the verzoek intake interface.
+- **Omgevingswet (2024)**: The Dutch Environment and Planning Act that replaced the Wabo/Wro, effective January 1, 2024.
+- **DSO-SWF**: SamenWerkingsFunctionaliteit -- the collaboration API within the DSO-LV for coordinating between bevoegd gezag and ketenpartners.
+- **PKIoverheid**: Dutch government PKI for mutual TLS authentication (PKIO Server 2020 certificate chain).
+- **BAG (Basisregistratie Adressen en Gebouwen)**: National address registry, used for locatie-validatie.
+- **BRK (Basisregistratie Kadaster)**: Cadastral registry for kadastrale aanduidingen.
+- **GML (Geography Markup Language)**: OGC standard for geospatial data encoding, used for locatie geometrie.
+- **OIN (Organisatie-Identificatienummer)**: Unique identifier for Dutch government organizations.
diff --git a/openspec/changes/dso-omgevingsloket/tasks.md b/openspec/changes/dso-omgevingsloket/tasks.md
new file mode 100644
index 000000000..1eb367803
--- /dev/null
+++ b/openspec/changes/dso-omgevingsloket/tasks.md
@@ -0,0 +1,161 @@
+# Tasks: dso-omgevingsloket
+
+## Task 1: STAM Endpoint Registration (REQ-DSO-001)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-001`
+- **files**: `lib/Controller/DSOController.php`, `appinfo/routes.php`
+- **acceptance_criteria**:
+ - GIVEN DSO adapter endpoint registered WHEN DSO-LV pushes verzoek THEN HTTP 202 returned with verzoekId
+ - GIVEN invalid webhook signature WHEN request arrives THEN HTTP 401 returned
+ - GIVEN malformed payload WHEN schema validation fails THEN HTTP 400 with field-level errors
+- [ ] Implement DSOController with STAM endpoint
+- [ ] Register route at /api/dso/stam/verzoeken
+- [ ] Add webhook signature validation
+- [ ] Add schema validation
+- [ ] Test
+
+## Task 2: Verzoek Payload Parsing (REQ-DSO-004)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-004`
+- **files**: `lib/Service/DSOParserService.php`
+- **acceptance_criteria**:
+ - GIVEN verzoek with aanvrager, locatie, activiteiten WHEN parser runs THEN structured data extracted
+ - GIVEN GML geometrie WHEN parser runs THEN GeoJSON conversion produced
+ - GIVEN version mismatch WHEN parsing THEN auto-detection attempted with warning
+- [ ] Implement DSOParserService
+- [ ] Add BSN/KVK extraction
+- [ ] Add locatie/BAG parsing
+- [ ] Add activiteiten parsing
+- [ ] Add GML to GeoJSON conversion
+- [ ] Test
+
+## Task 3: Verzoek Schema Validation (REQ-DSO-006)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-006`
+- **files**: `lib/Service/DSOParserService.php`
+- **acceptance_criteria**:
+ - GIVEN missing required fields WHEN validation runs THEN descriptive errors returned
+ - GIVEN invalid BSN (11-proef fails) WHEN validation runs THEN BSN error returned
+- [ ] Implement STAM schema validation
+- [ ] Add BSN 11-proef validation
+- [ ] Add date format validation
+- [ ] Test
+
+## Task 4: Melding and Informatieverzoek Reception (REQ-DSO-002, REQ-DSO-003)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-002`, `#req-dso-003`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN melding received WHEN processed THEN zaak created with "Melding" zaaktype
+ - GIVEN vooroverleg received WHEN processed THEN lightweight zaak created
+- [ ] Implement melding handling
+- [ ] Implement informatieverzoek handling
+- [ ] Implement vooroverleg handling
+- [ ] Test
+
+## Task 5: Bijlagen Download and Storage (REQ-DSO-005)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-005`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN verzoek with bijlagen WHEN processed THEN files downloaded and stored in Nextcloud Files
+ - GIVEN download failure WHEN retries exhausted THEN warning flagged on zaak
+- [ ] Implement bijlagen download with mTLS
+- [ ] Add retry with exponential backoff
+- [ ] Add file size limit check
+- [ ] Add folder structure creation
+- [ ] Test
+
+## Task 6: Activiteiten-to-Zaaktype Mapping (REQ-DSO-010)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-010`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN mapping table configured WHEN verzoek has activiteit THEN correct zaaktype used
+ - GIVEN empty mapping table WHEN admin loads defaults THEN 25+ mappings seeded
+- [ ] Implement mapping table lookup
+- [ ] Add default mapping seed
+- [ ] Test
+
+## Task 7: Samenloop Handling (REQ-DSO-011)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-011`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN multiple activiteiten with deelzaken strategy WHEN processed THEN hoofdzaak + deelzaken created
+ - GIVEN gecombineerd strategy WHEN processed THEN single combined zaak created
+- [ ] Implement deelzaken strategy
+- [ ] Implement gecombineerd strategy
+- [ ] Test
+
+## Task 8: Unmapped Activiteit Fallback (REQ-DSO-013)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-013`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN unmapped activiteit WHEN processed THEN triage zaak created with notification
+- [ ] Implement fallback zaaktype creation
+- [ ] Add notification to triage user
+- [ ] Test
+
+## Task 9: Automatic Zaak Creation (REQ-DSO-020)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-020`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN valid verzoek parsed WHEN zaak creation runs THEN zaak has all mapped fields
+ - GIVEN zaak created WHEN complete THEN EventService dispatches event for n8n
+- [ ] Implement zaak creation via OpenRegister
+- [ ] Add event dispatch
+- [ ] Test
+
+## Task 10: DSO-SWF Samenwerking (REQ-DSO-030)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-030`
+- **files**: `lib/Service/DSOSamenwerkingService.php`
+- **acceptance_criteria**:
+ - GIVEN zaak requires advies WHEN behandelaar marks for samenwerking THEN adviesverzoek sent via DSO-SWF
+ - GIVEN advies received WHEN processed THEN stored and behandelaar notified
+- [ ] Implement adviesverzoek sending
+- [ ] Implement advies reception
+- [ ] Test
+
+## Task 11: Status Push to DSO-LV (REQ-DSO-040)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-040`
+- **files**: `lib/Service/DSOStatusService.php`
+- **acceptance_criteria**:
+ - GIVEN zaak status changes WHEN DSO-originated zaak THEN status pushed to DSO-LV
+ - GIVEN push fails WHEN retries exhausted THEN manual-retry task created
+- [ ] Implement status mapping
+- [ ] Implement outbound push with retry
+- [ ] Test
+
+## Task 12: PKIoverheid Certificate Authentication (REQ-DSO-050)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-050`
+- **files**: `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN PKIoverheid certificate configured WHEN outbound call made THEN mTLS used
+ - GIVEN certificate expiring in 30 days WHEN health check runs THEN warning notification sent
+- [ ] Implement certificate validation
+- [ ] Add expiry monitoring
+- [ ] Test
+
+## Task 13: Source Registration (REQ-DSO-060)
+- **spec_ref**: `specs/dso-omgevingsloket/spec.md#req-dso-060`
+- **files**: `lib/Db/Source.php`, `lib/Service/DSOAdapterService.php`
+- **acceptance_criteria**:
+ - GIVEN new source type "dso" WHEN configured THEN DSO-specific fields stored
+ - GIVEN DSO source WHEN test connection clicked THEN STAM probe validates connectivity
+- [ ] Add "dso" source type
+- [ ] Implement test connection
+- [ ] Test
+
+## Task 14: Unit Tests
+- **spec_ref**: ADR-009
+- **files**: `tests/Unit/Service/DSOParserServiceTest.php`, `tests/Unit/Controller/DSOControllerTest.php`
+- [ ] Write parser tests (BSN validation, payload extraction, GML conversion)
+- [ ] Write controller tests (endpoint responses, validation errors)
+- [ ] Write adapter service tests (mapping, samenloop, fallback)
+
+## Task 15: API Documentation
+- **spec_ref**: ADR-010
+- **files**: `docs/features/dso-omgevingsloket.md`
+- [ ] Write endpoint documentation
+- [ ] Write configuration guide
+- [ ] Write mapping administration guide
+
+## Verification
+- [ ] All tasks checked off
+- [ ] Unit tests pass
+- [ ] Integration with DSO-LV test environment verified
+- [ ] Activiteiten mapping works end-to-end
diff --git a/openspec/changes/ibabs-notubiz-connector/.openspec.yaml b/openspec/changes/ibabs-notubiz-connector/.openspec.yaml
new file mode 100644
index 000000000..b4bbeb946
--- /dev/null
+++ b/openspec/changes/ibabs-notubiz-connector/.openspec.yaml
@@ -0,0 +1 @@
+schema: spec-driven
diff --git a/openspec/changes/ibabs-notubiz-connector/design.md b/openspec/changes/ibabs-notubiz-connector/design.md
new file mode 100644
index 000000000..098f9ca0c
--- /dev/null
+++ b/openspec/changes/ibabs-notubiz-connector/design.md
@@ -0,0 +1,32 @@
+# Design: iBabs & NotuBiz Connector
+
+## Architecture
+
+The RIS connector follows the existing OpenConnector synchronization pattern:
+
+### New Services
+- **IBabsConnectorService** (`lib/Service/IBabsConnectorService.php`): Handles iBabs API interactions (document push, agendapunt creation, besluit retrieval)
+- **NotuBizConnectorService** (`lib/Service/NotuBizConnectorService.php`): Handles NotuBiz API interactions
+- **RISMappingService** (`lib/Service/RISMappingService.php`): Maps zaak fields to RIS metadata fields
+
+### Integration Pattern
+Uses the existing Source + CallService + SynchronizationService infrastructure:
+- iBabs: Source type "json" with API key auth
+- NotuBiz: Source type "json" with OAuth2 auth
+- Outbound push: SynchronizationService with custom mapping
+- Inbound poll: Cron job polling for besluiten at configurable intervals
+
+### Data Flow
+1. **Outbound (documents out):** Zaak -> extract voorstel -> PDF conversion via Docudesk -> upload to iBabs/NotuBiz -> create agendapunt
+2. **Inbound (decisions back):** Poll RIS for besluiten -> map besluit status -> update zaak in Procest -> download besluitenlijst to Nextcloud Files
+
+## Dependencies
+- **Procest**: For zaak lifecycle management
+- **Docudesk**: For DOCX to PDF conversion
+- **iBabs REST API**: External service (api.ibabs.eu)
+- **NotuBiz API**: External service with OAuth2
+
+## Risks
+- iBabs and NotuBiz APIs are proprietary with limited public documentation
+- Procest app dependency not yet available
+- Rate limiting on external APIs requires careful throttling
diff --git a/openspec/changes/ibabs-notubiz-connector/proposal.md b/openspec/changes/ibabs-notubiz-connector/proposal.md
new file mode 100644
index 000000000..cdec4101a
--- /dev/null
+++ b/openspec/changes/ibabs-notubiz-connector/proposal.md
@@ -0,0 +1,12 @@
+# iBabs & NotuBiz Connector
+
+## Summary
+This change implements the ibabs-notubiz-connector feature as specified in the delta spec.
+
+## Motivation
+Required by Dutch government tenders for integration with external systems.
+
+## Scope
+- New adapter/connector implementation
+- API endpoints
+- Configuration UI
diff --git a/openspec/changes/ibabs-notubiz-connector/specs/ibabs-notubiz-connector/spec.md b/openspec/changes/ibabs-notubiz-connector/specs/ibabs-notubiz-connector/spec.md
new file mode 100644
index 000000000..97c8dc396
--- /dev/null
+++ b/openspec/changes/ibabs-notubiz-connector/specs/ibabs-notubiz-connector/spec.md
@@ -0,0 +1,267 @@
+---
+status: proposed
+---
+
+# iBabs & NotuBiz Connector
+
+## Purpose
+
+Provides bidirectional integration with iBabs and NotuBiz -- the two dominant raadsinformatiesystemen (RIS) used by Dutch municipalities for bestuurlijke besluitvorming (B&W/College). Found as a requirement in 20+ tenders: iBabs in 12+ and NotuBiz in 8+. The connector pushes collegevoorstellen and documents from Procest to the RIS for vergaderbehandeling, and receives besluiten and besluitenlijsten back into the zaak. Implements the standard B&W workflow pattern: documenten heen, besluiten terug.
+
+## Requirements
+
+### REQ-RIS-001: iBabs REST API Connection
+
+The connector MUST establish authenticated connections to the iBabs REST API using API key authentication. The connection is configured as an OpenConnector Source entity of type `json` with auth method `apikey`. The source stores the iBabs API URL (typically `https://api.ibabs.eu`), API key, and organisatie-ID. All API calls are routed through CallService which logs each request in the CallLog for audit and debugging.
+
+**Scenarios:**
+
+1. **GIVEN** an administrator creates a new Source with type "json" and auth "apikey" for iBabs **AND** enters the iBabs API URL, API key, and organisatie-ID in the configuration **WHEN** they save the source **THEN** the Source entity is persisted with the iBabs-specific configuration in the `configuration` JSON field and the source is marked as enabled.
+
+2. **GIVEN** an iBabs source is configured **WHEN** the administrator clicks "Test Connection" **THEN** CallService makes a lightweight GET request to the iBabs API (e.g., listing vergaderingen) and the response status is shown -- 200 OK confirms connectivity, 401 indicates invalid API key.
+
+3. **GIVEN** an iBabs API call returns rate limit headers (`X-RateLimit-Remaining`, `X-RateLimit-Reset`) **WHEN** CallService processes the response **THEN** the Source entity's rate limit fields are updated automatically (existing CallService.sourceRateLimit() behavior) preventing excessive API calls.
+
+4. **GIVEN** the iBabs API key expires or is revoked **WHEN** the next API call returns HTTP 401 **THEN** the CallLog records the failure, the Source status is updated to "error", and a Nextcloud notification is sent to the administrator.
+
+### REQ-RIS-002: Collegevoorstel Push to iBabs
+
+The connector MUST push a collegevoorstel (advies document plus bijlagen) from Procest to iBabs as a vergaderstuk. The push extracts the voorstel document from Nextcloud Files, converts to PDF if needed via Docudesk, and uploads it to iBabs with metadata (onderwerp, portefeuillehouder, zaaktype).
+
+**Scenarios:**
+
+1. **GIVEN** a zaak "Bestemmingsplan Centrum" has a voorstel document in Nextcloud Files **WHEN** the connector pushes the voorstel to iBabs **THEN** the document is uploaded via the iBabs document API with metadata fields: onderwerp from zaak omschrijving, portefeuillehouder from zaak-eigenschap, and zaaktype from Procest.
+
+2. **GIVEN** the voorstel document is a DOCX file **WHEN** the connector prepares the push **THEN** Docudesk converts the DOCX to PDF before uploading to iBabs.
+
+3. **GIVEN** the zaak has 3 bijlagen (advies, tekening, financieel overzicht) **WHEN** the connector pushes the voorstel **THEN** all bijlagen are uploaded to iBabs linked to the same vergaderstuk.
+
+4. **GIVEN** document upload to iBabs fails with HTTP 413 (payload too large) **WHEN** the connector handles the error **THEN** a CallLog entry is created with the error, the sync record is set to "failed", and the behandelaar receives a notification suggesting document compression.
+
+5. **GIVEN** the voorstel has a geheimhouding flag set on the zaak **WHEN** the connector pushes to iBabs **THEN** the document is marked as vertrouwelijk in the iBabs API metadata.
+
+### REQ-RIS-003: Agendapunt Creation in iBabs
+
+The connector MUST create or update an agendapunt in iBabs linked to the uploaded collegevoorstel. The target vergadering is determined by configuration: either the next upcoming collegevergadering (auto-select), a specific vergadering selected by the behandelaar, or a default vergadering type configured in the source settings.
+
+**Scenarios:**
+
+1. **GIVEN** a voorstel is pushed to iBabs **AND** the source configuration specifies auto-select for the next collegevergadering **WHEN** the connector creates the agendapunt **THEN** the iBabs API is queried for upcoming vergaderingen, the next one is selected, and the agendapunt is created with the voorstel linked.
+
+2. **GIVEN** the behandelaar selects a specific vergadering for the voorstel **WHEN** the connector creates the agendapunt **THEN** it uses the selected vergadering ID from the zaak-eigenschap.
+
+3. **GIVEN** no upcoming vergadering exists in iBabs **WHEN** the connector attempts to create an agendapunt **THEN** a warning is logged and the sync record is set to "pending" until a vergadering becomes available.
+
+### REQ-RIS-004: Besluit Retrieval from iBabs
+
+The connector MUST retrieve besluiten from iBabs after vergaderbehandeling. Retrieval happens via polling (configurable interval, default 15 minutes) or webhook if available. The besluit status (aangenomen, verworpen, aangehouden, doorgeschoven) is mapped to a Procest zaak status update.
+
+**Scenarios:**
+
+1. **GIVEN** a voorstel was pushed to iBabs for zaak "Bestemmingsplan Centrum" **AND** the college has the voorstel aangenomen **WHEN** the inbound poll retrieves the besluit **THEN** the zaak status in Procest is updated to reflect "Besluit: aangenomen" and the besluitdatum is recorded.
+
+2. **GIVEN** the college has the voorstel verworpen **WHEN** the besluit is retrieved **THEN** the zaak status is updated to "Besluit: verworpen" and a notification is sent to the behandelaar and portefeuillehouder.
+
+3. **GIVEN** the voorstel is aangehouden (deferred to a future vergadering) **WHEN** the besluit is retrieved **THEN** the zaak status is updated to "Besluit: aangehouden" and the connector watches for the rescheduled vergadering.
+
+4. **GIVEN** the college modifies the voorstel before besluit (e.g., amendement) **WHEN** the besluit is retrieved with modifications **THEN** the modifications are noted in the sync record and the behandelaar is notified of the discrepancy.
+
+### REQ-RIS-005: Besluitenlijst Retrieval
+
+The connector MUST retrieve the besluitenlijst (PDF/document) from iBabs after vergaderbehandeling, store it in Nextcloud Files, and link it to the source zaak.
+
+**Scenarios:**
+
+1. **GIVEN** a collegevergadering has concluded **WHEN** the connector polls for the besluitenlijst **THEN** the besluitenlijst PDF is downloaded, stored in `/RIS-besluiten/{year}/{vergadering-datum}/`, and linked to all zaken that had voorstellen in that vergadering.
+
+2. **GIVEN** the besluitenlijst is not yet published in iBabs (vergadering just ended) **WHEN** the connector polls **THEN** it retries at the configured interval until the besluitenlijst becomes available.
+
+3. **GIVEN** the besluitenlijst contains entries for 12 voorstellen from 12 different zaken **WHEN** the connector processes the besluitenlijst **THEN** each relevant zaak receives a link to the besluitenlijst document.
+
+### REQ-RIS-020: NotuBiz API Connection
+
+The connector MUST connect to the NotuBiz API with OAuth2 or API key authentication. The connection is configured as an OpenConnector Source entity with NotuBiz-specific configuration including organisatie-ID and default vergadertype. Authentication supports both OAuth2 (via AuthenticationService's existing client_credentials flow) and API key methods.
+
+**Scenarios:**
+
+1. **GIVEN** an administrator creates a NotuBiz source with OAuth2 authentication **WHEN** they configure the client_id, client_secret, and token endpoint **THEN** the Source entity uses the existing AuthenticationService OAuth2 flow to obtain and refresh access tokens automatically.
+
+2. **GIVEN** a NotuBiz source is configured **WHEN** the administrator tests connectivity **THEN** a lightweight API call verifies the connection and returns organisatie details from NotuBiz.
+
+3. **GIVEN** the NotuBiz OAuth2 token expires **WHEN** the next API call is made **THEN** AuthenticationService automatically refreshes the token using the stored credentials before retrying the call.
+
+### REQ-RIS-021: Vergaderstuk Push to NotuBiz
+
+The connector MUST push vergaderstukken (voorstel plus bijlagen) to NotuBiz for vergaderbehandeling. The push supports multiple event types: collegevergadering, raadsvergadering, and commissievergadering.
+
+**Scenarios:**
+
+1. **GIVEN** a zaak requires raadsbehandeling after collegebesluit **WHEN** the connector pushes stukken to NotuBiz **THEN** vergaderstukken are uploaded with the correct vergadertype (raadsvergadering) and metadata.
+
+2. **GIVEN** a voorstel requires commissiebehandeling before raadsbehandeling **WHEN** the connector pushes to NotuBiz **THEN** the vergaderstukken are first linked to the commissievergadering, and after commissiebehandeling, forwarded to the raadsvergadering.
+
+3. **GIVEN** a document pushed to NotuBiz needs to be updated (nieuwe versie) **WHEN** the behandelaar uploads a revised document **THEN** the connector updates the existing vergaderstuk in NotuBiz with the new version, preserving the agendapunt link.
+
+### REQ-RIS-030: Status-Based Outbound Sync
+
+The connector MUST trigger outbound sync when a zaak reaches the configurable status "Ter besluitvorming" in Procest. The trigger is implemented via OpenConnector's EventService which listens for zaak status change events from Procest. Only zaken with completed parafering (all required parafen collected) are eligible for push.
+
+**Scenarios:**
+
+1. **GIVEN** a zaak "Subsidieregeling Cultuur" reaches status "Ter besluitvorming" **AND** all required paraferingen are completed **WHEN** the status change event fires **THEN** the connector automatically pushes the voorstel to the configured RIS (iBabs or NotuBiz) and creates a sync record with status "synced".
+
+2. **GIVEN** a zaak reaches "Ter besluitvorming" but parafering is incomplete **WHEN** the status change event fires **THEN** the connector blocks the push, sets the sync record to "pending", and notifies the behandelaar that parafering must be completed first.
+
+3. **GIVEN** both iBabs and NotuBiz sources are configured **AND** the zaak requires both college and raadsbehandeling **WHEN** the outbound sync triggers **THEN** the connector pushes to iBabs for collegebesluit first, and after college aanname, pushes to NotuBiz for raadsbehandeling.
+
+### REQ-RIS-031: Inbound Besluit Sync
+
+The connector MUST poll or receive webhooks for besluit updates from the configured RIS and update the source zaak in Procest. Polling uses JobService background jobs at configurable intervals (default: 15 minutes). Each poll checks all sync records with status "synced" (outbound push completed) for besluit responses.
+
+**Scenarios:**
+
+1. **GIVEN** a voorstel was pushed to iBabs 3 hours ago **WHEN** the background poll job runs **THEN** it queries the iBabs API for the agendapunt status, finds "aangenomen", and updates the zaak status in Procest.
+
+2. **GIVEN** the poll finds no besluit yet (vergadering has not occurred) **WHEN** polling runs **THEN** the sync record remains "synced" and the poll continues at the next interval.
+
+3. **GIVEN** the RIS API is temporarily unavailable during polling **WHEN** the poll encounters an HTTP 503 **THEN** a CallLog error is recorded and the poll retries at the next scheduled interval.
+
+### REQ-RIS-033: Sync Audit Trail
+
+The connector MUST log all sync operations as OpenRegister objects for a complete audit trail. Each sync record captures direction (push/pull), timestamp, status, document IDs, and error details. The audit trail enables compliance with the Archiefwet requirement for traceability of bestuurlijke besluitvorming.
+
+**Scenarios:**
+
+1. **GIVEN** a voorstel push to iBabs succeeds **WHEN** the sync completes **THEN** a sync record is created with: zaakId, risType "ibabs", direction "outbound", status "synced", syncedAt timestamp, and document references (Nextcloud file ID mapped to iBabs document ID).
+
+2. **GIVEN** a besluit retrieval from NotuBiz succeeds **WHEN** the sync completes **THEN** a sync record is created with direction "inbound", the besluit document reference, and the mapped zaak status.
+
+3. **GIVEN** an auditor queries the sync history for a specific zaak **WHEN** they filter sync records by zaakId **THEN** they see the complete chronological history of all outbound pushes and inbound pulls with timestamps and statussen.
+
+### REQ-RIS-034: Retry with Configurable Backoff
+
+The connector MUST retry failed sync operations with configurable backoff intervals (default: 3 retries at 5, 15, and 60 minutes). Retries use the JobService to schedule future attempts. After all retries are exhausted, the sync record MUST be set to "failed" and a notification MUST be sent.
+
+**Scenarios:**
+
+1. **GIVEN** a voorstel push fails due to an iBabs API timeout **WHEN** the first retry triggers after 5 minutes **THEN** the push is reattempted with the same payload and credentials.
+
+2. **GIVEN** the first and second retries also fail **WHEN** the third retry at 60 minutes also fails **THEN** the sync record status is set to "failed" with the accumulated error messages, and a notification is sent to the behandelaar with a manual retry option.
+
+3. **GIVEN** the second retry succeeds **WHEN** the push completes successfully **THEN** the sync record status is updated to "synced" and no further retries are scheduled.
+
+### REQ-RIS-040: Document Flow Management
+
+The connector MUST manage bidirectional document flow: outbound documents are exported from Nextcloud Files, converted to PDF via Docudesk if needed, and pushed to the RIS. Inbound documents (besluit, besluitenlijst) are downloaded from the RIS, stored in Nextcloud Files, and linked to the zaak. Document metadata (onderwerp, datum, portefeuillehouder, zaaktype, geheimhouding) is mapped bidirectionally.
+
+**Scenarios:**
+
+1. **GIVEN** a zaak has 5 documents in Nextcloud Files (voorstel, 3 bijlagen, conceptbesluit) **WHEN** the outbound sync triggers **THEN** all documents are exported, non-PDF documents are converted via Docudesk, and uploaded to the RIS with metadata derived from zaak-eigenschappen.
+
+2. **GIVEN** a document in the RIS is marked as vertrouwelijk **WHEN** the inbound sync retrieves it **THEN** the document is stored in Nextcloud Files with restricted permissions matching the zaak's geheimhouding level.
+
+3. **GIVEN** the RIS returns a besluit document in a non-standard format **WHEN** the connector downloads it **THEN** it is stored as-is in Nextcloud Files with the original format, and a PDF conversion is attempted via Docudesk for display purposes.
+
+### REQ-RIS-050: Parafering Tracking
+
+The connector MUST track parafering status within Procest before allowing push to the RIS. The parafering route follows the standard municipal chain: steller, adviseur, parafeerder, portefeuillehouder, secretariaat. Only after all required paraferingen are completed is the push enabled. The parafering route is configurable per zaaktype (sequential, parallel, or mixed).
+
+**Scenarios:**
+
+1. **GIVEN** a zaak requires sequential parafering: steller > adviseur > parafeerder > portefeuillehouder **WHEN** the steller and adviseur have parafen but the parafeerder has not **THEN** the connector blocks outbound push and shows parafering progress (2/4 completed) in the sync status.
+
+2. **GIVEN** a zaaktype is configured with parallel parafering for adviseur and juridisch adviseur **WHEN** both adviseurs have parafen **THEN** the parafering proceeds to the next sequential step (parafeerder).
+
+3. **GIVEN** all required paraferingen are completed **WHEN** the secretariaat adds the final paraaf **THEN** the zaak automatically transitions to "Ter besluitvorming" and the outbound sync triggers.
+
+### REQ-RIS-060: OpenConnector Endpoint Registration
+
+The connector MUST be registered as OpenConnector endpoint types with separate configurations for iBabs and NotuBiz. Connection settings include API URL, authentication credentials, organisatie-ID, and default vergadertype. Health checks validate API connectivity and authentication.
+
+**Scenarios:**
+
+1. **GIVEN** an administrator wants to connect both iBabs and NotuBiz **WHEN** they create two separate Source entities **THEN** each source has its own configuration (API URL, credentials, organisatie-ID) and can be used independently or together for college+raad workflows.
+
+2. **GIVEN** an iBabs source is configured **WHEN** an n8n workflow references the source **THEN** it can trigger custom B&W-besluitvorming workflows including document preparation, parafering reminders, and besluit notifications.
+
+3. **GIVEN** the health check runs on the NotuBiz source **WHEN** the API responds but authentication fails **THEN** the health check reports "degraded" with the specific authentication error.
+
+## Data Model
+
+### Sync Record (stored in OpenRegister)
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| zaakId | string (UUID) | Yes | Source zaak in Procest |
+| risType | string (enum) | Yes | `ibabs` or `notubiz` |
+| risDocumentId | string | No | Document/agendapunt ID in the RIS |
+| risVergaderingId | string | No | Vergadering ID in the RIS |
+| direction | string (enum) | Yes | `outbound` (push) or `inbound` (pull) |
+| status | string (enum) | Yes | `pending`, `synced`, `failed`, `conflict` |
+| syncedAt | datetime | No | Timestamp of last successful sync |
+| retryCount | integer | No | Number of retries attempted |
+| nextRetryAt | datetime | No | Scheduled time for next retry |
+| errorMessage | string | No | Error details if status is `failed` |
+| documents | array | No | List of document references (Nextcloud file ID + RIS doc ID) |
+| besluitStatus | string (enum) | No | `aangenomen`, `verworpen`, `aangehouden`, `doorgeschoven` |
+| paraferingStatus | object | No | Current parafering progress (completed/total, current step) |
+
+## Dependencies
+
+- **OpenConnector**: Source registration and connection management (Source entity, CallService, EndpointService, EventService, JobService)
+- **OpenRegister**: Sync record storage and zaak object access
+- **Procest**: Zaak lifecycle management and parafering workflow
+- **Docudesk**: PDF conversion for outbound documents
+- **iBabs REST API**: External service (api.ibabs.eu)
+- **NotuBiz API**: External service (api.notubiz.nl)
+
+### Using Mock Register Data
+
+The **ORI** mock register provides test data for developing the iBabs/NotuBiz connector without requiring access to production RIS systems.
+
+**Loading the register:**
+```bash
+# Load ORI register (115 records, register slug: "ori", schemas: "vergadering", "agendapunt", "raadsdocument", "stemming", "raadslid", "fractie")
+docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/ori_register.json
+```
+
+**Test data for this spec's use cases:**
+- **Vergadering retrieval (REQ-RIS-003)**: 10+ vergaderingen with dates and types (raadsvergadering, commissievergadering) -- test sync back to ORI register
+- **Agendapunt creation (REQ-RIS-003)**: 30+ agendapunten linked to vergaderingen -- test push/pull of agenda items
+- **Besluit mapping (REQ-RIS-004)**: Stemmingen with aangenomen/verworpen results -- test besluit status mapping
+- **Document handling (REQ-RIS-002)**: 15+ raadsdocumenten (moties, amendementen, besluiten) -- test document upload/download sync
+
+## Current Implementation Status
+
+### Implemented
+- **None of the iBabs/NotuBiz-specific requirements are implemented.** There is no iBabs connector, NotuBiz connector, parafering workflow, or RIS sync mechanism in the codebase.
+
+### Partially relevant existing infrastructure
+- **Source entity** (`lib/Db/Source.php`, `src/entities/source/source.types.ts`): Supports source types `json`, `xml`, `soap`, `ftp`, `sftp` with multiple auth methods including `apikey`, `jwt`, `oauth`. Both iBabs (REST + API key) and NotuBiz (OAuth2/API key) can be configured as `json`-type sources with appropriate auth.
+- **CallService** (`lib/Service/CallService.php`): Generic HTTP client that handles REST calls to configured sources with full request/response logging, rate limiting, retry support, and authentication via Twig template rendering.
+- **SynchronizationService** (`lib/Service/SynchronizationService.php`): Full bidirectional sync framework with contracts, logs, and mapping. Supports sync between external sources and OpenRegister objects. This is directly relevant for outbound/inbound sync.
+- **AuthenticationService** (`lib/Service/AuthenticationService.php`): Handles OAuth2 client_credentials flow, JWT, API key, and password authentication -- all needed for iBabs/NotuBiz auth methods.
+- **EndpointService** (`lib/Service/EndpointService.php`): Manages endpoint configuration and routing with target types.
+- **JobService** (`lib/Service/JobService.php`): Background job execution for polling and retry logic.
+- **EventService** (`lib/Service/EventService.php`): Event dispatching for zaak status change triggers.
+
+### Not implemented
+- iBabs REST API client (document upload, agendapunt creation, besluit retrieval)
+- NotuBiz API client (vergaderstuk upload, agendapunt, besluit retrieval)
+- Bidirectional sync triggers (status-based outbound push, polling/webhook inbound)
+- Sync record storage (the data model described in the spec)
+- Conflict detection
+- Retry with configurable backoff
+- Parafering workflow (entirely within Procest scope)
+- Document flow with PDF conversion via Docudesk
+- Geheimhouding flag mapping
+- RIS-specific source type registration
+
+## Standards & References
+
+- **iBabs REST API**: Proprietary API by iBabs BV (now part of Meeting.nl). Documented at developer.ibabs.eu. Uses API key authentication, REST/JSON format.
+- **NotuBiz API**: Proprietary API by NotuBiz BV (part of CMSolutions). Supports OAuth2 and API key auth. REST/JSON format.
+- **Gemeentelijke besluitvormingsprocessen**: The B&W-besluitvorming workflow is standardized across Dutch municipalities: steller > adviseur > parafeerder > portefeuillehouder > secretariaat > collegevergadering > besluit.
+- **GEMMA procesarchitectuur**: The reference architecture for Dutch municipal decision-making processes.
+- **Archiefwet**: Dutch archiving law -- besluitenlijsten and vergaderstukken must be archived according to selectielijsten.
+- **ORI (Open Raadsinformatie)**: Open data standard for Dutch council information, maintained by VNG.
diff --git a/openspec/changes/ibabs-notubiz-connector/tasks.md b/openspec/changes/ibabs-notubiz-connector/tasks.md
new file mode 100644
index 000000000..25c859ffa
--- /dev/null
+++ b/openspec/changes/ibabs-notubiz-connector/tasks.md
@@ -0,0 +1,87 @@
+# Tasks: ibabs-notubiz-connector
+
+## Task 1: iBabs REST API Connection (REQ-RIS-001)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-001`
+- **files**: `lib/Service/IBabsConnectorService.php`
+- **acceptance_criteria**:
+ - GIVEN iBabs source configured WHEN test connection clicked THEN API connectivity verified
+ - GIVEN API key expired WHEN call returns 401 THEN Source status set to error with notification
+- [ ] Implement IBabsConnectorService with connection management
+- [ ] Add test connection method
+- [ ] Test
+
+## Task 2: Collegevoorstel Push to iBabs (REQ-RIS-002)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-002`
+- **files**: `lib/Service/IBabsConnectorService.php`
+- **acceptance_criteria**:
+ - GIVEN zaak with voorstel WHEN pushed to iBabs THEN document uploaded with metadata
+ - GIVEN DOCX document WHEN pushed THEN Docudesk converts to PDF first
+- [ ] Implement document push via CallService
+- [ ] Add PDF conversion via Docudesk
+- [ ] Add geheimhouding flag support
+- [ ] Test
+
+## Task 3: Agendapunt Creation (REQ-RIS-003)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-003`
+- **files**: `lib/Service/IBabsConnectorService.php`
+- **acceptance_criteria**:
+ - GIVEN voorstel pushed WHEN auto-select configured THEN next vergadering selected
+ - GIVEN no upcoming vergadering WHEN creating agendapunt THEN sync set to pending
+- [ ] Implement agendapunt creation
+- [ ] Add auto-select vergadering logic
+- [ ] Test
+
+## Task 4: Besluit Retrieval from iBabs (REQ-RIS-004)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-004`
+- **files**: `lib/Service/IBabsConnectorService.php`, `lib/Cron/RISPollJob.php`
+- **acceptance_criteria**:
+ - GIVEN voorstel aangenomen WHEN poll retrieves besluit THEN zaak status updated
+ - GIVEN voorstel verworpen WHEN retrieved THEN behandelaar notified
+- [ ] Implement besluit polling cron job
+- [ ] Add status mapping (aangenomen/verworpen/aangehouden/doorgeschoven)
+- [ ] Test
+
+## Task 5: Besluitenlijst Retrieval (REQ-RIS-005)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-005`
+- **files**: `lib/Service/IBabsConnectorService.php`
+- **acceptance_criteria**:
+ - GIVEN vergadering concluded WHEN poll finds besluitenlijst THEN PDF stored in Nextcloud Files
+- [ ] Implement besluitenlijst download
+- [ ] Add file storage in /RIS-besluiten/ folder structure
+- [ ] Test
+
+## Task 6: NotuBiz API Connection (REQ-RIS-020)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-020`
+- **files**: `lib/Service/NotuBizConnectorService.php`
+- **acceptance_criteria**:
+ - GIVEN NotuBiz source with OAuth2 WHEN configured THEN auto token refresh works
+- [ ] Implement NotuBizConnectorService
+- [ ] Add OAuth2 token management via AuthenticationService
+- [ ] Test
+
+## Task 7: Vergaderstuk Push to NotuBiz (REQ-RIS-021)
+- **spec_ref**: `specs/ibabs-notubiz-connector/spec.md#req-ris-021`
+- **files**: `lib/Service/NotuBizConnectorService.php`
+- **acceptance_criteria**:
+ - GIVEN zaak requires raadsbehandeling WHEN pushed THEN correct vergadertype used
+- [ ] Implement vergaderstuk push
+- [ ] Test
+
+## Task 8: Unit Tests
+- **spec_ref**: ADR-009
+- **files**: `tests/Unit/Service/IBabsConnectorServiceTest.php`, `tests/Unit/Service/NotuBizConnectorServiceTest.php`
+- [ ] Write connection management tests
+- [ ] Write document push tests
+- [ ] Write besluit retrieval tests
+
+## Task 9: API Documentation
+- **spec_ref**: ADR-010
+- **files**: `docs/features/ibabs-notubiz-connector.md`
+- [ ] Write configuration guide
+- [ ] Write workflow documentation
+
+## Verification
+- [ ] All tasks checked off
+- [ ] Unit tests pass
+- [ ] iBabs integration verified with test API
+- [ ] NotuBiz integration verified with test API
diff --git a/openspec/changes/stuf-adapter/.openspec.yaml b/openspec/changes/stuf-adapter/.openspec.yaml
new file mode 100644
index 000000000..b4bbeb946
--- /dev/null
+++ b/openspec/changes/stuf-adapter/.openspec.yaml
@@ -0,0 +1 @@
+schema: spec-driven
diff --git a/openspec/changes/stuf-adapter/design.md b/openspec/changes/stuf-adapter/design.md
new file mode 100644
index 000000000..6c9a88115
--- /dev/null
+++ b/openspec/changes/stuf-adapter/design.md
@@ -0,0 +1,41 @@
+# Design: StUF Adapter
+
+## Architecture
+
+The StUF adapter provides bidirectional translation between REST/ZGW APIs and legacy StUF-BG/StUF-ZKN SOAP interfaces.
+
+### New Services
+- **StUFBGService** (`lib/Service/StUFBGService.php`): Handles StUF-BG person and address queries (npsLv01/npsLa01, adrLv01/adrLa01)
+- **StUFZKNService** (`lib/Service/StUFZKNService.php`): Handles StUF-ZKN zaak operations (zakLk01/zakLv01)
+- **StUFXMLBuilder** (`lib/Service/StUFXMLBuilder.php`): Builds StUF-compliant XML responses with proper namespaces and stuurgegevens
+- **StUFFieldMapper** (`lib/Service/StUFFieldMapper.php`): Maps StUF fields to/from OpenRegister object properties
+
+### Integration with Existing Infrastructure
+- **SOAPService**: Already exists for SOAP communication; StUF outbound queries leverage it
+- **CallService**: Routes SOAP requests through existing logging and certificate handling
+- **EndpointService**: StUF inbound endpoints registered as OpenConnector endpoints
+- **AuthenticationService**: Extended with WS-Security UsernameToken support
+
+### Inbound Flow (legacy apps query OpenConnector)
+1. SOAP request arrives at StUF endpoint
+2. EndpointService routes to StUFBGService/StUFZKNService
+3. Service parses SOAP XML, extracts query parameters
+4. OpenRegister is queried for matching objects
+5. Results are mapped via StUFFieldMapper and built into StUF XML response
+
+### Outbound Flow (OpenConnector queries legacy StUF sources)
+1. Workflow triggers StUF query via SynchronizationService
+2. SOAPService sends npsLv01/zakLv01 SOAP request
+3. Response parsed by StUFBGService/StUFZKNService
+4. Mapped data stored in OpenRegister
+
+## Dependencies
+- **SOAPService**: Existing SOAP client infrastructure
+- **OpenRegister**: Object storage for person/address/zaak data
+- **EndpointService**: Endpoint routing
+- **php-soap extension**: Required for SOAP handling
+
+## Risks
+- StUF XML namespace handling is complex and version-dependent
+- StUF-BG 3.10 vs StUF-ZKN 3.10e have subtle schema differences
+- WS-Security PasswordDigest implementation requires careful crypto handling
diff --git a/openspec/changes/stuf-adapter/proposal.md b/openspec/changes/stuf-adapter/proposal.md
new file mode 100644
index 000000000..c072b0037
--- /dev/null
+++ b/openspec/changes/stuf-adapter/proposal.md
@@ -0,0 +1,12 @@
+# StUF Adapter
+
+## Summary
+This change implements the stuf-adapter feature as specified in the delta spec.
+
+## Motivation
+Required by Dutch government tenders for integration with external systems.
+
+## Scope
+- New adapter/connector implementation
+- API endpoints
+- Configuration UI
diff --git a/openspec/changes/stuf-adapter/specs/stuf-adapter/spec.md b/openspec/changes/stuf-adapter/specs/stuf-adapter/spec.md
new file mode 100644
index 000000000..1cfef2829
--- /dev/null
+++ b/openspec/changes/stuf-adapter/specs/stuf-adapter/spec.md
@@ -0,0 +1,314 @@
+---
+status: proposed
+---
+
+# StUF Adapter
+
+## Purpose
+
+Provides bidirectional translation between modern REST/ZGW APIs and legacy StUF-BG (personen/adressen) and StUF-ZKN (zaken/documenten) SOAP-based interfaces. 79% of Dutch government tenders still require StUF support despite the migration to ZGW APIs. The adapter enables OpenRegister objects to be exposed as StUF services (for legacy consumers) and allows OpenConnector to query legacy StUF sources (for data import). Supports StUF-BG 3.10 and StUF-ZKN 3.10/3.10e.
+
+## Requirements
+
+### REQ-STUF-001: StUF-BG Inbound Person Query (npsLv01/npsLa01)
+
+The adapter MUST expose a SOAP endpoint that accepts StUF-BG 3.10 `npsLv01` (persoon opvragen) requests and returns `npsLa01` (persoon antwoord) responses with correctly formed StUF-BG XML. The endpoint is registered as an OpenConnector Endpoint entity of type "source" with targetType pointing to a SOAP handler. Incoming SOAP XML is parsed by a raw POST handler that extracts the SOAP action and delegates to the appropriate StUF message handler.
+
+**Scenarios:**
+
+1. **GIVEN** the StUF-BG endpoint is registered in OpenConnector **AND** a legacy application sends a `npsLv01` SOAP request with BSN `999993653` **WHEN** the adapter receives the request **THEN** it extracts the BSN from the StUF-BG XML, queries OpenRegister for the matching person object (using the BRP schema), and returns a `npsLa01` SOAP response with the person's geslachtsnaam, voorvoegsel, voornamen, geboortedatum, and verblijfsadres.
+
+2. **GIVEN** a `npsLv01` request queries by geslachtsnaam "Moulin" (partial match) **WHEN** the adapter searches OpenRegister **THEN** it returns all matching persons in a multi-record `npsLa01` response, respecting the `maximumAantal` parameter if specified.
+
+3. **GIVEN** a `npsLv01` request includes a `scope` element requesting only BSN and naam fields **WHEN** the adapter builds the response **THEN** only the requested fields are included in the `npsLa01` response, with unrequested fields omitted (not set to `geenWaarde`).
+
+4. **GIVEN** a `npsLv01` request with a BSN that does not exist in OpenRegister **WHEN** the adapter searches **THEN** it returns an empty `npsLa01` response (zero records) with correct stuurgegevens but no error.
+
+5. **GIVEN** a `npsLv01` request has malformed XML or missing required StUF elements **WHEN** the adapter validates the request **THEN** it returns a StUF `Fo01` fault message with diagnostic information including the specific validation error.
+
+### REQ-STUF-002: StUF-BG Field Mapping
+
+The adapter MUST map StUF-BG person fields to OpenRegister object properties using configurable mapping objects. The default mapping covers the core BRP fields: `bsn` -> `burgerservicenummer`, `geslachtsnaam`, `voorvoegsel`, `voornamen`, `geboortedatum`, `verblijfsadres` (with sub-fields straatnaam, huisnummer, postcode, woonplaats). Mappings are stored as OpenRegister objects in a dedicated "stuf-mappings" schema.
+
+**Scenarios:**
+
+1. **GIVEN** the default BRP field mapping is loaded **AND** an OpenRegister person object has `{"burgerservicenummer": "999993653", "geslachtsnaam": "Moulin", "voornamen": "Suzanne"}` **WHEN** the adapter builds a `npsLa01` response **THEN** the XML contains `999993653`, `Moulin`, and `Suzanne` in the correct StUF-BG namespace.
+
+2. **GIVEN** a municipality uses a custom field name "achternaam" instead of "geslachtsnaam" in their OpenRegister schema **WHEN** they update the StUF-BG mapping to map `geslachtsnaam` -> `achternaam` **THEN** the adapter uses the custom mapping for all subsequent `npsLa01` responses.
+
+3. **GIVEN** an OpenRegister person has a geboortedatum stored in ISO 8601 format ("1990-05-15") **WHEN** the adapter maps to StUF-BG **THEN** the date is transformed to StUF format `YYYYMMDD` ("19900515") using the date transformation rule.
+
+4. **GIVEN** a person's verblijfsadres is stored as a nested object in OpenRegister **WHEN** the adapter maps to StUF-BG **THEN** the nested fields are correctly placed into the StUF-BG `verblijfsadres` element hierarchy.
+
+### REQ-STUF-004: StUF-BG Address Query (adrLv01/adrLa01)
+
+The adapter MUST expose `adrLv01` (adres opvragen) and `adrLa01` (adres antwoord) for BAG-adressen. Address queries search the BAG register in OpenRegister and return nummeraanduiding data in StUF-BG format.
+
+**Scenarios:**
+
+1. **GIVEN** a legacy application queries an address by postcode "1234AB" and huisnummer "10" **WHEN** the adapter receives the `adrLv01` request **THEN** it queries the BAG schema in OpenRegister and returns an `adrLa01` response with the matching nummeraanduiding(en).
+
+2. **GIVEN** the BAG register contains 3 addresses matching postcode "1234AB" **WHEN** the query does not specify huisnummer **THEN** all 3 addresses are returned in the `adrLa01` response.
+
+3. **GIVEN** the BAG register has no matching address **WHEN** the query is processed **THEN** an empty `adrLa01` response is returned.
+
+### REQ-STUF-010: StUF-BG Outbound Query (OpenConnector Queries Legacy Source)
+
+The adapter MUST support querying external StUF-BG services via SOAP and mapping the responses to OpenRegister objects. Outbound queries use the existing SOAPService (`lib/Service/SOAPService.php`) to send `npsLv01` SOAP requests and parse `npsLa01` responses into JSON objects. The parsed data is stored in OpenRegister via the SynchronizationService.
+
+**Scenarios:**
+
+1. **GIVEN** a StUF-BG source is configured in OpenConnector with WSDL URL and endpoint **WHEN** a workflow requests person data by BSN **THEN** CallService routes the request to SOAPService, which sends a `npsLv01` SOAP request, parses the `npsLa01` XML response, and returns a JSON object with mapped person fields.
+
+2. **GIVEN** the external StUF-BG service returns a `Fo01` fault message **WHEN** the adapter processes the response **THEN** the SOAP fault is mapped to a CallLog entry with HTTP-equivalent status (e.g., Fo01 "not found" -> 404, Fo01 "unauthorized" -> 401) and descriptive error details.
+
+3. **GIVEN** the external StUF-BG service returns multiple person records **WHEN** the adapter processes the response **THEN** each person is extracted as a separate JSON object and can be stored as individual OpenRegister objects via SynchronizationService.
+
+4. **GIVEN** a StUF-BG synchronization is configured to pull person data nightly **WHEN** the sync job runs **THEN** SynchronizationService queries the external StUF source, maps responses to OpenRegister objects using the configured field mapping, and creates/updates records with change detection.
+
+### REQ-STUF-011: PKIoverheid mTLS Authentication
+
+The adapter MUST support certificate-based mutual TLS authentication for StUF endpoints. This leverages the existing CallService certificate handling: `getCertificate()` writes client certificates and SSL keys to temporary files, the SOAP/HTTP request uses them for mTLS, and `removeFiles()` cleans up after the request.
+
+**Scenarios:**
+
+1. **GIVEN** a StUF source is configured with a PKIoverheid client certificate and private key **WHEN** the adapter makes a SOAP request **THEN** CallService writes the certificate to a temporary file, passes it to the Guzzle/SOAPService client for mTLS, and removes the file after the response.
+
+2. **GIVEN** the PKIoverheid certificate is stored as a PEM string in the Source configuration **AND** the PEM contains escaped newlines (`\n`) **WHEN** CallService writes the certificate **THEN** escaped newlines are converted to actual newlines (existing `writeFile()` behavior) ensuring the certificate is valid.
+
+3. **GIVEN** the certificate has expired **WHEN** the adapter attempts a connection **THEN** the mTLS handshake fails, a descriptive error is logged in CallLog, and the Source status is updated to indicate certificate expiry.
+
+### REQ-STUF-012: WS-Security UsernameToken Authentication
+
+The adapter MUST support WS-Security UsernameToken authentication for StUF endpoints. This adds a SOAP header with username and password (optionally with nonce and timestamp) to outbound SOAP requests. The authentication method is configured as a new auth type in AuthenticationService.
+
+**Scenarios:**
+
+1. **GIVEN** a StUF source is configured with WS-Security authentication (username + password) **WHEN** the adapter sends a SOAP request **THEN** the SOAP envelope includes a `wsse:Security` header with `wsse:UsernameToken`, `wsse:Username`, and `wsse:Password` elements.
+
+2. **GIVEN** WS-Security with PasswordDigest is configured **WHEN** the adapter builds the security header **THEN** the password is hashed as `Base64(SHA1(Nonce + Created + Password))` per the WS-Security UsernameToken 1.0 profile.
+
+3. **GIVEN** WS-Security with PasswordText is configured **WHEN** the adapter builds the security header **THEN** the password is included as plaintext in the UsernameToken (suitable only over TLS).
+
+### REQ-STUF-020: StUF-ZKN Inbound Zaak Management (zakLk01/zakLv01)
+
+The adapter MUST expose SOAP endpoints for StUF-ZKN 3.10 zaak operations: `zakLk01` (zaak aanmaken/bijwerken) for creating or updating zaken, and `zakLv01`/`zakLa01` (zaak opvragen) for retrieving zaak data including related documenten and statussen. The adapter maps StUF-ZKN zaak fields to Procest zaak objects in OpenRegister.
+
+**Scenarios:**
+
+1. **GIVEN** a legacy formulierensysteem sends a StUF-ZKN `zakLk01` message to create a new zaak **WHEN** the adapter receives the SOAP request **THEN** it maps the StUF fields (zaakidentificatie, omschrijving, startdatum, zaaktype, status) to OpenRegister properties, creates the zaak object in Procest's register, and returns a `Bv03` bevestiging message with the zaakidentificatie.
+
+2. **GIVEN** a legacy system sends a `zakLk01` with an existing zaakidentificatie **WHEN** the adapter receives the update message **THEN** it finds the existing zaak in OpenRegister, updates the modified fields, and returns a `Bv03` bevestiging.
+
+3. **GIVEN** a legacy application sends a `zakLv01` request for zaak "ZAAK-2024-001" **WHEN** the adapter processes the query **THEN** it retrieves the zaak from OpenRegister with its statussen and linked documenten, and returns a `zakLa01` response with the complete zaak data in StUF-ZKN format.
+
+4. **GIVEN** the `zakLk01` message contains invalid data (e.g., missing required zaaktype) **WHEN** validation fails **THEN** the adapter returns a `Fo03` foutmelding with the specific validation error.
+
+5. **GIVEN** a `zakLv01` request queries by zaaktype and date range **WHEN** the adapter processes the query **THEN** it filters OpenRegister objects by the zaaktype and startdatum range and returns matching zaken in the `zakLa01` response.
+
+### REQ-STUF-022: StUF-ZKN Document Linking (edcLk01)
+
+The adapter MUST support `edcLk01` (document koppelen aan zaak) messages for document management via StUF-ZKN. This builds on the existing edcLk01 handling in SOAPService which already detects `body['edcLk01']['object']['inhoud']` and base64-decodes document content.
+
+**Scenarios:**
+
+1. **GIVEN** a legacy DMS sends an `edcLk01` message with a base64-encoded PDF document **WHEN** the adapter processes the message **THEN** the document content is base64-decoded (using the existing SOAPService logic at lines 224-232), stored in Nextcloud Files, and linked to the referenced zaak.
+
+2. **GIVEN** an `edcLk01` message contains document metadata (titel, auteur, creatiedatum, vertrouwelijkheidaanduiding) **WHEN** the adapter processes the message **THEN** the metadata is stored alongside the document in Nextcloud Files and linked as zaak-document properties.
+
+3. **GIVEN** an `edcLk01` references a zaak that does not exist **WHEN** the adapter validates the reference **THEN** it returns a `Fo03` fault message indicating the zaak was not found.
+
+### REQ-STUF-030: StUF-ZKN Outbound Zaak Query
+
+The adapter MUST support querying external StUF-ZKN services for zaak data and mapping responses to OpenRegister objects. This enables data import from legacy zaaksystemen during migration. The adapter sends `zakLv01` SOAP requests and parses `zakLa01` responses.
+
+**Scenarios:**
+
+1. **GIVEN** a legacy zaaksysteem is configured as a StUF-ZKN source **WHEN** a migration workflow queries for all zaken of type "Omgevingsvergunning" **THEN** the adapter sends a `zakLv01` with zaaktype filter, parses the `zakLa01` response, and maps each zaak to an OpenRegister object.
+
+2. **GIVEN** the legacy system supports `genereerZaakIdentificatie` **WHEN** a workflow needs to create a zaak in the legacy system **THEN** the adapter first requests a zaak ID via `genereerZaakIdentificatie` and uses it in the subsequent `zakLk01`.
+
+3. **GIVEN** the `zakLa01` response includes linked document references **WHEN** the adapter processes the zaak data **THEN** document references are stored as zaak-eigenschappen with their StUF document IDs, enabling subsequent `edcLv01` retrieval.
+
+### REQ-STUF-040: WSDL and XSD Bundling
+
+The adapter MUST bundle WSDL files for StUF-BG 3.10 and StUF-ZKN 3.10 with the app. The WSDL files are used both for outbound SOAP client setup (SOAPService engine configuration) and for inbound request validation. The XSD schemas are used for XML validation of outbound messages.
+
+**Scenarios:**
+
+1. **GIVEN** the adapter is installed **WHEN** a developer inspects the app directory **THEN** WSDL files are present at `lib/StUF/wsdl/stuf-bg-3.10.wsdl` and `lib/StUF/wsdl/stuf-zkn-3.10.wsdl` along with their XSD dependencies.
+
+2. **GIVEN** a StUF-BG source is configured **WHEN** SOAPService.setupEngine() initializes the SOAP client **THEN** the bundled WSDL is used if no external WSDL URL is specified in the Source configuration.
+
+3. **GIVEN** a municipality uses StUF-ZKN 3.10e (extended version) **WHEN** they configure the source **THEN** they can specify the extended WSDL URL in the Source configuration, overriding the bundled 3.10 version.
+
+### REQ-STUF-041: XML Namespace Handling
+
+The adapter MUST correctly handle XML namespaces for `StUF`, `BG`, `ZKN`, `xsi`, and `gml` in all generated SOAP messages. Namespace prefixes and URIs must match the StUF-BG and StUF-ZKN schema definitions exactly, as legacy systems are strict about namespace validation.
+
+**Scenarios:**
+
+1. **GIVEN** the adapter generates a `npsLa01` response **WHEN** the XML is built **THEN** it includes the correct namespace declarations: `xmlns:StUF="http://www.egem.nl/StUF/StUF0301"`, `xmlns:BG="http://www.egem.nl/StUF/sector/bg/0310"`, etc.
+
+2. **GIVEN** a legacy consumer validates responses against the StUF XSD **WHEN** the adapter sends a response **THEN** the XML validates against the schema with zero namespace errors.
+
+3. **GIVEN** the adapter processes an incoming message with a non-standard namespace prefix **WHEN** it parses the XML **THEN** it resolves elements by namespace URI (not prefix) ensuring interoperability with different StUF implementations.
+
+### REQ-STUF-042: Stuurgegevens Population
+
+The adapter MUST correctly populate StUF `stuurgegevens` on all outbound messages. Stuurgegevens include: `zender` (with organisatie code and applicatie naam), `ontvanger` (from the configured target), `referentienummer` (unique message ID), `tijdstipBericht` (timestamp), and `crossRefnummer` (referencing the inbound message for responses).
+
+**Scenarios:**
+
+1. **GIVEN** the adapter sends a `npsLa01` response to an inbound `npsLv01` request **WHEN** stuurgegevens are populated **THEN** `zender` contains the adapter's OIN and application name, `ontvanger` contains the requesting system's code (from the request's zender), `referentienummer` is a unique UUID, `tijdstipBericht` is the current datetime in StUF format, and `crossRefnummer` is the request's referentienummer.
+
+2. **GIVEN** the adapter sends an outbound `npsLv01` query to a BRP system **WHEN** stuurgegevens are populated **THEN** `zender` contains the municipality's OIN (from Source configuration) and `ontvanger` contains the BRP system's code.
+
+3. **GIVEN** the configured zender code is missing in the Source settings **WHEN** the adapter attempts to send a message **THEN** it returns an error indicating that StUF stuurgegevens configuration is incomplete, preventing malformed messages.
+
+### REQ-STUF-043: noValue Attribute Handling
+
+The adapter MUST handle StUF `noValue` attribute semantics correctly. The StUF standard defines four noValue indicators: `geenWaarde` (empty by design), `nietOndersteund` (field not supported), `waardeOnbekend` (value unknown), and `vastgesteldOnbekend` (officially determined as unknown). These are represented as `StUF:noValue` attributes on XML elements.
+
+**Scenarios:**
+
+1. **GIVEN** an OpenRegister person object has an explicit null value for `voorvoegsel` (not applicable for this person) **WHEN** the adapter generates StUF-BG XML **THEN** the `voorvoegsel` element includes `StUF:noValue="geenWaarde"` with empty content.
+
+2. **GIVEN** the adapter receives a StUF-BG response with `geboortedatum StUF:noValue="waardeOnbekend"` **WHEN** it maps to an OpenRegister object **THEN** the field is stored as null with a metadata annotation indicating "waardeOnbekend".
+
+3. **GIVEN** the adapter receives a StUF field with `noValue="nietOndersteund"` **WHEN** mapping to OpenRegister **THEN** the field is omitted from the stored object (not supported by the source system).
+
+4. **GIVEN** the scope element in a `npsLv01` request does not include a specific field **WHEN** building the response **THEN** that field is excluded entirely from the XML (different from `noValue` which explicitly communicates absence).
+
+### REQ-STUF-050: Configurable Field Mapping
+
+The adapter MUST provide configurable field mappings between StUF XML paths and OpenRegister object properties, stored as mapping objects in OpenRegister. Default mappings for BRP-personen (StUF-BG) and ZGW-zaken (StUF-ZKN) are pre-seeded. Custom mappings can be added for municipality-specific StUF extensions.
+
+**Scenarios:**
+
+1. **GIVEN** the default StUF-BG person mapping is loaded **WHEN** a developer inspects the mapping in OpenRegister **THEN** it contains entries like `{"stufPath": "inp.bsn", "registerProperty": "burgerservicenummer", "direction": "bidirectional", "transformation": null}` for each mapped field.
+
+2. **GIVEN** a municipality has a custom StUF extension adding a "klantbeeld-id" field **WHEN** the admin adds a custom mapping entry **THEN** the adapter includes this field in both inbound and outbound StUF messages.
+
+3. **GIVEN** the mapping includes a date transformation (StUF `YYYYMMDD` to ISO 8601) **WHEN** the adapter maps a geboortedatum field **THEN** the value is transformed in both directions: "19900515" (StUF) <-> "1990-05-15" (OpenRegister).
+
+4. **GIVEN** a mapping entry has direction "inbound-only" **WHEN** the adapter processes an inbound StUF message **THEN** the field is mapped from StUF to OpenRegister but is NOT included when generating outbound StUF responses.
+
+5. **GIVEN** the default ZGW-zaak mapping is loaded **WHEN** a `zakLk01` message arrives **THEN** StUF-ZKN fields (zaakidentificatie, omschrijving, startdatum, einddatum, zaaktype, status) are mapped to their OpenRegister equivalents using the pre-seeded mapping.
+
+### REQ-STUF-053: Value Transformations
+
+The adapter MUST support value transformations in field mappings: date format conversion (StUF `YYYYMMDD` to ISO 8601), code list lookups (e.g., geslachtsaanduiding "M"/"V"/"O" to full text), and string concatenation (combining voorvoegsel + geslachtsnaam).
+
+**Scenarios:**
+
+1. **GIVEN** a date transformation is configured for geboortedatum **WHEN** the adapter maps StUF "19900515" to OpenRegister **THEN** the stored value is "1990-05-15T00:00:00Z" in ISO 8601 format.
+
+2. **GIVEN** a code list transformation maps geslachtsaanduiding **WHEN** StUF sends "V" **THEN** the OpenRegister object stores "vrouw", and vice versa for outbound messages.
+
+3. **GIVEN** a concatenation transformation combines voorvoegsel and geslachtsnaam **WHEN** the adapter maps to a "volledige_naam" field **THEN** it produces "van Moulin" from voorvoegsel "van" and geslachtsnaam "Moulin", handling missing voorvoegsel gracefully.
+
+### REQ-STUF-060: OpenConnector Source Registration
+
+The adapter MUST be registered as an OpenConnector source type, configurable via the connector UI. Connection settings include endpoint URL, authentication method (mTLS or WS-Security), certificates, and zender/ontvanger codes. The source supports health checks validating connectivity and authentication against the StUF endpoint.
+
+**Scenarios:**
+
+1. **GIVEN** an administrator creates a new StUF source **WHEN** they select type "soap" and configure WSDL URL, mTLS certificate, and stuurgegevens codes **THEN** the Source entity is created with StUF-specific configuration in the `configuration` JSON field.
+
+2. **GIVEN** a StUF source is configured **WHEN** the administrator tests connectivity **THEN** the adapter sends a minimal SOAP request (e.g., a ping or capability query) and reports success/failure with diagnostic details.
+
+3. **GIVEN** a StUF source is configured with WS-Security **WHEN** an n8n workflow queries the source **THEN** the WS-Security headers are automatically added to the SOAP envelope by AuthenticationService.
+
+4. **GIVEN** a StUF source health check detects an SSL handshake failure **WHEN** the health check result is displayed **THEN** it includes the specific SSL error (e.g., certificate expired, CN mismatch) to help diagnose the issue.
+
+## Data Model
+
+### StUF Field Mapping (stored in OpenRegister)
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| stufStandard | string (enum) | Yes | `StUF-BG` or `StUF-ZKN` |
+| stufVersion | string | Yes | e.g., "3.10", "3.10e" |
+| stufPath | string | Yes | XPath-like path in StUF XML (e.g., `inp.bsn`, `zakLa01.object.omschrijving`) |
+| registerSchema | string | Yes | Target OpenRegister schema slug |
+| registerProperty | string | Yes | Target property name in OpenRegister |
+| direction | string (enum) | Yes | `bidirectional`, `inbound-only`, `outbound-only` |
+| transformation | string (enum) | No | `date-stuf-to-iso`, `date-iso-to-stuf`, `code-list`, `concatenation`, `custom` |
+| transformationConfig | object | No | Configuration for the transformation (e.g., code list values, concatenation template) |
+| isActive | boolean | Yes | Whether this mapping is currently active |
+
+### Stuurgegevens Configuration (stored in Source.configuration)
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| zenderOrganisatie | string | Yes | OIN of the sending organization |
+| zenderApplicatie | string | Yes | Name of the sending application |
+| ontvangerOrganisatie | string | Yes | OIN of the target organization |
+| ontvangerApplicatie | string | No | Name of the target application |
+| stufVersion | string | Yes | StUF version ("0301" for StUF 3.01, "0310" for StUF 3.10) |
+
+## Dependencies
+
+- **OpenConnector**: Source/endpoint registration and connection management (Source entity, CallService, SOAPService, EndpointService, AuthenticationService)
+- **OpenRegister**: Object storage, field mapping configuration, and BRP/BAG/zaak register schemas
+- **PHP SOAP extension**: SOAP client/server functionality (ext-soap)
+- **PKIoverheid root certificates**: For mTLS validation
+- **StUF-BG 3.10 and StUF-ZKN 3.10 XSD schemas**: For XML validation
+
+### Using Mock Register Data
+
+The **BRP** and **BAG** mock registers provide test data for StUF-BG person/address queries without requiring external government endpoints.
+
+**Loading the registers:**
+```bash
+# Load BRP register (35 persons, register slug: "brp", schema: "ingeschreven-persoon")
+docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/brp_register.json
+
+# Load BAG register (32 addresses, register slug: "bag", schema: "nummeraanduiding")
+docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/bag_register.json
+```
+
+**Test data for this spec's use cases:**
+- **StUF-BG npsLv01/npsLa01**: BSN `999993653` (Suzanne Moulin) -- test person query and response mapping
+- **StUF-BG adrLv01/adrLa01**: Use BAG `nummeraanduiding` records -- test address query and response mapping
+- **Field mapping validation**: BRP records include all fields from the StUF-BG mapping table (bsn, geslachtsnaam, voorvoegsel, voornamen, geboortedatum, verblijfsadres)
+
+## Current Implementation Status
+
+### Implemented (partial)
+- **SOAP engine** (`lib/Service/SOAPService.php`): A working generic SOAP client that supports WSDL-driven requests, SOAP 1.1/1.2, cookie jar management, and XML response parsing. This is the outbound foundation.
+- **edcLk01 handling** (`lib/Service/SOAPService.php`, lines 224-232): There is specific StUF-ZKN code -- the SOAPService already handles `edcLk01` document messages by detecting `body['edcLk01']['object']['inhoud']` and base64-decoding the document content. This directly relates to REQ-STUF-022.
+- **Source type `soap`** (`src/entities/source/source.types.ts`): Sources can be configured as type `soap` with WSDL URL, SOAP version, and authentication. StUF endpoints can be set up as SOAP sources today.
+- **CallService SOAP routing** (`lib/Service/CallService.php`, line ~466): When a source has type `soap`, calls are automatically routed to the SOAPService.
+- **Certificate handling** (`lib/Service/CallService.php`): `getCertificate()` writes client certificates and SSL keys to temporary files, `removeFiles()` cleans up after requests. Supports PEM format with escaped newline conversion.
+- **AuthenticationService** (`lib/Service/AuthenticationService.php`): Has JWT, OAuth2, API key, and password authentication. Can be extended for WS-Security UsernameToken.
+
+### Not implemented
+- **Inbound SOAP server** (REQ-STUF-001, REQ-STUF-020): No SOAP server endpoint exists. The current SOAPService is client-only (outbound). Exposing StUF-BG/ZKN endpoints as a SOAP server requires a raw POST handler that parses incoming SOAP XML.
+- **StUF-BG field mapping** (REQ-STUF-002): No mapping between StUF-BG XML paths and OpenRegister object properties.
+- **StUF-ZKN field mapping** (REQ-STUF-020): No mapping between StUF-ZKN zaak fields and Procest/OpenRegister objects.
+- **WSDL files bundled** (REQ-STUF-040): No StUF-BG or StUF-ZKN WSDL/XSD files are included in the codebase.
+- **XML namespace handling** (REQ-STUF-041): No StUF-specific namespace management.
+- **Stuurgegevens** (REQ-STUF-042): No automatic population of zender/ontvanger/referentienummer/tijdstip.
+- **noValue attribute handling** (REQ-STUF-043): No support for StUF noValue semantics.
+- **Configurable field mapping** (REQ-STUF-050): No mapping configuration storage in OpenRegister.
+- **Value transformations** (REQ-STUF-053): No date format, code list, or concatenation transformations.
+- **WS-Security UsernameToken** (REQ-STUF-012): Not implemented as a specific auth method.
+- **Scope filtering** (in REQ-STUF-001): Not implemented.
+- **Fault message handling** (Fo01/Fo02/Fo03, Bv03): Not implemented.
+
+### Summary
+The outbound SOAP client infrastructure is in place and already has one piece of StUF-ZKN awareness (edcLk01 document handling). The inbound SOAP server side is entirely missing and represents the larger implementation effort.
+
+## Standards & References
+
+- **StUF-BG 3.10**: Standaard Uitwisseling Formaat - Basisgegevens. SOAP-based standard for person and address data exchange in Dutch government. Maintained by VNG Realisatie.
+- **StUF-ZKN 3.10 / 3.10e**: Standaard Uitwisseling Formaat - Zaak-/Documentservices. SOAP-based standard for case and document management exchange. The "e" extension adds extra message types.
+- **ZGW APIs (Zaakgericht Werken)**: The modern REST-based successor to StUF-ZKN. This adapter bridges the gap between legacy StUF and modern ZGW.
+- **WS-Security**: OASIS standard for SOAP message security. UsernameToken profile is commonly used by Dutch government StUF endpoints.
+- **PKIoverheid**: Dutch government PKI for mTLS authentication. Required for most production StUF endpoints.
+- **GEMMA**: Reference architecture for Dutch municipalities -- defines the role of StUF in the information architecture.
+- **BRP (Basisregistratie Personen)**: National person registry, accessed via StUF-BG by municipalities.
+- **RGBZ (Referentiemodel Gemeentelijke Basisgegevens Zaken)**: The information model underlying StUF-ZKN.
+- **CMIS**: Content Management Interoperability Services -- sometimes used alongside StUF-ZKN for document management.
diff --git a/openspec/changes/stuf-adapter/tasks.md b/openspec/changes/stuf-adapter/tasks.md
new file mode 100644
index 000000000..f1daa8722
--- /dev/null
+++ b/openspec/changes/stuf-adapter/tasks.md
@@ -0,0 +1,106 @@
+# Tasks: stuf-adapter
+
+## Task 1: StUF-BG Inbound Person Query (REQ-STUF-001)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-001`
+- **files**: `lib/Service/StUFBGService.php`
+- **acceptance_criteria**:
+ - GIVEN npsLv01 request with BSN WHEN processed THEN npsLa01 response returned with person data
+ - GIVEN BSN not found WHEN searched THEN empty npsLa01 returned (no error)
+ - GIVEN malformed XML WHEN received THEN Fo01 fault returned
+- [ ] Implement StUFBGService with npsLv01/npsLa01 handling
+- [ ] Add SOAP XML parsing for person queries
+- [ ] Add scope field filtering
+- [ ] Test
+
+## Task 2: StUF-BG Field Mapping (REQ-STUF-002)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-002`
+- **files**: `lib/Service/StUFFieldMapper.php`
+- **acceptance_criteria**:
+ - GIVEN default BRP mapping WHEN person data mapped THEN StUF-BG XML fields correct
+ - GIVEN custom field mapping WHEN configured THEN custom mapping used
+ - GIVEN ISO date WHEN mapped THEN converted to YYYYMMDD format
+- [ ] Implement StUFFieldMapper with configurable mappings
+- [ ] Add date format transformation
+- [ ] Add nested object mapping (verblijfsadres)
+- [ ] Test
+
+## Task 3: StUF-BG Address Query (REQ-STUF-004)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-004`
+- **files**: `lib/Service/StUFBGService.php`
+- **acceptance_criteria**:
+ - GIVEN adrLv01 with postcode WHEN searched THEN matching addresses returned
+ - GIVEN no match WHEN searched THEN empty adrLa01 returned
+- [ ] Implement adrLv01/adrLa01 handling
+- [ ] Test
+
+## Task 4: StUF XML Builder (REQ-STUF-001, REQ-STUF-002)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-001`
+- **files**: `lib/Service/StUFXMLBuilder.php`
+- **acceptance_criteria**:
+ - GIVEN person data WHEN building npsLa01 THEN valid StUF-BG 3.10 XML produced
+ - GIVEN error condition WHEN building Fo01 THEN valid SOAP fault produced
+- [ ] Implement StUFXMLBuilder for response generation
+- [ ] Add namespace management for StUF-BG 3.10
+- [ ] Add Fo01 fault message generation
+- [ ] Test
+
+## Task 5: StUF-BG Outbound Query (REQ-STUF-010)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-010`
+- **files**: `lib/Service/StUFBGService.php`
+- **acceptance_criteria**:
+ - GIVEN StUF source configured WHEN BSN queried THEN npsLv01 sent via SOAPService
+ - GIVEN Fo01 fault returned WHEN processed THEN mapped to CallLog entry
+- [ ] Implement outbound npsLv01 query via SOAPService
+- [ ] Add response parsing
+- [ ] Add SynchronizationService integration
+- [ ] Test
+
+## Task 6: PKIoverheid mTLS Authentication (REQ-STUF-011)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-011`
+- **files**: `lib/Service/CallService.php`
+- **acceptance_criteria**:
+ - GIVEN PKIoverheid certificate in source WHEN SOAP request made THEN mTLS used
+ - GIVEN expired certificate WHEN connection attempted THEN error logged
+- [ ] Verify existing CallService certificate handling works for StUF
+- [ ] Test
+
+## Task 7: WS-Security UsernameToken (REQ-STUF-012)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-012`
+- **files**: `lib/Service/AuthenticationService.php`, `lib/Service/StUFBGService.php`
+- **acceptance_criteria**:
+ - GIVEN WS-Security configured WHEN SOAP sent THEN wsse:Security header included
+ - GIVEN PasswordDigest mode WHEN building header THEN Base64(SHA1(Nonce+Created+Password))
+- [ ] Add WS-Security UsernameToken auth type
+- [ ] Implement PasswordDigest hashing
+- [ ] Test
+
+## Task 8: StUF-ZKN Inbound Zaak Management (REQ-STUF-020)
+- **spec_ref**: `specs/stuf-adapter/spec.md#req-stuf-020`
+- **files**: `lib/Service/StUFZKNService.php`
+- **acceptance_criteria**:
+ - GIVEN zakLk01 message WHEN processed THEN zaak created in OpenRegister
+ - GIVEN zakLv01 query WHEN processed THEN zaak data returned in StUF-ZKN format
+- [ ] Implement StUFZKNService
+- [ ] Add zakLk01 (create/update) handling
+- [ ] Add zakLv01/zakLa01 (query) handling
+- [ ] Test
+
+## Task 9: Unit Tests
+- **spec_ref**: ADR-009
+- **files**: `tests/Unit/Service/StUFFieldMapperTest.php`, `tests/Unit/Service/StUFXMLBuilderTest.php`
+- [ ] Write field mapper tests (BRP mapping, date conversion, nested objects)
+- [ ] Write XML builder tests (valid StUF-BG output, Fo01 fault)
+- [ ] Write service tests (query handling, response parsing)
+
+## Task 10: API Documentation
+- **spec_ref**: ADR-010
+- **files**: `docs/features/stuf-adapter.md`
+- [ ] Write WSDL endpoint documentation
+- [ ] Write field mapping configuration guide
+- [ ] Write WS-Security setup guide
+
+## Verification
+- [ ] All tasks checked off
+- [ ] Unit tests pass
+- [ ] StUF-BG npsLv01/npsLa01 exchange works
+- [ ] StUF-ZKN zakLk01 creates zaak
diff --git a/openspec/config.yaml b/openspec/config.yaml
new file mode 100644
index 000000000..8631cec12
--- /dev/null
+++ b/openspec/config.yaml
@@ -0,0 +1,29 @@
+schema: conduction
+
+context: |
+ Project: OpenConnector
+ Repo: ConductionNL/openconnector
+ Type: Nextcloud App (PHP)
+ Description: Integration platform for connecting Nextcloud to external systems (APIs, sources, endpoints)
+ Key components: Sources, Endpoints, Jobs, Mappings, Synchronizations, Webhooks
+ Database: PostgreSQL (own tables via Nextcloud ORM)
+ Mount path: /var/www/html/custom_apps/openconnector
+
+ Shared specs: See ../openspec/specs/ for cross-project conventions
+ Project guidelines: See ../project.md for workspace-wide standards
+
+rules:
+ proposal:
+ - Reference shared nextcloud-app spec for app structure requirements
+ - Consider impact on dependent apps that use OpenConnector for integrations
+ - "ADR-011: Before implementing ANY utility (validation, formatting, parsing), search OpenRegister lib/Formats/, lib/Service/, and lib/Handler/ for existing implementations. Common duplications: BSN validation (BsnFormat.php), date formatting, slug generation, UUID handling. If found, reuse via DI or document why duplication is necessary."
+ specs:
+ - Include API specs for source/endpoint configuration
+ - Document mapping transformation rules where applicable
+ design:
+ - OpenConnector operates independently of OpenRegister (no hard dependency)
+ - If duplicating OpenRegister utilities, add @see reference to canonical source
+ - "ADR-008: Follow Controller -> Service -> Mapper layering pattern"
+ tasks:
+ - Test with external API endpoints to verify connectivity
+ - Verify mapping transformations produce expected output
diff --git a/openspec/specs/dso-omgevingsloket/spec.md b/openspec/specs/dso-omgevingsloket/spec.md
deleted file mode 100644
index 51c4d4e1d..000000000
--- a/openspec/specs/dso-omgevingsloket/spec.md
+++ /dev/null
@@ -1,231 +0,0 @@
----
-status: proposed
----
-
-# DSO / Omgevingsloket Adapter
-
-## Purpose
-
-Provides integration with the Digitaal Stelsel Omgevingswet (DSO) Landelijke Voorziening for receiving and processing vergunningaanvragen, meldingen, and informatieverzoeken from the Omgevingsloket. Required by 32% of tenders (all VTH-related). The adapter receives DSO-verzoeken via the STAM koppelvlak, parses them into zaak objects in Procest, maps activiteiten to zaaktypen, and supports samenwerking met bevoegd gezag via DSO-SWF (SamenWerkingsFunctionaliteit). Replaces the legacy OLO (Omgevingsloket Online) integration.
-
-## Requirements
-
-### DSO-LV Inbound (Receive Verzoeken)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-001 | Receive vergunningaanvragen from DSO-LV via the STAM (STAndaard Machtiging) koppelvlak REST API | MUST | Planned |
-| DSO-002 | Receive meldingen (activiteiten waarvoor geen vergunning nodig is) from DSO-LV | MUST | Planned |
-| DSO-003 | Receive informatieverzoeken and vooroverleg-aanvragen from DSO-LV | SHOULD | Planned |
-| DSO-004 | Parse the DSO-verzoek XML/JSON payload into structured data: aanvrager, locatie, activiteiten, bijlagen, projectbeschrijving | MUST | Planned |
-| DSO-005 | Download bijlagen (documenten, tekeningen, rapporten) from DSO-LV and store in Nextcloud Files | MUST | Planned |
-| DSO-006 | Validate the received verzoek against DSO-LV schema and reject malformed requests with descriptive errors | MUST | Planned |
-
-### Activiteiten-to-Zaaktype Mapping
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-010 | Map DSO activiteiten (e.g., bouwen, milieu, kappen, uitrit) to Procest zaaktypen via configurable mapping table | MUST | Planned |
-| DSO-011 | Support samenloop: one DSO-verzoek with multiple activiteiten can result in multiple zaak objects or one zaak with multiple deelzaken | MUST | Planned |
-| DSO-012 | Default mapping configuration for common Omgevingswet activiteiten is pre-seeded | MUST | Planned |
-| DSO-013 | Unmapped activiteiten create a zaak with a generic "Onbekend DSO-activiteit" zaaktype and flag for manual triage | MUST | Planned |
-| DSO-014 | Mapping table is editable via the OpenConnector admin UI | SHOULD | Planned |
-
-### Zaak Creation
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-020 | Automatically create a zaak in Procest for each received DSO-verzoek | MUST | Planned |
-| DSO-021 | Map aanvrager (initiatiefnemer) data to the zaak: BSN/KVK-nummer, naam, adres, contactgegevens | MUST | Planned |
-| DSO-022 | Map locatie to the zaak: BAG-adres, kadastrale aanduiding, GML-geometrie (punt of polygoon) | MUST | Planned |
-| DSO-023 | Set zaak startdatum to DSO-verzoek indieningsdatum | MUST | Planned |
-| DSO-024 | Link downloaded bijlagen to the created zaak | MUST | Planned |
-| DSO-025 | Store the original DSO-verzoek reference (verzoekId, bronorganisatie) on the zaak for traceability | MUST | Planned |
-| DSO-026 | Extract bouwkosten from DSO-verzoek for legesberekening (if provided by aanvrager) | SHOULD | Planned |
-
-### DSO-SWF (Samenwerking)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-030 | Support samenwerking met bevoegd gezag: when another overheidsorgaan is betrokken bij dezelfde aanvraag, coordinate via DSO-SWF | SHOULD | Planned |
-| DSO-031 | Send adviesverzoeken to ketenpartners (provincie, waterschap, omgevingsdienst) via DSO-SWF | SHOULD | Planned |
-| DSO-032 | Receive adviezen from ketenpartners and link to the zaak | SHOULD | Planned |
-| DSO-033 | Track samenwerkingsstatus per zaak: welke organisaties zijn betrokken, welke adviezen zijn ontvangen | SHOULD | Planned |
-
-### Status Updates (Outbound to DSO-LV)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-040 | Push zaak status updates back to DSO-LV so the aanvrager can track progress via the Omgevingsloket | MUST | Planned |
-| DSO-041 | Map Procest zaak statussen to DSO-LV statuscodes: ontvangen, in behandeling, besluit genomen, etc. | MUST | Planned |
-| DSO-042 | Push the vergunningbesluit (verleend, geweigerd, buiten behandeling) to DSO-LV | MUST | Planned |
-| DSO-043 | Push vergunningdocumenten (beschikking PDF) to DSO-LV for publication | SHOULD | Planned |
-
-### Authentication & Security
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-050 | Authenticate with DSO-LV using PKIoverheid certificates (mTLS) | MUST | Planned |
-| DSO-051 | Validate incoming DSO-LV webhook signatures to prevent spoofing | MUST | Planned |
-| DSO-052 | Support DSO-LV test environment (pre-productie) alongside production for acceptance testing | SHOULD | Planned |
-| DSO-053 | Store DSO API credentials and certificates securely in Nextcloud's credential store | MUST | Planned |
-
-### OpenConnector Integration
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| DSO-060 | Registered as an OpenConnector source type with DSO-LV-specific configuration | MUST | Planned |
-| DSO-061 | Connection settings: DSO-LV API URL, PKIoverheid certificates, organisatie OIN, bevoegd-gezag code | MUST | Planned |
-| DSO-062 | Health check: validate connectivity and certificate validity against DSO-LV | SHOULD | Planned |
-| DSO-063 | n8n workflow integration: DSO-verzoek ontvangst triggers a configurable n8n workflow for intake processing | SHOULD | Planned |
-
-## Data Model
-
-### DSO-Verzoek (stored in OpenRegister before zaak creation)
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| verzoekId | string | Yes | DSO-LV unique verzoek identifier |
-| bronorganisatie | string | Yes | OIN of the submitting DSO-LV instance |
-| type | string (enum) | Yes | `aanvraag`, `melding`, `informatieverzoek`, `vooroverleg` |
-| indieningsdatum | datetime | Yes | Date/time of submission in DSO-LV |
-| aanvrager | object | Yes | Initiatiefnemer: BSN/KVK, naam, adres, contactgegevens |
-| locatie | object | Yes | BAG-adres, kadastrale aanduiding, GML-geometrie |
-| activiteiten | array | Yes | List of DSO activiteiten with codes and omschrijvingen |
-| bouwkosten | decimal | No | Opgegeven bouwkosten (for legesberekening) |
-| bijlagen | array | No | References to downloaded documents in Nextcloud Files |
-| zaakId | string (UUID) | No | Created Procest zaak reference (set after processing) |
-| status | string (enum) | Yes | `ontvangen`, `verwerkt`, `fout` |
-
-## Scenarios
-
-### Receive vergunningaanvraag from Omgevingsloket
-
-```
-GIVEN the DSO-LV adapter is configured with valid PKIoverheid certificates
-AND an initiatiefnemer submits a vergunningaanvraag via het Omgevingsloket
-WHEN DSO-LV sends the verzoek to our STAM endpoint
-THEN the verzoek payload is parsed and validated
-AND bijlagen are downloaded and stored in Nextcloud Files
-AND activiteiten are mapped to zaaktypen
-AND a zaak is created in Procest with aanvrager, locatie, and activiteiten data
-AND a status "ontvangen" is pushed back to DSO-LV
-```
-
-### Multiple activiteiten with samenloop
-
-```
-GIVEN a verzoek contains activiteiten "bouwen" and "kappen"
-AND "bouwen" maps to zaaktype "Omgevingsvergunning Bouwen"
-AND "kappen" maps to zaaktype "Omgevingsvergunning Kappen"
-WHEN the adapter processes the verzoek
-THEN two deelzaken are created under one hoofdzaak
-AND both share the same aanvrager and locatie
-AND each deelzaak follows its own behandelproces
-```
-
-### Push besluit to DSO-LV
-
-```
-GIVEN a zaak originated from a DSO-verzoek
-AND the vergunning is verleend
-WHEN the zaak status changes to "Besluit genomen" in Procest
-THEN the adapter pushes status "besluit genomen" to DSO-LV
-AND the beschikking PDF is uploaded to DSO-LV
-AND the aanvrager can view the besluit in het Omgevingsloket
-```
-
-### Unknown activiteit fallback
-
-```
-GIVEN a verzoek contains an activiteit not in the mapping table
-WHEN the adapter processes the verzoek
-THEN a zaak is created with zaaktype "Onbekend DSO-activiteit"
-AND the zaak is flagged for manual triage
-AND a notification is sent to the VTH-behandelaar
-```
-
-## Dependencies
-
-- **OpenConnector**: Source registration and connection management
-- **OpenRegister**: Verzoek and mapping table storage
-- **Procest**: Zaak creation and lifecycle management
-- **Docudesk**: PDF generation for beschikkingen pushed to DSO-LV
-- **DSO-LV STAM API**: External service (Kadaster/RWS)
-- **PKIoverheid certificates**: For mTLS authentication
-- **BAG/BRK services**: For locatie-validatie (via OpenConnector)
-
-### Using Mock Register Data
-
-The **DSO** mock register provides test data for developing the DSO adapter without requiring access to the DSO-LV production/test environment.
-
-**Loading the register:**
-```bash
-# Load DSO register (53 records, register slug: "dso", schemas: "activiteit", "locatie", "omgevingsdocument", "vergunningaanvraag")
-docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/dso_register.json
-```
-
-**Test data for this spec's use cases:**
-- **Activiteiten-to-zaaktype mapping (DSO-010)**: 20+ activiteit records (bouwen, kappen, uitrit aanleggen, etc.) -- test mapping configuration
-- **Vergunningaanvraag parsing (DSO-004)**: 10+ vergunningaanvraag records with activiteiten, locatie, and aanvrager data
-- **Samenloop testing (DSO-011)**: Vergunningaanvragen referencing multiple activiteiten -- test single-zaak vs multi-deelzaak creation
-
-## Current Implementation Status
-
-### Implemented
-- **None of the DSO-specific requirements are implemented.** There is no DSO adapter, STAM endpoint, activiteiten-mapping, or DSO-SWF integration in the codebase.
-
-### Partially relevant existing infrastructure
-- **SOAP engine** (`lib/Service/SOAPService.php`): A generic SOAP client exists that can call SOAP sources using WSDL, Guzzle HTTP, and the `php-soap` extension. It already handles SOAP 1.1/1.2, cookie management, WSDL caching, and binary data encoding. This could serve as a foundation for DSO-LV STAM SOAP communication.
-- **Source entity** (`lib/Db/Source.php`, `src/entities/source/source.types.ts`): Sources support types `json`, `xml`, `soap`, `ftp`, `sftp` with configurable authentication (`apikey`, `jwt`, `username-password`, `oauth`, etc.). A new `dso` source type would need to be added.
-- **CallService** (`lib/Service/CallService.php`): Routes SOAP-type sources to the SOAPService (line ~448). Already supports certificate file writing to disk for mTLS connections.
-- **SynchronizationService** (`lib/Service/SynchronizationService.php`): Full sync framework with contracts, logging, and mapping between external and internal objects. Could be leveraged for DSO-verzoek sync.
-- **AuthenticationService** (`lib/Service/AuthenticationService.php`): Has certificate handling logic that could be extended for PKIoverheid mTLS.
-
-### Not implemented
-- DSO-LV STAM koppelvlak endpoint (inbound REST/SOAP receiver)
-- DSO verzoek parsing (XML/JSON payload to structured data)
-- Activiteiten-to-zaaktype mapping table and UI
-- Samenloop handling (multiple deelzaken from one verzoek)
-- DSO-SWF samenwerking (adviesverzoeken, adviezen)
-- Status push back to DSO-LV (outbound)
-- PKIoverheid certificate validation chain
-- DSO-LV webhook signature verification
-- DSO-specific source type registration
-- Bijlagen download and Nextcloud Files storage
-- All zaak creation logic (depends on Procest)
-
-## Standards & References
-
-- **DSO-LV STAM koppelvlak**: REST API specification maintained by Kadaster/RWS for the Digitaal Stelsel Omgevingswet. Defines the verzoek intake interface.
-- **Omgevingswet (2024)**: The Dutch Environment and Planning Act that replaced the Wabo/Wro, effective January 1, 2024.
-- **DSO-SWF**: SamenWerkingsFunctionaliteit — the collaboration API within the DSO-LV for coordinating between bevoegd gezag and ketenpartners.
-- **PKIoverheid**: Dutch government PKI for mutual TLS authentication (PKIO Server 2020 certificate chain).
-- **BAG (Basisregistratie Adressen en Gebouwen)**: National address registry, used for locatie-validatie.
-- **BRK (Basisregistratie Kadaster)**: Cadastral registry for kadastrale aanduidingen.
-- **GML (Geography Markup Language)**: OGC standard for geospatial data encoding, used for locatie geometrie.
-- **OIN (Organisatie-Identificatienummer)**: Unique identifier for Dutch government organizations.
-
-## Specificity Assessment
-
-### Sufficient for implementation
-- The data model for DSO-Verzoek is well-defined with clear field types.
-- Requirements are granular with individual IDs and clear MUST/SHOULD priorities.
-- Scenarios cover the main flows (receive, samenloop, besluit push, unknown activiteit).
-
-### Missing or ambiguous
-- **STAM API version**: The spec doesn't specify which version of the STAM koppelvlak API to target. The DSO has evolved significantly since its 2024 launch.
-- **Authentication flow details**: How PKIoverheid certificates are obtained, renewed, and stored is not specified. The CallService already writes certs to disk — how does this integrate?
-- **Webhook vs polling**: DSO-001 says "receive" but doesn't clarify whether this is a webhook (DSO pushes to us) or polling (we poll DSO). The STAM interface is typically push-based but the mechanism needs clarification.
-- **Status mapping table**: DSO-041 mentions mapping Procest statussen to DSO statuscodes, but the actual mapping values are not defined.
-- **Error handling**: DSO-006 mentions "descriptive errors" but doesn't define error response format (HTTP status codes, error schema).
-- **Samenloop strategy**: DSO-011 says "multiple zaak objects or one zaak with multiple deelzaken" — which strategy is preferred? This is a significant architectural decision.
-- **Procest dependency**: All zaak creation logic depends on Procest, which is itself under development. The interface between this adapter and Procest is undefined.
-- **n8n workflow template**: DSO-063 mentions n8n integration but doesn't specify the workflow structure or trigger mechanism.
-
-### Open questions
-1. Which STAM API version and environment (pre-prod/prod) endpoints should be targeted first?
-2. Should the adapter support the legacy OLO format during a transition period, or DSO-only?
-3. How are PKIoverheid certificates provisioned — uploaded via UI, or configured via Nextcloud admin settings?
-4. What is the preferred samenloop strategy: one hoofdzaak with deelzaken, or separate independent zaken?
-5. How does the adapter discover which activiteiten mappings exist? Is there a national registry of activiteit codes?
diff --git a/openspec/specs/ibabs-notubiz-connector/spec.md b/openspec/specs/ibabs-notubiz-connector/spec.md
deleted file mode 100644
index f1c219ffe..000000000
--- a/openspec/specs/ibabs-notubiz-connector/spec.md
+++ /dev/null
@@ -1,215 +0,0 @@
----
-status: proposed
----
-
-# iBabs & NotuBiz Connector
-
-## Purpose
-
-Provides bidirectional integration with iBabs and NotuBiz — the two dominant raadsinformatiesystemen (RIS) used by Dutch municipalities for bestuurlijke besluitvorming (B&W/College). Found as a requirement in 20+ tenders: iBabs in 12+ and NotuBiz in 8+. The connector pushes collegevoorstellen and documents from Procest to the RIS for vergaderbehandeling, and receives besluiten and besluitenlijsten back into the zaak. Implements the standard B&W workflow pattern: documenten heen, besluiten terug.
-
-## Requirements
-
-### iBabs API Integration
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| RIS-001 | Connect to the iBabs REST API with API key authentication | MUST | Planned |
-| RIS-002 | Push a collegevoorstel (advies + bijlagen) to iBabs as a vergaderstuk | MUST | Planned |
-| RIS-003 | Create or update an agendapunt in iBabs linked to the collegevoorstel | MUST | Planned |
-| RIS-004 | Retrieve besluiten from iBabs after vergaderbehandeling | MUST | Planned |
-| RIS-005 | Retrieve the besluitenlijst (PDF/document) from iBabs | MUST | Planned |
-| RIS-006 | Support iBabs document upload (PDF, DOCX) with metadata (onderwerp, portefeuillehouder, zaaktype) | MUST | Planned |
-| RIS-007 | Support iBabs vergadering retrieval: list upcoming and past vergaderingen with agendapunten | SHOULD | Planned |
-| RIS-008 | Map iBabs besluit status (aangenomen, verworpen, aangehouden, doorgeschoven) to Procest zaak status updates | MUST | Planned |
-
-### NotuBiz API Integration
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| RIS-020 | Connect to the NotuBiz API with OAuth2 or API key authentication | MUST | Planned |
-| RIS-021 | Push vergaderstukken (voorstel + bijlagen) to NotuBiz | MUST | Planned |
-| RIS-022 | Create or update agendapunten in NotuBiz linked to vergaderstukken | MUST | Planned |
-| RIS-023 | Retrieve besluiten and besluitenlijst from NotuBiz after behandeling | MUST | Planned |
-| RIS-024 | Support NotuBiz event types: collegevergadering, raadsvergadering, commissievergadering | SHOULD | Planned |
-| RIS-025 | Map NotuBiz besluit metadata to Procest zaak properties | MUST | Planned |
-
-### Bidirectional Sync
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| RIS-030 | Outbound sync: when a zaak reaches status "Ter besluitvorming" in Procest, automatically push voorstel to configured RIS | MUST | Planned |
-| RIS-031 | Inbound sync: poll or webhook for besluit updates from RIS, update the source zaak in Procest | MUST | Planned |
-| RIS-032 | Conflict detection: if a zaak has been modified in both Procest and the RIS, flag for manual resolution | SHOULD | Planned |
-| RIS-033 | Sync history: log all sync operations (push/pull, timestamp, status, document IDs) as OpenRegister objects for audit trail | MUST | Planned |
-| RIS-034 | Retry failed syncs with configurable backoff (default: 3 retries, 5/15/60 minute intervals) | SHOULD | Planned |
-
-### Document Flow
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| RIS-040 | Outbound documents: export from Nextcloud Files, convert to PDF if needed (via Docudesk), push to RIS | MUST | Planned |
-| RIS-041 | Inbound documents: download besluit/besluitenlijst from RIS, store in Nextcloud Files, link to zaak | MUST | Planned |
-| RIS-042 | Document metadata mapping: onderwerp, datum, portefeuillehouder, zaaktype, geheimhouding | MUST | Planned |
-| RIS-043 | Support geheimhouding flag: mark documents as vertrouwelijk in the RIS when the zaak has geheimhouding | SHOULD | Planned |
-
-### Parafering Support (Ambtelijk Deel)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| RIS-050 | Track parafering status within Procest before pushing to RIS: steller > adviseur > parafeerder > portefeuillehouder > secretariaat | MUST | Planned |
-| RIS-051 | Only push to RIS after all required paraferingen are completed (configurable per zaaktype) | MUST | Planned |
-| RIS-052 | Parafering route is configurable: sequential, parallel, or mixed per zaaktype | SHOULD | Planned |
-| RIS-053 | Mobile-friendly parafering: API supports paraferen from any device (responsive UI in Procest) | SHOULD | Planned |
-
-### OpenConnector Integration
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| RIS-060 | Registered as an OpenConnector endpoint type with separate configurations for iBabs and NotuBiz | MUST | Planned |
-| RIS-061 | Connection settings: API URL, authentication credentials, organisatie-ID, default vergadertype | MUST | Planned |
-| RIS-062 | Health check: validate API connectivity and authentication | SHOULD | Planned |
-| RIS-063 | n8n workflow integration: connector can be triggered from n8n nodes for custom B&W-besluitvorming workflows | SHOULD | Planned |
-
-## Data Model
-
-### Sync Record (stored in OpenRegister)
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| zaakId | string (UUID) | Yes | Source zaak in Procest |
-| risType | string (enum) | Yes | `ibabs` or `notubiz` |
-| risDocumentId | string | No | Document/agendapunt ID in the RIS |
-| direction | string (enum) | Yes | `outbound` (push) or `inbound` (pull) |
-| status | string (enum) | Yes | `pending`, `synced`, `failed`, `conflict` |
-| syncedAt | datetime | No | Timestamp of last successful sync |
-| errorMessage | string | No | Error details if status is `failed` |
-| documents | array | No | List of document references (Nextcloud file ID + RIS doc ID) |
-
-## Scenarios
-
-### Push collegevoorstel to iBabs
-
-```
-GIVEN a zaak "Bestemmingsplan Centrum" has completed parafering in Procest
-AND the zaak reaches status "Ter besluitvorming"
-WHEN the outbound sync triggers
-THEN the voorstel document and bijlagen are exported from Nextcloud
-AND pushed to iBabs as vergaderstukken with metadata (onderwerp, portefeuillehouder)
-AND an agendapunt is created for the next collegevergadering
-AND a sync record is stored with status "synced"
-```
-
-### Receive besluit from iBabs
-
-```
-GIVEN a collegevoorstel was pushed to iBabs for zaak "Bestemmingsplan Centrum"
-AND the college has behandeld the voorstel
-WHEN the inbound sync polls iBabs for updates
-THEN the besluit (aangenomen/verworpen) is retrieved
-AND the besluitenlijst PDF is downloaded and stored in Nextcloud Files
-AND the zaak status in Procest is updated to reflect the besluit
-AND the besluit document is linked to the zaak
-```
-
-### NotuBiz raadsvergadering flow
-
-```
-GIVEN a collegevoorstel requires raadsbehandeling after collegebesluit
-WHEN the connector pushes stukken to NotuBiz for raadsvergadering
-THEN vergaderstukken are uploaded with commissie/raad metadata
-AND after raadsbehandeling, the raadsbesluit is synced back to Procest
-```
-
-### Failed sync with retry
-
-```
-GIVEN a voorstel push to iBabs fails due to API timeout
-WHEN the first retry is triggered after 5 minutes
-THEN if it succeeds, the sync record is updated to "synced"
-AND if all 3 retries fail, the sync record is set to "failed" with error details
-AND a notification is sent to the behandelaar
-```
-
-## Dependencies
-
-- **OpenConnector**: Endpoint registration and connection management
-- **OpenRegister**: Sync record storage and zaak object access
-- **Procest**: Zaak lifecycle management and parafering workflow
-- **Docudesk**: PDF conversion for outbound documents
-- **iBabs REST API**: External service (api.ibabs.eu)
-- **NotuBiz API**: External service (api.notubiz.nl)
-
-### Using Mock Register Data
-
-The **ORI** mock register provides test data for developing the iBabs/NotuBiz connector without requiring access to production RIS systems.
-
-**Loading the register:**
-```bash
-# Load ORI register (115 records, register slug: "ori", schemas: "vergadering", "agendapunt", "raadsdocument", "stemming", "raadslid", "fractie")
-docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/ori_register.json
-```
-
-**Test data for this spec's use cases:**
-- **Vergadering retrieval (RIS-007)**: 10+ vergaderingen with dates and types (raadsvergadering, commissievergadering) -- test sync back to ORI register
-- **Agendapunt creation (RIS-003)**: 30+ agendapunten linked to vergaderingen -- test push/pull of agenda items
-- **Besluit mapping (RIS-008)**: Stemmingen with aangenomen/verworpen results -- test besluit status mapping
-- **Document handling (RIS-006)**: 15+ raadsdocumenten (moties, amendementen, besluiten) -- test document upload/download sync
-
-## Current Implementation Status
-
-### Implemented
-- **None of the iBabs/NotuBiz-specific requirements are implemented.** There is no iBabs connector, NotuBiz connector, parafering workflow, or RIS sync mechanism in the codebase.
-
-### Partially relevant existing infrastructure
-- **Source entity** (`lib/Db/Source.php`, `src/entities/source/source.types.ts`): Supports source types `json`, `xml`, `soap`, `ftp`, `sftp` with multiple auth methods including `apikey`, `jwt`, `oauth`. Both iBabs (REST + API key) and NotuBiz (OAuth2/API key) could be configured as `json`-type sources with appropriate auth.
-- **CallService** (`lib/Service/CallService.php`): Generic HTTP client that handles REST calls to configured sources. Could be used for iBabs/NotuBiz API calls without modification.
-- **SynchronizationService** (`lib/Service/SynchronizationService.php`): Full bidirectional sync framework with contracts, logs, and mapping. Supports sync between external sources and OpenRegister objects. This is directly relevant for RIS-030/031 (bidirectional sync).
-- **AuthenticationService** (`lib/Service/AuthenticationService.php`): Handles various auth methods. iBabs API key and NotuBiz OAuth2 should be supportable.
-- **EndpointService** (`lib/Service/EndpointService.php`): Manages endpoint configuration and routing.
-- **JobService** (`lib/Service/JobService.php`): Background job execution — could be used for polling and retry logic (RIS-031, RIS-034).
-
-### Not implemented
-- iBabs REST API client (document upload, agendapunt creation, besluit retrieval)
-- NotuBiz API client (vergaderstuk upload, agendapunt, besluit retrieval)
-- Bidirectional sync triggers (status-based outbound push, polling/webhook inbound)
-- Sync record storage (the data model described in the spec)
-- Conflict detection (RIS-032)
-- Retry with configurable backoff (RIS-034)
-- Parafering workflow (RIS-050 through RIS-053) — entirely within Procest scope
-- Document flow with PDF conversion via Docudesk
-- Geheimhouding flag mapping
-- RIS-specific source type registration
-
-## Standards & References
-
-- **iBabs REST API**: Proprietary API by iBabs BV (now part of Meeting.nl). Documented at developer.ibabs.eu. Uses API key authentication, REST/JSON format.
-- **NotuBiz API**: Proprietary API by NotuBiz BV (part of CMSolutions). Supports OAuth2 and API key auth. REST/JSON format.
-- **Gemeentelijke besluitvormingsprocessen**: The B&W-besluitvorming workflow is standardized across Dutch municipalities: steller > adviseur > parafeerder > portefeuillehouder > secretariaat > collegevergadering > besluit.
-- **GEMMA procesarchitectuur**: The reference architecture for Dutch municipal decision-making processes.
-- **Archiefwet**: Dutch archiving law — besluitenlijsten and vergaderstukken must be archived according to selectielijsten.
-
-## Specificity Assessment
-
-### Sufficient for implementation
-- The sync record data model is well-defined.
-- The document flow direction (outbound voorstel, inbound besluit) is clear.
-- Scenarios cover the main happy path and error/retry cases.
-- Parafering route requirements are specific (sequential, parallel, mixed).
-
-### Missing or ambiguous
-- **iBabs API version**: No specific API version is mentioned. iBabs has multiple API generations.
-- **NotuBiz API version**: Similarly unspecified. NotuBiz API access may require a specific contract/license.
-- **Webhook vs polling**: RIS-031 says "poll or webhook" but doesn't specify which is preferred or what the polling interval should be.
-- **Vergadering selection**: RIS-003 says "create agendapunt" but doesn't specify how the target vergadering is selected (next upcoming? manual selection? configurable default?).
-- **Document format requirements**: RIS-006 mentions PDF/DOCX but iBabs may require specific metadata fields or format constraints not documented here.
-- **Parafering scope ambiguity**: RIS-050-053 describe parafering within Procest, but the spec is for the OpenConnector adapter. The boundary between Procest and OpenConnector is unclear.
-- **Multi-tenant**: Can multiple iBabs/NotuBiz connections be configured simultaneously (e.g., different vergadertypen mapped to different RIS instances)?
-- **Besluit status mapping**: RIS-008 lists status values (aangenomen, verworpen, aangehouden, doorgeschoven) but doesn't define the target Procest zaak statussen.
-
-### Open questions
-1. Are iBabs and NotuBiz API access agreements/licenses in place? Both are proprietary APIs with access restrictions.
-2. Should parafering logic live in Procest (as a zaak workflow) or in OpenConnector (as a sync prerequisite)? The spec mixes both.
-3. What is the polling interval for inbound besluit sync? Is a webhook option available from either RIS?
-4. How is the target vergadering selected when pushing a collegevoorstel? Manual or automatic?
-5. Is there a test/sandbox environment available for both iBabs and NotuBiz APIs?
diff --git a/openspec/specs/prometheus-metrics/spec.md b/openspec/specs/prometheus-metrics/spec.md
index acc782df4..d01259c3e 100644
--- a/openspec/specs/prometheus-metrics/spec.md
+++ b/openspec/specs/prometheus-metrics/spec.md
@@ -1,42 +1,178 @@
+---
+status: implemented
+---
+
# Prometheus Metrics Endpoint
## Purpose
-Expose application metrics in Prometheus text exposition format at `GET /api/metrics` for monitoring, alerting, and operational dashboards.
+
+Expose application metrics in Prometheus text exposition format at `GET /api/metrics` for monitoring, alerting, and operational dashboards. Provide a health check endpoint at `GET /api/health` for liveness/readiness probes in container orchestration environments.
## Requirements
### REQ-PROM-001: Metrics Endpoint
-- MUST expose `GET /index.php/apps/openconnector/api/metrics` returning `text/plain; version=0.0.4; charset=utf-8`
-- MUST require admin authentication (Nextcloud admin or API token)
-- MUST return metrics in Prometheus text exposition format
-
-### REQ-PROM-002: Standard Metrics
-Every app MUST expose these standard metrics:
-- `openconnector_info` (gauge, labels: version, php_version, nextcloud_version) — always 1
-- `openconnector_up` (gauge) — 1 if app is healthy, 0 if degraded
-- `openconnector_requests_total` (counter, labels: method, endpoint, status) — HTTP request count
-- `openconnector_request_duration_seconds` (histogram, labels: method, endpoint) — request latency
-- `openconnector_errors_total` (counter, labels: type) — error count by type
-
-### REQ-PROM-003: App-Specific Metrics
-- `openconnector_sources_total` (gauge, labels: type) — total sources by type (rest/soap/graphql)
-- `openconnector_calls_total` (counter, labels: source, method, status) — API calls made
-- `openconnector_call_duration_seconds` (histogram, labels: source) — call latency
-- `openconnector_synchronizations_total` (counter, labels: source, status) — sync operations
-- `openconnector_sync_objects_total` (counter, labels: source) — objects synced
-
-### REQ-PROM-004: Health Check
-- MUST expose `GET /index.php/apps/openconnector/api/health` returning JSON `{"status": "ok"|"degraded"|"error", "checks": {...}}`
-- Checks: database connectivity, required dependencies available, source endpoint reachability
+
+The app MUST expose `GET /index.php/apps/openconnector/api/metrics` returning `text/plain; version=0.0.4; charset=utf-8`. The endpoint MUST require admin authentication (Nextcloud admin session or API token). All metrics MUST follow the Prometheus text exposition format with `# HELP`, `# TYPE`, and metric lines.
+
+**Scenarios:**
+
+1. **GIVEN** an authenticated Nextcloud admin user **WHEN** they request `GET /index.php/apps/openconnector/api/metrics` **THEN** the response has status 200, content-type `text/plain; version=0.0.4; charset=utf-8`, and the body contains valid Prometheus exposition format lines.
+
+2. **GIVEN** an unauthenticated user **WHEN** they request the metrics endpoint **THEN** the response is HTTP 401 Unauthorized and no metrics data is exposed.
+
+3. **GIVEN** a monitoring system (e.g., Prometheus scraper) with a valid API token **WHEN** it scrapes the metrics endpoint at its configured interval **THEN** fresh metrics are returned reflecting current application state, not cached values.
+
+4. **GIVEN** the metrics endpoint is called **AND** a database query for one metric category fails **WHEN** the remaining metric categories succeed **THEN** the failing metric emits a zero-value fallback and the endpoint still returns HTTP 200 with partial metrics (degraded but not broken).
+
+5. **GIVEN** the metrics endpoint is called frequently (every 15 seconds) **WHEN** each scrape runs the database queries **THEN** query execution completes within 500ms using indexed COUNT queries on the existing OpenConnector tables.
+
+### REQ-PROM-002: Application Info Gauge
+
+The app MUST expose an `openconnector_info` gauge metric with labels `version` (app version), `php_version`, and `nextcloud_version`. The value is always 1. This enables Prometheus queries like `openconnector_info{version="2.1.0"}` to track which version is deployed.
+
+**Scenarios:**
+
+1. **GIVEN** OpenConnector version 2.1.0 is installed on Nextcloud 30.0.0 running PHP 8.3.0 **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_info{version="2.1.0",php_version="8.3.0",nextcloud_version="30.0.0"} 1`.
+
+2. **GIVEN** the app is upgraded from 2.1.0 to 2.2.0 **WHEN** the metrics endpoint is called after upgrade **THEN** the version label reflects "2.2.0" on the next scrape.
+
+3. **GIVEN** the app version cannot be determined **WHEN** the metrics endpoint is called **THEN** the version label defaults to "0.0.0" rather than omitting the metric.
+
+### REQ-PROM-003: Application Up Gauge
+
+The app MUST expose an `openconnector_up` gauge metric. The value is 1 if the app is healthy (database accessible, core tables exist), 0 if degraded (database errors, missing tables).
+
+**Scenarios:**
+
+1. **GIVEN** the application is running normally with database connectivity **WHEN** the metrics endpoint is called **THEN** `openconnector_up` is 1.
+
+2. **GIVEN** the database connection is lost **WHEN** the metrics endpoint is called **THEN** `openconnector_up` is 0 (the endpoint itself may still respond if the framework can serve the request).
+
+3. **GIVEN** the sources table is missing (migration not run) **WHEN** the metrics endpoint is called **THEN** `openconnector_up` is 0 and the health check details explain the missing table.
+
+### REQ-PROM-004: Sources Gauge by Type
+
+The app MUST expose `openconnector_sources_total` as a gauge with label `type` (rest/soap/graphql/json/xml/ftp/sftp). The value is the current count of configured sources per type, queried from the `openconnector_sources` table grouped by `type` column.
+
+**Scenarios:**
+
+1. **GIVEN** there are 5 sources of type "json", 2 of type "soap", and 1 of type "xml" **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_sources_total{type="json"} 5`, `openconnector_sources_total{type="soap"} 2`, and `openconnector_sources_total{type="xml"} 1`.
+
+2. **GIVEN** no sources are configured **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_sources_total{type="rest"} 0` as a zero-value placeholder.
+
+3. **GIVEN** a source has a NULL type value in the database **WHEN** the metrics endpoint is called **THEN** it is counted under the default label "rest" (existing MetricsController behavior).
+
+### REQ-PROM-005: Call Counter by Status
+
+The app MUST expose `openconnector_calls_total` as a counter with label `status` (HTTP status code). The value is the total number of API calls logged in the `openconnector_call_logs` table, grouped by `status_code`. This enables monitoring of error rates and API call volumes.
+
+**Scenarios:**
+
+1. **GIVEN** 150 calls with status 200, 30 calls with status 400, and 5 calls with status 500 are logged **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_calls_total{status="200"} 150`, `openconnector_calls_total{status="400"} 30`, and `openconnector_calls_total{status="500"} 5`.
+
+2. **GIVEN** no calls have been logged **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_calls_total{status="200"} 0` as a zero-value placeholder.
+
+3. **GIVEN** a new call is logged with status 429 (rate limited) **WHEN** the next metrics scrape runs **THEN** `openconnector_calls_total{status="429"}` appears with count 1.
+
+### REQ-PROM-006: Synchronization Metrics
+
+The app MUST expose synchronization metrics: `openconnector_synchronizations_total` (gauge, total configured synchronizations) and `openconnector_synchronization_runs_total` (counter with label `status`, total sync log entries grouped by result). These enable monitoring of sync health and failure rates.
+
+**Scenarios:**
+
+1. **GIVEN** 10 synchronizations are configured **AND** 500 sync log entries exist (400 success, 80 partial, 20 error) **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_synchronizations_total 10`, `openconnector_synchronization_runs_total{status="success"} 400`, `openconnector_synchronization_runs_total{status="partial"} 80`, and `openconnector_synchronization_runs_total{status="error"} 20`.
+
+2. **GIVEN** no sync log entries exist **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_synchronization_runs_total{status="success"} 0` as a zero-value placeholder.
+
+3. **GIVEN** a sync run fails due to a source being disabled **WHEN** the sync log records the failure **THEN** the next scrape increments `openconnector_synchronization_runs_total{status="error"}`.
+
+### REQ-PROM-007: Endpoint Metrics
+
+The app MUST expose `openconnector_endpoints_total` (gauge) counting the total number of registered endpoints, and `openconnector_endpoint_hits_total` (counter with labels `endpoint`, `method`) tracking request counts per endpoint. This enables monitoring of which endpoints are most active.
+
+**Scenarios:**
+
+1. **GIVEN** 15 endpoints are registered **AND** endpoint "/api/objects" has received 200 GET and 50 POST requests **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_endpoints_total 15`, `openconnector_endpoint_hits_total{endpoint="/api/objects",method="GET"} 200`, and `openconnector_endpoint_hits_total{endpoint="/api/objects",method="POST"} 50`.
+
+2. **GIVEN** an endpoint is created but never called **WHEN** the metrics endpoint is called **THEN** it appears in `openconnector_endpoints_total` but not in `openconnector_endpoint_hits_total` (no zero-value emission per endpoint).
+
+3. **GIVEN** the endpoint metrics query would return more than 100 distinct endpoint/method combinations **WHEN** the metrics endpoint is called **THEN** results are limited to the top 100 by hit count to prevent metric cardinality explosion.
+
+### REQ-PROM-008: Job Queue Metrics
+
+The app MUST expose `openconnector_jobs_total` (gauge) counting configured jobs, and `openconnector_job_runs_total` (counter with label `status`) counting job execution log entries. This enables monitoring of background job health.
+
+**Scenarios:**
+
+1. **GIVEN** 5 jobs are configured **AND** job logs show 100 success runs and 10 error runs **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_jobs_total 5`, `openconnector_job_runs_total{status="success"} 100`, and `openconnector_job_runs_total{status="error"} 10`.
+
+2. **GIVEN** a job has been stuck (no recent runs) for over 1 hour **WHEN** the metrics endpoint is called **THEN** the job appears in `openconnector_jobs_total` but its last run timestamp is available via the health check for alerting.
+
+3. **GIVEN** no jobs are configured **WHEN** the metrics endpoint is called **THEN** `openconnector_jobs_total 0` is emitted.
+
+### REQ-PROM-009: Mapping and Rule Metrics
+
+The app MUST expose `openconnector_mappings_total` (gauge) and `openconnector_rules_total` (gauge) counting configured mappings and rules respectively. These are lightweight counters providing operational overview.
+
+**Scenarios:**
+
+1. **GIVEN** 20 mappings and 8 rules are configured **WHEN** the metrics endpoint is called **THEN** the output includes `openconnector_mappings_total 20` and `openconnector_rules_total 8`.
+
+2. **GIVEN** a mapping is deleted **WHEN** the next metrics scrape runs **THEN** `openconnector_mappings_total` reflects the decreased count.
+
+3. **GIVEN** database access fails for the mapping count **WHEN** the metrics endpoint collects this metric **THEN** a zero-value fallback is emitted with a warning logged.
+
+### REQ-PROM-010: Health Check Endpoint
+
+The app MUST expose `GET /index.php/apps/openconnector/api/health` returning JSON `{"status": "ok"|"degraded"|"error", "checks": {...}}`. Checks include: database connectivity (SELECT 1), source table accessibility (COUNT from sources table), and optionally source endpoint reachability for critical sources. The health endpoint requires admin authentication.
+
+**Scenarios:**
+
+1. **GIVEN** the database is accessible and the sources table exists **WHEN** the health endpoint is called **THEN** the response is `{"status": "ok", "checks": {"database": "ok", "sources_table": "ok"}}`.
+
+2. **GIVEN** the database is accessible but the sources table is missing **WHEN** the health endpoint is called **THEN** the response is `{"status": "degraded", "checks": {"database": "ok", "sources_table": "error"}}`.
+
+3. **GIVEN** the database connection fails entirely **WHEN** the health endpoint is called **THEN** the response is `{"status": "error", "checks": {"database": "error"}}`.
+
+4. **GIVEN** a Kubernetes readiness probe is configured to use the health endpoint **WHEN** the status is "error" **THEN** Kubernetes marks the pod as not ready and stops routing traffic to it.
+
+5. **GIVEN** the health check includes a critical source reachability check **AND** the source is unreachable **WHEN** the health endpoint is called **THEN** status is "degraded" (not "error", since the app itself works) with `{"source_reachability": {"source_name": "unreachable"}}`.
+
+## Data Model
+
+No new data model entities are required. Metrics are computed at query time from existing OpenConnector tables:
+- `openconnector_sources` (type column for source counts)
+- `openconnector_call_logs` (status_code column for call counts)
+- `openconnector_synchronizations` (total count)
+- `openconnector_synchronization_logs` (result column for sync run counts)
+- `openconnector_endpoints` (total count)
+- `openconnector_jobs` (total count)
+- `openconnector_job_logs` (status for job run counts)
+- `openconnector_mappings` (total count)
+- `openconnector_rules` (total count)
## Current Implementation Status
-- **Not implemented**: No MetricsController, HealthController, or metrics/monitoring code exists in the app.
+
+### Implemented
+- **MetricsController** (`lib/Controller/MetricsController.php`): Fully implemented with `index()` method returning Prometheus text format. Exposes `openconnector_info`, `openconnector_up`, `openconnector_sources_total` (by type), `openconnector_calls_total` (by status), `openconnector_synchronizations_total`, and `openconnector_synchronization_runs_total` (by status). Uses IDBConnection query builder for all database queries with proper error handling and zero-value fallbacks.
+- **HealthController** (`lib/Controller/HealthController.php`): Fully implemented with `index()` method returning JSON health status. Checks database connectivity (SELECT 1) and sources table accessibility (COUNT from sources). Returns `{"status": "ok"|"degraded"|"error", "checks": {...}}`.
+- **Route registration**: Both endpoints are registered and accessible at their respective paths.
+
+### Not implemented
+- **Endpoint metrics** (REQ-PROM-007): No endpoint hit tracking. Would require adding a counter mechanism to EndpointService.
+- **Job queue metrics** (REQ-PROM-008): No job run counting from job_logs table.
+- **Mapping/rule metrics** (REQ-PROM-009): No mapping or rule count metrics.
+- **Request duration histogram** (from original spec): No latency tracking -- would require middleware or CallService instrumentation.
+- **Critical source reachability** in health check: Only database and table checks are implemented.
+- **Admin authentication enforcement**: The `@NoCSRFRequired` annotation is present but explicit admin-only access control is not enforced beyond standard Nextcloud route authentication.
## Standards & References
-- Prometheus text exposition format: https://prometheus.io/docs/instrumenting/exposition_formats/
-- OpenMetrics specification: https://openmetrics.io/
-- Nextcloud server monitoring patterns
-- OpenRegister MetricsService and HeartbeatController as reference implementation
+
+- **Prometheus text exposition format**: https://prometheus.io/docs/instrumenting/exposition_formats/
+- **OpenMetrics specification**: https://openmetrics.io/
+- **Nextcloud server monitoring patterns**: Nextcloud's own `status.php` and OCS monitoring endpoints.
+- **OpenRegister MetricsService and HeartbeatController**: Reference implementation in the sibling app OpenRegister.
## Specificity Assessment
-Highly specific — metric names, types, and labels are fully defined. Implementation follows a standard pattern that can be shared via a base MetricsService trait/class from OpenRegister.
+
+Highly specific -- metric names, types, and labels are fully defined. The core implementation already exists in MetricsController and HealthController. Remaining work is incremental: adding endpoint/job/mapping counters and optionally request duration histograms.
diff --git a/openspec/specs/stuf-adapter/spec.md b/openspec/specs/stuf-adapter/spec.md
deleted file mode 100644
index 153731af8..000000000
--- a/openspec/specs/stuf-adapter/spec.md
+++ /dev/null
@@ -1,206 +0,0 @@
----
-status: proposed
----
-
-# StUF Adapter
-
-## Purpose
-
-Provides bidirectional translation between modern REST/ZGW APIs and legacy StUF-BG (personen/adressen) and StUF-ZKN (zaken/documenten) SOAP-based interfaces. 79% of Dutch government tenders still require StUF support despite the migration to ZGW APIs. The adapter enables OpenRegister objects to be exposed as StUF services (for legacy consumers) and allows OpenConnector to query legacy StUF sources (for data import). Supports StUF-BG 3.10 and StUF-ZKN 3.10/3.10e.
-
-## Requirements
-
-### StUF-BG Inbound (Legacy Consumer Queries OpenRegister)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-001 | Expose a SOAP endpoint that accepts StUF-BG 3.10 `npsLv01` (persoon opvragen) requests | MUST | Planned |
-| STUF-002 | Map StUF-BG person fields (`bsn`, `geslachtsnaam`, `voorvoegsel`, `voornamen`, `geboortedatum`, `verblijfsadres`) to OpenRegister object properties | MUST | Planned |
-| STUF-003 | Expose `npsLa01` (persoon antwoord) response with correctly formed StUF-BG XML | MUST | Planned |
-| STUF-004 | Support StUF-BG `adrLv01` (adres opvragen) and `adrLa01` (adres antwoord) for BAG-adressen | SHOULD | Planned |
-| STUF-005 | Support `scope` element filtering — return only requested fields in the response | MUST | Planned |
-| STUF-006 | Handle StUF `sortering` and `maximumAantal` parameters for result limiting | SHOULD | Planned |
-
-### StUF-BG Outbound (OpenConnector Queries Legacy Source)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-010 | Query external StUF-BG services via SOAP and map responses to OpenRegister objects | MUST | Planned |
-| STUF-011 | Support certificate-based mutual TLS authentication (PKIoverheid) for StUF endpoints | MUST | Planned |
-| STUF-012 | Support WS-Security (UsernameToken) authentication for StUF endpoints | MUST | Planned |
-| STUF-013 | Parse StUF-BG `npsLa01` responses and extract person/address data into flat JSON | MUST | Planned |
-| STUF-014 | Handle StUF `Fo01`/`Fo02` fault messages and map to HTTP error responses with diagnostic info | MUST | Planned |
-
-### StUF-ZKN Inbound (Legacy Consumer Manages Zaken)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-020 | Expose a SOAP endpoint that accepts StUF-ZKN 3.10 `zakLk01` (zaak aanmaken/bijwerken) messages | MUST | Planned |
-| STUF-021 | Map StUF-ZKN zaak fields (`zaakidentificatie`, `omschrijving`, `startdatum`, `einddatum`, `zaaktype`, `status`) to Procest zaak objects in OpenRegister | MUST | Planned |
-| STUF-022 | Support `edcLk01` (document koppelen aan zaak) for document management via StUF-ZKN | SHOULD | Planned |
-| STUF-023 | Support `zakLv01` (zaak opvragen) and respond with `zakLa01` including related documenten and statussen | MUST | Planned |
-| STUF-024 | Handle `Bv03` (bevestiging) and `Fo03` (foutmelding) asynchronous response patterns | SHOULD | Planned |
-
-### StUF-ZKN Outbound (OpenConnector Queries Legacy Zaaksysteem)
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-030 | Query external StUF-ZKN services for zaak data and map to OpenRegister objects | MUST | Planned |
-| STUF-031 | Support `genereerZaakIdentificatie` for obtaining zaak IDs from legacy systems | SHOULD | Planned |
-| STUF-032 | Support document retrieval via `edcLv01` and store in Nextcloud Files | SHOULD | Planned |
-
-### SOAP/XML Processing
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-040 | WSDL files for StUF-BG 3.10 and StUF-ZKN 3.10 are bundled with the adapter | MUST | Planned |
-| STUF-041 | XML namespace handling for `StUF`, `BG`, `ZKN`, `xsi`, `gml` namespaces | MUST | Planned |
-| STUF-042 | StUF `stuurgegevens` (zender, ontvanger, referentienummer, tijdstip) correctly populated on all messages | MUST | Planned |
-| STUF-043 | StUF `noValue` attribute handling: `geenWaarde`, `nietOndersteund`, `waardeOnbekend`, `vastgesteldOnbekend` | MUST | Planned |
-| STUF-044 | XML schema validation of outbound messages against StUF XSD schemas | SHOULD | Planned |
-
-### Field Mapping Configuration
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-050 | Field mappings between StUF XML paths and OpenRegister object properties are configurable via mapping objects stored in OpenRegister | MUST | Planned |
-| STUF-051 | Default mapping configurations for BRP-personen (StUF-BG) and ZGW-zaken (StUF-ZKN) are pre-seeded | MUST | Planned |
-| STUF-052 | Custom mappings can be added for municipality-specific StUF extensions | SHOULD | Planned |
-| STUF-053 | Mapping supports value transformations: date format conversion (StUF `YYYYMMDD` to ISO 8601), code list lookups, string concatenation | MUST | Planned |
-
-### OpenConnector Integration
-
-| ID | Requirement | Priority | Status |
-|----|------------|----------|--------|
-| STUF-060 | The adapter is registered as an OpenConnector source type, configurable via the connector UI | MUST | Planned |
-| STUF-061 | Connection settings: endpoint URL, authentication method (mTLS/WS-Security), certificates, zender/ontvanger codes | MUST | Planned |
-| STUF-062 | Health check: validate connectivity and authentication against the StUF endpoint | SHOULD | Planned |
-
-## Scenarios
-
-### Query BRP via StUF-BG
-
-```
-GIVEN an external StUF-BG service is configured in OpenConnector
-WHEN a user or workflow requests person data by BSN
-THEN OpenConnector sends a StUF-BG npsLv01 SOAP request
-AND parses the npsLa01 response
-AND returns a JSON object with mapped person fields
-```
-
-### Legacy system queries zaak via StUF-ZKN
-
-```
-GIVEN a legacy application sends a StUF-ZKN zakLv01 SOAP request
-WHEN the adapter receives the request at the SOAP endpoint
-THEN it resolves the zaak from OpenRegister by zaakidentificatie
-AND returns a zakLa01 response with zaak data, statussen, and documenten
-AND stuurgegevens are correctly populated with the adapter's zender code
-```
-
-### Create zaak from StUF-ZKN message
-
-```
-GIVEN a legacy formulierensysteem sends a StUF-ZKN zakLk01 message
-WHEN the adapter receives the create-zaak message
-THEN it maps the StUF fields to OpenRegister properties
-AND creates a zaak object in Procest's register
-AND returns a Bv03 bevestiging message
-```
-
-### Certificate-based authentication
-
-```
-GIVEN a StUF endpoint requires PKIoverheid mTLS
-WHEN the connection is configured with client certificate and key
-THEN SOAP requests include the client certificate
-AND the server's certificate is validated against the PKIoverheid chain
-```
-
-## Dependencies
-
-- **OpenConnector**: Source/endpoint registration and connection management
-- **OpenRegister**: Object storage and field mapping configuration
-- **PHP SOAP extension**: SOAP client/server functionality
-- **PKIoverheid root certificates**: For mTLS validation
-- **StUF-BG 3.10 and StUF-ZKN 3.10 XSD schemas**: For XML validation
-
-### Using Mock Register Data
-
-The **BRP** and **BAG** mock registers provide test data for StUF-BG person/address queries without requiring external government endpoints.
-
-**Loading the registers:**
-```bash
-# Load BRP register (35 persons, register slug: "brp", schema: "ingeschreven-persoon")
-docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/brp_register.json
-
-# Load BAG register (32 addresses, register slug: "bag", schema: "nummeraanduiding")
-docker exec -u www-data nextcloud php occ openregister:load-register /var/www/html/custom_apps/openregister/lib/Settings/bag_register.json
-```
-
-**Test data for this spec's use cases:**
-- **StUF-BG npsLv01/npsLa01**: BSN `999993653` (Suzanne Moulin) -- test person query and response mapping
-- **StUF-BG adrLv01/adrLa01**: Use BAG `nummeraanduiding` records -- test address query and response mapping
-- **Field mapping validation**: BRP records include all fields from the StUF-BG mapping table (bsn, geslachtsnaam, voorvoegsel, voornamen, geboortedatum, verblijfsadres)
-
-## Current Implementation Status
-
-### Implemented (partial)
-- **SOAP engine** (`lib/Service/SOAPService.php`): A working generic SOAP client that supports WSDL-driven requests, SOAP 1.1/1.2, cookie jar management, and XML response parsing. This is the outbound foundation (STUF-010/030).
-- **edcLk01 handling** (`lib/Service/SOAPService.php`, lines 218-223): There is **specific StUF-ZKN code** — the SOAPService already handles `edcLk01` document messages by detecting `body['edcLk01']['object']['inhoud']` and base64-decoding the document content. This directly relates to STUF-022 (document koppelen).
-- **Source type `soap`** (`src/entities/source/source.types.ts`): Sources can be configured as type `soap` with WSDL URL, SOAP version, and authentication. StUF endpoints can be set up as SOAP sources today.
-- **CallService SOAP routing** (`lib/Service/CallService.php`, line ~448): When a source has type `soap`, calls are automatically routed to the SOAPService.
-- **Certificate handling** (`lib/Service/CallService.php`): Supports writing client certificates and SSL keys to disk for mTLS connections. This is directly relevant for PKIoverheid mTLS (STUF-011).
-- **AuthenticationService** (`lib/Service/AuthenticationService.php`): Has certificate and authentication handling that could support WS-Security (STUF-012).
-
-### Not implemented
-- **Inbound SOAP server** (STUF-001, STUF-020, STUF-023): No SOAP server endpoint exists. The current SOAPService is client-only (outbound). Exposing StUF-BG/ZKN endpoints as a SOAP server requires a fundamentally different architecture.
-- **StUF-BG field mapping** (STUF-002, STUF-003): No mapping between StUF-BG XML paths (`bsn`, `geslachtsnaam`, etc.) and OpenRegister object properties.
-- **StUF-ZKN field mapping** (STUF-021): No mapping between StUF-ZKN zaak fields and Procest/OpenRegister objects.
-- **WSDL files bundled** (STUF-040): No StUF-BG or StUF-ZKN WSDL/XSD files are included in the codebase.
-- **XML namespace handling** (STUF-041): No StUF-specific namespace management (StUF, BG, ZKN, xsi, gml).
-- **Stuurgegevens** (STUF-042): No automatic population of zender/ontvanger/referentienummer/tijdstip.
-- **noValue attribute handling** (STUF-043): No support for StUF noValue semantics.
-- **Configurable field mapping** (STUF-050-053): No mapping configuration UI or storage in OpenRegister.
-- **Scope filtering** (STUF-005): Not implemented.
-- **Fault message handling** (STUF-014, STUF-024): No Fo01/Fo02/Fo03 or Bv03 handling.
-- **WS-Security UsernameToken** (STUF-012): Not implemented as a specific auth method.
-
-### Summary
-The outbound SOAP client infrastructure is in place and already has one piece of StUF-ZKN awareness (edcLk01 document handling). The inbound SOAP server side is entirely missing and represents the larger implementation effort.
-
-## Standards & References
-
-- **StUF-BG 3.10**: Standaard Uitwisseling Formaat - Basisgegevens. SOAP-based standard for person and address data exchange in Dutch government. Maintained by VNG Realisatie.
-- **StUF-ZKN 3.10 / 3.10e**: Standaard Uitwisseling Formaat - Zaak-/Documentservices. SOAP-based standard for case and document management exchange. The "e" extension adds extra message types.
-- **ZGW APIs (Zaakgericht Werken)**: The modern REST-based successor to StUF-ZKN. This adapter bridges the gap between legacy StUF and modern ZGW.
-- **WS-Security**: OASIS standard for SOAP message security. UsernameToken profile is commonly used by Dutch government StUF endpoints.
-- **PKIoverheid**: Dutch government PKI for mTLS authentication. Required for most production StUF endpoints.
-- **GEMMA**: Reference architecture for Dutch municipalities — defines the role of StUF in the information architecture.
-- **BRP (Basisregistratie Personen)**: National person registry, accessed via StUF-BG by municipalities.
-- **RGBZ (Referentiemodel Gemeentelijke Basisgegevens Zaken)**: The information model underlying StUF-ZKN.
-- **CMIS**: Content Management Interoperability Services — sometimes used alongside StUF-ZKN for document management.
-
-## Specificity Assessment
-
-### Sufficient for implementation
-- StUF message types are well-known and standardized (npsLv01, npsLa01, zakLk01, etc.).
-- The requirement IDs clearly separate inbound/outbound and BG/ZKN concerns.
-- The scenarios cover the main integration patterns (query BRP, expose zaken, create zaken, certificate auth).
-- The edcLk01 handling already in the code proves the pattern works.
-
-### Missing or ambiguous
-- **SOAP server architecture**: How to expose inbound SOAP endpoints within Nextcloud is a significant architectural question. Nextcloud routes are REST-based. Running a SOAP server may require a separate endpoint or a raw POST handler that processes SOAP XML.
-- **StUF version specifics**: The spec says "3.10" but doesn't address version negotiation. Some municipalities run 3.01 or custom extensions.
-- **Performance requirements**: No mention of expected throughput, response time SLAs, or concurrent request handling.
-- **Mapping storage format**: STUF-050 says "configurable via mapping objects stored in OpenRegister" but doesn't define the mapping object schema (which register, which schema, what fields).
-- **Pre-seeded mappings scope**: STUF-051 says "default mapping configurations" but doesn't list which specific fields are included in the default BRP and ZGW mappings.
-- **Asynchronous patterns**: STUF-024 mentions Bv03/Fo03 async patterns but doesn't detail the callback mechanism (how does the adapter receive async responses?).
-- **Multi-source routing**: Can the adapter expose multiple StUF endpoints for different registers/schemas, or is it one global SOAP endpoint?
-
-### Open questions
-1. How should the inbound SOAP server be hosted within Nextcloud? As a regular route that parses raw SOAP XML, or as a separate PHP SOAP server process?
-2. Which StUF-BG and StUF-ZKN WSDL/XSD files should be bundled? Where are the official schema packages obtained?
-3. Should the adapter support StUF-BG 3.01 (still in use by some municipalities) alongside 3.10?
-4. What is the expected mapping object schema in OpenRegister for field mappings (STUF-050)?
-5. How does WS-Security UsernameToken integrate with the existing AuthenticationService — as a new auth type, or as middleware on the SOAP transport?
diff --git a/tests/Unit/Controller/HealthControllerTest.php b/tests/Unit/Controller/HealthControllerTest.php
new file mode 100644
index 000000000..b349a4a17
--- /dev/null
+++ b/tests/Unit/Controller/HealthControllerTest.php
@@ -0,0 +1,126 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Tests\Unit\Controller;
+
+use OCA\OpenConnector\Controller\HealthController;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IDBConnection;
+use OCP\DB\IResult;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IRequest;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests for the health check endpoint controller.
+ */
+class HealthControllerTest extends TestCase
+{
+
+ /**
+ * @var HealthController
+ */
+ private HealthController $controller;
+
+ /**
+ * @var IDBConnection|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $db;
+
+ /**
+ * @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $logger;
+
+
+ /**
+ * Set up test fixtures.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $request = $this->createMock(IRequest::class);
+ $this->db = $this->createMock(IDBConnection::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->controller = new HealthController(
+ 'openconnector',
+ $request,
+ $this->db,
+ $this->logger
+ );
+
+ }//end setUp()
+
+
+ /**
+ * Test that healthy database returns ok status.
+ *
+ * @return void
+ */
+ public function testHealthyDatabaseReturnsOk(): void
+ {
+ $result = $this->createMock(IResult::class);
+ $result->method('closeCursor')->willReturn(true);
+
+ $qb = $this->createMock(IQueryBuilder::class);
+ $qb->method('select')->willReturnSelf();
+ $qb->method('from')->willReturnSelf();
+ $qb->method('createFunction')->willReturn('1');
+ $qb->method('executeQuery')->willReturn($result);
+
+ $this->db->method('getQueryBuilder')
+ ->willReturn($qb);
+
+ $response = $this->controller->index();
+
+ $this->assertInstanceOf(JSONResponse::class, $response);
+ $data = $response->getData();
+ $this->assertSame('ok', $data['status']);
+ $this->assertSame('ok', $data['checks']['database']);
+ $this->assertSame('ok', $data['checks']['sources_table']);
+
+ }//end testHealthyDatabaseReturnsOk()
+
+
+ /**
+ * Test that database failure returns error status.
+ *
+ * @return void
+ */
+ public function testDatabaseFailureReturnsError(): void
+ {
+ $qb = $this->createMock(IQueryBuilder::class);
+ $qb->method('select')->willReturnSelf();
+ $qb->method('from')->willReturnSelf();
+ $qb->method('createFunction')->willReturn('1');
+ $qb->method('executeQuery')->willThrowException(new \Exception('Connection refused'));
+
+ $this->db->method('getQueryBuilder')
+ ->willReturn($qb);
+
+ $response = $this->controller->index();
+
+ $data = $response->getData();
+ $this->assertSame('error', $data['status']);
+ $this->assertSame('error', $data['checks']['database']);
+
+ }//end testDatabaseFailureReturnsError()
+
+
+}//end class
diff --git a/tests/Unit/Controller/MetricsControllerTest.php b/tests/Unit/Controller/MetricsControllerTest.php
new file mode 100644
index 000000000..a6882060a
--- /dev/null
+++ b/tests/Unit/Controller/MetricsControllerTest.php
@@ -0,0 +1,328 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Tests\Unit\Controller;
+
+use OCA\OpenConnector\Controller\MetricsController;
+use OCP\AppFramework\Http\TextPlainResponse;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\DB\IResult;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\DB\QueryBuilder\IFunctionBuilder;
+use OCP\IRequest;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests for the Prometheus metrics endpoint controller.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
+class MetricsControllerTest extends TestCase
+{
+
+ /**
+ * @var MetricsController
+ */
+ private MetricsController $controller;
+
+ /**
+ * @var IConfig|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $config;
+
+ /**
+ * @var IDBConnection|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $db;
+
+ /**
+ * @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $logger;
+
+
+ /**
+ * Set up test fixtures.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $request = $this->createMock(IRequest::class);
+ $this->config = $this->createMock(IConfig::class);
+ $this->db = $this->createMock(IDBConnection::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->controller = new MetricsController(
+ 'openconnector',
+ $request,
+ $this->config,
+ $this->db,
+ $this->logger
+ );
+
+ }//end setUp()
+
+
+ /**
+ * Test that index returns a TextPlainResponse.
+ *
+ * @return void
+ */
+ public function testIndexReturnsTextPlainResponse(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+
+ $this->assertInstanceOf(TextPlainResponse::class, $response);
+
+ }//end testIndexReturnsTextPlainResponse()
+
+
+ /**
+ * Test that the response contains the info metric.
+ *
+ * @return void
+ */
+ public function testIndexContainsInfoMetric(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('2.1.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('openconnector_info{version="2.1.0"', $body);
+ $this->assertStringContainsString('php_version="'.PHP_VERSION.'"', $body);
+ $this->assertStringContainsString('nextcloud_version="30.0.0"', $body);
+
+ }//end testIndexContainsInfoMetric()
+
+
+ /**
+ * Test that the response contains the up metric.
+ *
+ * @return void
+ */
+ public function testIndexContainsUpMetric(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('openconnector_up 1', $body);
+
+ }//end testIndexContainsUpMetric()
+
+
+ /**
+ * Test that the response contains source metrics.
+ *
+ * @return void
+ */
+ public function testIndexContainsSourceMetrics(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('# HELP openconnector_sources_total', $body);
+ $this->assertStringContainsString('# TYPE openconnector_sources_total gauge', $body);
+
+ }//end testIndexContainsSourceMetrics()
+
+
+ /**
+ * Test that the response contains endpoint metrics.
+ *
+ * @return void
+ */
+ public function testIndexContainsEndpointMetrics(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('# HELP openconnector_endpoints_total', $body);
+ $this->assertStringContainsString('# TYPE openconnector_endpoints_total gauge', $body);
+ $this->assertStringContainsString('openconnector_endpoints_total 0', $body);
+
+ }//end testIndexContainsEndpointMetrics()
+
+
+ /**
+ * Test that the response contains job metrics.
+ *
+ * @return void
+ */
+ public function testIndexContainsJobMetrics(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('# HELP openconnector_jobs_total', $body);
+ $this->assertStringContainsString('# TYPE openconnector_jobs_total gauge', $body);
+ $this->assertStringContainsString('# HELP openconnector_job_runs_total', $body);
+ $this->assertStringContainsString('# TYPE openconnector_job_runs_total counter', $body);
+
+ }//end testIndexContainsJobMetrics()
+
+
+ /**
+ * Test that the response contains mapping and rule metrics.
+ *
+ * @return void
+ */
+ public function testIndexContainsMappingRuleMetrics(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('# HELP openconnector_mappings_total', $body);
+ $this->assertStringContainsString('# TYPE openconnector_mappings_total gauge', $body);
+ $this->assertStringContainsString('# HELP openconnector_rules_total', $body);
+ $this->assertStringContainsString('# TYPE openconnector_rules_total gauge', $body);
+
+ }//end testIndexContainsMappingRuleMetrics()
+
+
+ /**
+ * Test that database errors produce zero-value fallbacks.
+ *
+ * @return void
+ */
+ public function testDatabaseErrorProducesZeroFallback(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('1.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('30.0.0');
+
+ $qb = $this->createMock(IQueryBuilder::class);
+ $qb->method('select')->willReturnSelf();
+ $qb->method('from')->willReturnSelf();
+ $qb->method('groupBy')->willReturnSelf();
+ $qb->method('createFunction')->willReturn('COUNT(*) AS cnt');
+ $qb->method('executeQuery')->willThrowException(new \Exception('DB error'));
+
+ $this->db->method('getQueryBuilder')
+ ->willReturn($qb);
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ // Should still return 200 with zero-value fallbacks.
+ $this->assertInstanceOf(TextPlainResponse::class, $response);
+ $this->assertStringContainsString('openconnector_sources_total{type="rest"} 0', $body);
+ $this->assertStringContainsString('openconnector_calls_total{status="200"} 0', $body);
+
+ }//end testDatabaseErrorProducesZeroFallback()
+
+
+ /**
+ * Test that the default version is 0.0.0 when not configured.
+ *
+ * @return void
+ */
+ public function testDefaultVersionIsZero(): void
+ {
+ $this->config->method('getAppValue')
+ ->willReturn('0.0.0');
+ $this->config->method('getSystemValueString')
+ ->willReturn('0.0.0');
+
+ $this->mockDbForAllCollectors();
+
+ $response = $this->controller->index();
+ $body = $response->render();
+
+ $this->assertStringContainsString('version="0.0.0"', $body);
+
+ }//end testDefaultVersionIsZero()
+
+
+ /**
+ * Mock the database connection for all collector methods.
+ *
+ * Returns empty results for all grouped queries and 0 for count queries.
+ *
+ * @return void
+ */
+ private function mockDbForAllCollectors(): void
+ {
+ $result = $this->createMock(IResult::class);
+ $result->method('fetchAll')->willReturn([]);
+ $result->method('fetchOne')->willReturn('0');
+ $result->method('closeCursor')->willReturn(true);
+
+ $qb = $this->createMock(IQueryBuilder::class);
+ $qb->method('select')->willReturnSelf();
+ $qb->method('from')->willReturnSelf();
+ $qb->method('groupBy')->willReturnSelf();
+ $qb->method('createFunction')->willReturn('COUNT(*) AS cnt');
+ $qb->method('executeQuery')->willReturn($result);
+
+ $this->db->method('getQueryBuilder')
+ ->willReturn($qb);
+
+ }//end mockDbForAllCollectors()
+
+
+}//end class
diff --git a/tests/Unit/Service/DSOParserServiceTest.php b/tests/Unit/Service/DSOParserServiceTest.php
new file mode 100644
index 000000000..a43eaa777
--- /dev/null
+++ b/tests/Unit/Service/DSOParserServiceTest.php
@@ -0,0 +1,262 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Tests\Unit\Service;
+
+use OCA\OpenConnector\Service\DSOParserService;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests for the DSO payload parser service.
+ */
+class DSOParserServiceTest extends TestCase
+{
+
+ /**
+ * @var DSOParserService
+ */
+ private DSOParserService $parser;
+
+
+ /**
+ * Set up test fixtures.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $this->parser = new DSOParserService($logger);
+
+ }//end setUp()
+
+
+ /**
+ * Test that a valid BSN passes the 11-proef.
+ *
+ * @return void
+ */
+ public function testValidBSNPassesElfProef(): void
+ {
+ // 999993653 is a well-known test BSN.
+ $this->assertTrue($this->parser->validateBSN('999993653'));
+
+ }//end testValidBSNPassesElfProef()
+
+
+ /**
+ * Test that an invalid BSN fails the 11-proef.
+ *
+ * @return void
+ */
+ public function testInvalidBSNFailsElfProef(): void
+ {
+ $this->assertFalse($this->parser->validateBSN('123456789'));
+
+ }//end testInvalidBSNFailsElfProef()
+
+
+ /**
+ * Test that a non-numeric BSN fails validation.
+ *
+ * @return void
+ */
+ public function testNonNumericBSNFails(): void
+ {
+ $this->assertFalse($this->parser->validateBSN('abcdefghi'));
+
+ }//end testNonNumericBSNFails()
+
+
+ /**
+ * Test that a valid ISO date passes validation.
+ *
+ * @return void
+ */
+ public function testValidISODatePasses(): void
+ {
+ $this->assertTrue($this->parser->validateISODate('2024-01-15'));
+ $this->assertTrue($this->parser->validateISODate('2024-01-15T10:30:00'));
+
+ }//end testValidISODatePasses()
+
+
+ /**
+ * Test that an invalid date format fails validation.
+ *
+ * @return void
+ */
+ public function testInvalidDateFormatFails(): void
+ {
+ $this->assertFalse($this->parser->validateISODate('15-01-2024'));
+ $this->assertFalse($this->parser->validateISODate('not-a-date'));
+
+ }//end testInvalidDateFormatFails()
+
+
+ /**
+ * Test that a valid payload passes validation.
+ *
+ * @return void
+ */
+ public function testValidPayloadPassesValidation(): void
+ {
+ $payload = [
+ 'verzoekId' => 'dso-12345',
+ 'type' => 'aanvraag',
+ 'indieningsdatum' => '2024-06-15',
+ 'aanvrager' => ['bsn' => '999993653', 'naam' => 'Test'],
+ 'locatie' => ['bagAdres' => ['postcode' => '1234AB']],
+ 'activiteiten' => [['code' => 'bouwen-01', 'omschrijving' => 'Bouwen']],
+ ];
+
+ $errors = $this->parser->validatePayload($payload);
+ $this->assertEmpty($errors);
+
+ }//end testValidPayloadPassesValidation()
+
+
+ /**
+ * Test that missing required fields produce errors.
+ *
+ * @return void
+ */
+ public function testMissingRequiredFieldsProduceErrors(): void
+ {
+ $payload = [];
+
+ $errors = $this->parser->validatePayload($payload);
+
+ $this->assertNotEmpty($errors);
+
+ $fieldNames = array_column($errors, 'field');
+ $this->assertContains('verzoekId', $fieldNames);
+ $this->assertContains('type', $fieldNames);
+ $this->assertContains('indieningsdatum', $fieldNames);
+ $this->assertContains('aanvrager', $fieldNames);
+ $this->assertContains('locatie', $fieldNames);
+ $this->assertContains('activiteiten', $fieldNames);
+
+ }//end testMissingRequiredFieldsProduceErrors()
+
+
+ /**
+ * Test that an invalid type produces an error.
+ *
+ * @return void
+ */
+ public function testInvalidTypeProducesError(): void
+ {
+ $payload = [
+ 'verzoekId' => 'dso-12345',
+ 'type' => 'ongeldig',
+ 'indieningsdatum' => '2024-06-15',
+ 'aanvrager' => ['naam' => 'Test'],
+ 'locatie' => ['bagAdres' => []],
+ 'activiteiten' => [['code' => 'bouwen-01']],
+ ];
+
+ $errors = $this->parser->validatePayload($payload);
+ $fieldNames = array_column($errors, 'field');
+ $this->assertContains('type', $fieldNames);
+
+ }//end testInvalidTypeProducesError()
+
+
+ /**
+ * Test that an invalid BSN produces an error.
+ *
+ * @return void
+ */
+ public function testInvalidBSNInPayloadProducesError(): void
+ {
+ $payload = [
+ 'verzoekId' => 'dso-12345',
+ 'type' => 'aanvraag',
+ 'indieningsdatum' => '2024-06-15',
+ 'aanvrager' => ['bsn' => '123456789', 'naam' => 'Test'],
+ 'locatie' => ['bagAdres' => []],
+ 'activiteiten' => [['code' => 'bouwen-01']],
+ ];
+
+ $errors = $this->parser->validatePayload($payload);
+ $fieldNames = array_column($errors, 'field');
+ $this->assertContains('aanvrager.bsn', $fieldNames);
+
+ }//end testInvalidBSNInPayloadProducesError()
+
+
+ /**
+ * Test that parseVerzoek extracts all fields.
+ *
+ * @return void
+ */
+ public function testParseVerzoekExtractsAllFields(): void
+ {
+ $payload = [
+ 'verzoekId' => 'dso-12345',
+ 'bronorganisatie' => '00000001234567890000',
+ 'type' => 'aanvraag',
+ 'indieningsdatum' => '2024-06-15',
+ 'aanvrager' => ['bsn' => '999993653', 'naam' => 'Jansen'],
+ 'locatie' => ['bagAdres' => ['postcode' => '1234AB', 'huisnummer' => '10']],
+ 'activiteiten' => [['code' => 'bouwen-01', 'omschrijving' => 'Bouwen']],
+ 'bouwkosten' => '250000',
+ ];
+
+ $verzoek = $this->parser->parseVerzoek($payload);
+
+ $this->assertSame('dso-12345', $verzoek['verzoekId']);
+ $this->assertSame('aanvraag', $verzoek['type']);
+ $this->assertSame('ontvangen', $verzoek['status']);
+ $this->assertSame(250000.0, $verzoek['bouwkosten']);
+ $this->assertSame('999993653', $verzoek['aanvrager']['bsn']);
+ $this->assertCount(1, $verzoek['activiteiten']);
+ $this->assertSame('bouwen-01', $verzoek['activiteiten'][0]['code']);
+
+ }//end testParseVerzoekExtractsAllFields()
+
+
+ /**
+ * Test that GML point conversion works.
+ *
+ * @return void
+ */
+ public function testParseLocatieConvertsGMLPoint(): void
+ {
+ $payload = [
+ 'verzoekId' => 'dso-12345',
+ 'type' => 'aanvraag',
+ 'indieningsdatum' => '2024-06-15',
+ 'aanvrager' => [],
+ 'locatie' => [
+ 'gmlGeometrie' => '52.370216 4.895168',
+ ],
+ 'activiteiten' => [],
+ ];
+
+ $verzoek = $this->parser->parseVerzoek($payload);
+
+ $this->assertNotNull($verzoek['locatie']['geometrie']);
+ $this->assertSame('Point', $verzoek['locatie']['geometrie']['type']);
+ $this->assertEqualsWithDelta(4.895168, $verzoek['locatie']['geometrie']['coordinates'][0], 0.0001);
+ $this->assertEqualsWithDelta(52.370216, $verzoek['locatie']['geometrie']['coordinates'][1], 0.0001);
+
+ }//end testParseLocatieConvertsGMLPoint()
+
+
+}//end class
diff --git a/tests/Unit/Service/IBabsConnectorServiceTest.php b/tests/Unit/Service/IBabsConnectorServiceTest.php
new file mode 100644
index 000000000..b99d18b8e
--- /dev/null
+++ b/tests/Unit/Service/IBabsConnectorServiceTest.php
@@ -0,0 +1,151 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Tests\Unit\Service;
+
+use OCA\OpenConnector\Service\IBabsConnectorService;
+use OCA\OpenConnector\Service\CallService;
+use OCA\OpenConnector\Db\Source;
+use OCA\OpenConnector\Db\SourceMapper;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests for the iBabs connector service.
+ */
+class IBabsConnectorServiceTest extends TestCase
+{
+
+ /**
+ * @var IBabsConnectorService
+ */
+ private IBabsConnectorService $service;
+
+ /**
+ * @var CallService|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $callService;
+
+
+ /**
+ * Set up test fixtures.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->callService = $this->createMock(CallService::class);
+ $sourceMapper = $this->createMock(SourceMapper::class);
+ $logger = $this->createMock(LoggerInterface::class);
+
+ $this->service = new IBabsConnectorService(
+ $this->callService,
+ $sourceMapper,
+ $logger
+ );
+
+ }//end setUp()
+
+
+ /**
+ * Test that besluit status mapping works correctly.
+ *
+ * @return void
+ */
+ public function testMapBesluitStatusAangenomen(): void
+ {
+ $result = $this->service->mapBesluitStatus('aangenomen');
+ $this->assertSame('Besluit: aangenomen', $result);
+
+ }//end testMapBesluitStatusAangenomen()
+
+
+ /**
+ * Test that besluit status mapping handles verworpen.
+ *
+ * @return void
+ */
+ public function testMapBesluitStatusVerworpen(): void
+ {
+ $result = $this->service->mapBesluitStatus('verworpen');
+ $this->assertSame('Besluit: verworpen', $result);
+
+ }//end testMapBesluitStatusVerworpen()
+
+
+ /**
+ * Test that besluit status mapping handles aangehouden.
+ *
+ * @return void
+ */
+ public function testMapBesluitStatusAangehouden(): void
+ {
+ $result = $this->service->mapBesluitStatus('aangehouden');
+ $this->assertSame('Besluit: aangehouden', $result);
+
+ }//end testMapBesluitStatusAangehouden()
+
+
+ /**
+ * Test that unknown besluit status returns onbekend.
+ *
+ * @return void
+ */
+ public function testMapBesluitStatusUnknown(): void
+ {
+ $result = $this->service->mapBesluitStatus('unknown-status');
+ $this->assertSame('Besluit: onbekend', $result);
+
+ }//end testMapBesluitStatusUnknown()
+
+
+ /**
+ * Test that test connection fails without organisatieId.
+ *
+ * @return void
+ */
+ public function testTestConnectionFailsWithoutOrganisatieId(): void
+ {
+ $source = $this->createMock(Source::class);
+ $source->method('getConfiguration')->willReturn('{}');
+
+ $result = $this->service->testConnection($source);
+
+ $this->assertFalse($result['success']);
+ $this->assertStringContainsString('Organisation ID', $result['message']);
+
+ }//end testTestConnectionFailsWithoutOrganisatieId()
+
+
+ /**
+ * Test that push voorstel returns not-implemented placeholder.
+ *
+ * @return void
+ */
+ public function testPushVoorstelReturnsPlaceholder(): void
+ {
+ $source = $this->createMock(Source::class);
+ $source->method('getConfiguration')->willReturn('{"organisatieId": "test-123"}');
+
+ $result = $this->service->pushVoorstel($source, ['onderwerp' => 'Test voorstel']);
+
+ $this->assertFalse($result['success']);
+ $this->assertNull($result['vergaderstukId']);
+
+ }//end testPushVoorstelReturnsPlaceholder()
+
+
+}//end class
diff --git a/tests/Unit/Service/StUFFieldMapperTest.php b/tests/Unit/Service/StUFFieldMapperTest.php
new file mode 100644
index 000000000..922ddba2c
--- /dev/null
+++ b/tests/Unit/Service/StUFFieldMapperTest.php
@@ -0,0 +1,217 @@
+
+ * @copyright 2024 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenConnector\Tests\Unit\Service;
+
+use OCA\OpenConnector\Service\StUFFieldMapper;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests for the StUF field mapper service.
+ */
+class StUFFieldMapperTest extends TestCase
+{
+
+ /**
+ * @var StUFFieldMapper
+ */
+ private StUFFieldMapper $mapper;
+
+
+ /**
+ * Set up test fixtures.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $this->mapper = new StUFFieldMapper($logger);
+
+ }//end setUp()
+
+
+ /**
+ * Test mapping a person to StUF-BG format.
+ *
+ * @return void
+ */
+ public function testMapPersonToStUF(): void
+ {
+ $person = [
+ 'burgerservicenummer' => '999993653',
+ 'geslachtsnaam' => 'Moulin',
+ 'voornamen' => 'Suzanne',
+ 'geboortedatum' => '1990-05-15',
+ ];
+
+ $result = $this->mapper->mapPersonToStUF($person);
+
+ $this->assertSame('999993653', $result['inp.bsn']);
+ $this->assertSame('Moulin', $result['geslachtsnaam']);
+ $this->assertSame('Suzanne', $result['voornamen']);
+ $this->assertSame('19900515', $result['geboortedatum']);
+
+ }//end testMapPersonToStUF()
+
+
+ /**
+ * Test mapping StUF-BG data back to OpenRegister format.
+ *
+ * @return void
+ */
+ public function testMapStUFToPerson(): void
+ {
+ $stufData = [
+ 'inp.bsn' => '999993653',
+ 'geslachtsnaam' => 'Moulin',
+ 'voornamen' => 'Suzanne',
+ 'geboortedatum' => '19900515',
+ ];
+
+ $result = $this->mapper->mapStUFToPerson($stufData);
+
+ $this->assertSame('999993653', $result['burgerservicenummer']);
+ $this->assertSame('Moulin', $result['geslachtsnaam']);
+ $this->assertSame('1990-05-15', $result['geboortedatum']);
+
+ }//end testMapStUFToPerson()
+
+
+ /**
+ * Test ISO date to StUF date conversion.
+ *
+ * @return void
+ */
+ public function testIsoDateToStUF(): void
+ {
+ $this->assertSame('19900515', $this->mapper->isoDateToStUF('1990-05-15'));
+ $this->assertSame('20240101', $this->mapper->isoDateToStUF('2024-01-01'));
+
+ }//end testIsoDateToStUF()
+
+
+ /**
+ * Test StUF date to ISO date conversion.
+ *
+ * @return void
+ */
+ public function testStufDateToISO(): void
+ {
+ $this->assertSame('1990-05-15', $this->mapper->stufDateToISO('19900515'));
+ $this->assertSame('2024-01-01', $this->mapper->stufDateToISO('20240101'));
+
+ }//end testStufDateToISO()
+
+
+ /**
+ * Test address mapping to StUF format.
+ *
+ * @return void
+ */
+ public function testMapAddressToStUF(): void
+ {
+ $address = [
+ 'straatnaam' => 'Hoofdstraat',
+ 'huisnummer' => '10',
+ 'postcode' => '1234AB',
+ 'woonplaats' => 'Utrecht',
+ ];
+
+ $result = $this->mapper->mapAddressToStUF($address);
+
+ $this->assertSame('Hoofdstraat', $result['gor.straatnaam']);
+ $this->assertSame('10', $result['aoa.huisnummer']);
+ $this->assertSame('1234AB', $result['aoa.postcode']);
+ $this->assertSame('Utrecht', $result['wpl.woonplaatsNaam']);
+
+ }//end testMapAddressToStUF()
+
+
+ /**
+ * Test nested verblijfsadres mapping.
+ *
+ * @return void
+ */
+ public function testMapPersonWithVerblijfsadres(): void
+ {
+ $person = [
+ 'burgerservicenummer' => '999993653',
+ 'geslachtsnaam' => 'Moulin',
+ 'verblijfsadres' => [
+ 'straatnaam' => 'Hoofdstraat',
+ 'huisnummer' => '10',
+ 'postcode' => '1234AB',
+ 'woonplaats' => 'Utrecht',
+ ],
+ ];
+
+ $result = $this->mapper->mapPersonToStUF($person);
+
+ $this->assertArrayHasKey('verblijfsadres', $result);
+ $this->assertSame('Hoofdstraat', $result['verblijfsadres']['gor.straatnaam']);
+
+ }//end testMapPersonWithVerblijfsadres()
+
+
+ /**
+ * Test custom field mapping.
+ *
+ * @return void
+ */
+ public function testCustomFieldMapping(): void
+ {
+ $person = [
+ 'achternaam' => 'Jansen',
+ ];
+
+ $customMapping = [
+ 'achternaam' => 'geslachtsnaam',
+ ];
+
+ $result = $this->mapper->mapPersonToStUF($person, $customMapping);
+
+ $this->assertSame('Jansen', $result['geslachtsnaam']);
+
+ }//end testCustomFieldMapping()
+
+
+ /**
+ * Test invalid ISO date returns unchanged.
+ *
+ * @return void
+ */
+ public function testInvalidIsoDateReturnsUnchanged(): void
+ {
+ $this->assertSame('not-a-date', $this->mapper->isoDateToStUF('not-a-date'));
+
+ }//end testInvalidIsoDateReturnsUnchanged()
+
+
+ /**
+ * Test invalid StUF date returns unchanged.
+ *
+ * @return void
+ */
+ public function testInvalidStUFDateReturnsUnchanged(): void
+ {
+ $this->assertSame('notadate', $this->mapper->stufDateToISO('notadate'));
+
+ }//end testInvalidStUFDateReturnsUnchanged()
+
+
+}//end class