Operator Console (OP) — Architecture, Part 2: Integrations, Build & Ops¶
See
README.mdfor the cross-part index, andpart1-domain-and-api.mdfor §0–§11 (domain, API, security, migrations).
This file covers §12 through §21 plus Appendix A (ADRs) and Appendix B (Open Questions).
12. Integration Points¶
OP is a thin orchestrator: it owns the campaign lifecycle but delegates contract reads, link signing, dispatch, payment status, tenant config and audit emission to sibling modules. All inbound calls are Spring Modulith in-process; only the Keycloak Admin REST API leaves the JVM.
12.1 ING — Contract reads (Spring Modulith in-process)¶
ContractQueryService.findEligible(tenantId, agencyId, windowDays)
→ Page<ContractSummary> // cohort v0.0.0
ContractQueryService.findById(tenantId, contractId)
→ ContractDetail // contract detail V1
ContractQueryService.search(tenantId, agencyId, query, cursor)
→ Page<ContractSearchResult> // global search V1
AnomalyService.findOpen(tenantId, agencyId, filters)
→ Page<AnomalySummary> // anomaly queue v0.0.3
AnomalyService.resolve(tenantId, anomalyId, resolution)
→ void // resolution v0.0.3
12.2 AUTH — SignedLinkService¶
SignedLinkService.createLink(
contractId, campaignContractId, tenantId, correlationId, ttlHours)
→ SignedLinkToken { linkId, token, expiresAt }
SignedLinkService.reissueForCascade(originalLinkId, ttlHours) // V1 reminder
→ SignedLinkToken
SignedLinkService.revokeLinks(campaignContractIds[]) // v0.0.3 cancellation
→ RevokeResult { revokedCount }
12.3 NOTIF — NotificationDispatchService¶
NotificationDispatchService.dispatch(DispatchRequest {
campaignContractId, // idempotency key (UNIQUE on notifications)
correlationId, // = campaignContractId (D16 / OP-ADR-05)
tenantId,
customerPhone, // E.164
linkToken,
templateVars: { nomClient, montant, dateEcheance, nomCabinet, typeContrat, numeroContrat },
countryCode // → provider selection (country-profile pattern)
})
NotificationDispatchService.cancelPending(campaignId) // v0.0.3 cancellation
→ CancelResult { cancelledCount }
OQ-OP-04: confirm with the NOTIF ARCHITECT that
dispatch()returns the existing row (or is a silent no-op) on conflict againstUNIQUE(campaign_contract_id). Blocks OP-001 dev-start.
12.4 PAY — Payment status reads¶
PaymentQueryService.getStatusForContracts(tenantId, contractIds[])
→ Map<UUID, PaymentStatus> // pointwise read for V1 polling
In v0.0.0, OP subscribes to the Spring PAY_CONFIRMED / PAY_FAILED events. There is no active call from OP into PAY in v0.0.0.
12.5 TENANT — TenantConfigCache¶
TenantConfigCache.getRenewalWindowDays(tenantId) → int
TenantConfigCache.getCimaLegalInfo(tenantId) → CimaInfo
TenantConfigCache.getDefaultAgencyId(tenantId) → UUID // v0.0.0 default agency
12.6 AUDIT — Event emission¶
AuditEventBuilder
.forEvent(AuditEventType.OP_CAMPAIGN_VALIDATED)
.tenantId(tenantId)
.actorId(operatorId)
.correlationId(campaignId)
.payload(Map.of("campaignId", id, "contractCount", n, "agencyId", agencyId))
.build()
.publish(eventPublisher);
OQ-X14: The AUDIT catalog (
AuditEventTypeenum) must accept all 14 OP event types before OP-001 dev-start. Blocks OP-001 dev-start.
12.7 Keycloak Admin REST API (V1 — user management)¶
POST /admin/realms/papillon/users
PUT /admin/realms/papillon/users/{userId}
PUT /admin/realms/papillon/users/{userId}/execute-actions-email
Keycloak Admin client (papillon-op-admin, confidential, service account) scoped to manage-users. The ≥ 1 active cabinet_admin guard is enforced at the service layer before any deactivation.
13. Frontend Architecture (React 19.2)¶
13.1 App structure¶
frontend/operator/
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── components/
│ │ ├── Layout/
│ │ ├── Campaigns/ # CampaignList, CampaignDetail, CampaignForm, ContractTable
│ │ ├── Contracts/
│ │ ├── Dashboard/ # V1
│ │ ├── Settings/ # v0.0.0 read, V1 edit
│ │ ├── Users/ # V1
│ │ ├── Import/ # v0.0.3
│ │ ├── Anomalies/ # v0.0.3
│ │ └── PlatformAdmin/ # D20 role-gated
│ ├── hooks/ # useCampaigns, useCampaignPolling, useAuth, useLocalNotes (V1)
│ ├── api/ # Axios client + per-resource modules
│ ├── types/
│ ├── utils/ # auth, formatting (XOF/dates), errors
│ ├── styles/ # variables, global, theme
│ └── index.html
└── vite.config.ts
13.2 Key libraries & decisions¶
- Router: TanStack Router v1 (D22).
- Data fetch: React Query v5 (D22).
- Auth: Keycloak JS adapter (D21).
- Styling: CSS Modules (D23) — no Tailwind, no styled-components.
- Forms: React Hook Form v7 (D23) + Zod validation.
- Async:
AbortControlleron every request — cancel on unmount. - Notifications: toast library (v0.0.0: simple banner; v0.1.0: toast queue).
13.3 Polling implementation (v0.0.0)¶
const useCampaignPolling = (campaignId: string, enabled = true) => {
return useQuery({
queryKey: ['campaigns', campaignId, 'status'],
queryFn: () => api.campaigns.getStatus(campaignId),
refetchInterval: enabled ? 30000 : false,
refetchOnWindowFocus: false,
});
};
Suspended when document.visibilityState === 'hidden'. Repolls on focus regain.
13.4 Error handling & offline¶
client.interceptors.response.use(
(res) => res,
(err) => {
if (!navigator.onLine || err.code === 'ECONNREFUSED') {
showOfflineBanner();
return Promise.reject({ offline: true });
}
return Promise.reject(err);
}
);
Mutation buttons are disabled while offline.
13.5 RBAC enforcement (UI)¶
const navItems = [
{ label: 'Campagnes', path: '/campaigns', minRole: 'agency_operator' },
{ label: 'Utilisateurs', path: '/users', minRole: 'cabinet_admin' },
{ label: 'Admin', path: '/admin', minRole: 'PLATFORM_ADMIN' },
].filter(item => hasRole(item.minRole));
API endpoints reject the call server-side if the role is insufficient — the UI gating is purely cosmetic.
13.6 Polling vs SSE switchover (V1)¶
When CampaignDetail mounts, attempt SSE. On failure (5 s timeout), fall back to 30 s polling. Exponential back-off on reconnect (1 s, 2 s, 4 s, 8 s, capped at 60 s).
14. Deployment & Configuration¶
14.1 Environment profiles¶
| Profile | Database | Keycloak | NOTIF | Observability |
|---|---|---|---|---|
local |
H2 (in-memory) | docker-compose | Mock/print | console logs |
dev |
Postgres (local Docker) | Keycloak staging | NOTIF staging | ELK optional |
staging |
Postgres (managed) | Keycloak staging | NOTIF staging | CloudWatch + Sentry |
prod |
Postgres (managed) | Keycloak prod | NOTIF prod | CloudWatch + Sentry + Datadog |
14.2 Spring profiles¶
Activation: -Dspring.profiles.active=dev or SPRING_PROFILES_ACTIVE=dev.
14.3 OP-specific env¶
OP_POLLING_INTERVAL_MS=30000 # v0.0.0–v0.0.5
OP_CAMPAIGN_NAME_TEMPLATE="{YYYY-MM-DD} - {agency_name}"
OP_BLOCKING_ANOMALY_THRESHOLD=5 # FR-OP-02
OP_DISPATCH_RECOVERY_BATCH_SIZE=100
OP_DISPATCH_RECOVERY_INTERVAL_MIN=60
Cross-module:
TENANT_CACHE_TTL_MIN=10
KEYCLOAK_REALM=papillon
KEYCLOAK_CLIENT_ID=papillon-operator
AUDIT_BUFFER_SIZE=1000
14.4 Docker & Coolify¶
OP ships in the single papillon-app:latest container. The frontend bundle (dist/operator/) is embedded in the WAR. Nginx acts as reverse proxy in dev and staging.
15. Monitoring, Observability & SLOs¶
15.1 Core metrics¶
| Metric | Collection | SLO v0.0.0 |
|---|---|---|
op_campaigns_created_total |
Spring counter | — |
op_campaigns_sent_total |
Spring counter | — |
op_campaign_contracts_status |
Gauge (per-status) | — |
op_api_request_duration |
Micrometer | p99 < 2 s |
op_notif_dispatch_latency |
Histogram (async) | p99 < 10 s |
op_link_creation_latency |
Histogram (sync) | p99 < 500 ms |
op_dispatch_recovery_lag |
Gauge (pending contracts) | < 100 at p95 |
Export: Prometheus (local), CloudWatch (staging/prod).
15.2 Alerting¶
| Alert | Threshold | Escalation |
|---|---|---|
| Campaign send fail rate | > 5% over 5 min window | Ops + Product |
| Dispatch recovery backlog | > 500 pending | Ops |
| Link creation p99 | > 1 s | Dev team (AUTH module) |
| Tenant block registry sync failure | Any error | Ops (manual reconcile) |
15.3 Logs¶
Structured logging via logback.xml + JSON layout. Key fields: correlation_id, tenant_id, campaign_id, contract_id, user_id, action.
15.4 Health check endpoint (v0.1.0)¶
Checks: Postgres connectivity, Keycloak token validation, NOTIF client availability, TENANT cache age.
16. Vertical Slice Decomposition¶
PO input — not for DEVELOPER. The DEVELOPER reads only the story file at
docs/stories/<module>/<STORY-ID>.md. This section exists for the PRODUCT_OWNER to decompose into self-contained stories.
Tier 0 — OP-S0: Test infrastructure + Flyway migration¶
Scope:
- No new external dependency (Postgres, Keycloak, outbox are already provisioned by TENANT S0 + AUDIT S0)
- V005__op_campaigns.sql (3 tables + indexes)
- Spring Modulith module declaration @ApplicationModule("operator")
- No-op listener @ApplicationModuleListener(ImportCommitted.class) (OQ-X13)
- Empty CampaignDispatchRecovery stub
- CampaignTestDataBuilder (test fixture)
- Spring Modulith boundary test (OperatorModuleTest)
Complexity: S
Dependencies: TENANT S0, AUDIT S0, ING-001 (ImportCommitted type)
OP-001 [V0.0.0] — Create + send fire-and-forget campaign (split candidate)¶
Scope:
- Eligibility cohort query (GET /contracts?eligible=true)
- Create BROUILLON campaign (FR-OP-03 naming, snapshot cohort_params)
- Send: BROUILLON → ENVOYÉE
- CampaignSendWorker: outbox-backed @ApplicationModuleListener; per-contract TX_i
- CampaignDispatchRecovery: full implementation (startup + every 5 min)
- Audit event OP_CAMPAIGN_VALIDATED with correlation_id = campaign_contract.id (D16)
- Frontend: campaign list page + "Create and send" CTA + confirmation modal
ACs (summary):
1. GET /contracts?eligible=true returns contracts within the renewal_window_days window, excluding those already in an active campaign.
2. POST /campaigns creates a BROUILLON campaign with the correct name (FR-OP-03) and N campaign_contracts rows in state INCLUS.
3. POST /campaigns/{id}/send transitions to ENVOYÉE; all N contracts move to NOTIF_EN_ATTENTE after fan-out.
4. CampaignDispatchRecovery idempotently rescues INCLUS contracts stuck under an ENVOYÉE campaign.
Suggested sub-split:
- OP-001a: cohort query + BROUILLON creation (API + DB, no dispatch). Complexity S.
- OP-001b: send + CampaignSendWorker + CampaignDispatchRecovery. Complexity M.
- OP-001c: frontend campaign list + create modal. Complexity S.
Overall complexity: M
Dependencies: OP-S0, ING-001, AUTH-001 (SignedLinkService), NOTIF-001 (NotificationDispatchService), AUDIT (OP events catalog — OQ-X14)
OP-002 [V0.0.1] — Pre-send inline edit + post-send status view¶
Scope:
- Inline edit of phone_number, amount, due_date on campaign_contract (cabinet_admin only, FR-OP-07)
- Audit FIELD_CORRECTED
- Blocking-anomaly guard (FR-OP-02): "Validate and Send" button disabled while any BLOQUANTE anomaly is unresolved
- Read-only post-send status view: poll GET /campaigns/{id}/status every 30 s
- Frontend: campaign detail page with contract list + edit controls + per-status counters
Complexity: M Dependencies: OP-001, ING anomalies API (read, for the guard)
OP-003 [V0.0.2] — Multi-agency routing + branch-operator write extension¶
Scope:
- AgencyFilter middleware (injects agency_id for agency_operator)
- Campaign creation: agency_id auto-derived from the JWT context
- All lists (campaigns, contracts, anomalies) filtered by agency for agency_operator
- cabinet_admin / supervisor: Agency column + per-agency filter
- agency_id present on every audit event
- Lift inline-edit RBAC pre-send for agency_operator (PRD V0 §4 OP "branch operators can edit") — branch operators may now PATCH campaign_contracts for their own agency
Complexity: S
Dependencies: OP-002, TENANT-002 (agency_id on contracts)
OP-004 [V0.0.3] — Post-send actions + anomalies + import UI (split candidate — 3 sub-slices)¶
Sub-split:
- OP-004a: campaign cancellation (FR-OP-05) — link revocation (AUTH) + NOTIF queue cancellation + status flip. Complexity M.
- OP-004b: anomalies UI + inline resolution (FR-OP-02, state RÉSOLUE) +
ANOMALY_RESOLVEDaudit. Complexity M. - OP-004c: import upload UI (US-OP-01) — delegates to ING, shows the result, audit
IMPORT_TRIGGERED. Complexity S.
V0 note: bulk exclusion, resend/remind, and off-platform marking are not part of PRD V0 §4 OP v0.0.3 (which covers view + revoke + cancel on the post-send actions side only). Off-platform marking (OP-014) and reminders (OP-013) stay V1.
Overall complexity: M
Dependencies: OP-003, AUTH (revokeLinks), NOTIF (cancelPending), ING (anomalies + import APIs)
OP-005 [V0.1.0] — Observability¶
Scope:
- Micrometer metrics: campaign send rate, CampaignSendWorker duration, recovery scan counter, fan-out lag
- Health endpoint for the dispatch backlog
- Structured-logging MDC enrichment: campaignId, tenantId, correlationId
Complexity: S Dependencies: OP-001
V1 slices (for PO decomposition into OP-006+)¶
| Slice | US | Scope | Complexity | Dependencies |
|---|---|---|---|---|
| OP-006 | US-OP-09 | Dashboard 4 widgets (SQL aggregates, agency-scoped) | M | OP-003 |
| OP-007 | US-OP-10 | Global contract search (300 ms debounce, name + phone + ref) | S | OP-003 |
| OP-008 | US-OP-11 | CSV/XLSX export (signed URL, 10 min, 10 000-row cap, supervisor+admin) | M | OP-006 |
| OP-009 | US-OP-13 | Keycloak Admin API user management (CRUD + password reset) | M | AUTH V1 |
| OP-010 | US-OP-05 | Real-time SSE + polling fallback + reconnect back-off | M | OP-002 |
| OP-011 | FR-OP-01 | Scheduled cohort job (2×/week, @Scheduled, auto-creates BROUILLON) |
M | OP-003 |
| OP-012 | US-OP-03 | Ad-hoc campaign creation (contract picker + eligibility filter) | M | OP-011 |
| OP-013 | US-OP-05 AC4 | Link resend + reminder (AUTH reissueForCascade) |
S | AUTH OQ-X6 |
| OP-014 | US-OP-06 | Off-platform marking (V1 standalone) | S | OP-010 |
| OP-015 | US-OP-14 | Add contract to a sent campaign | S | OP-012 |
| OP-016 | US-OP-12 | Edit cabinet settings (TENANT API + CIMA fields) | S | TENANT API V1 |
| OP-017 | FR-OP-15 | Offline note sync localStorage → POST /notes |
S | — |
17. Technical Risks¶
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Fan-out too slow for large campaigns (1 000+ contracts in V1) | Low v0.0.0, Medium V1 | Medium | OP-ADR-01: per-contract TX_i. V1: configurable executor pool + batch size. V2: dedicated worker with queue. |
| Outbox retry storm (duplicate dispatch) | Low | Medium | UNIQUE(notifications.campaign_contract_id) → structural idempotence. |
Slow cohort query against large contracts tables |
Low v0.0.0, Medium V1 | High | idx_cc_tenant_contract_status covers the NOT EXISTS. Verify (tenant_id, due_date) index on contracts. |
| Keycloak Admin API unavailable (V1) | Low | Low | Circuit breaker + retry. User management is not critical for sending campaigns. |
| Partial campaign cancellation (links revoked, status not flipped) | Very low | High | AUTH.revokeLinks is atomic. OP flips status only after confirmation. V1 reconciliation job detects inconsistent states. |
| Agency-scope bypass via forged JWT | Very low | Critical | The agency_id JWT claim is verified against Keycloak user attributes. Server-side middleware enforces. |
| NOTIF unavailable at send time | Low | High | Transactional outbox; NOTIF picks up on recovery. OP surfaces a 503; the operator retries through the UI. |
| PII leak in audit events | Low | Critical | All AUDIT events pass through PiiHasher. OP never holds raw DOB. |
| Export of 10 000 rows times out | Medium | Low | Streaming response + server-side cap. V1: async export with a signed MinIO URL. |
| State "campaign ENVOYÉE, contracts stuck INCLUS" after restart without outbox | Very low | High | CampaignDispatchRecovery startup scan + every 5 min. UNIQUE provides idempotence. |
18. Test Infrastructure¶
18.1 TestContainers dependencies¶
OP introduces no new container. It reuses:
- PostgreSQLContainer (single instance for platformDataSource + tenantDataSource)
- KeycloakContainer (from AUTH S0) for the RBAC integration tests
- WireMockContainer (from NOTIF S0) for the end-to-end SMS/WA dispatch tests
18.2 In-process Spring mocks¶
@MockBean SignedLinkService // AUTH
@MockBean NotificationDispatchService // NOTIF
@MockBean ContractQueryService // ING
@MockBean AnomalyService // ING
@MockBean PaymentQueryService // PAY
18.3 Tenant isolation test¶
@Test
void campaignListIsolatedByTenant() {
UUID tenantA = fixture.createTenant("tenant-a");
UUID tenantB = fixture.createTenant("tenant-b");
UUID campA = fixture.createCampaign(tenantA, "Campagne A");
UUID campB = fixture.createCampaign(tenantB, "Campagne B");
String tokenA = keycloakTestClient.operatorToken(tenantA);
List<Map<String, Object>> result = given()
.header("Authorization", "Bearer " + tokenA)
.get("/api/operator/campaigns")
.then().statusCode(200)
.extract().jsonPath().getList("data");
assertThat(result).hasSize(1);
assertThat(result.get(0).get("id")).isEqualTo(campA.toString());
}
18.4 Fan-out crash recovery test¶
@Test
void campaignDispatchRecoveryAfterPartialFanOut() {
UUID campaignId = fixture.createSentCampaign(tenantId, 5);
fixture.dispatchFirstN(campaignId, 2);
assertContractStatuses(campaignId, Map.of("NOTIF_EN_ATTENTE", 2, "INCLUS", 3));
campaignDispatchRecovery.runRecovery(tenantId);
assertContractStatuses(campaignId, Map.of("NOTIF_EN_ATTENTE", 5, "INCLUS", 0));
campaignDispatchRecovery.runRecovery(tenantId);
assertNotificationCount(campaignId, 5); // still 5, never 7
}
18.5 Agency-scoping test¶
@Test
void agencyOperatorCannotSeeOtherAgencyCampaigns() {
UUID agencyA = fixture.createAgency(tenantId, "Agence A");
UUID agencyB = fixture.createAgency(tenantId, "Agence B");
UUID campA = fixture.createCampaign(tenantId, agencyA);
UUID campB = fixture.createCampaign(tenantId, agencyB);
String token = keycloakTestClient.agencyOperatorToken(tenantId, agencyA);
List<Map<String, Object>> result = given()
.header("Authorization", "Bearer " + token)
.get("/api/operator/campaigns")
.then().statusCode(200)
.extract().jsonPath().getList("data");
assertThat(result).extracting("id").containsOnly(campA.toString());
}
18.6 Status polling test¶
@Test
void pollingStatusReflectsNotifEvents() {
UUID campaignId = fixture.createSentCampaign(tenantId, 3);
fixture.simulateNotifEvent(campaignId, 0, "NOTIF_SENT");
fixture.simulateNotifEvent(campaignId, 1, "NOTIF_SENT");
given()
.header("Authorization", "Bearer " + operatorToken)
.get("/api/operator/campaigns/{id}/status", campaignId)
.then().statusCode(200)
.body("notifLivre", equalTo(2))
.body("notifEnAttente", equalTo(1));
}
19. Local Dev Environment¶
19.1 No new service¶
OP adds no Docker container. The full infrastructure (Postgres, Keycloak, MinIO, WireMock) is already provisioned by TENANT S0 + AUDIT S0 + NOTIF S0.
19.2 New environment variables¶
KC_ADMIN_CLIENT_ID=papillon-op-admin
KC_ADMIN_CLIENT_SECRET=change-me-dev
OP_RECOVERY_INTERVAL_MS=60000 # dev: 1 min for fast iteration
OP_SSE_HEARTBEAT_MS=30000 # V1
19.3 Smoke verification¶
docker compose up -d
./gradlew bootRun --args='--spring.profiles.active=dev'
TOKEN=$(curl -s -X POST \
http://localhost:8180/realms/papillon/protocol/openid-connect/token \
-d "client_id=papillon-operator" \
-d "grant_type=password" \
-d "[email protected]" \
-d "password=demo" \
| jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/operator/settings | jq .
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/operator/contracts?eligible=true" | jq .
CAMPAIGN=$(curl -s -X POST \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"type":"SCHEDULED"}' \
http://localhost:8080/api/operator/campaigns)
CAMPAIGN_ID=$(echo $CAMPAIGN | jq -r .id)
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{}' \
"http://localhost:8080/api/operator/campaigns/$CAMPAIGN_ID/send" | jq .
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/operator/campaigns/$CAMPAIGN_ID/status" | jq .
20. Known Technical Debt & Forward-Compat Markers¶
| Item | Impact | Target |
|---|---|---|
| No enum domain types | Strings used for campaign.type, campaign_contract.status. Schema migration cost V2. |
V2 |
| Cohort expression hardcoded to window days | Cron-based scheduling requires cohort_params restructure. |
V1 (OP-011) |
| No linked indexes on contracts (due_date) | Cohort query could be slow if the contracts table grows large. Mitigated by ContractEligibleCache. |
v0.0.3+ |
| SSE implementation skipped v0.0.0–v0.0.5 | Polling scales to ~1 k concurrent operators; SSE scales further. | V1 (OP-010) |
| OP user mgmt hardwired to Keycloak Admin API | No OIDC federation support. | V2 |
| Campaign-name collision handling (FR-OP-03) | name + agency_id is UNIQUE, but the increment logic is naive. |
v0.1.0 |
| No analytics/BI export (V1) | Data analysis requires manual SQL. | V1 (OP-008) |
21. References¶
- PRD:
docs/prd/prd-v0.md§4 (OP), §8 (platform requirements);docs/prd/operator-console-prd.md(V1 source of truth) - Multi-tenant model:
docs/standards/multi-tenant-model.md - Platform blueprint:
docs/architecture/platform-saas-blueprint.md§12 (repo structure), §5.2 (campaign engine folding) - Critical rules:
docs/standards/critical-rules.md(D1–D16 decisions) - Tech stack:
docs/standards/tech-stack.md - ADRs: Appendix A below (OP-ADR-01..06)
- Frontend decisions:
docs/architecture/decisions/D20-D23.md - Connectivity:
docs/standards/connectivity-low-bandwidth.md - AUDIT catalog:
docs/standards/critical-rules.md§B.1 (14 OP event types) - Legal:
docs/legal/memo-conformite-CI-v2.md§F
Appendix A — Architecture Decision Records¶
OP-ADR-01: Campaign fan-out — CampaignSendWorker + CampaignDispatchRecovery¶
Context: Sending a campaign involves N signed-link creations (AUTH) and N dispatch requests (NOTIF), one per contract. Those DB writes must all succeed.
Decision:
1. TX0: campaign → ENVOYÉE, publish CampaignValidated to the spring-modulith outbox.
2. CampaignSendWorker (@ApplicationModuleListener, outbox-backed): per-contract TX_i. Idempotent via UNIQUE(campaign_contract_id) on notifications.
3. CampaignDispatchRecovery: startup scan + every 5 min.
Why it's sound: the spring-modulith outbox guarantees at-least-once delivery. Idempotence via the unique constraint makes re-execution safe. The recovery loop guards against outbox corruption.
Rejected alternatives: serial in-transaction option (long TX at V1 scale); batch REST endpoint on NOTIF (over-engineering).
OP-ADR-02: Full V1 status enums shipped from v0.0.0¶
Context: v0.0.0 only reaches a subset of states; the full V1 FSM requires 4 campaign states and 11 contract states.
Decision: Ship the complete CHECK constraints in V005__op_campaigns.sql. Application code only exercises the reachable subset.
Why it's sound: mirrors AUDIT-ADR-03 and ING-ADR-10. Avoids ALTER TABLE on hot tables between V0 sub-versions.
OP-ADR-03: Settings belong to TENANT; OP reads via TenantConfigCache¶
Decision: Drop cabinet_settings. OP reads renewal_window_days and the CIMA fields from TenantConfigCache. US-OP-12 calls the TENANT API; TENANT emits TenantSettingsChanged; OP emits SETTINGS_CHANGED to AUDIT.
Why it's sound: removes cross-module duplication. A 5 min cache TTL is sufficient.
OP-ADR-04: v0.0.0 cohort trigger = operator-on-demand only¶
Decision: v0.0.0 = cohort query is triggered at campaign-creation time. No @Scheduled cron before V1 (OP-011).
Why it's sound: matches the sales-demo narrative. The scheduled cron arrives together with multi-agency routing (v0.0.2), which is what shapes the per-agency scheduling model.
OP-ADR-05: correlation_id = campaign_contract.id (D16 confirmation)¶
Decision: correlation_id = campaign_contract.id (UUID minted at INSERT). OP passes it to SignedLinkService.createLink(...) and NotificationDispatchService.dispatch(...). Standalone operator events do not carry it.
Why it's sound: campaign_contract.id is the natural identity of a renewal event. OQ-X3 — OP leg resolved.
OP-ADR-06: Real-time V1 = SSE; v0.0.0–v0.0.5 = polling 30 s¶
Decision: - v0.0.0–v0.0.5: 30 s polling. - V1: SSE with 30 s heartbeat; polling fallback. Suspended out of focus.
Why it's sound: shipping SSE in v0.0.0 would add the SseEmitter lifecycle for a marginal gain over fire-and-forget.
Appendix B — Open Questions¶
OP-specific (carried into stories)¶
| ID | Question | Owner | Blocking? |
|---|---|---|---|
| OQ-OP-01 | cabinet_admin self-deactivation guard: ≥ 1 active cabinet_admin enforced at service layer. Confirm. |
Founder | Blocks OP-009 |
| OQ-OP-02 | Partial phone masking in list views (+225 XX XX ** **): ARTCI requirement or product choice? |
Founder + Legal | Blocks OP-003 |
| OQ-OP-03 | Scheduled cohort-job timezone: v0.0.0 UTC+0 (CI only). Scheduler must accept a ZoneId from TenantConfigCache from day one. |
OP/TENANT | Blocks OP-011 |
| OQ-OP-04 | UNIQUE(notifications.campaign_contract_id): confirm that NotificationDispatchService.dispatch() returns the existing row (or is a silent no-op) on conflict. |
NOTIF ARCHITECT | Blocks OP-001 dev-start |
| OQ-OP-05 | Export generation (OP-008): in-memory sync + presigned MinIO URL, or background job? Recommendation: sync in V1, background in V2. | Founder | Blocks OP-008 |
Forward-look (non-blocking, parked)¶
| ID | Question | Scope |
|---|---|---|
| OQ-OP-F1 | Multiple concurrent campaigns (modal/sidebar UI) from v0.0.1+? | UX |
| OQ-OP-F2 | XLSX export format in V1 — custom metrics (conversion per hour, per agency)? | OP-008 |
| OQ-OP-F3 | Which ING anomalies block sending in v0.0.3? Escalation workflow? | OP-004b |
| OQ-OP-F4 | Keycloak SAML v2 — federation supported for multi-agency login? | AUTH V2 |
| OQ-OP-F5 | Circuit-breaker strategy for PAY module failures (webhook retry from v0.0.4+)? | PAY |
Cross-module OQs carried from the OP PRD: OQ-OP-01..06 (see docs/prd/operator-console-prd.md §A.6). OQ-X3 (PROGRESS): OP leg resolved. OQ-X13 (PROGRESS): no-op listener delivered in OP-S0. OQ-X14: the AUDIT catalog must accept the 14 OP event types before OP-001 dev-start.
Document version: 1.1 (V0 review restructure) Last updated: 2026-05-23 Next review: upon founder approval; before OP-001 kickoff