Aller au contenu

Operator Console (OP) — Architecture, Part 2: Integrations, Build & Ops

See README.md for the cross-part index, and part1-domain-and-api.md for §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 against UNIQUE(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 (AuditEventType enum) 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: AbortController on 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)

GET /api/operator/health/ready
GET /api/operator/health/live

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_RESOLVED audit. 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