diff --git a/.specter-prompt.txt b/.specter-prompt.txt index afd81d0f..deb910d8 100644 --- a/.specter-prompt.txt +++ b/.specter-prompt.txt @@ -4,19 +4,19 @@ You are running HEADLESS. Do NOT use AskUserQuestion, interactive prompts, or sk Write files directly using the Write tool. ## Inputs -- Change: mcp-tools (title: mcp-tools Specification) -- Context brief: openspec/changes/mcp-tools/context-brief.md +- Change: notubiz-ibabs-griffie-koppeling (title: NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync)) +- Context brief: openspec/changes/notubiz-ibabs-griffie-koppeling/context-brief.md - Data model ADR: openspec/architecture/adr-000-data-model.md ## Step 1: Read context Read these files COMPLETELY before writing anything: -- openspec/changes/mcp-tools/context-brief.md (features, stories, stakeholders, journeys) +- openspec/changes/notubiz-ibabs-griffie-koppeling/context-brief.md (features, stories, stakeholders, journeys) - openspec/architecture/adr-000-data-model.md (entity definitions) - All files in openspec/architecture/ (app ADRs) - All files in .claude/openspec/architecture/ (company ADRs, if present) ## Step 2: Get artifact templates -Run: `openspec instructions proposal --change mcp-tools --json` +Run: `openspec instructions proposal --change notubiz-ibabs-griffie-koppeling --json` This returns the template and rules for the proposal artifact. Then do the same for: design, specs, tasks (in that order). @@ -35,8 +35,8 @@ For each artifact (proposal → design → specs → tasks): ## Step 4: Commit After all 4 artifacts are written: ```bash -git add openspec/changes/mcp-tools/ -git commit -m 'feat: Add OpenSpec change mcp-tools from Specter' +git add openspec/changes/notubiz-ibabs-griffie-koppeling/ +git commit -m 'feat: Add OpenSpec change notubiz-ibabs-griffie-koppeling from Specter' ``` Do NOT push — the caller handles pushing. Do NOT create branches — stay on the current branch. diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/.openspec.yaml b/openspec/changes/notubiz-ibabs-griffie-koppeling/.openspec.yaml new file mode 100644 index 00000000..4a1c6774 --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-22 diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/context-brief.md b/openspec/changes/notubiz-ibabs-griffie-koppeling/context-brief.md new file mode 100644 index 00000000..b73f275d --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/context-brief.md @@ -0,0 +1,163 @@ +--- +status: draft +--- +# NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync) + +## Placement & Information Architecture + +**Placement type:** `SETTING` — Setting under the app's Beheer/Admin/Configuration surface. Lives in the existing settings UI; no top-level menu entry. + +**Lives at:** Beheer > Integraties / Beheer + +**Rationale:** Bidirectional sync configured by griffie +_Source: /tmp/ia-doc-dec-cat-conn.md_ + +> **Implementation note for builders:** Respect the placement above. Do not promote this spec to a top-level menu item, sub-page, or new route unless the placement type explicitly says so. If the placement is `DETAIL_TAB`, `WIDGET`, `ACTION`, `SETTING`, or `INFRA`, the feature must NOT introduce a new entry in the app sidebar. When in doubt, ask before creating a new top-level surface. + +## Purpose + +Vrijwel alle 342 Nederlandse gemeenten (en de 12 provincies en 21 waterschappen) gebruiken één van twee griffie-systemen voor het beheer van politieke besluitvorming: NOTUBIZ (geschat marktaandeel circa 60% gemeenten) of iBabs (Gemeente Oplossingen, circa 35%). De overige paar procent gebruikt Companion (oudere LIAS-leverancier) of een zelfgebouwd systeem. Beide marktleiders zijn proprietary-SaaS, met gesloten datamodellen, en de gemeente betaalt jaarlijks zes-cijferige bedragen voor licenties, hosting en aanvullende modules. Bovendien zijn de exit-kosten enorm: alle agenda's, besluiten, moties en stemmingen zitten vast in vendor-formaten. + +Decidesk wil het democratisch besluitvormingsproces openen via een open datamodel (gebaseerd op het Popolo-standaard, het OASIS Akoma Ntoso-standaard en de Nederlandse Officiële Bekendmakingen-standaard STOP/TPOD). Maar gemeenten kunnen niet van de ene op de andere dag overstappen — het is realistischer om decidesk eerst naast NOTUBIZ of iBabs te draaien als open laag, met bidirectional sync, zodat: (1) de griffie haar bestaande tool blijft gebruiken voor de officiële workflow, (2) raadsleden via decidesk een betere UX krijgen voor consultatie en participatie, (3) alle data tegelijkertijd in een open formaat beschikbaar komt voor burgers en hergebruik door andere apps (mydash dashboards, opencatalogi inzichten, docudesk PDF-publicatie), en (4) de gemeente over 2-5 jaar de optie heeft om NOTUBIZ/iBabs uit te faseren zonder data-verlies. + +Deze spec definieert de bidirectional synchronisatie: lezen uit NOTUBIZ/iBabs van alle vergadering-gerelateerde objecten (agenda, vergaderstukken, aanwezigheid, stemming, besluiten, moties, amendementen, schriftelijke vragen, fracties), schrijven terug van decidesk-gegenereerde inzichten (samenvattingen, transcript-links, participatie-signalen), en het oplossen van conflicten bij gelijktijdige bewerkingen via een hybrid CRDT/last-writer-wins-with-audit-trail-model. + +## Data Model + +Deze spec hergebruikt grotendeels de bestaande decidesk-schema's en voegt een synchronisatie-laag toe in openconnector. + +**Bestaande decidesk-schema's** die door deze sync worden gevuld: `Vergadering`, `Agendapunt`, `Vergaderstuk`, `Aanwezigheid`, `Stemming`, `Besluit`, `Motie`, `Amendement`, `SchriftelijkeVraag`, `Persoon`, `Fractie`, `Rol`. + +**Nieuwe schema's in deze spec:** + +**Schema: `ExternalIdentifier`** — koppelt een decidesk-object aan zijn equivalent in NOTUBIZ of iBabs. Velden: `id` (uuid), `localObject` (ref, polymorphic), `localObjectType` (string, schema-naam), `provider` (enum: notubiz, ibabs, companion), `externalId` (string, vendor-id), `externalUrl` (uri), `externalEtag` (string, voor conditional GETs), `lastSyncedAt` (datetime), `syncDirection` (enum: pull-only, push-only, bidirectional), `lastModifiedLocal` (datetime) en `lastModifiedExternal` (datetime). + +**Schema: `SyncJob`** — uitvoeringsregister voor een sync-operatie. Velden: `id` (uuid), `provider`, `direction` (enum: pull, push, full), `scope` (enum: incremental, full, single-object), `targetObjectType` (string, optioneel), `targetObjectId` (string, optioneel), `startedAt` (datetime), `finishedAt` (datetime), `status` (enum: pending, running, success, partial, failed), `objectsProcessed` (int), `objectsCreated` (int), `objectsUpdated` (int), `objectsSkipped` (int), `objectsConflicted` (int), `errorLog` (text), `triggeredBy` (enum: webhook, cron, manual, event). + +**Schema: `SyncConflict`** — onopgeloste conflicten bij bidirectional sync. Velden: `id` (uuid), `externalIdentifier` (ref), `field` (string, JSONPath), `localValue` (json), `externalValue` (json), `localChangedAt` (datetime), `externalChangedAt` (datetime), `localChangedBy` (ref naar User of system), `detectedAt` (datetime), `status` (enum: open, resolved-local, resolved-external, resolved-merged, resolved-ignored), `resolvedBy` (ref naar User), `resolvedAt` (datetime), `resolution` (text — vrije notitie). + +**Schema: `FractieRol`** — per fractie de rolverdeling die bij beide providers wordt gesynchroniseerd. Velden: `id` (uuid), `fractie` (ref), `persoon` (ref), `rol` (enum: fractievoorzitter, plaatsvervangend-fractievoorzitter, secretaris, woordvoerder, gewoon-lid), `portefeuille` (array string, bv. ["zorg", "onderwijs"]), `actiefVan` (date), `actiefTot` (date, optioneel). + +## Requirements + +**REQ-NIK-001: Volledige initiële import per provider** +Bij installatie of bij toevoegen van een nieuw kalenderjaar MOET de connector een volledige history-import kunnen uitvoeren. +- GIVEN een gemeente met een NOTUBIZ-account en bestaande historie van 2 jaar, WHEN een beheerder `full-sync notubiz --from 2024-01-01` triggert, THEN MOET het systeem alle vergaderingen, agendapunten, vergaderstukken, stemmingen en besluiten van die periode importeren binnen 24 uur, gepaginated en met rate-limit respect (NOTUBIZ default: 60 requests/min). +- GIVEN dezelfde gemeente schakelt later over op iBabs voor sommige commissies, WHEN ook iBabs wordt geconfigureerd, THEN MOET de connector duplicate-detectie uitvoeren op `Vergadering.datum + Vergadering.naam` en bestaande Vergaderingen koppelen via een tweede `ExternalIdentifier` in plaats van duplicaten te creëren. +- GIVEN een vergaderstuk in NOTUBIZ is 200MB groot, WHEN het wordt geïmporteerd, THEN MOET de connector het bestand streamen naar object-storage in plaats van in-memory te laden. + +**REQ-NIK-002: Incrementele pull elke 15 minuten via cron of webhook** +Standaard MOET de connector elke 15 minuten een delta-pull uitvoeren; bij beschikbaarheid van webhooks MOET die de primaire trigger zijn. +- GIVEN NOTUBIZ ondersteunt geen webhooks (per 2026), WHEN de cron loopt, THEN MOET de connector een `?modified-since={lastSyncedAt}` query uitvoeren op alle relevante endpoints. +- GIVEN iBabs publiceert wel webhooks op `meeting.updated`, WHEN een webhook binnenkomt, THEN MOET de connector binnen 60 seconden de wijziging in decidesk reflecteren. +- GIVEN een delta-pull bevat 0 wijzigingen, WHEN deze klaar is, THEN MOET de SyncJob `status: success` met `objectsProcessed: 0` registreren en geen events uitsturen. + +**REQ-NIK-003: Push van decidesk-wijzigingen terug naar provider** +Wijzigingen die in decidesk worden gedaan op gesynchroniseerde objecten MOETEN terug naar de provider worden geduwd voor de velden die de provider als writable markeert. +- GIVEN een griffier wijzigt in decidesk de titel van een agendapunt, WHEN de wijziging wordt opgeslagen, THEN MOET een push-job binnen 30 seconden de wijziging naar de provider sturen via `PATCH /agenda-items/{id}` (NOTUBIZ) of `agendaItems.update` (iBabs SOAP). +- GIVEN de provider rejected de push (bijvoorbeeld omdat de vergadering is afgesloten), WHEN de rejection binnenkomt, THEN MOET de connector een SyncConflict creëren met `resolution: provider-rejected` en de wijziging in decidesk markeren als "niet-gesynchroniseerd". +- GIVEN een veld is niet writable in de provider (bv. `Stemming.uitslag` in NOTUBIZ is read-only), WHEN een decidesk-wijziging daarvoor wordt aangeboden, THEN MOET de connector de push skippen, een waarschuwing loggen en de wijziging in decidesk als local-only behouden. + +**REQ-NIK-004: Conflict-detectie bij gelijktijdige bewerking** +Wanneer hetzelfde veld lokaal én extern is gewijzigd sinds de laatste sync MOET de connector een SyncConflict aanmaken in plaats van automatisch te overschrijven. +- GIVEN een Vergadering met `lastSyncedAt: 10:00`, GIVEN een griffier wijzigt om 10:30 in NOTUBIZ de locatie naar "Burgerzaal", GIVEN tegelijk wijzigt een decidesk-gebruiker om 10:35 de locatie naar "Raadszaal", WHEN om 10:45 de sync loopt, THEN MOET een SyncConflict worden aangemaakt met beide waarden en `status: open`. +- GIVEN een SyncConflict met `status: open`, WHEN een griffier de conflict-resolution UI opent, THEN MOET hij beide waarden zien, kunnen kiezen voor lokaal, extern of een nieuwe merged value, en moet de keuze worden gelogd in `resolvedBy`/`resolution`. +- GIVEN een SyncConflict blijft 7 dagen `open`, WHEN de cron loopt, THEN MOET een notificatie naar de griffier en de decidesk-beheerder worden gestuurd, en MOET de relevante objecten in decidesk een visuele "in conflict"-badge tonen. + +**REQ-NIK-005: Per-fractie rollen, portefeuilles en woordvoerderschappen** +FractieRol-objecten MOETEN worden gesynchroniseerd zodat de juiste persoon namens de fractie spreekt over een onderwerp. +- GIVEN NOTUBIZ levert per fractie een lijst leden met rol-velden, WHEN deze worden geïmporteerd, THEN MOET een FractieRol per persoon-fractie-combinatie worden aangemaakt en gekoppeld aan het lopende kalenderjaar. +- GIVEN een fractievoorzitter wisselt, WHEN dat bij NOTUBIZ wordt geregistreerd, THEN MOET de oude FractieRol `actiefTot` krijgen en een nieuwe FractieRol worden aangemaakt; bestaande verwijzingen in decidesk (bv. in Stemmingen) MOGEN niet worden gemuteerd. +- GIVEN iBabs werkt zonder expliciete portefeuilles, WHEN een import uit iBabs loopt, THEN MOET het `portefeuille`-veld leeg blijven en MOET de griffie het in de UI handmatig kunnen vullen zonder dat de sync het overschrijft. + +**REQ-NIK-006: Stemming-detectie inclusief hoofdelijke stemming** +Stemmingen MOETEN met volledige uitsplitsing per persoon worden gesynchroniseerd, niet alleen totalen. +- GIVEN een hoofdelijke stemming over een motie, WHEN deze uit NOTUBIZ wordt geïmporteerd, THEN MOET er per aanwezig raadslid één `StemUitgebracht`-object zijn met `stem: voor|tegen|onthouden|afwezig|niet-deelgenomen`. +- GIVEN een fractie-stemming (zonder hoofdelijk), WHEN deze wordt geïmporteerd, THEN MOET één `StemUitgebracht` per fractie worden aangemaakt met `aantal`-veld en `stem`-veld; individuele leden MOETEN automatisch worden gemarkeerd als "verondersteld conform fractielijn" tenzij anders aangegeven. +- GIVEN een stemming wordt later geannuleerd (door een procedurefout), WHEN dat bij de provider wordt geregistreerd, THEN MOET de Stemming `status: geannuleerd` krijgen met audit-link naar de annulering, MAG het record NIET worden verwijderd. + +**REQ-NIK-007: Moties, amendementen en schriftelijke vragen met versiebeheer** +Moties en amendementen MOETEN met volledige versiehistorie worden gesynchroniseerd inclusief de relatie tot agendapunt en uiteindelijke besluit. +- GIVEN een motie M2024-15 wordt in NOTUBIZ aangemaakt en later geamendeerd (versie 2), WHEN beide versies worden geïmporteerd, THEN MOET decidesk twee `MotieVersie`-records hebben gekoppeld aan dezelfde Motie en MOET de UI standaard de meest recente versie tonen met "vorige versies"-link. +- GIVEN een schriftelijke vraag wordt door de wethouder beantwoord na 6 weken, WHEN het antwoord wordt gepubliceerd, THEN MOET de SchriftelijkeVraag een `antwoord`-veld krijgen en de `status` op `beantwoord` worden gezet, met `aantalDagen` berekend. +- GIVEN een amendement wordt ingetrokken vóór stemming, WHEN dat in NOTUBIZ gebeurt, THEN MOET het in decidesk `status: ingetrokken` krijgen en MOET de relatie met het agendapunt blijven bestaan voor traceerbaarheid. + +**REQ-NIK-008: Aanwezigheid bij vergaderingen** +Per vergadering MOET een aanwezigheidslijst worden bijgehouden synchroon met de griffie-administratie. +- GIVEN NOTUBIZ levert presentielijsten via `/meetings/{id}/attendance`, WHEN deze wordt geïmporteerd, THEN MOET een Aanwezigheid-record per raadslid worden aangemaakt met `status: aanwezig|afwezig-gemeld|afwezig-zonder-bericht|deels-aanwezig` en eventuele `vervangenDoor`-ref. +- GIVEN een raadslid komt 30 minuten te laat, WHEN dat in iBabs wordt geregistreerd via tap-in op de microfoonkoppeling, THEN MOET de Aanwezigheid `aanwezigVanaf`-tijdstip krijgen. +- GIVEN een Aanwezigheid-update na publicatie van de besluitenlijst, WHEN deze wordt ontvangen, THEN MOET een notificatie naar de griffier worden gestuurd voor verificatie, omdat publicatie-na-correctie reputatieschade kan veroorzaken. + +**REQ-NIK-009: Vergaderstukken met inhoudelijke metadata** +PDF-vergaderstukken en bijlagen MOETEN met provider-metadata worden gesynchroniseerd. +- GIVEN een vergaderstuk van 50MB bij agendapunt 7, WHEN het wordt geïmporteerd, THEN MOET het bestand worden gestreamd naar object-storage en MOET een Vergaderstuk-record worden gemaakt met `titel`, `bestandsnaam`, `mimeType`, `grootte`, `sha256`, `vertrouwelijkheidsniveau` en link naar het Agendapunt. +- GIVEN een stuk wordt aangemerkt als `vertrouwelijk` of `geheim` door de griffie, WHEN dat wordt geïmporteerd, THEN MOET het bestand NIET publiek toegankelijk zijn en MOET een toegangs-ACL gelden van enkel raadsleden en griffie. +- GIVEN een vergaderstuk wordt na de vergadering vervangen door een gecorrigeerde versie, WHEN deze wordt geïmporteerd, THEN MOET de oude versie als VergaderstukVersie behouden blijven en MOET de actieve versie de meest recente zijn. + +**REQ-NIK-010: Observability — sync-dashboard en alerting** +De connector MOET een operationeel dashboard met statistieken en alert-thresholds bieden. +- GIVEN een beheerder opent `/admin/sync-status`, WHEN de pagina laadt, THEN MOET deze per provider de laatste 24 uur SyncJobs tonen, het aantal open conflicten, gemiddelde sync-latency en quota-gebruik bij de provider. +- GIVEN drie opeenvolgende sync-jobs hebben `status: failed`, WHEN de derde faalt, THEN MOET een PagerDuty/Slack/e-mail alert naar de geconfigureerde on-call gaan. +- GIVEN het aantal openstaande SyncConflicts groter dan 25 is, WHEN de health-check loopt, THEN MOET de overall connector-status `degraded` worden, zichtbaar in mydash en in het admin-paneel. + +## Standards & Sources + +- **OASIS Akoma Ntoso 1.0** — XML-schema voor parlementaire en juridische documenten; gebruikt voor canonieke serialisatie van moties en besluiten. +- **Popolo specification** — JSON-LD ontology voor "popolarized" politieke data (Personen, Organisaties (fracties), Memberships, Posts, Events). +- **STOP/TPOD** (Standaard Officiële Publicaties / Toepassingsprofiel) — KOOP/Logius standaard voor officiële bekendmakingen door gemeenten (besluitenlijsten, raadsbesluiten). +- **DCAT-AP-NL** — voor publicatie van open vergaderdata als dataset op data.overheid.nl. +- **NOTUBIZ API** — REST + JSON, OAuth2. Endpoints: `/meetings`, `/agenda-items`, `/documents`, `/voting`, `/decisions`, `/motions`, `/amendments`, `/questions`, `/people`, `/parties`, `/attendance`. Rate-limit 60 req/min per tenant. +- **iBabs API** — SOAP + JSON-REST (modern). Endpoints documented in iBabs API portal; modules: `Public Data`, `Meeting Items`, `Voting`, `Documents`. Webhooks via "Notifications" module. +- **AVG artikel 6 lid 1 sub e + artikel 6 lid 3** — wettelijke grondslag voor verwerking persoonsgegevens van raadsleden (publieke functie). Voor insprekers: artikel 6 lid 1 sub a (toestemming). +- **Wet open overheid (WOO) artikel 3.3** — actieve openbaarmakingsplicht voor besluitenlijsten, stemmingen, agenda's, vergaderstukken. +- **Gemeentewet artikel 19-25** — procedures rond raadsvergaderingen. +- **Reglement van Orde van de gemeenteraad** (gemeente-specifiek) — bepaalt hoe moties, amendementen en schriftelijke vragen worden behandeld. + +## Cross-app integration + +- **decidesk** (base) — eigenaar van alle politiek-domein-schema's; deze spec vult ze via openconnector. +- **openconnector** — host voor de NOTUBIZ-adapter, iBabs-adapter en de generieke `SyncJob`/`SyncConflict`-engine. Adapters draaien als losse jobs in de openconnector-runner. +- **openregister** — host voor de schema-definities en de `ExternalIdentifier`-koppeltabel. +- **raadsvergadering-livestream-transcript** (zustertspec) — consumeert de microfoon-events die deze adapter publiceert om Spreker-koppelingen op te bouwen. +- **opentalk** (optioneel) — kan stemmingen real-time pushen naar een Talk-kanaal van de fractie. +- **docudesk** — gebruikt gesynchroniseerde Besluiten voor automatische PDF-besluitenlijst-generatie met STOP/TPOD-conform XML. +- **opencatalogi** — publiceert de gesynchroniseerde data als open dataset op data.overheid.nl conform DCAT-AP-NL. +- **mydash** — KPI's: aantal vergaderingen per maand, aantal aangenomen/verworpen moties, presentiepercentage per fractie, sync-success-rate per provider. +- **softwarecatalog** — registreert decidesk + de NOTUBIZ/iBabs-adapter als toegepaste software voor Pas-toe-of-leg-uit-rapportage. + +## Target users + +- **Griffie** (primair) — blijft in haar vertrouwde tool werken, maar krijgt automatisch een open kopie en betere dashboards. Krijgt notificaties bij conflicten en kan deze in één UI oplossen. +- **Raadsleden** — krijgen een betere mobiele/zoek-ervaring via decidesk, terwijl hun standaard-workflow (stukken voorbereiden in iBabs/NOTUBIZ-app) onveranderd blijft. +- **Fracties** — kunnen hun eigen moties, schriftelijke vragen en stemgedrag analyseren over jaren heen. +- **Burgers en journalisten** — krijgen consistente, doorzoekbare toegang tot besluitvorming over alle gemeenten heen. +- **Onderzoekers en Rekenkamers** — kunnen longitudinaal en cross-municipaal analyseren (bv. hoe stemden GroenLinks-fracties over windenergie?). +- **CIO/CISO** — krijgt een exit-strategie van vendor lock-in zonder big-bang migratie. +- **VNG en BZK** — krijgen sectorale dataset over besluitvormingsprocessen; input voor beleid rond toegankelijke democratie. +- **Toezichthouders WOO** — kunnen verifiëren of actieve openbaarmakingsplicht wordt nageleefd. +- **Civic tech-ontwikkelaars** (Open State Foundation, Code for NL) — krijgen via één consistente API toegang tot besluitvormingsdata over honderden gemeenten, in plaats van per gemeente een aparte koppeling te moeten bouwen of te scrapen. +- **Lokale rekenkamers** — krijgen een audit-trail van wijzigingen op besluiten en moties die uniek is voor decidesk (de huidige proprietary systemen loggen mutatie-historie soms onvolledig). + +## Implementatie-overwegingen + +**Keuze 1: Polymorphic ExternalIdentifier boven separate join-tabellen per type.** Met circa 12 verschillende decidesk-objecttypen zou per-type een join-tabel beheermatig zwaar zijn. Eén polymorphic tabel met `localObjectType + localObjectId + provider + externalId` als unique-key biedt voldoende performance (indexes op `(localObjectType, localObjectId)` en `(provider, externalId)` zijn voor de verwachte volumes — tienduizenden records per gemeente per jaar — ruim genoeg). + +**Keuze 2: Last-writer-wins met conflict-flag, geen automatic merge.** Echte CRDT-merge over rijke domeinmodellen (motie-tekst, besluit-formulering) is gevaarlijk: een automatische tekstmerge kan een juridisch onjuiste tekst produceren. Beter is om expliciet conflicten te detecteren en aan een mens (griffier) voor te leggen. Voor velden waar conflicten zelden voorkomen (`Vergadering.locatie`, `Persoon.email`) is dat geen pijn; voor zware velden (motie-tekst) hoort het ook zo. + +**Keuze 3: Pull-based default, push-back opt-in per organisatie.** Niet elke gemeente wil decidesk-wijzigingen terug naar NOTUBIZ/iBabs duwen — sommige zien decidesk in eerste instantie als read-only publieke laag. Push moet per `(organisatie, provider, objecttype)` configureerbaar zijn, met sane defaults (notities altijd local-only, agenda-titel push-able). + +**Keuze 4: Schemas blijven in decidesk-register, niet in een aparte sync-register.** Argument: een sync-laag mag het canonieke datamodel niet vervuilen. ExternalIdentifier en SyncJob horen wel in een aparte register `decidesk-sync` om de scheiding van zorgen helder te houden, maar mogen geen circulaire dependencies veroorzaken. + +## Out-of-scope (toekomstige iteraties) + +Niet in v1: synchronisatie met Companion (LIAS) — kleine markt, separate spec waard wanneer een klant het concreet vraagt; bulk-export naar STOP/TPOD-XML voor officiële bekendmakingen (dat is een aparte publicatie-spec via docudesk); historische backfill verder dan 2 jaar (eerst valideren op 2-jaar performance); webhook-receiver voor providers die nog geen webhooks publiceren (afhankelijk van Forum Standaardisatie-richting); meer geavanceerde conflict-merge (bv. semi-automatic merge bij niet-overlappende edits in lange teksten — interessant maar niet kritisch voor v1); auto-detectie van privacy-gevoelige content in vergaderstukken (zou een NLP-stap toevoegen — apart traject); integratie met Statenvergaderingen (provincies) en Algemeen Bestuur (waterschappen) — provincies en waterschappen gebruiken ook NOTUBIZ/iBabs maar hebben specifieke schema-uitbreidingen die separate validatie vereisen. + +## Risico's en mitigaties + +**Risico: Vendor-API-veranderingen breken sync.** NOTUBIZ en iBabs kunnen breaking changes doorvoeren met korte aankondigingstermijn. Mitigatie: connector-versies geversioneerd; contract-tests die dagelijks tegen sandbox-omgevingen lopen; alert wanneer een sandbox-test faalt zodat de bug-fix vóór productie-rollout van de API klaar is. + +**Risico: Performance-degradatie bij grote gemeenten.** Amsterdam met 45 raadsleden en 18 commissies per maand genereert mogelijk meer dan 10.000 sync-events per dag. Mitigatie: per-organisatie sharding; rate-limit-awareness met token-bucket per provider; bulk-endpoints prefereren boven per-object-GETs. + +**Risico: Conflict-explosie bij gelijktijdige gewone bewerkingen.** Als griffier én decidesk-redacteur tegelijk werken zonder coördinatie kunnen conflicten zich opstapelen tot een onwerkbare queue. Mitigatie: per-veld locking-hints in de UI ("griffie bewerkt dit veld in NOTUBIZ — wijzig niet"); reactieve UI-updates via SSE bij externe wijzigingen; conflict-quota-alert bij overschrijden van 10 conflicten per week. + +**Risico: Push-back schaadt griffie-workflow.** Als decidesk per ongeluk verkeerde data terugduwt naar NOTUBIZ kan dat de officiële agenda verstoren. Mitigatie: push-back default off; per `(organisatie, objecttype, veld)` granulair aan-/uit-zetten; dry-run-mode waarin een week mock-pushes worden gelogd zonder daadwerkelijk te schrijven. diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/design.md b/openspec/changes/notubiz-ibabs-griffie-koppeling/design.md new file mode 100644 index 00000000..9a566afc --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/design.md @@ -0,0 +1,300 @@ +# Design — NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync) + +## Context + +The decidesk platform aims to disintermediate municipal political decision-making by exposing vendor-controlled data (NOTUBIZ/iBabs) through an open data model. The sync layer bridges proprietary systems and open standards without requiring immediate vendor exit — incremental migration over 2–5 years. + +**Current state:** + +- Decidesk schemas (`Meeting`, `AgendaItem`, `Person`, `Motion`, `Vote`, `Decision`, etc.) are defined in ADR-000 and map to Popolo standard. +- No external sync layer exists today. +- OpenRegister provides `ObjectService`, `RelationService`, `AuditTrailService`, file attachment, schema management, and webhook support. +- OpenConnector provides app hosting, cron scheduling, background job runner, and webhook receiver framework. + +**Stakeholders:** + +- **Decidesk team** — owns the canonical data model; sync populates it. +- **OpenConnector team** — hosts the adapters; owns sync orchestration, job runner, webhook dispatch. +- **OpenRegister team** — provides platform services (ObjectService, audit trails, permissions). +- **Griffies** (primary users) — stay in NOTUBIZ/iBabs for official workflows; get real-time open-data mirror. +- **Raadsleden** — use decidesk for better UX; changes in decidesk don't disrupt official workflow. + +## Goals / Non-Goals + +**Goals:** + +1. Ship full bidirectional sync: pull all governance objects from NOTUBIZ/iBabs every 15 min (cron) or on webhook (iBabs); push decidesk changes to provider for writable fields. +2. Detect and surface conflicts when same field edited locally + externally since last sync, requiring manual resolution. +3. Sync per-fraction role data (fractievoorzitter, portefeuilles, woordvoerderschappen) with succession handling. +4. Sync voting with full per-person breakdown (hoofdelijke stemming, fractie-stemming). +5. Stream large documents (50MB+) to object storage, not memory. +6. Provide observability: SyncJob dashboard, open-conflict queue, alert thresholds, per-provider health. + +**Non-Goals:** + +- Automatic conflict merge (too risky for legal documents). Manual resolution required. +- Companion (LIAS) adapter — small market, defer until concrete demand. +- Bulk STOP/TPOD XML export for official publications (separate docudesk spec). +- Historical backfill beyond 2 years (performance validation first). +- NLP-based privacy content detection (separate initiative). +- Province/waterboard schema extensions (separate per-org-type validation). + +## Decisions + +### D1: Polymorph ExternalIdentifier over per-type join tables + +**Choice:** Single `ExternalIdentifier` register with `localObjectType` enum + `localObjectId` UUID, plus `provider`, `externalId`, `externalUrl`, `externalEtag`. + +**Alternatives considered:** + +- Per-type join tables (ExternalMeetingId, ExternalPersonId, etc.) — matches traditional SQL join pattern but becomes unmaintainable with 12+ decidesk object types. +- Inline vendor fields directly in decidesk schemas (e.g., `meeting.notubizId`, `meeting.ibabsId`) — tight coupling, hard to add third provider. + +**Why polymorph:** Single index structure `(localObjectType, localObjectId, provider)` covers all 12 types; unique constraint `(provider, externalId)` prevents duplicate imports; clean separation of concern (sync metadata lives in sync register, not domain schemas). + +### D2: Last-writer-wins with explicit conflict detection, no automatic merge + +**Choice:** Detect simultaneous edits via `lastModifiedLocal` + `lastModifiedExternal` timestamps. On conflict, create `SyncConflict` record; human (griffier) resolves via UI. + +**Alternatives considered:** + +- Automatic CRDT merge on text fields (e.g., `motie.text`) — unsafe for legal documents; auto-merge can produce juridically incorrect content. +- Always prefer external (provider-of-truth) — loses decidesk-user edits; violates data integrity. +- Per-field conflict strategy (auto-merge for safe fields like `location`, manual for risky like `motie.text`) — complex config, fragile. + +**Why last-writer-wins + conflict flag:** Motie text, besluit formulering, etc. are legal documents; automatic merge is a liability. For low-risk fields (`location`, `email`), conflict rate is low and manual resolution is acceptable. Griffier gets a clear UI showing both values + who changed when. + +### D3: Pull-based default, push-back opt-in per organization + +**Choice:** Sync direction is per `(organization, provider, objecttype)`. Defaults: read-only (pull). Push must be explicitly enabled and configured with writable-field allowlist. + +**Alternatives considered:** + +- Always bidirectional (push by default) — risks corrupting official agenda if decidesk has bugs. +- Separate "read-only" and "sync-back" instances — duplicate infrastructure; confusing for users. + +**Why opt-in push:** Griffie is the authority for official workflow. Decidesk is initially a mirror. Push introduces risk (bad data back to vendor); enable only when org is confident. Writable-field allowlist prevents accidental write to read-only provider fields (e.g., `Stemming.uitslag` in NOTUBIZ is never writable). + +### D4: Schemas in decidesk-sync register, not domain registers + +**Choice:** `ExternalIdentifier`, `SyncJob`, `SyncConflict`, `FractieRol` live in a separate `decidesk-sync` register. Domain schemas (`Meeting`, `Person`, etc.) stay in `decidesk` register but add FK relations to ExternalIdentifier. + +**Alternatives considered:** + +- Embed sync metadata directly in domain schemas (`meeting.externalId`, `meeting.externalEtag`) — pollutes domain model; makes sync optional/optional. +- Separate app per adapter (notubiz-app, ibabs-app) — prevents unified sync engine. + +**Why separate register:** Sync is a technical layer, not a domain concern. Domain schemas remain clean. Separation of concerns: openconnector owns sync; decidesk owns governance model. FK relations make it clear when an object is sync-bound. + +### D5: Webhook-first for iBabs, cron-fallback for NOTUBIZ + +**Choice:** iBabs publishes webhooks (Notifications module) → primary trigger. NOTUBIZ has no webhooks (as of 2026) → cron every 15 min with `?modified-since={lastSyncedAt}` query. + +**Alternatives considered:** + +- Cron for both (simpler implementation, less real-time) — misses iBabs webhook advantage. +- Webhook + cron for both (redundancy) — extra load; hard to de-dupe if both fire simultaneously. + +**Why webhook-first:** iBabs offers real-time notifications; use them to reduce latency. NOTUBIZ cron is acceptable (15 min is reasonable for governance data). If webhook fails, cron is a fallback (eventual consistency). + +### D6: Streaming documents to object storage, never loading full file in memory + +**Choice:** When importing a `Vergaderstuk` (document) > 1MB, stream directly to `ObjectService::saveFile()` or openregister's file storage backend. Never `file_get_contents()` or load into memory. + +**Alternatives considered:** + +- Inline all documents up to 50MB (what we see in practice) — memory pressure; can crash PHP worker. +- Async job queue for document imports — adds latency; complicates error handling. + +**Why streaming:** Dutch municipalities have documents 20–200MB (scans of thick agendas). Streaming is the safe default. ObjectService's file backend (likely S3 or similar) handles the burden. + +### D7: Per-organization configuration via admin UI, no env vars or code + +**Choice:** Each organization (gemeente) configures its NOTUBIZ/iBabs connection via an OpenRegister admin page: API key, endpoint URL, writable-field allowlist, sync direction, cron schedule, webhook secret. + +**Alternatives considered:** + +- Env vars (NOTUBIZ_API_KEY, IBABS_ENDPOINT, etc.) — doesn't scale to 300+ gemeenten; not multi-tenant. +- Config files — hard to rotate credentials; no audit trail. + +**Why admin UI:** Multi-tenant. Credentials stored encrypted in database (openregister's secret storage). Each org controls its own sync settings. Changes are auditable. + +### D8: FractieRol includes succession handling (no in-place updates) + +**Choice:** When a fractievoorzitter changes, the old `FractieRol` gets `actiefTot = today`; a new `FractieRol` is created with `actiefVan = today`. Old role stays in history for audit/analysis. + +**Alternatives considered:** + +- In-place update (change `persoon` FK on the old role) — loses historical record; breaks references in Stemmingen that point to the old fractievoorzitter. +- Delete + create — no audit trail; hard to reconstruct who was in role when. + +**Why immutable + new record:** Auditable; fractievoorzitter name is a fact at voting time. If role changes retroactively, old stemmingen should still reference the person-who-was-chair-then. + +## Reuse Analysis + +| Code Path | Source | Reuse Strategy | +|---|---|---| +| Schema management | `openregister/lib/Service/SchemaService.php` | Register definitions loaded at app init; no custom schema engine. | +| Object CRUD | `openregister/lib/Service/ObjectService.php` | `saveObject()`, `findAll()` for all 12 domain types + 4 sync types. | +| Relation management | `openregister/lib/Service/RelationService.php` | ExternalIdentifier FK relations use standard relation system. | +| File attachment | `openregister/lib/Service/FileService.php` | `saveFile()` for document streaming; no custom upload handler. | +| Audit trail | `openregister/lib/Service/AuditTrailService.php` | All mutations logged automatically; no custom audit code. | +| Webhook dispatch | `openregister/lib/Service/WebhookService.php` (OR) + `openconnector` framework | iBabs webhooks routed to connector; NOTUBIZ cron via native job runner. | +| Job scheduling | `openconnector` job runner | Cron jobs (NOTUBIZ), background sync reconciliation, conflict expiry checks. | +| Permission checks | `openregister/lib/Service/AuthorizationService.php` | Push-back only if user has write permission on the domain object. | +| Multi-tenancy | `openregister` built-in | Organization scoping automatic; no custom tenant isolation code. | + +No custom CRUD, REST API, or permission system. All leverages platform. + +## Seed Data + +**Seed data required:** This change introduces 4 new schemas (`ExternalIdentifier`, `SyncJob`, `SyncConflict`, `FractieRol`) and modifies domain schemas. Per ADR-001 (data-layer), we MUST include 3–5 realistic seed objects per schema in `lib/Settings/decidesk_sync_register.json`. + +**Seed objects:** + +### ExternalIdentifier (3 examples) + +```json +{ + "@self": { + "register": "decidesk-sync", + "schema": "ExternalIdentifier", + "slug": "ext-meeting-notubiz-1" + }, + "localObjectType": "Meeting", + "localObjectId": "uuid-meeting-1", + "provider": "notubiz", + "externalId": "MEETING_12345", + "externalUrl": "https://notubiz.nl/meetings/12345", + "externalEtag": "e9f5a0c5e2b1a3d4f6c8e9g2h4", + "lastSyncedAt": "2026-05-22T10:30:00Z", + "syncDirection": "bidirectional", + "lastModifiedLocal": "2026-05-22T09:00:00Z", + "lastModifiedExternal": "2026-05-22T08:45:00Z" +} +``` + +### SyncJob (3 examples) + +```json +{ + "@self": { + "register": "decidesk-sync", + "schema": "SyncJob", + "slug": "syncjob-notubiz-2026-05-22-10" + }, + "provider": "notubiz", + "direction": "pull", + "scope": "incremental", + "targetObjectType": "Meeting", + "startedAt": "2026-05-22T10:00:00Z", + "finishedAt": "2026-05-22T10:03:45Z", + "status": "success", + "objectsProcessed": 12, + "objectsCreated": 2, + "objectsUpdated": 8, + "objectsSkipped": 2, + "objectsConflicted": 0, + "errorLog": null, + "triggeredBy": "cron" +} +``` + +### SyncConflict (2 examples) + +```json +{ + "@self": { + "register": "decidesk-sync", + "schema": "SyncConflict", + "slug": "conflict-meeting-location-1" + }, + "externalIdentifier": "uuid-ext-meeting-1", + "field": "location", + "localValue": "Raadszaal", + "externalValue": "Burgerzaal", + "localChangedAt": "2026-05-22T10:35:00Z", + "externalChangedAt": "2026-05-22T10:30:00Z", + "localChangedBy": "user-griffier-1", + "detectedAt": "2026-05-22T10:45:00Z", + "status": "open", + "resolvedBy": null, + "resolution": null +} +``` + +### FractieRol (3 examples) + +```json +{ + "@self": { + "register": "decidesk-sync", + "schema": "FractieRol", + "slug": "fractierole-groenlinks-voorzitter-2026" + }, + "fractie": "uuid-fractie-groenlinks", + "persoon": "uuid-person-marieke", + "rol": "fractievoorzitter", + "portefeuille": ["klimaat", "duurzaamheid"], + "actiefVan": "2026-01-01", + "actiefTot": null +} +``` + +(More examples in actual `decidesk_sync_register.json` with varied persons, roles, and providers.) + +## Deduplication Check + +**Result: No overlap found.** + +Checked `openspec/specs/` and `openregister/lib/Service/`: + +- **ObjectService** (existing) — generic CRUD for all schemas. Sync uses it; no custom model. +- **RelationService** (existing) — manages FK relations. Sync uses for ExternalIdentifier; no custom relation engine. +- **AuditTrailService** (existing) — automatic change tracking. No custom audit code needed. +- **WebhookService** (existing) — dispatch and retry. iBabs webhooks routed via this; no custom webhook handler. +- **FileService** (existing) — upload, download, streaming. Document import uses this; no custom file logic. +- **ConflictResolution** — NOT in openregister. Custom conflict detection + resolution UI required for sync domain. +- **SyncJob tracking** — NOT in openregister. Custom SyncJob + status monitoring required. + +**Custom capabilities required:** + +1. Sync adapters (NOTUBIZ + iBabs API clients) — domain-specific field mapping. +2. Conflict detection engine (CRDT-style lastModified comparison) — sync-specific business logic. +3. SyncJob orchestration (push/pull sequencing, rate-limit respect) — openconnector job runner hosting. +4. Admin dashboard + conflict-resolution UI — observability layer. + +No duplication; all new code is justified. + +## Risks / Trade-offs + +| Risk | Mitigation | +|---|---| +| **R1 — Vendor API contract breakage.** NOTUBIZ/iBabs can introduce breaking changes with short notice (API versioning, endpoint removal, field removal, format changes). | Daily contract tests against sandbox endpoints; alert if test fails; feature flags per provider version in adapter code; ADR-specific version pinning in openconnector config. | +| **R2 — Performance at large gemeenten.** Amsterdam: 45 raadsleden, 18 commissies/maand, 3–4 meetings/week → 10K+ sync events/day; 1GB+ of documents/month. | Per-organization sharding of sync jobs (each gemeente's sync runs on dedicated pool); rate-limit awareness (token-bucket per provider); bulk endpoints preferred over per-object GETs; document streaming not memory-loaded. | +| **R3 — Conflict explosion during dual-editing.** Griffier edits meeting in NOTUBIZ while decidesk-user edits same meeting in decidesk → conflicts pile up; griffier's queue becomes unmanageable. | UI field-locking hints ("this field is being edited in NOTUBIZ now"); SSE push of external changes for reactive UI; conflict-quota alert when >10 conflicts/week per org. | +| **R4 — Push-back corrupts official workflow.** Bad data pushed back to NOTUBIZ (due to decidesk bug or misconfiguration) alters the official agenda, damaging griffie credibility. | Push default OFF per org; per-(org, provider, objecttype, field) granular enable switch; dry-run mode (mock pushes logged for 7 days before actual write); push limited to non-governance-critical fields (titles, descriptions); never touch `Stemming.uitslag` or `Besluit.tekst` without explicit org policy. | +| **R5 — LLM spoofing persons in chat companion.** Future AI Chat integration: LLM invents a realistic-sounding name for person creating action item → collides with real raadslid, causing confusion. | Action-item creation by chat uses explicit person UUID lookup (not free-text name input); no free-text person creation in sync; schema validation prevents invalid person FK. | +| **R6 — Sync latency masking real-time changes.** Griffier makes change at 10:00 in NOTUBIZ; cron at 10:15 pulls it; user in decidesk doesn't see it until 10:15 → appears stale. | 15-min pull frequency is acceptable for governance data. If iBabs, webhook reduces to 60s. For NOTUBIZ, document roadmap item: webhook support. UI shows "last synced at X" timestamp. | + +## Migration Plan + +**Forward path:** + +1. Add `decidesk_sync_register.json` with 4 schemas + seed data to openconnector. +2. Add `ExternalIdentifier` FK relation to decidesk domain schemas in `decidesk_register.json` (backwards-compatible; no data migration). +3. Implement NOTUBIZ adapter as new openconnector app. +4. Implement iBabs adapter as new openconnector app. +5. Implement SyncService orchestration in openconnector/lib/Service/SyncService. +6. Implement conflict detection engine (CrdtService). +7. Implement admin dashboard + conflict-resolution UI. +8. Deploy to shared dev/staging; pilot with 1–2 gemeenten (NOTUBIZ or iBabs, not both yet). +9. Gather feedback; iterate. +10. Expand to production. + +**No breaking changes to decidesk.** Domain schemas are only extended with optional `externalIdentifier` FK; existing objects unaffected. + +**Rollback:** Disable adapters in openconnector config; sync stops. All data remains (no deletion). Can re-enable later. + +**Compatibility:** Requires openregister 1.0+, openconnector 2.0+. + diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/hydra.json b/openspec/changes/notubiz-ibabs-griffie-koppeling/hydra.json new file mode 100644 index 00000000..4f314ae4 --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/hydra.json @@ -0,0 +1,8 @@ +{ + "spec_slug": "notubiz-ibabs-griffie-koppeling", + "title": "NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync)", + "app": "decidesk", + "repo": "https://github.com/ConductionNL/decidesk", + "depends_on": [], + "issue": null +} \ No newline at end of file diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/proposal.md b/openspec/changes/notubiz-ibabs-griffie-koppeling/proposal.md new file mode 100644 index 00000000..c8ab4bea --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/proposal.md @@ -0,0 +1,109 @@ +--- +kind: openconnector +depends_on: [] +--- + +# NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync) + +## Why + +Vrijwel alle 342 Nederlandse gemeenten gebruiken één van twee griffie-systemen voor het beheer van politieke besluitvorming: NOTUBIZ (±60% van gemeenten) of iBabs (Gemeente Oplossingen, ±35%). Beide zijn proprietary-SaaS met gesloten datamodellen; gemeenten betalen jaarlijks zes-cijferige bedragen voor licenties. Exit-kosten zijn enorm: alle agenda's, besluiten, moties en stemmingen zitten vast in vendor-formaten. + +Decidesk wil het democratisch besluitvormingsproces openen via een open datamodel (gebaseerd op Popolo, OASIS Akoma Ntoso, en de Nederlandse STOP/TPOD-standaard). Gemeenten kunnen echter niet van de ene op de andere dag overstappen. De realistische aanpak is decidesk naast NOTUBIZ of iBabs te draaien als open laag, met bidirectional sync: + +1. **Griffie blijft in haar workflow.** Haar bestaande tool is normatief voor officiële processen. +2. **Raadsleden krijgen beter UX.** Via decidesk: betere consultatie- en participatie-interface. +3. **Open data direct beschikbaar.** Voor burgers, hergebruik via mydash dashboards, opencatalogi inzichten, docudesk PDF-publicatie. +4. **Exit-optie zonder data-verlies.** Over 2–5 jaar kan de gemeente NOTUBIZ/iBabs uitfaseren. + +Deze spec definieert de bidirectional synchronisatie: lezen van alle vergadering-gerelateerde objecten (agenda, vergaderstukken, aanwezigheid, stemming, besluiten, moties, amendementen, schriftelijke vragen, fracties), schrijven terug van decidesk-gegenereerde inzichten (samenvattingen, transcript-links, participatie-signalen), en conflict-resolutie bij gelijktijdige bewerkingen. + +## What Changes + +**NEW openconnector apps/modules:** + +- **NOTUBIZ adapter** — leest via REST/JSON API, schrijft via REST/JSON API (OAuth2). Endpoints: `/meetings`, `/agenda-items`, `/documents`, `/voting`, `/decisions`, `/motions`, `/amendments`, `/questions`, `/people`, `/parties`, `/attendance`. Rate-limit: 60 req/min per tenant. +- **iBabs adapter** — leest via SOAP + modern JSON-REST, schrijft via JSON-REST. Endpoints via iBabs API portal (modules: Public Data, Meeting Items, Voting, Documents). Webhooks via Notifications module. +- **Sync engine** — polling (cron) + webhooks; conflict detection; state machine voor SyncJob tracking. + +**NEW schemas in OpenRegister (decidesk-sync register):** + +- `ExternalIdentifier` — polymorph koppeling tussen decidesk-object en vendor-id. Velden: `localObjectType`, `localObjectId`, `provider`, `externalId`, `externalUrl`, `externalEtag`, `lastSyncedAt`, `syncDirection`, `lastModifiedLocal`, `lastModifiedExternal`. +- `SyncJob` — uitvoeringslog. Velden: `provider`, `direction`, `scope`, `targetObjectType`, `startedAt`, `finishedAt`, `status`, `objectsProcessed`, `objectsCreated`, `objectsUpdated`, `objectsSkipped`, `objectsConflicted`, `errorLog`, `triggeredBy`. +- `SyncConflict` — onopgeloste gelijktijdige bewerkingen. Velden: `externalIdentifier`, `field` (JSONPath), `localValue`, `externalValue`, `localChangedAt`, `externalChangedAt`, `localChangedBy`, `detectedAt`, `status`, `resolvedBy`, `resolution`. +- `FractieRol` — per fractie de rolverdeling. Velden: `fractie`, `persoon`, `rol` (enum: fractievoorzitter, plaatsvervangend-fractievoorzitter, secretaris, woordvoerder, gewoon-lid), `portefeuille` (array), `actiefVan`, `actiefTot`. + +**MODIFIED decidesk schemas:** + +- `Vergadering` (Meeting), `Agendapunt` (AgendaItem), `Vergaderstuk` (DigitalDocument), `Aanwezigheid` (new; tracks attendance), `Stemming` (VotingRound + Vote per raadslid), `Besluit` (Decision), `Motie` (Motion), `Amendement` (Amendment), `SchriftelijkeVraag` (new; written questions), `Persoon` (Person), `Fractie` (GovernanceBody), `Rol` (Membership/Post) — alle gevuld via sync. + +**New capabilities:** + +- **sync-pull-incremental** — delta-pull elke 15 min via cron (`?modified-since={lastSyncedAt}`); webhook-triggering voor iBabs. +- **sync-push** — wijzigingen in decidesk-velden teruschrijven naar provider (per `(org, provider, objecttype)` configureerbaar). +- **sync-conflict-detection** — CRDT-style tracking van `lastModifiedLocal` + `lastModifiedExternal`; SyncConflict voor gelijktijdige edits. +- **sync-fraction-roles** — FractieRol synchronisatie inclusief woordvoerderschappen, portefeuilles. +- **sync-voting-detail** — per-persoon stemming (hoofdelijk + fractie-stemming). +- **sync-document-streaming** — grote PDF's streamen naar object-storage. +- **sync-observability** — admin dashboard met SyncJob statistieken, alert-thresholds, conflict-queue. + +## Capabilities + +### New Capabilities + +1. **sync-pull-incremental** — Incremental pull every 15 minutes, respecting provider rate limits. +2. **sync-push** — Push decidesk changes back to provider (writable fields only, configurable per org/provider/object-type). +3. **sync-conflict-detection** — Detect simultaneous edits; create SyncConflict for manual resolution. +4. **sync-fraction-roles** — Sync FractieRol with role titles, portefeuilles, succession handling. +5. **sync-voting-detail** — Full per-person voting with hoofdelijke voting breakdown. +6. **sync-document-streaming** — Stream large documents to object storage. +7. **sync-observability** — SyncJob dashboard, alert thresholds, conflict queue UI. + +### Modified Capabilities + +None. This change is purely additive in openconnector; decidesk schema extensions are additive. + +## Impact + +**Code locations:** + +- `openconnector/apps/notubiz-adapter/` — new app (REST client, polling logic, field mapping). +- `openconnector/apps/ibabs-adapter/` — new app (SOAP + REST client, webhook receiver, field mapping). +- `openconnector/lib/Service/SyncService/` — generic sync orchestration (push, pull, conflict resolution). +- `openconnector/lib/Service/CrdtService/` — conflict detection engine. +- `openconnector/lib/Controller/SyncStatusController.php` — admin dashboard. +- `openconnector/lib/Settings/decidesk_sync_register.json` — schema definitions for `ExternalIdentifier`, `SyncJob`, `SyncConflict`, `FractieRol` + seed data. +- `decidesk/lib/Settings/decidesk_register.json` — extend `Meeting`, `AgendaItem`, `Person`, etc. with sync metadata (fk to ExternalIdentifier). + +**Dependencies:** + +- `openregister` — provides `ObjectService`, `RelationService`, `AuditTrailService`, schema management. +- `openconnector` — provides app hosting, job runner, webhook framework. +- NOTUBIZ API (sandbox for dev/test). +- iBabs API (sandbox for dev/test). + +**Reused (no changes):** + +- `decidesk` data model — schemas unchanged; sync fills them. +- `openregister` CRUD, audit trails, file attachment, permission framework. +- `openconnector` job scheduling, webhook dispatch, app lifecycle. + +**Out of scope (future iterations):** + +- Companion (LIAS) adapter — small market, separate spec. +- STOP/TPOD XML export for official publications — separate spec via docudesk. +- Historical backfill beyond 2 years. +- Advanced conflict merge (text diffing) — v2. +- Privacy-gevoelige content auto-detection (NLP) — separate initiative. +- Province/waterboard schema extensions — separate validation per org type. + +## Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| **Vendor API changes break sync.** NOTUBIZ/iBabs can introduce breaking changes. | Contract tests run daily against sandbox; alert on test failure before prod rollout. | +| **Performance degradation at large gemeenten.** Amsterdam: 45 raadsleden, 18 commissies/maand → 10K+ events/dag. | Per-org sharding; rate-limit awareness (token bucket); prefer bulk endpoints. | +| **Conflict explosion during simultaneous edits.** Griffier + decidesk-user edit same field → conflict queue explodes. | Per-veld UI locking hints; SSE reactive updates; conflict-quota alert (>10/week). | +| **Push-back corrupts griffie workflow.** Bad data pushed to NOTUBIZ damages official agenda. | Push default off; per-(org, provider, objecttype, field) granular control; dry-run mode. | +| **LLM-generated names spoofing vendor persons.** Chat companion creates action items with realistic-looking names that collide with real persons. | UUID-based person refs (not free-text names); create-action-item requires explicit person lookup. | + diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/specs/notubiz-ibabs-griffie-koppeling/spec.md b/openspec/changes/notubiz-ibabs-griffie-koppeling/specs/notubiz-ibabs-griffie-koppeling/spec.md new file mode 100644 index 00000000..6d8af416 --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/specs/notubiz-ibabs-griffie-koppeling/spec.md @@ -0,0 +1,234 @@ +--- +status: draft +--- + +# Spec: NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync) + +## Purpose + +Decidesk synchronizes governance data bidirectionally with NOTUBIZ (±60% of Dutch municipalities) and iBabs (Gemeente Oplossingen, ±35%) to create an open-data mirror of official decision-making without requiring immediate vendor exit. This spec captures the sync adapter contracts, data-mapping rules, conflict-detection engine, and observability requirements. + +## ADDED Requirements + +--- + + + + + +### Requirement: REQ-NIK-001 — Full initial import per provider + +The system SHALL support a full historical import of all governance objects for a given time range when a new provider is connected or a new calendar year begins. + +#### Scenario: Full NOTUBIZ history import +- **GIVEN** a gemeente with a NOTUBIZ account and 2 years of existing meeting history +- **WHEN** an admin runs `sync full --provider notubiz --from 2024-01-01` +- **THEN** the system MUST import all Vergaderingen, Agendapunten, Vergaderstukken, Stemmingen, and Besluiten from that date within 24 hours, paginated with rate-limit respect (NOTUBIZ: 60 requests/min) + +#### Scenario: Duplicate detection across providers +- **GIVEN** a gemeente later adds iBabs for some commissies (while NOTUBIZ covers others) +- **WHEN** iBabs sync runs +- **THEN** the system MUST deduplicate by `(Vergadering.datum + Vergadering.naam)` and link both ExternalIdentifiers to the same decidesk Meeting (no duplicates created) + +#### Scenario: Large document streaming +- **GIVEN** a Vergaderstuk is 200MB +- **WHEN** it is imported +- **THEN** the system MUST stream the file to object-storage (not load in memory); create Vergaderstuk record with `grootte`, `sha256`, `mimeType` metadata + +--- + +### Requirement: REQ-NIK-002 — Incremental pull every 15 minutes + +The system SHALL execute a delta-pull every 15 minutes (cron default) or via webhook when the provider supports it. + +#### Scenario: NOTUBIZ delta-pull +- **GIVEN** NOTUBIZ does not support webhooks (as of 2026) +- **WHEN** cron fires every 15 minutes +- **THEN** the system MUST execute `?modified-since={lastSyncedAt}` query on all relevant endpoints; create a SyncJob with `status: success, objectsProcessed: ` + +#### Scenario: iBabs webhook-triggered pull +- **GIVEN** iBabs Notifications module publishes a `meeting.updated` webhook +- **WHEN** the webhook is received +- **THEN** the system MUST pull the updated Meeting + agenda + decisions within 60 seconds + +#### Scenario: Zero-change sync +- **GIVEN** a delta-pull finds 0 changes +- **WHEN** it completes +- **THEN** the system MUST record SyncJob with `status: success, objectsProcessed: 0`; MUST NOT emit events + +--- + +### Requirement: REQ-NIK-003 — Push decidesk changes back to provider + +Modifications to synchronized objects in decidesk MUST be pushed back to the provider for writable fields. + +#### Scenario: Agendapunt title push +- **GIVEN** a griffier changes a Agendapunt title in decidesk +- **WHEN** the change is saved +- **THEN** within 30 seconds, the system MUST push the change to the provider via `PATCH /agenda-items/{id}` (NOTUBIZ) or `agendaItems.update` (iBabs) + +#### Scenario: Provider rejects push +- **GIVEN** the provider rejects a push (e.g., meeting is closed) +- **WHEN** the rejection arrives +- **THEN** the system MUST create a SyncConflict with `resolution: provider-rejected`; mark the local change as "not-synchronized" + +#### Scenario: Read-only field push skip +- **GIVEN** a field is not writable in the provider (e.g., `Stemming.uitslag` in NOTUBIZ) +- **WHEN** a decidesk change attempts a push on that field +- **THEN** the system MUST skip the push, log a warning, and keep the local change as "local-only" + +--- + +### Requirement: REQ-NIK-004 — Conflict detection for simultaneous edits + +When the same field is modified locally and externally since the last sync, a SyncConflict MUST be created. + +#### Scenario: Simultaneous meeting location edit +- **GIVEN** a Vergadering with `lastSyncedAt: 10:00` +- **GIVEN** at 10:30, a griffier changes location to "Burgerzaal" in NOTUBIZ +- **GIVEN** at 10:35, a decidesk-user changes location to "Raadszaal" in decidesk +- **WHEN** the 10:45 sync runs +- **THEN** a SyncConflict MUST be created with `status: open`, showing both values, who changed, and when + +#### Scenario: Manual conflict resolution +- **GIVEN** a SyncConflict with `status: open` +- **WHEN** a griffier opens the conflict-resolution UI +- **THEN** the UI MUST display both values, allow selection (local/external/merged), log the choice in `resolvedBy` / `resolution` + +#### Scenario: Conflict escalation alert +- **GIVEN** a SyncConflict remains `open` for 7 days +- **WHEN** the health-check cron runs +- **THEN** a notification MUST be sent to the griffier and decidesk-admin; the affected object MUST show a "in-conflict" badge in decidesk UI + +--- + +### Requirement: REQ-NIK-005 — Per-fraction roles with succession handling + +FractieRol objects MUST be synchronized to track role assignments, portefeuilles, and woordvoerderschappen with proper succession. + +#### Scenario: Import fraction roles from NOTUBIZ +- **GIVEN** NOTUBIZ provides a member list with role fields (fractievoorzitter, etc.) +- **WHEN** the import runs +- **THEN** a FractieRol MUST be created per (Fractie, Persoon) pair with `actiefVan = current calendar year` + +#### Scenario: Fraction chair succession +- **GIVEN** a fractievoorzitter is replaced in NOTUBIZ +- **WHEN** the update is synced +- **THEN** the old FractieRol MUST receive `actiefTot = today`; a new FractieRol MUST be created with `actiefVan = today` and the new person; existing Stemmingen MUST continue referencing the old person-role combination + +#### Scenario: Portefeuille handling for iBabs (no explicit portefeuilles) +- **GIVEN** iBabs does not model portefeuilles +- **WHEN** iBabs FractieRol is imported +- **THEN** the `portefeuille` field MUST remain empty; the griffier MUST be able to manually fill it in the UI without sync overwriting it + +--- + +### Requirement: REQ-NIK-006 — Stemming (voting) with full per-person breakdown + +Stemmingen MUST include individual per-person votes, not just totals. + +#### Scenario: Import hoofdelijke (roll-call) voting +- **GIVEN** a roll-call vote on a Motion +- **WHEN** imported from NOTUBIZ +- **THEN** one StemUitgebracht (Vote) object MUST exist per present raadslid with `stem ∈ {voor, tegen, onthouden, afwezig, niet-deelgenomen}` + +#### Scenario: Import fractie-stemming (group vote) +- **GIVEN** a group vote (fractie, not per-person) +- **WHEN** imported +- **THEN** one Vote per fractie MUST be created with `aantal` and `stem` fields; individual members MUST be automatically marked as "verondersteld conform fractielijn" unless explicitly overridden + +#### Scenario: Stemming annulment with audit +- **GIVEN** a vote is annulled due to procedure error +- **WHEN** the provider registers the annulment +- **THEN** the Stemming MUST get `status: geannulled`; an audit link to the annulment MUST be added; the record MUST NOT be deleted + +--- + +### Requirement: REQ-NIK-007 — Motions, amendments, written questions with version control + +Moties, Amendementen, and SchriftelijkeVragen MUST be synchronized with full version history. + +#### Scenario: Motion amendment import +- **GIVEN** Motion M2024-15 is submitted, then amended (version 2) +- **WHEN** both are imported +- **THEN** two MotieVersie records MUST exist linked to the same Motie; the UI MUST show the latest version by default with a "earlier versions" link + +#### Scenario: Written question answer +- **GIVEN** a SchriftelijkeVraag is answered by a wethouder 6 weeks later +- **WHEN** the answer is published +- **THEN** the SchriftelijkeVraag MUST receive `antwoord` field + `status: beantwoord` + `aantalDagen: 42` (calculated) + +#### Scenario: Amendment withdrawal +- **GIVEN** an amendment is withdrawn before voting +- **WHEN** that action is recorded in the provider +- **THEN** the Amendement MUST get `status: ingetrokken`; the relation to the Agendapunt MUST remain for traceability + +--- + +### Requirement: REQ-NIK-008 — Aanwezigheid (attendance) tracking per meeting + +Attendance data MUST be kept synchronized with the griffie's administration. + +#### Scenario: Attendance list import +- **GIVEN** NOTUBIZ provides `/meetings/{id}/attendance` data +- **WHEN** imported +- **THEN** one Aanwezigheid record per raadslid MUST be created with `status ∈ {aanwezig, afwezig-gemeld, afwezig-zonder-bericht, deels-aanwezig}` and optional `vervangenDoor` (proxy) reference + +#### Scenario: Late arrival tracking +- **GIVEN** a raadslid arrives 30 minutes into the meeting and iBabs records tap-in time +- **WHEN** synced +- **THEN** the Aanwezigheid MUST receive `aanwezigVanaf` timestamp (2026-05-22T14:30:00Z) + +#### Scenario: Post-publication attendance correction alert +- **GIVEN** attendance is updated after the decision list is published +- **WHEN** the update is received +- **THEN** a notification MUST be sent to the griffier for verification (publication-after-correction can damage credibility) + +--- + +### Requirement: REQ-NIK-009 — Vergaderstukken (documents) with metadata + +PDF and attachment documents MUST be synchronized with complete provider metadata. + +#### Scenario: Large document import with streaming +- **GIVEN** a 50MB Vergaderstuk at Agendapunt 7 +- **WHEN** imported +- **THEN** the file MUST be streamed to object-storage; a Vergaderstuk record MUST be created with `titel`, `bestandsnaam`, `mimeType`, `grootte`, `sha256`, `vertrouwelijkheidsniveau`, linked to Agendapunt + +#### Scenario: Confidential document access control +- **GIVEN** a document is marked `vertrouwelijk` or `geheim` by the griffie +- **WHEN** imported +- **THEN** the file MUST NOT be publicly accessible; access-ACL MUST restrict to raadsleden + griffie only + +#### Scenario: Document versioning +- **GIVEN** a document is replaced with a corrected version after the meeting +- **WHEN** imported +- **THEN** the old version MUST be retained as VergaderstukVersie; the active version MUST be the latest + +--- + +### Requirement: REQ-NIK-010 — Observability: sync dashboard and alerting + +The system MUST provide an operational dashboard and alert thresholds for sync health. + +#### Scenario: Sync status dashboard +- **GIVEN** an admin opens `/admin/sync-status` +- **WHEN** the page loads +- **THEN** it MUST display per-provider: + - Last 24h SyncJob list with status/counts + - Number of open SyncConflicts + - Average sync latency + - Quota usage (rate-limit %) + +#### Scenario: Failed sync alerting +- **GIVEN** three consecutive SyncJobs fail +- **WHEN** the third fails +- **THEN** a PagerDuty/Slack/e-mail alert MUST be sent to the configured on-call + +#### Scenario: Conflict queue health +- **GIVEN** the number of open SyncConflicts exceeds 25 +- **WHEN** the health-check runs +- **THEN** the overall connector-status MUST become `degraded`; this MUST be visible in mydash and the admin panel + +--- + diff --git a/openspec/changes/notubiz-ibabs-griffie-koppeling/tasks.md b/openspec/changes/notubiz-ibabs-griffie-koppeling/tasks.md new file mode 100644 index 00000000..3f258223 --- /dev/null +++ b/openspec/changes/notubiz-ibabs-griffie-koppeling/tasks.md @@ -0,0 +1,291 @@ +# Tasks — NOTUBIZ en iBabs Griffie-Koppeling (Bidirectional Sync) + +> Scope reminder: this change implements bidirectional synchronization between decidesk and NOTUBIZ/iBabs via openconnector adapters. See `proposal.md`, `design.md`, and `specs/*/spec.md` for context. +> +> Acceptance gates: every task's checkbox flips only when its acceptance criteria pass. Do not mark tasks done by inspection — run the listed commands. + +## 1. Schema definitions and seed data + +- [ ] 1.1 Create `openconnector/lib/Settings/decidesk_sync_register.json` with 4 new schemas: + - `ExternalIdentifier` (polymorph sync binding) + - `SyncJob` (execution log) + - `SyncConflict` (conflict tracking) + - `FractieRol` (fraction role data) + Per ADR-001 (data-layer), MUST include 3–5 seed objects per schema with realistic Dutch values (gemeente names, dates, person names per KNAW naming conventions). + **Acceptance:** `composer check:strict` is clean; schema validation passes; seed objects load via `ConfigurationService::importFromApp()` without duplicates on re-import. + +- [ ] 1.2 Extend `decidesk/lib/Settings/decidesk_register.json` to add optional `externalIdentifier` FK relation to domain schemas: + - `Meeting` → `ExternalIdentifier` + - `AgendaItem` → `ExternalIdentifier` + - `Person` → `ExternalIdentifier` + - `Motion`, `Amendment`, `Vote`, `Decision`, `Vergaderstuk` — same pattern + (Backwards-compatible; no data migration required for existing objects.) + **Acceptance:** Schema validation passes; no existing decidesk objects are modified; FK relations resolve correctly in `ObjectService::findAll()`. + +--- + +## 2. NOTUBIZ adapter + +- [ ] 2.1 Create `openconnector/apps/notubiz-adapter/` with app structure: + - `appinfo/info.xml` (app metadata) + - `lib/NotubizAdapterApp.php` (app entry point) + - `lib/Service/NotubizApiClient.php` (REST client, OAuth2 auth, rate-limit handling) + - `lib/Service/NotubizFieldMapper.php` (vendor fields → decidesk schema mapping) + - Unit tests at `tests/Unit/Service/NotubizApiClientTest.php` and `NotubizFieldMapperTest.php` + **Acceptance:** `composer check:strict` is clean; unit tests pass; PHPStan level 8. + +- [ ] 2.2 Implement `NotubizApiClient` with methods: + - `getMeetings($modifiedSince, $limit, $offset)` → REST GET `/meetings?modified-since=...` + - `getAgendaItems($meetingId)` → REST GET `/agenda-items?meeting_id=...` + - `getDocuments($agendaItemId)` → REST GET `/documents?agenda_item_id=...` + - `getVoting($meetingId)` → REST GET `/voting?meeting_id=...` + - `getDecisions($meetingId)` → REST GET `/decisions?meeting_id=...` + - `getMotions($meetingId)` → REST GET `/motions?meeting_id=...` + - `getAmendments($motionId)` → REST GET `/amendments?motion_id=...` + - `getAttendance($meetingId)` → REST GET `/meetings/{id}/attendance` + Rate-limit: 60 requests/min per tenant (token-bucket backpressure in client). + **Acceptance:** Unit tests mock HTTP responses; all methods handle 200/404/403/429 status codes correctly; rate-limit backpressure tested. + +- [ ] 2.3 Implement `NotubizFieldMapper` mapping vendor fields to decidesk schemas: + - NOTUBIZ `Meeting` → decidesk `Meeting` (map `id`, `title`, `date`, `location`, `status`) + - NOTUBIZ `AgendaItem` → decidesk `AgendaItem` (map `id`, `title`, `order`, `duration`) + - NOTUBIZ `Document` → decidesk `Vergaderstuk` (map `id`, `filename`, `size`, `confidentiality_level`) + - NOTUBIZ `Vote` → decidesk `Vote` (map `id`, `person_id`, `value` ∈ {voor, tegen, onthouden}, `timestamp`) + - etc. for Motion, Amendment, Decision, Attendance + **Acceptance:** Mapper is stateless; unit tests verify each entity type maps correctly; no field is dropped without explicit comment explaining why. + +- [ ] 2.4 Create NotubizAdapter background job runner: + - `lib/Job/NotubizFullSyncJob.php` (full import for date range; triggered manually or on calendar-year change) + - `lib/Job/NotubizIncrementalSyncJob.php` (cron-triggered every 15 min; delta-pull via `modified-since`) + - `lib/Job/NotubizPushJob.php` (push local changes back to NOTUBIZ via PATCH/POST) + Register in `appinfo/info.xml` as IJobList jobs. + **Acceptance:** Jobs are enqueueable via OpenConnector's job runner; unit tests verify job execution flow. + +--- + +## 3. iBabs adapter + +- [ ] 3.1 Create `openconnector/apps/ibabs-adapter/` with app structure: + - `appinfo/info.xml` (app metadata) + - `lib/IbabsAdapterApp.php` (app entry point) + - `lib/Service/IbabsApiClient.php` (SOAP + JSON-REST client, OAuth2 auth) + - `lib/Service/IbabsFieldMapper.php` (vendor fields → decidesk schema mapping) + - `lib/Controller/WebhookController.php` (receive `meeting.updated` webhooks) + - Unit tests + **Acceptance:** `composer check:strict` is clean; unit tests pass; PHPStan level 8. + +- [ ] 3.2 Implement `IbabsApiClient` with methods: + - `getMeetings($modifiedSince, ...)` → JSON-REST GET or SOAP call + - `getAgendaItems($meetingId)` → JSON-REST + - `getDocuments($agendaItemId)` → JSON-REST + - `getVoting($meetingId)` → JSON-REST + - `getDecisions($meetingId)` → JSON-REST + - `getMotions($meetingId)` → JSON-REST + - `getAmendments($motionId)` → JSON-REST + - `getAttendance($meetingId)` → JSON-REST + Webhook endpoints documented in iBabs API portal for Notifications module. + **Acceptance:** Unit tests verify SOAP/REST dual support; error handling for both protocols. + +- [ ] 3.3 Implement `IbabsFieldMapper` with same entity coverage as NOTUBIZ mapper (Meeting, AgendaItem, Document, Vote, Motion, Amendment, Decision, Attendance). + Map iBabs field names → decidesk schema. + **Acceptance:** Mapper is stateless; unit tests verify each entity type maps correctly. + +- [ ] 3.4 Create `IbabsAdapterWebhookController` to receive `meeting.updated` webhooks: + - `POST /apps/ibabs-adapter/webhook` handler + - Verify webhook signature (HMAC-SHA256 against configured secret) + - Extract meeting UUID; trigger incremental sync for that meeting + - Respond 202 Accepted; async job processes the actual sync + **Acceptance:** Unit tests verify signature validation; integration test ensures webhook → job trigger. + +- [ ] 3.5 Create iBabs background job runner (similar to NOTUBIZ): + - `lib/Job/IbabsFullSyncJob.php` (full import for date range) + - `lib/Job/IbabsIncrementalSyncJob.php` (cron-triggered every 15 min; can also be triggered by webhook) + - `lib/Job/IbasPushJob.php` (push changes back) + **Acceptance:** Jobs are enqueueable; unit tests verify flow. + +--- + +## 4. Sync orchestration engine + +- [ ] 4.1 Create `openconnector/lib/Service/SyncService/SyncService.php`: + - Orchestrates pull, push, conflict detection across all adapters. + - Constructor injects: `ObjectService`, `AdapterRegistry` (discovery of NOTUBIZ + iBabs adapters), `CrdtService` (conflict engine), `RelationService`, `FileService`. + - Public methods: + - `pullIncremental(string $provider, string $organization): SyncJobResult` — delta-pull via adapter. + - `pullFull(string $provider, string $organization, string $fromDate): SyncJobResult` — full import. + - `push(string $provider, string $organization, array $changes): SyncJobResult` — push local changes. + - `detectConflicts(string $provider): array` — find all open conflicts. + - `resolveConflict(string $conflictId, string $resolution, string $userId): bool` — manual resolution. + - Transactions: all-or-nothing per SyncJob (rollback on partial failure). + **Acceptance:** `composer check:strict` is clean; unit tests for happy path + error cases; transaction boundaries verified. + +- [ ] 4.2 Implement `SyncService::pullIncremental()`: + - Call `adapter.getObjects($modifiedSince, $limit, $offset)` for each entity type (Meeting, AgendaItem, etc.). + - For each object, check if ExternalIdentifier exists via `RelationService::findRelations()`. + - If exists, merge (update) via `ObjectService::saveObject()` + create audit trail. + - If not exists, create new object + create ExternalIdentifier. + - Create SyncJob record with counts: `objectsProcessed`, `objectsCreated`, `objectsUpdated`, `objectsSkipped`, `status: success`. + - On adapter error: catch, log to `SyncJob.errorLog`, set `status: failed`. + **Acceptance:** Unit tests mock adapter; verify correct CRUD sequence; SyncJob counts are accurate. + +- [ ] 4.3 Implement `SyncService::pullFull()`: + - Similar to incremental, but no `modifiedSince` filter; paginate through entire history. + - Deduplicate by `(Meeting.date + Meeting.name)` if provider supports it (NOTUBIZ does). + - Stream documents via `FileService::saveFile()` (not in-memory load). + - Create SyncJob with scope: `full`. + **Acceptance:** Unit tests verify pagination; document streaming tested with mock file. + +- [ ] 4.4 Implement `SyncService::push()`: + - Accept array of local changes: `[{objectId, objectType, field, newValue, oldValue}, ...]`. + - For each change: + - Check if ExternalIdentifier exists; if not, skip (local-only object). + - Check if field is writable in provider config (per `(org, provider, objecttype, field)` allowlist). + - Call `adapter.updateObject(externalId, field, newValue)` via PATCH/POST. + - Handle rejection: create SyncConflict with `resolution: provider-rejected`. + - Create SyncJob with counts. + **Acceptance:** Unit tests verify write check; rejection handling; non-writable field skip. + +- [ ] 4.5 Implement `CrdtService::detectConflicts()`: + - Scan all ExternalIdentifier records with `lastModifiedLocal ≠ lastModifiedExternal`. + - For each, compare `localValue` vs `externalValue` (via `ObjectService::getObject()` + adapter `getObject(externalId)`). + - If both changed since `lastSyncedAt`: create SyncConflict with both values, timestamps, who changed. + - Return array of new conflicts. + **Acceptance:** Unit tests verify conflict detection; timestamp comparison logic verified. + +- [ ] 4.6 Implement `SyncService::resolveConflict()`: + - Accept `conflictId`, `resolution` (enum: local, external, merged), `mergedValue` (if merged), `userId`. + - Update SyncConflict: `status`, `resolvedBy`, `resolvedAt`, `resolution` (text). + - If local/merged: update the decidesk object; push change back to provider if push is enabled. + - If external: pull the external value into decidesk. + - Update ExternalIdentifier `lastSyncedAt = now`. + **Acceptance:** Unit tests verify all three resolution paths; audit trail is created. + +--- + +## 5. Admin UI and observability + +- [ ] 5.1 Create `openconnector/lib/Controller/SyncStatusController.php` with route: + - `GET /apps/openconnector/sync-status` — admin panel page + - Render last 24h SyncJob list (per-provider, status distribution, counts) + - Show open SyncConflict queue (count, oldest conflict age, notification status) + - Show sync latency (avg pull time, last push time, next cron trigger) + - Show quota usage (NOTUBIZ: requests/min; iBabs: API calls/day) + - Render overall health status: `ok`, `degraded`, `failed` + **Acceptance:** Page loads without errors; SyncJob data is accurate; health status logic verified by unit tests. + +- [ ] 5.2 Create conflict-resolution UI: + - `GET /apps/openconnector/conflicts` — list open conflicts (sortable, paginated) + - `GET /apps/openconnector/conflicts/{id}` — detail view with both values side-by-side + - `PATCH /apps/openconnector/conflicts/{id}` — submit resolution (local/external/merged) + - Display: field name, localValue, externalValue, who changed (local user + external user), when + - Show audit trail of past resolutions on this object + **Acceptance:** UI is accessible; resolution submission updates SyncConflict; form validation works. + +- [ ] 5.3 Create alerting: + - On 3 consecutive SyncJob failures: call PagerDuty/Slack/e-mail via configured webhook. + - On >25 open SyncConflicts: emit event to set connector-status `degraded`. + - On SyncConflict remaining open >7 days: send reminder notification to griffier. + - All alerts logged in SyncJob + SyncConflict audit trails. + **Acceptance:** Unit tests verify alert conditions; mock webhook endpoints receive expected payloads. + +--- + +## 6. Per-organization configuration and secrets + +- [ ] 6.1 Create admin settings page: + - `GET /admin/settings/sync-configuration` — form to add/edit provider connection + - Input fields: provider name (NOTUBIZ/iBabs), organization, API endpoint, API key, OAuth2 credentials (encrypted storage via `openregister` secret manager) + - Checkbox: enable bidirectional sync (default: read-only) + - Per-entity checkboxes: which object types to sync + - Per-object-type field allowlist: which fields can be pushed back to provider + - Cron schedule: sync frequency (default 15 min) + - Webhook secret (for iBabs) + - Test connection button: calls adapter `testConnection()` → returns success/error + **Acceptance:** Form saves credentials encrypted; form loads existing config; test button works. + +- [ ] 6.2 Implement `ConfigurationService` for provider config: + - `getConfiguration(string $provider, string $organization): array` — load from encrypted storage + - `saveConfiguration(string $provider, string $organization, array $config): void` — encrypt + persist + - `testConnection(string $provider, string $organization): bool` — calls adapter health check + - `getAdapterRegistry(): AdapterRegistry` — discover all registered adapters (NOTUBIZ, iBabs, future Companion) + **Acceptance:** Unit tests verify encryption/decryption; integration test verifies adapter discovery. + +--- + +## 7. Integration tests and contract tests + +- [ ] 7.1 Create integration tests: + - `tests/Integration/SyncService/SyncServiceTest.php` — end-to-end pull → update → conflict detection + - Mock NOTUBIZ adapter responses; verify SyncJob, ExternalIdentifier, and conflict flow + - Test full import, incremental pull, push, conflict resolution + - Verify transaction boundaries (all-or-nothing per SyncJob) + **Acceptance:** All integration tests pass; >80% code coverage for SyncService. + +- [ ] 7.2 Create adapter contract tests: + - `tests/Contract/NotubizApiContractTest.php` — hits real NOTUBIZ sandbox endpoint (or VCR cassette) + - `tests/Contract/IbabsApiContractTest.php` — hits real iBabs sandbox endpoint (or VCR cassette) + - Verify endpoint availability, response format, rate-limit headers + - Run daily in CI; alert if test fails (indicates vendor API breakage) + **Acceptance:** Contract tests run daily; VCR cassettes recorded against sandbox; CI integration working. + +- [ ] 7.3 Create performance tests: + - Simulate large gemeente: 45 raadsleden, 18 commissies/maand, 3–4 meetings/week, 100+ documents/meeting + - Verify full import completes in <24h + - Verify incremental pull completes in <5 min + - Verify push completes in <30 sec per object + - Measure memory usage (no >512MB spike) + **Acceptance:** Benchmarks recorded; performance regressions detected by CI. + +--- + +## 8. Documentation and deployment + +- [ ] 8.1 Write operator-facing documentation: + - `docs/operators/sync-setup.md` — how to configure NOTUBIZ/iBabs connection per gemeente + - `docs/operators/sync-monitoring.md` — how to use the sync-status dashboard, respond to alerts + - `docs/operators/sync-troubleshooting.md` — common issues (API changes, rate-limit, conflict explosion) and mitigations + - `docs/operators/sync-conflict-resolution.md` — step-by-step conflict resolution workflow + **Acceptance:** Docs are complete; links are correct; screenshots included for UI workflows. + +- [ ] 8.2 Create repair step for schema + app initialization: + - `openconnector/lib/Migration/Version001000000Date20260522000000.php` — create decidesk-sync register + schemas + - `openconnector/lib/Settings/AppInfo/Application.php` — register apps in DI container + - Idempotent: re-running the migration does not create duplicates + **Acceptance:** `php occ db:add-missing-primary-keys` works; migrations are idempotent. + +- [ ] 8.3 Deploy to dev/staging: + - Enable openconnector; install NOTUBIZ and iBabs adapter apps + - Configure 1–2 test municipalities with sandbox endpoints + - Run full import; verify data integrity (count objects, spot-check field values) + - Run incremental sync for 3 days; verify delta counts are accurate + - Create test conflicts; resolve manually; verify resolution propagates + **Acceptance:** Full pilot workflow completes; no errors in logs. + +--- + +## 9. Deduplication check + +- [ ] 9.1 Verify no overlap with existing openregister/openconnector services: + - Check `ObjectService` (existing generic CRUD) — sync uses it; no duplicate. + - Check `RelationService` (existing FK relations) — sync uses for ExternalIdentifier; no duplicate. + - Check `AuditTrailService` (existing change tracking) — automatic; no custom audit code. + - Check `FileService` (existing file storage) — document streaming uses it; no duplicate. + - Check `WebhookService` (existing webhook dispatch) — iBabs webhooks routed via this; no duplicate. + - NEW custom code needed: SyncService, CrdtService, adapters, conflict UI. + Document findings in a comment in `lib/Service/SyncService/SyncService.php`. + **Acceptance:** Finding document is complete and accurate; no code duplication. + +--- + +## 10. Seed data generation + +- [ ] 10.1 Add seed data for all 4 schemas to `openconnector/lib/Settings/decidesk_sync_register.json`: + - 3–5 ExternalIdentifier objects (per provider, per object type) + - 3–5 SyncJob objects (mix of success, failed, partial statuses) + - 2–3 SyncConflict objects (mix of open, resolved-local, resolved-external) + - 3–5 FractieRol objects (mix of roles, portefeuilles, active/inactive) + Use realistic Dutch values: gemeente names (gemeente-Amsterdam, gemeente-Utrecht), person names (Jan de Vries, Maria García), role names, date ranges. + **Acceptance:** Seed data loads via `ConfigurationService::importFromApp()`; no duplicates on re-import; all objects validate against schema. + +--- +