Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions openspec/changes/migrate-committees-to-decidiq/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
kind: code
---

# Proposal: migrate-committees-to-decidiq

## Summary

Retire dossiq's `bezwaaradviescommissie` schema and read objection advisory committees from decidiq's `GovernanceBody` instead. The target only became capable of holding one in decidiq#874, which added the four fields that were missing and the write path that made a migration possible at all; this is the dossiq half that consumes it.

## Motivation

A bezwaaradviescommissie is a governance body. decidiq owns governance bodies — `GovernanceBody`, `Membership`, `Post`, seeded and surfaced — and dossiq carrying a parallel committee schema is duplication that shows up as drift: two places to add a field, two places to fix a bug, and no shared view of who sits on what.

This was originally scoped as "move the schema" and could not be done that way. Measured on 2026-08-24, decidiq's `GovernanceBody` could not represent the committee at all: no `active` (the ONE field dossiq's live code reads and throws on), no numeric `quorum` for Awb 7:13, no `jurisdiction`, no `Membership.external` for 7:13(2), and no writable cross-app seam — the API was GET-only. decidiq#874 closes all five.

## Affected Projects

- [x] Project: `dossiq` — this change. A migration, a read path, and the retirement of the local schema.
- [ ] Project: `decidiq` — already done in #874. Nothing further is asked of it.

## Scope

### In Scope

1. **A migration** that, for each `bezwaaradviescommissie`, causes a decidiq `GovernanceBody` to exist with `bodyType: advisory-body`, `statutoryBasis: Awb 7:13`, and the mapped fields, and records the resulting id on the dossiq side so the mapping is auditable and the migration idempotent.

**It commands decidiq with a TYPED EVENT, not by calling the REST seam and not by writing into decidiq's register.** ADR-041 is explicit that cross-app *commands* travel as typed `IEventDispatcher` events, and ADR-066 amended it only for *collection* — it left the command rule standing, with gate-27 (`no-phantom-cross-app-rpc`) enforcing it. "Create a governance body in decidiq" is a command.

⚠️ **This corrects the first draft of this proposal**, which said the migration would go "via the cross-app write path" that decidiq#874 added. That seam is real and correct — for EXTERNAL callers. For an in-process app-to-app command it is the wrong door, and an in-process HTTP call to our own instance would also have no session to authenticate with.

The pattern already exists in this app: `ContractDecisionDelegationService` commands decidiq by dispatching `DecisionRequestedEvent` with an `externalReference` / `correlationId`, and the result returns as `DecisionConcludedEvent` carrying that correlation. The committee migration follows the same shape.

🔴 **This means decidiq needs work that #874 did NOT include**: a `GovernanceBodyRequestedEvent` (or equivalent), a listener that creates the body, and a "created" event carrying the correlation and the new id back. That is a decidiq-side change and a prerequisite for this one.
2. **A fan-out for the roster.** `members[]` is a list of uids on one object; decidiq models it as `Person` + `Membership` rows. Each member becomes a Membership on the new body, with `role` from chair/secretary/member and `external` set for members outside the administrative organ.
3. **A read path** — the eight call sites that read the local schema today (`AdvisoryCommitteeService`, `PanelIndependenceChecker`, `BezwaarAdviceRequestedListener`, `BezwaarAuditTrail`, `SettingsService`, `SchemaSlugMap`, and two more) resolve committees from decidiq, with the local schema as fallback until the migration has run everywhere.
4. **Retirement** of the local schema once the fallback is no longer reachable.

### Out of Scope

- **`bacAdviceRequest`.** The advice REQUEST has its own lifecycle and stays in dossiq for now; only the COMMITTEE moves. (Its `bezwaar` foreign key was fixed separately — see the page-topology-cleanup change.)
- Any change to decidiq. #874 is the whole of it.

## Risks

- 🔴 **A fan-out migration is not idempotent by default.** Re-running must not mint a second Person and Membership per member. The mapping record from In Scope 1 is what makes re-runs safe, and it has to be written BEFORE the memberships, not after.
- 🔴 **`active` is load-bearing.** `AdvisoryCommitteeService` throws "Committee is archived and cannot accept new bezwaaren" on it. A migration that drops or defaults it starts routing objections to disbanded committees, and nothing errors.
- ⚠️ **The read path must not fail closed on decidiq's absence.** decidiq is an optional runtime dependency; a dossiq install without it must still function, which means the fallback is not a migration-window convenience but a permanent branch until decidiq becomes required.
- ⚠️ **Cross-app id references are runtime lookups.** The committee id stored on a bezwaar points into decidiq's register after this; see the fleet lesson about pinned references dying silently when the other side moves.

## Status

**BLOCKED**, and on more than first thought. decidiq#874 is merged, so the SCHEMA half of the target exists. But the command seam does not: per ADR-041 this migration must dispatch a typed event, and decidiq has no event/listener pair for creating a governance body. That prerequisite is a decidiq change which does not yet exist.

Written now so the shape is agreed — including the correction above, which was found by reading ADR-041/ADR-066 rather than by assuming the REST seam was the answer.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# migrate-committees-to-decidiq Specification

**Status**: planned (BLOCKED on decidiq#874)
**Scope**: dossiq

## Purpose

Read objection advisory committees from decidiq's `GovernanceBody` instead of dossiq's own `bezwaaradviescommissie`, and migrate the existing ones across. The target only became able to hold a committee in decidiq#874.

## ADDED Requirements

### Requirement: REQ-MCD-001 Committees migrate to governance bodies

The system SHALL provide a repair step that creates a decidiq `GovernanceBody` for each local `bezwaaradviescommissie`, mapping `name`, `domain`, `jurisdiction`, `quorum`, `active`, `termStartsOn`/`termEndsOn`, and setting `bodyType: advisory-body` with `statutoryBasis` naming Awb 7:13.

The step SHALL run under a system identity. A repair step executes during `occ upgrade`, where there is no session; without one, OpenRegister resolves the actor as `Anonymous` and refuses every create, and the resulting `$output->warning()` does NOT fail the upgrade — so the migration silently does nothing while the upgrade reports success.

The step SHALL record the resulting body id on the dossiq side BEFORE creating any memberships, and SHALL skip a committee that already carries one.

#### Scenario: A committee becomes a governance body

- GIVEN a `bezwaaradviescommissie` with a quorum of 3 and `active: true`
- WHEN the migration runs
- THEN a decidiq `GovernanceBody` exists with `bodyType: advisory-body`, `quorum: 3`, `active: true` and a `statutoryBasis` naming Awb 7:13
- AND the local committee records the new body's id

#### Scenario: Re-running mints nothing new

- GIVEN a committee already carrying a migrated body id
- WHEN the migration runs again
- THEN no second body and no duplicate memberships are created

#### Scenario: The step has an identity

- GIVEN the migration running with no user session
- WHEN it writes
- THEN the writes succeed
- AND a run that could not establish a system identity FAILS rather than reporting a warning and continuing

### Requirement: REQ-MCD-002 The roster fans out to memberships

The system SHALL create one `Membership` per entry in the committee's `members[]`, linked to the new body, with `role` derived from the chair/secretary/member position and `external` set for members who sit from outside the administrative organ.

`members[]` is a list of uids on ONE object; decidiq models the roster as separate `Person` + `Membership` rows. The fan-out is therefore not a field copy, and it is the part that must not run twice.

#### Scenario: The chair is recorded as chair

- GIVEN a committee whose `chair` names one of its members
- WHEN the roster is migrated
- THEN that member's Membership carries `role: chair`
- AND an Awb 7:13(2) external chair carries `external: true`

### Requirement: REQ-MCD-003 Reads resolve from decidiq, falling back locally

The system SHALL resolve committees from decidiq, falling back to the local schema when decidiq is absent or the committee has not been migrated.

The fallback SHALL NOT be treated as a migration-window convenience. decidiq is an OPTIONAL runtime dependency, so an install without it must keep working; the branch is permanent until decidiq becomes required.

The `active` flag SHALL be read from whichever source served the committee. `AdvisoryCommitteeService` refuses new bezwaren on an archived committee, so a read that loses `active` starts routing objections to disbanded committees — and, defaulting to available, does so without erroring.

#### Scenario: decidiq absent

- GIVEN an instance without decidiq
- WHEN a committee is read
- THEN it resolves from the local schema
- AND no error is raised

#### Scenario: The archive gate still holds after migration

- GIVEN a migrated committee with `active: false`
- WHEN a new bezwaar is assigned to it
- THEN it is refused, exactly as before the migration
53 changes: 53 additions & 0 deletions openspec/changes/migrate-committees-to-decidiq/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Tasks: migrate-committees-to-decidiq

> ⛔ BLOCKED on TWO things, not one.
>
> 1. decidiq#874 — MERGED. The schema half of the target now exists.
> 2. A decidiq event/listener pair for creating a governance body — DOES NOT
> EXIST. Per ADR-041 a cross-app COMMAND travels as a typed event (ADR-066
> amended that only for collection, and gate-27 enforces it), so the REST
> write seam #874 added is the wrong door for an in-process migration. That
> prerequisite is a decidiq change nobody has written.

## Implementation Tasks

### Task 1: The migration repair step
- **spec_ref**: `openspec/changes/migrate-committees-to-decidiq/specs/migrate-committees-to-decidiq/spec.md#requirement-req-mcd-001-committees-migrate-to-governance-bodies`
- **files**: `lib/Repair/MigrateCommitteesToDecidiq.php`, `appinfo/info.xml`
- **prerequisite**: a decidiq `GovernanceBodyRequestedEvent` + listener + a
created-event carrying the correlation back. Mirror
`ContractDecisionDelegationService`, which is this app's working example of
commanding decidiq by event and correlating the answer.
- **acceptance_criteria**:
- GIVEN committees WHEN the step runs THEN each DISPATCHES a typed event and, on the answer, a GovernanceBody exists with the mapped fields and `bodyType: advisory-body`
- GIVEN the step WHEN inspected THEN it makes NO HTTP call to this instance and writes NOTHING into decidiq's register — gate-27 forbids the registry as an RPC bus and ADR-022/066 forbid reaching into another app's register
- GIVEN a second run THEN nothing is created
- GIVEN no session THEN the writes still succeed (runAsSystem), and a failure to obtain one FAILS rather than warning
- GIVEN the test fake THEN it implements `runAsSystem()`, so removing the wrapper breaks the suite
- [ ] Implement
- [ ] Test

### Task 2: Roster fan-out
- **spec_ref**: `...#requirement-req-mcd-002-the-roster-fans-out-to-memberships`
- **files**: `lib/Repair/MigrateCommitteesToDecidiq.php`
- **acceptance_criteria**:
- GIVEN a committee with N members WHEN migrated THEN N Memberships exist on the new body with correct roles
- GIVEN a re-run THEN no duplicates
- [ ] Implement
- [ ] Test

### Task 3: Read path with a permanent fallback
- **spec_ref**: `...#requirement-req-mcd-003-reads-resolve-from-decidiq-falling-back-locally`
- **files**: `lib/Service/Bezwaar/AdvisoryCommitteeService.php`, `lib/Service/Bezwaar/PanelIndependenceChecker.php`, `lib/Listener/BezwaarAdviceRequestedListener.php`, `lib/Service/Bezwaar/BezwaarAuditTrail.php`, `lib/Service/SettingsService.php`, `lib/Service/Settings/SchemaSlugMap.php`
- **acceptance_criteria**:
- GIVEN decidiq absent THEN reads fall back locally with no error
- GIVEN a migrated committee with `active: false` THEN the archive refusal still fires
- [ ] Implement
- [ ] Test

### Task 4: Retire the local schema
- **spec_ref**: all
- **acceptance_criteria**:
- Deferred until the fallback is provably unreachable on supported installs. Retiring it while any install still reads it removes a working feature.
- [ ] Implement
- [ ] Test
Loading