feat(fm): one agreement per partner, edited in place
sequenceDiagram
participant P as portal / OEG
participant FM as our FM
participant DB as federation_agreements
participant X as Partner FM
P->>FM: POST /internal/agreements (partner already has one)
FM-->>P: 409 agreement-exists, "Edit agreement {id} instead."
P->>FM: PATCH /internal/agreements/{id} {permitted_zone_ids, zone_resources}
Note over FM: create checks run on the merged agreement<br/>(e.g. zone_resources must name permitted zones → 400)
FM->>DB: update in place (unique: one non-expired row per partner)
FM->>X: ZONES REMOVE / ADD for the zones that changed
P->>FM: PATCH {status: suspended}
FM->>X: ZONES REMOVE (all offered zones)
P->>FM: PATCH {status: active}
FM->>X: ZONES ADDA partner could hold several agreements, and the newest valid one won. Changing zones or terms meant creating another, which in the portal showed up as a list where the last entry quietly replaced the others. GSMA OPG.04 has no agreement object (zones are offered "based on the prior agreement"), so the shape is ours to pick, and one editable agreement per partner is what the portal needs.
POST /internal/agreements now answers 409 agreement-exists when the partner already has an agreement that isn't expired, and names it. PATCH takes every field except the partner: zones, zone terms, API types and app mappings as well as status and dates. It runs the create checks on the merged result, so a PATCH that leaves zone_resources pointing at a removed zone gets a 400. A PATCH that would bring an expired agreement back next to the live one also gets the 409. Zone edits reach a federated partner as ZONES ADD/REMOVE through the notifier that already exists.
A partial unique index enforces the rule: one row per partner where status <> 'expired'. Migration 0008 resolves partners that already have several. It keeps the agreement FM applies today (active and valid now, else active, else the newest) and marks the rest expired, so the history stays readable.
flowchart LR
A["operator-3 (Athens)<br/>active, valid until 2027-12-31"] -->|kept| K[active]
B["operator-3 (Athens)<br/>suspended"] -->|0008| E[expired]Merge order: OEG !47 relays the new 409 (without it OEG turns it into a 502); it's safe to merge before this. Portal !15 (merged) uses the wider PATCH and should go last, because develop rejects the new fields.
| Result | |
|---|---|
| full suite, Postgres + NATS + Keycloak as in CI | 442 passed, 1 skipped; same on a fresh database |
| mypy, ruff | clean |
migration on the testbed (ns oop, three operators, DBs backed up first) |
OP-1 had two agreements for operator-3: the active one stays, the suspended one becomes expired; OP-2 and OP-3 unchanged |
| second agreement for the same partner | 409 agreement-exists, "Edit agreement 1a4d2b51… instead." |
| PATCH removing Barcelona / adding it back | 200; OP-1 FM zone_change_notified REMOVE / ADD delivered: true; OP-2 FM applies the callback; OP-2's zone list loses and regains Barcelona |
PATCH leaving zone_resources for a zone not permitted |
400, zone_resources lists zones missing from permitted_zone_ids |
| PATCH reviving an expired agreement next to the live one | 409 agreement-exists |
| portal (!15 (merged) branch): edit end date, add and remove a flavour, suspend, activate | every PATCH 200; suspend drops Barcelona from OP-2's zones, activate brings it back |

