Skip to main content

End-to-end process flow

Verified against the code on 2026-10-18 (base wp-0a-risk-score-hygiene at ca7f7fbad, migrations through V278); §4 updated on 2026-10-30 for the fixes through V287 and Edge Ledger V5. That base includes L1, L3, L4, L5a, L5b, L5c, L6a and L6b (payments, V277).

This is the end-to-end process flow of the platform as it is built today. It covers who does each step, where they do it, what has to be true first, what happens, and what the user sees when it fails. Where the code does not do what an earlier document said, this page follows the code and points out the gap.

The three modules: Edge Node, Control Plane and the Console

Figure 1 — The three modules and where each runs. The Edge Node evaluates inside the PSP's estate; the Control Plane and Console run in the Hokeka cloud and see decision records, not transaction data.

Related documents:

Conventions​

  • Paths. B/ is BACKEND/src/main/java/com/posgateway/aml/, EH/ is edge-host/src/main/java/com/hokeka/edge/, F/ is FRONTEND/src/ and M/ is BACKEND/src/main/resources/db/migration/. Line numbers are those on the base named above.

  • API paths. Every Control Plane path lives under the context path /api/v1 (BACKEND/src/main/resources/application.properties:4). The Console adds that prefix itself (F/config/api.ts:8-13). The Edge's own API (/edge/evaluate, /edge/status) is served by the Edge Node on the PSP's premises, not by the Control Plane.

  • Two checks per hop. "Console" names the route rule in F/lib/consoleRbac.ts (CONSOLE_ROUTE_RULES or canPerformAction). The Console only hides what the server would refuse. "Guard" names the server-side @PreAuthorize, which is what actually decides.

  • Standard error bodies (B/exception/GlobalExceptionHandler.java):

    Server exceptionHTTPCode
    IllegalArgumentException with "not found"404ERR_NOT_FOUND_001
    other IllegalArgumentException400ERR_BAD_REQUEST_001
    SecurityException403ERR_ACCESS_DENIED_002, with the server's message
    ResponseStatusException / ConflictExceptionits statusERR_HTTP_<status>
    IllegalStateException or other RuntimeException500ERR_INTERNAL_001, generic message

    Domain handlers return their own codes (filing, lifecycle, invites, Edge data). The Console turns a 403 into "You do not have permission to : " (F/lib/actionErrors.ts:200-221) and Edge tunnel codes into plain sentences such as "The PSP's Edge is offline." (F/lib/edgeData.ts:278-316).

1. Actors and runtimes​

flowchart LR
subgraph PSP["PSP premises"]
API["PSP payment API"] -->|"POST /edge/evaluate"| EDGE["Edge Node<br/>edge-host + edge-engine"]
EDGE --- LEDGER[("Edge Ledger<br/>Postgres")]
EDGE --- AS[("Aerospike<br/>feature store")]
end
subgraph HOKEKA["Hokeka cloud"]
CP["Control Plane<br/>BACKEND :2637"] --- PG[("Postgres<br/>workflow, no transactions")]
CP --- AMS["aml-microservice<br/>sanctions sidecar"]
CON["Console<br/>FRONTEND"] -->|"/api/v1"| CP
end
EDGE -->|"decision records v2, metrics,<br/>bundle / config / sanctions pulls"| CP
EDGE -.->|"Edge-opened mTLS tunnel<br/>/api/v1/edge/tunnel"| CP
USERS["Platform staff and PSP staff"] --> CON
RuntimeWho runs itWhat it does
Edge Node (edge-host/, edge-engine/)the PSP, on its own premisesThe only evaluator. It answers POST /edge/evaluate, records every evaluation in its own Edge Ledger, ships sealed decision records v2, serves live transaction reads over the tunnel, and purges by the PSP's retention
Control Plane (BACKEND/)HokekaUsers, roles, PSPs, Edge enrolment and CA, rule bundles, Edge Config, alerts, cases, SAR/STR/CTR filing, the filed-document store, billing and payments. It never stores transaction data from Edge PSPs
Console (FRONTEND/)Hokeka (served with the Control Plane)The web app every human uses
aml-microserviceHokeka, inside the cloud stackAerospike-backed sanctions lookups for the Control Plane. Not exposed to the Console

Platform scope and PSP scope​

  • Platform operators belong to the HOKEKA_PLATFORM sentinel PSP, or have no PSP (pspId 0; M/V127__seed_demo_accounts.sql:27; /auth/me returns pspId: 0, B/controller/auth/AuthenticationController.java:473).
    • Server rule (B/service/security/PspIsolationService.java:96-123): SUPER_ADMIN, PLATFORM_ADMIN and APP_CONTROLLER always count as platform; ADMIN counts only on the sentinel PSP.
    • Console rule (F/lib/userAccess.ts:25-33): APP_CONTROLLER always; SUPER_ADMIN, ADMIN, PLATFORM_ADMIN only with pspId 0 or HOKEKA_PLATFORM.
    • Platform operators see a PSP's data only while that PSP has support access on (journey i).
  • PSP users hold a role row that belongs to their own PSP. Each PSP gets eight roles when it is registered.

Roles​

Per-PSP roles are created by RoleService.provisionPspRoles from PSP_ROLE_TEMPLATES (B/service/RoleService.java:34, :108-130, templates at :263-326), called inside PSP registration (B/service/psp/PspService.java:171). V271 back-filled them for existing PSPs and V272 added MANAGE_ROLES and VIEW_AUDIT_LOGS to PSP admins. The Console's fallback copy is F/lib/rolePermissionTemplates.ts.

RoleScopeMain permissions (abridged)
SUPER_ADMIN, ADMINplatformevery permission
PLATFORM_ADMINplatformMANAGE_PSP, CONFIGURE_SYSTEM, PSP_SETTINGS_VIEW, view permissions; MANAGE_USERS and MANAGE_ROLES from M/V224__platform_user_management_permissions.sql:1-9
APP_CONTROLLERplatformno permissions; counted as platform
PSP_ADMINPSPall case permissions; SAR view, create, approve, file; MANAGE_USERS, MANAGE_ROLES, MANAGE_RULES, MANAGE_WATCHLISTS, PSP_SETTINGS_VIEW/EDIT, MERCHANT_VIEW/EDIT, VIEW_AUDIT_LOGS, EXPORT_DATA
MLROPSPall case permissions; SAR view, create, approve, reject, file, amend; OVERRIDE_SCREENING_MATCH, MANAGE_WATCHLISTS, VIEW_AUDIT_LOGS, EXPORT_DATA
COMPLIANCE_OFFICERPSPas MLRO without REJECT_SAR, EXPORT_DATA, OVERRIDE_SCREENING_MATCH
CASE_MANAGERPSPall case permissions; VIEW_SAR, CREATE_SAR
PSP_ANALYSTPSPVIEW_CASES, ASSIGN_CASES, case notes and evidence, VIEW_SAR
SCREENING_ANALYSTPSPVIEW_CASES, VIEW_SAR, screening overrides, watchlists
AUDITORPSPread-only, plus VIEW_AUDIT_LOGS
PSP_USERPSPVIEW_CASES, ADD_CASE_NOTES, transaction and screening views; no SAR access (V257, tipping-off)

Role hierarchy (B/config/SecurityConfig.java:102-120): SUPER_ADMIN and PLATFORM_ADMIN > ADMIN > COMPLIANCE_OFFICER, INVESTIGATOR, ANALYST, VIEWER, PSP_ADMIN, MLRO; PSP_ADMIN > PSP_USER; MLRO > COMPLIANCE_OFFICER > CASE_MANAGER > INVESTIGATOR > ANALYST > VIEWER. PSP_ADMIN does not inherit MLRO or COMPLIANCE_OFFICER, which matters for the SAR guards in journey g.

RoleAssignmentPolicy (B/service/security/RoleAssignmentPolicy.java) decides who may hold which role:

  • platform users hold global roles only (:84-87);
  • PSP users never hold a platform role (:88-90); they hold their own PSP's role rows or a global tenant template (:91-94, list at :41-43);
  • a PSP's custom role may carry only permissions the creator holds, never MANAGE_PSP or CONFIGURE_SYSTEM (:164-180).

2. Journeys​

flowchart TD
A["a. Platform bootstrap"] --> B["b. PSP onboarding"]
B --> C["c. PSP configuration"]
B --> D["d. Edge install and enrolment"]
C --> E["e. Transaction evaluation"]
D --> E
E --> F["f. Alert, case, investigation"]
F --> G["g. SAR/STR and CTR filing"]
G --> H["h. Retention, legal holds, back-fill"]
C --> I["i. Support access"]
E --> J["j. Billing and payments"]
J --> K["k. Suspension and termination"]
A --> L["l. Platform admin operations"]

a. Platform bootstrap​

#Who, wherePreconditionWhat happensSuccessFailure: what the user seesNext
a1Hokeka ops, on the Control Plane host: scripts/install/hokeka-install.sh (role control-plane, scripts/install/roles/control-plane.sh:24)Linux host with Dockerhokeka_write_control_plane_env writes .env with mode 600 and a random SEED_ADMIN_PASSWORD if absent (scripts/install/lib/control-plane-env.sh:5-13,46-49), sets SPRING_PROFILES_ACTIVE=production (:56-58), generates the Edge keys and CA, and starts composestack healthy (:298-310)compose refuses to start without SEED_ADMIN_PASSWORD (docker-compose.prod.yml:233)a2
a2Control Plane boot.env presentDataSeeder creates admin (role ADMIN, no PSP) only if missing and never overwrites a password (B/config/DataSeeder.java:66-89). DefaultCredentialGuard rotates a historical default admin password to the seed value and disables other demo users (B/config/DefaultCredentialGuard.java:36,101-110)app upa blank seed password on prod/production stops startup (DataSeeder.java:113-121)a3
a3Operator, Console /login; POST /auth/login (AuthenticationController.java:157, public)the seed password, read from .env as rootsession created (one per user, SecurityConfig.java:280); the response carries mustChangePassword and psp.status/dashboard400 missing fields; 401 "Invalid username or password"; 403 "User account is disabled"a4
a4Operator, /profilesigned inThe seeded admin is not forced to change its password: DataSeeder never sets must_change_password (column default false, B/entity/User.java:57-58). The operator changes it by hand (PUT /users/me/password, B/controller/UserController.java:263-279), then creates personal accounts at /users and stops using adminrotated400 PASSWORD_CHANGE_REFUSED (wrong current password, policy, or same password)b1
a5Any user with a temporary password (set by an admin, by POST /auth/register, or by a reset)must_change_password truePasswordChangeRequiredFilter refuses every call except /auth/**, /users/me, /users/me/password, /error, /actuator/**, /logout (B/config/security/PasswordChangeRequiredFilter.java:42-44) with 403 PASSWORD_CHANGE_REQUIRED. The Console shows "Choose a new password" (F/pages/Auth/ForcePasswordChangePage.tsx:59, routed by F/components/Auth/ProtectedRoute.tsx:44-46)password changed, normal Console400 PASSWORD_CHANGE_REFUSEDback to the user's journey

Password policy (B/service/auth/PasswordPolicy.java:19-65): 12 to 72 characters, with upper case, lower case, a digit and a symbol, and not containing the username or the email's local part. Refusals carry PASSWORD_POLICY.

Password reset (B/controller/auth/PasswordResetController.java):

  1. Console /forgot-password calls POST /auth/password-reset/request. It always answers 200. The email link is built from CONSOLE_BASE_URL (app.console-base-url; B/service/auth/ConsoleLinkBuilder.java:42-88). The installer does not write CONSOLE_BASE_URL, and with it unset no email is sent; the request is only logged and audited (B/service/auth/PasswordResetService.java:171-183).
  2. /password-reset/confirm calls POST /auth/password-reset/confirm. A temporary password is emailed and must_change_password is set (PasswordResetService.java:222-227). The user then goes through hop a5.

b. PSP onboarding​

sequenceDiagram
actor PO as Platform operator
actor PA as PSP admin
participant C as Console
participant CP as Control Plane
PO->>C: /psps "Register PSP"
C->>CP: POST /psps
CP-->>C: PSP PENDING, 8 roles provisioned
PO->>C: /settings, Platform Admin tab, invite
C->>CP: POST /admin/onboarding/invites
CP-->>PA: email: CONSOLE_BASE_URL/accept-invite?token=
PA->>C: /accept-invite
C->>CP: POST /auth/register-with-invite
CP-->>C: signed in; psp.status PENDING
C-->>PA: "Awaiting approval"
PO->>C: /psps status ACTIVE + reason
C->>CP: PUT /psps/{id}/status
PA->>C: reload, /users "Invite User"
C->>CP: POST /onboarding/invites
#Who, wherePreconditionWhat happensSuccessFailure: what the user seesNext
b1Platform operator, /psps "Register PSP" (F/pages/Psps/PspsListPage.tsx:185-193). Console: MANAGE_PSP, platform only (consoleRbac.ts:177). API POST /psps (B/controller/psp/PspController.java:168-174), guard hasAnyRole('SUPER_ADMIN','ADMIN','PLATFORM_ADMIN')pspCode, legal name, contact emailPspService.registerPsp (B/service/psp/PspService.java:139-192) saves the PSP as PENDING (:163), provisions the eight PSP roles in the same transaction (:171), then copies default rules (the High Value Transaction Threshold, R-2, at the USD 10,000 equivalent in the PSP's currency, RuleProvisioningService; V298) and a default subscription (best effort, failures only logged, :176-189)PSP row in the list400 ERR_BAD_REQUEST_001 "PSP Code already exists" (:144), shown in the dialogb2
b2Platform operator, /settings Platform Admin tab (F/pages/Settings/tabs/PlatformAdminTab.tsx; tab shown only to platform admins, F/lib/settingsTabs.ts:25-30). API POST /admin/onboarding/invites (B/controller/admin/OnboardingInviteAdminController.java:29-35), guard platform roles (:16)PSP chosen in the picker (GET /settings/psps, hasAnyRole('SUPER_ADMIN','ADMIN'), B/controller/SettingsController.java:223)OnboardingInviteService.issue (B/service/auth/OnboardingInviteService.java:165-237) binds the invite to the email and to the PSP's role row, checks RoleAssignmentPolicy, stores only a SHA-256 of the token, emails CONSOLE_BASE_URL/accept-invite?token=…, audits ONBOARDING_INVITE_ISSUED. Life: 7 days by default, at most 30 (:56,59,183-187)"Invitation emailed to …"400 INVITE_INVALID, INVITE_EMAIL_INVALID, INVITE_ROLE_INVALID, INVITE_EXPIRY_INVALID; 403 INVITE_ROLE_INVALID (role not assignable); 404 INVITE_PSP_REQUIRED (PSP not found); 409 INVITE_EMAIL_IN_USE. With CONSOLE_BASE_URL unset: "Not emailed: app.console-base-url is not configured", and the token is shown once to hand overb3
b3Invitee, public /accept-invite (F/pages/Auth/AcceptInvitePage.tsx). API POST /auth/register-with-invite (AuthenticationController.java:126-141, public, 10 requests/min per IP)valid tokenredeem (OnboardingInviteService.java:317-380) uses the invite's email, re-checks the role row, applies the password policy, consumes the invite once, creates the user, audits ONBOARDING_INVITE_REDEEMEDsigned in automatically400 FIELDS_REQUIRED, PASSWORD_POLICY; 403 INVITE_INVALID (unknown, expired or used), INVITE_EMAIL_MISMATCH, INVITE_NOT_ACCOUNT, INVITE_ROLE_INVALID; 409 ACCOUNT_EXISTS. Messages from F/features/api/invites.ts:44-59b4
b4PSP admin, any Console pagePSP is PENDINGPspActivationFilter (B/config/security/PspActivationFilter.java:114-118,205-223) allows only /users/me, /users/me/** and /psps/me and refuses the rest with 403 PSP_AWAITING_APPROVAL. The Console shows "Awaiting approval" (F/pages/Auth/PspAccessStatePage.tsx:11-15, chosen by F/components/Auth/accessState.ts:24-37)waits; the screen re-reads the status every 30 s and opens the Console after approval—b5
b5Platform operator, /psps row menu, reason dialog. API PUT /psps/{id}/status {status:"ACTIVE", reason} (PspController.java:192-201), platform rolesPSP PENDINGapplyStatusChange (PspService.java:350-395): transition check, reason 1–500 characters, activation, audit PSP_STATUS_CHANGE, webhook. The status cache (30 s) is evicted on commit (B/service/psp/PspStatusChangeListener.java:34-40)PSP ACTIVE400 PSP_STATUS_INVALID, PSP_REASON_REQUIRED; 409 PSP_STATUS_TRANSITION_INVALIDb6, c, d
b6PSP admin, /users "Invite User" (F/pages/Users/UsersTab.tsx:307). Console MANAGE_USERS. API POST /onboarding/invites (B/controller/auth/PspOnboardingInviteController.java:36-47), guard platform roles or MANAGE_USERSPSP ACTIVEinvite pinned to the admin's own PSP; only that PSP's roles or tenant templates (RoleAssignmentPolicy.java:78-95)invite emailed403 INVITE_PSP_FORBIDDEN (other PSP), 403 INVITE_ROLE_INVALID (platform role), plus the b2 codesb3 for the staff member
b7PSP admin, /users (role change) and /users/roles (custom roles, F/pages/Users/RolesTab.tsx). API PUT /users/{id} (UserController.java:164-184, MANAGE_USERS, same-PSP), /roles (B/controller/RoleController.java, create/update/delete need MANAGE_ROLES)user in own PSPRoleAssignmentPolicy applied; a role change expires the user's sessions (B/service/UserService.java:82-107)role changed403 for another PSP's user or a forbidden permissionthe user's journeys

Self sign-up is off: /signup shows an "invitation only" page (F/pages/Auth/InvitationOnlyPage.tsx), and POST /auth/register needs a platform role (AuthenticationController.java:70-118).

c. PSP configuration​

Every data setting, Edge limit and blocklist change reaches the Edge the same way. A PspDataSettingsChangedEvent makes EdgeConfigService (B/service/residency/EdgeConfigService.java:87-132) hash the new content. If the hash changed, it issues a new config version, and after commit the tunnel sends config.updated (B/edge/tunnel/TunnelResidencyEventListener.java:53-72). The Edge then fetches GET /api/v1/edge/config, sealed and signed (EH/residency/EdgeConfigPoller.java:87-94). With the tunnel down, the Edge picks up the change at its next 15 s poll. The config sections are retention, supportAccess, crossPspIntelligence, ctr, limits, merchantBlocklist, sanctions and tunnel (B/service/residency/EdgeConfigDocumentBuilder.java:112-119).

Data settings maker-checker (B/service/residency/PspDataSettingsAccess.java, PspDataSettingsService.java):

  • the maker is the PSP's PSP_ADMIN or ADMIN. Platform operators can never propose;
  • the checker is a different user with PSP_ADMIN, ADMIN, MLRO or COMPLIANCE_OFFICER in the same PSP (:454-461);
  • each setting has one pending change at a time (409 otherwise), and every change needs a reason of up to 2000 characters.
#Who, wherePreconditionWhat happensSuccessFailureNext
c1Maker, /settings "Data Retention" tab (F/pages/Settings/SettingsPage.tsx:654; shown to tenant users with a checker role, F/lib/dataResidency.ts:16-19). API PUT /psps/{pspId}/data-retention (B/controller/residency/PspDataSettingsController.java:65-78)value 5–25 years (PspDataSettingsService.java:51-53,184-187)pending change, 202pending400 out of range or same value; 403 platform or non-maker; 409 change already pendingc2
c2Checker, DataSettingChangesPanel (F/pages/Settings/tabs/DataSettingChangesPanel.tsx:89). API POST /psps/{pspId}/data-settings/changes/{changeId}/approve or /reject; history GET .../data-settings/changes (PspDataSettingsController.java:115-135)pending changean increase applies at once; a reduction waits 30 days (REDUCTION_DELAY_DAYS, :55,278-293) and is applied by a job every 5 minutes (:325-338). The waiting reduction is sent to the Edge as retention.pendingChange. Proposing the current value again cancels a waiting reduction (:192-196)APPLIED, or APPROVED with effectiveAt403 the maker cannot approve or reject; 409 not pendingh2
c3Support access: journey i
c4Maker, "Support & Data Sharing" tab. API PUT /psps/{pspId}/cross-psp-opt-in (PspDataSettingsController.java:99-112)—opting in is maker-checker (202); opting out applies at once (200). The flag gates both contributing and receiving in CrossPspFraudIntelligenceService (B/service/fraud/CrossPspFraudIntelligenceService.java:70-75). It reaches the Edge as crossPspIntelligence.optIn, but the Edge only reports it in /edge/status (EH/EdgeController.java:183)opted inas c1–c2—
c5Merchants, /merchants (Console MERCHANT_VIEW; F/pages/Merchants/MerchantsPage.tsx). API POST /merchants/onboard (MERCHANT_EDIT or ONBOARDING_INVITE, B/controller/MerchantController.java:70-111); PUT /merchants/{id} (MERCHANT_EDIT, :356-371)—onboarding screens the merchant and its owners and returns APPROVE, REVIEW or REJECT (201, 202 or 403). The Console can list, view and onboard only; update and close are API-only. Close: journey kmerchant ACTIVE / UNDER_REVIEW / BLOCKED400 (empty body) on bad input; PUT returns every validation error as 404 (:364-366)—
c6Platform or the PSP's PSP_ADMIN/ADMIN, /lists "Edge merchant blocklist" (F/pages/Lists/ListsPage.tsx). API GET, POST, DELETE /psps/{pspId}/edge-config/merchant-blocklist[/{merchantId}] (B/controller/residency/EdgeConfigAdminController.java:116-140)reasonCode SANCTIONED, REGULATOR_ORDER, PSP_TERMINATED or FRAUD_CONFIRMED (B/service/residency/EdgeConfigInputService.java:47,206-242)sent in the Edge Config; the Edge answers BLOCK for that merchant (EH/sanctions/SanctionsScreener.java:96). Sanctions screening also adds and removes SANCTIONED entries by itself (B/service/residency/MerchantSanctionsBlocklistService.java:92-133)blocked on the Edge400 bad reason or id; 409 "Merchant is already blocked"; 404 unblock of a merchant not blockede2
c7Same roles, /lists "Edge limits". API GET, POST, DELETE /psps/{pspId}/edge-config/limits (EdgeConfigAdminController.java:86-112)scope CARD/MERCHANT/PSP, metric COUNT/AMOUNT (AMOUNT needs a currency), window PT1H/P1D/P7D/P30D, action ALERT/HOLD/BLOCK (EdgeConfigInputService.java:43-46,145-188)stored in edge_limits, sent as limits[], enforced on the Edge as LIMIT_<id> (EH/typology/ComplianceOverlay.java:262-329)limit active400 invalid; 404 retire of a missing limite4
c8Blacklist readers / writers, /lists "Payment blacklist". API GET, POST, PATCH, DELETE /blacklist (B/controller/risk/PaymentBlacklistController.java:46-105; write roles SUPER_ADMIN, ADMIN, PLATFORM_ADMIN, COMPLIANCE_OFFICER, PSP_ADMIN or MANAGE_WATCHLISTS)—compiled into the rule bundle as reference-list values (B/service/edge/typology/RepositoryEdgeReferenceLists.java). There is no bundle.updated push for list changes; the Edge gets them at its next bundle poll (15 s), through the bundle version stamp (B/service/edge/EdgeRuleCompiler.java:149-179)entry live on the Edge409 duplicatee5
c9Rule author, /rules-generation (Console MANAGE_RULES; F/pages/RulesGeneration/RulesGenerationPage.tsx). API POST /rules, PUT /rules/{id}, /enable, /disable, DELETE (retire), /rollback, POST /rules/generate (B/controller/RulesController.java:120-454; writes hasAnyRole('SUPER_ADMIN','ADMIN') or hasAuthority('MANAGE_RULES'))rule must be Edge-deployableevery change is a proposal in RuleGovernanceService (B/service/rules/RuleGovernanceService.java)pending version400 "Rule is not edge-deployable" (RulesController.java:74-83); 409 a proposal is already pendingc10
c10A different reviewer. API GET /rules/governance/pending, POST /rules/governance/versions/{versionId}/approve or /reject (RulesController.java:417-445)pending version; review reasonactivation (now, or at a future effectiveFrom through a per-minute job, :211-216) reloads Drools and publishes RuleVersionActivatedEvent (:255-277). After commit EdgeBundleUpdateNotifier sends bundle.updated to that PSP, or to every PSP with an ACTIVE node for a global rule (B/service/edge/EdgeBundleUpdateNotifier.java:50-84). The Edge pulls GET /api/v1/edge/bundlerule live on the Edge403 the maker cannot approve or reject (:168-169,192-193); only platform reviewers approve global rules (:385-389); 409 not pendinge5
c11/limits-aml (Console MANAGE_RULES or CONFIGURE_SYSTEM). API GET/POST /limits/aml (B/controller/limits/LimitsManagementController.java:23,111-127, roles SUPER_ADMIN, ADMIN, COMPLIANCE_OFFICER, PSP_ADMIN, PSP_USER)value > 0stored as GlobalLimit AML_TRANSACTION / AML_DAILY. Not sent to the Edge: only the legacy cloud DecisionEngine enforces them. Edge PSPs use c7saved400; 403 for roles outside the class guard—
c12PSP admin, /settings Billing tab BillingContactsCard (SettingsPage.tsx:640-643). API GET /billing/profile, PUT /billing/profile/recipients (PSP_ADMIN or platform roles), PUT /billing/profile/payment (PSP_SETTINGS_EDIT) (B/controller/psp/PspBillingProfileController.java:34-70)—billing email and CC (B/service/billing/BillingProfileService.java:92-102), billing phone (Kenyan mobile, :139-141) and the auto-pay switch. VAT rate and VAT number are read-only for the PSP; the platform sets them (hop j4)saved400 {"error": …} (invalid email, CC over 1000 characters, invalid phone)j

d. Edge install and enrolment​

sequenceDiagram
actor PA as PSP admin
actor PO as Platform operator
participant C as Console
participant CP as Control Plane
participant E as Edge Node
PA->>C: /edge-nodes Setup wizard
C->>CP: POST /edge/nodes {pspId, displayName}
CP-->>C: node PENDING + enrolment code (once, 24 h)
PO->>C: /edge-nodes Approve
C->>CP: POST /edge/nodes/{id}/approve
PA->>E: install.sh --pspid N --edgeid ... + code + Control Plane public keys
E->>CP: POST /api/v1/edge/enroll (CSR, public keys, code)
CP-->>E: 202 ACTIVE + client certificate chain
loop every 15 s
E->>CP: GET /edge/bundle, /edge/config, /edge/sanctions/list (mTLS)
end
E->>CP: WSS /api/v1/edge/tunnel (mTLS)
loop every 60 s
E->>CP: POST /edge/metrics
end
#Who, wherePreconditionWhat happensSuccessFailure: what the user seesNext
d1PSP admin or platform operator, /edge-nodes Setup wizard (F/pages/EdgeNodes/SetupWizard.tsx:206-208). Console MANAGE_PSP or PSP_SETTINGS_VIEW (consoleRbac.ts:178). API POST /edge/nodes {pspId, displayName} (B/controller/edge/EdgeAdminController.java:52-53,165-174), guard SUPER_ADMIN, ADMIN, PLATFORM_ADMIN, PSP_ADMINPSP existsEdgeEnrollmentService.requestNode (B/service/edge/EdgeEnrollmentService.java:97-123) creates a PENDING node and a 256-bit single-use code, stored only as a SHA-256, valid 24 h (:62). The edge id typed in the wizard is used only to build the install commandcode shown once with a 24 h warning (SetupWizard.tsx:322-327)400 "pspId is required" / displayName missing; 403 other PSP; 404 PSP not foundd2
d2Platform operator, /edge-nodes Approve / Reject (shown on PENDING nodes, F/pages/EdgeNodes/EdgeNodesPage.tsx:291). API POST /edge/nodes/{id}/approve or /reject (EdgeAdminController.java:178-189), platform rolesnode PENDING, PSP ACTIVEPENDING → APPROVED (or REJECTED, terminal) (EdgeEnrollmentService.java:126-153)APPROVED409 not PENDING; 403 "owning PSP is not ACTIVE" (:448-455)d3. An Edge that enrols before approval gets 202 PENDING_APPROVAL, keeps its code and retries until approved
d2aPSP admin or platform operator, node drawer "Reissue code" (F/pages/EdgeNodes/NodeDetailDrawer.tsx). API POST /edge/nodes/{id}/enrollment-code (EdgeAdminController.java:227-236)node PENDING or APPROVEDold code stops working; audited EDGE_ENROLLMENT_CODE_REISSUEDnew code once409 for any other status (EdgeEnrollmentService.java:324-339)d3
d3PSP infrastructure engineer, PSP host: edge-host/deploy/install.sh (root) or install.ps1 (Administrator)code, numeric PSP id--pspid must match ^[1-9][0-9]*$ (install.sh:138-142); writes .env and the compose stack. The Control Plane public keys come with the ACTIVE enrolment answer and are pinned to controlplane-keys.json; --controlplane-signing-key / --controlplane-ingest-key (or CONTROLPLANE_ED25519_PUBLIC_KEY / CONTROLPLANE_X25519_PUBLIC_KEY) override them (KEY_MANAGEMENT.md). Without --image the installer builds the Edge image locally from the verified release artifactsEdge startsrefuses to boot on a non-numeric EDGE_PSPID (EH/config/PspIdValidator.java:62-79), an http:// Control Plane URL (EH/channel/SecureChannel.java:63-73), or missing compose variables (edge-host/deploy/docker-compose.yml:100-123)d4
d4Edge, automatically at start and on every bundle poll (at most once per 60 s) (EH/activation/ActivationService.java:104-212). API POST /api/v1/edge/enroll on the edge vhost (BACKEND/nginx/edge-tunnel.conf:170-190, 10/min per IP) → EdgeDistributionController.enroll (B/controller/edge/EdgeDistributionController.java:124-157)node APPROVED, code validEdge sends an EC P-256 CSR (CN=<edgeId>, OU=psp-<pspId>) and its public keys. The Control Plane checks the CSR, then the code, node status, PSP status and PSP id (EdgeEnrollmentService.java:182-247), pins the keys, marks the node ACTIVE and signs a 90-day client certificate (B/service/edge/pki/EdgeCertificateService.java:92-121)202 with the certificate chain; Edge persists ACTIVE400 bad CSR or key; 403 code missing, unknown, used or expired, node not APPROVED, PSP not ACTIVE, PSP id mismatch; 409 edge id already belongs to another node. The reason shows on the Edge's /edge/statusd5
d5Edge, every EDGE_BUNDLE_POLL_INTERVAL (15 s) over mTLSACTIVE, Ed25519 key setRule bundle GET /api/v1/edge/bundle (304 unchanged; 503 empty-rule-set), Edge Config GET /api/v1/edge/config (B/controller/edge/EdgeResidencyController.java:91-116), sanctions list GET /api/v1/edge/sanctions/list and chunks (B/controller/edge/EdgeSanctionsListController.java:53-120). Bundles and config are verified against CONTROLPLANE_ED25519_PUBLIC_KEY (EH/channel/RuleBundlePoller.java:139,186-187). The merchant blocklist and limits travel inside the Edge Configbundle loaded401/403 closes the gate: every evaluation answers HOLD NODE_NOT_AUTHORIZED. Without a bundle: HOLD NO_BUNDLE (EH/EdgeEngine.java:279-297). With the Ed25519 key blank, no poll is ever sent and the node stays on NO_BUNDLEd6
d6Edge tunnel client (EH/tunnel/EdgeTunnelClient.java) to wss://<host>/api/v1/edge/tunnelACTIVE, tunnel enabled in confignginx requires a verified client certificate and adds X-Hokeka-Proxy-Secret (edge-tunnel.conf:114-142); the Control Plane trusts forwarded identity only with EDGE_PROXY_SHARED_SECRET (B/service/edge/EdgeIdentityResolver.java:199-208); handshake and hello checks in B/edge/tunnel/TunnelHandshakeInterceptor.java:47-82 and TunnelSessionHub.java:215-237; re-check every 30 sConsole can pull live data (journey f)403 on upgrade; close codes 4403 (not authorised or tunnel disabled), 4409, 4426, 4500. Reconnect with 1–60 s backoffe
d7Edge, every 60 s: POST /api/v1/edge/metrics (EdgeDistributionController.java:222-262)ACTIVEsealed metrics, replay guard; lastSeenAt updated by every Edge call204400/403/413—
d8Edge, every 6 h: POST /api/v1/edge/certificate/renew (B/controller/edge/EdgeCertificateController.java:90-109)inside the 30-day renewal windownew certificate; the old one stays valid until it expiresrenewed403 node not ACTIVE; 400 CSR CN/OU wrong—
d8aControl Plane, every minute: EdgeLivenessMonitor.check (B/service/edge/EdgeLivenessMonitor.java, ShedLock edge.livenessMonitor)node ACTIVE, PSP ACTIVEa node not heard from (bundle poll, last_seen_at; else activation) for EDGE_MONITOR_SILENT_AFTER (10 min) gets silent_since (V288), audit EDGE_NODE_SILENT, and one email to the PSP's billing/contact address with EDGE_MONITOR_ALERT_EMAIL copied (BillingEmailService.sendEdgeLivenessNotice); the first poll after that clears it, audits EDGE_NODE_RECOVERED and sends one recovery email. Gauge hokeka_edge_nodes_silentat most one alert per outagemail not configured or no recipient: logged warning, the audit and silent_since still stand—
d9Suspend / reinstate / revoke: see journey k

e. Transaction evaluation​

sequenceDiagram
participant P as PSP payment API
participant E as Edge Node
participant L as Edge Ledger
participant CP as Control Plane
P->>E: POST /edge/evaluate
E->>E: 1 screen (blocklist, sanctions, PEP)
E->>E: 2 derive features (Aerospike)
E->>E: 3 compliance overlay (CTR, structuring, limits, FX)
E->>E: 4 rules (bundle IR), 5 strongest action
E->>L: 6 one statement: evaluation + txn ref + outbox row
E-->>P: 200 {action, score, triggeredRuleIds, edgeAssessmentId, ...}
loop every 2 s
E->>CP: POST /api/v1/edge/assessments (sealed decision records v2)
CP->>CP: verify, dedupe, store, decision_stats_hourly
CP->>CP: alert / case / CTR deadline, Kafka, webhook
CP-->>E: ack
end
#Who, wherePreconditionWhat happensSuccessFailure: what the caller seesNext
e1PSP payment API → Edge POST /edge/evaluate (EH/EdgeController.java:295-353). TLS 1.3 required (EH/config/EdgeTlsGuard.java:62-103); client certificates optional, off by default (EDGE_TLS_CLIENT_AUTH, edge-host/src/main/resources/application.yml:17-19). No API keyEdge enrolledtxn_id checked first—422 TXN_REF_LOOKS_LIKE_PAN or TXN_REF_INVALID; nothing is stored (:300-310)e2
e2Edge: screening (EH/sanctions/SanctionsScreener.java:87-151)—merchant on the blocklist → BLOCK; sanctioned entry ≥ 0.95 with no conflicting attribute → BLOCK, otherwise above threshold → HOLD; other entries (PEP, RCA, debarment) add a reason only; no list, no list loaded, no screenable name (default) or every source older than 72 h → HOLD with a SCREENING_* code; every evaluation records screeningStatus and the list version——e3
e3Edge: features (EH/store/AerospikeFeatureStore.java:155-175)—48 hourly and 31 daily buckets plus card profiles—store unavailable and a pan_hash given → HOLD FEATURE_STORE_UNAVAILABLE (fail closed, EdgeController.java:322-332); the overlay and rules are skipped. With no Aerospike configured, every request with a pan_hash gets thise4
e4Edge: compliance overlay (EH/typology/ComplianceOverlay.java:134)—CTR card-day counter → CTR_REPORTABLE; CASH_STRUCTURING_24H → HOLD; limits LIMIT_<id> with the limit's action—HOLD CTR_CONFIG_INVALID, CTR_FX_UNAVAILABLE, CTR_AGGREGATE_UNAVAILABLE, LIMIT_FX_UNAVAILABLE, LIMITS_UNAVAILABLEe5
e5Edge: rules (EH/EdgeEngine.java:278-307, EH/EdgeRuleInterpreter.java:289-311)bundle loadedevery rule runs; action is the maximum, score the sum. The cloud compiles SpEL rules into the bundle's rule IR (B/service/edge/EdgeRuleCompiler.java)—HOLD NODE_NOT_AUTHORIZED, NO_BUNDLE, EVALUATOR_FAULT; before any check, HOLD LICENCE_SUSPENDED while the Edge Config licence is SUSPENDED or its lease has expired (EH/EdgeController.java, licenceRefusal)e6
e6Edge: strongest action. Severity ALLOW(0) < ALERT(1) < FLAG(2) < HOLD(3) < SUSPEND(4) < BLOCK(5) (EdgeRuleInterpreter.java:33-42)—overlay and screening merged with max (EdgeController.java:360-370). The HTTP response carries the raw engine action, so it can be FLAG or SUSPEND (EH/EdgeEvaluation.java:36); integrators treat FLAG as ALERT and SUSPEND as HOLD. The ledger and the decision record map them that way (EH/ledger/LedgerEvaluations.java:97-107)——e7
e7Edge ledger (EH/ledger/EdgeLedger.java:68-89,308-326)—a UUID edgeAssessmentId; one statement writes edge_evaluations, edge_txn_refs and edge_outbox200 {action, score, triggeredRuleIds, reasons, triggeredRuleVersionIds, edgeAssessmentId, ruleBundleVersion, ruleBundleHash}ledger down → HOLD LEDGER_UNAVAILABLE, kept in a bounded retry buffer (EH/ledger/EvaluationRecorder.java:68-82)e8
e8Edge OutboxShipper every 2 s (EH/channel/OutboxShipper.java) → POST /api/v1/edge/assessments (B/controller/edge/EdgeResidencyController.java:127-164)ACTIVE, CONTROLPLANE_X25519_PUBLIC_KEY setbatch of decision records v2 sealed to the Control Plane and signed by the node. The Control Plane checks mTLS identity and an ACTIVE node, opens the envelope, runs the replay guard, validates each record (B/service/edge/records/DecisionRecordIngestService.java:62-112) and dedupes on a canonical-JSON SHA-256200 ack; rows marked shipped400 batch (one record with a non-UUID id fails the whole batch); 403 → node marked unauthorised; 413 → batch halved. Per-record SCHEMA_INVALID, FORBIDDEN_FIELD, NODE_MISMATCH, UNKNOWN_SITE, CONFLICT, DUPLICATEe9
e9Control Plane DecisionRecordStore + DecisionRecordWorkflowService (B/service/edge/records/)record acceptedone transaction: assessment row, findings, decision_stats_hourly upsert, then the workflow: alert when the decision is ALERT, HOLD or BLOCK, or sanctionsHit, or ctrRequired (one per record, :146-150); case SANCTIONS_HIT whenever sanctionsHit (:194-201), and a RULE_VIOLATION case per triggered rule for a non-fail-closed ALERT/HOLD/BLOCK only when rules.auto-create-cases is on (:202-221); CTR deadline CTR_FILING due next business Friday 17:00 when ctrRequired (:233-251); Kafka alerts.generated and the RISK_ALERT webhookalert visible at /alerts—f, g (CTR)

The legacy cloud ingest POST /transactions/ingest (B/controller/TransactionController.java:45,106) still runs the cloud DecisionEngine until R12. Do not integrate new PSPs on it.

f. Alert triage, case and investigation​

stateDiagram-v2
[*] --> open
open --> investigating
open --> pending_second_review: true positive, 1st reviewer (202)
pending_second_review --> closed: 2nd, different reviewer
open --> false_positive: disposition FALSE_POSITIVE
open --> resolved: other disposition
open --> case_created: escalate-to-case
case_created --> [*]
#Who, wherePreconditionWhat happensSuccessFailure: what the user seesNext
f1Investigator, /alerts (F/pages/Alerts/AlertsPage.tsx). Console VIEW_CASES. API GET /alerts?page,size,status,sort,cursor (B/controller/AlertController.java:82-159), hasAuthority('VIEW_CASES')—server-side keyset paging, size ≤ 100. PSP users see their PSP's alerts; platform staff see only PSPs with support access on, audited (:102-112)list403f2
f2/alerts detail. API GET /alerts/{id} (AlertController.java:170-206)—live Edge pull with purpose ALERT_REVIEW; an Edge failure is reported in transactionDetailsStatus and does not fail the page. EdgeOfflineBanner / EdgeSupportGate (F/components/EdgeData/WorkflowDetailTransactions.tsx:33-58)alert + live transaction403, 404; banner "The PSP's Edge is offline."f3–f5
f3Status / bulk triage. Console alert.updateStatus (ASSIGN_CASES or CLOSE_CASES). API PUT /alerts/{id}/status (AlertController.java:276-298)—open, investigating set directly; resolved, closed, false_positive, escalated need a disposition (B/service/alert/AlertDispositionService.java:139-155). Bulk sends one call per alert, true positives excluded (AlertsPage.tsx:95-157)"N alert(s) marked as X."400 missing disposition or system-only status; 409 already disposedf4
f4Resolve with disposition, AlertDispositionPanel (F/components/Alerts/AlertDispositionPanel.tsx:42-61). API PUT /alerts/{id}/resolve (AlertController.java:241-264)—one transaction: disposition, derived status, rule-effectiveness feedback, alert-to-case bridge, fraud-incident bridge, audit (AlertDispositionService.java:81-126). ESCALATED, TRUE_POSITIVE_*, MERGED_WITH_CASE, PENDING_INFORMATION and ONGOING_MONITORING open or reuse a case (B/service/case_management/AlertToCaseService.java:43-51)200400 "disposition is required"f5 or f7
f5Maker-checker for true positives (B/service/alert/AlertDispositionMakerCheckerService.java:23-43)—first reviewer: status pending_second_review, 202, nothing final stored. Second reviewer: any user with ASSIGN_CASES or CLOSE_CASES and access to the alert, with a different username"Awaiting second review: your first review is recorded…", then closed403 ERR_HTTP_403 "Second reviewer must differ from the first reviewer (maker-checker)"f7
f6Escalate to case, EscalateToCaseButton. Console alert.escalateToCase (CREATE_CASES). API POST /alerts/{id}/escalate-to-case (AlertController.java:311-352)—alert disposed ESCALATED and a case opened, or the open case reused (reused:true)navigates to /cases/all?caseId=409 "already disposed and has no open case"f7
f7Case worker, /cases/all → CaseActionsPanel (F/components/Cases/CaseActionsPanel.tsx). Console VIEW_CASEScase NEWassign POST /cases/{id}/assign (B/controller/CaseWorkflowController.java:194-217; server checks only the per-case canAct ASSIGN, the Console checks ASSIGN_CASES); note POST /compliance/cases/{id}/notes (ADD_CASE_NOTES, B/controller/ComplianceCaseController.java:76-84; platform staff refused, closed case 409); evidence POST /cases/{caseId}/evidence (ADD_CASE_EVIDENCE, B/controller/EvidenceController.java:69-104)case ASSIGNED / updated403; 409f8, f9
f8Live investigation over the tunnel. Console VIEW_TRANSACTION_DETAILS: /transaction-monitoring/live, /records/transaction/:id, /customer-360. API GET /transactions, /transactions/{id}, /{id}/related, /transactions/export, POST /transactions:batchGet (B/controller/transaction/EdgeTransactionController.java:58-178)PSP's Edge tunnel upthe Control Plane forwards the read to the Edge and streams the answer; nothing is stored in the cloud. Every pull is written to edge_data_access_loglive rowsEDGE_OFFLINE 503, EDGE_TIMEOUT/DEADLINE_EXCEEDED 504, EDGE_BUSY 429 (Retry-After), SUPPORT_ACCESS_DISABLED/TENANT_MISMATCH/OP_NOT_ALLOWED 403, NOT_FOUND 404, INVALID_PARAMS/INVALID_CURSOR/PAGE_LIMIT_EXCEEDED 400 (B/edge/tunnel/TunnelErrorCode.java:12-35). The Console shows the offline banner (F/components/EdgeData/EdgeOfflineBanner.tsx) and keeps the cloud workflow usablef9
f8aPlatform staff on f8PSP support access onEdgeSupportGate (F/components/EdgeData/EdgeSupportGate.tsx) asks for the PSP and a 10–500 character reason, held for 60 minutes; sent as X-Support-Reasonaudited pull403 SUPPORT_ACCESS_DISABLEDf9
f9Escalate. Console case.escalate (ESCALATE_CASES). API POST /cases/{id}/escalate (CaseWorkflowController.java:165-188)reasonCaseEscalationService (:171-262) picks the target by hierarchy: an unassigned or analyst case goes to a COMPLIANCE_OFFICER, a compliance officer's case to the MLRO; mlroNotifiedAt set when the target is the MLROcase ESCALATED400 blank reason; 403 no ESCALATE_CASES; 409 nobody to escalate tof10, g1
f10Close / reopen. API POST /compliance/cases/workflow/status (B/controller/compliance/ComplianceCaseWorkflowController.java:80-90, class guard VIEW_CASES)—CLOSED_CLEARED, CLOSED_SAR_FILED, CLOSED_BLOCKED need CLOSE_CASES; REOPENED needs REOPEN_CASES (B/service/CaseWorkflowService.java:240-258)closed / reopened403; leaving a closed status other than by reopening returns 409 ERR_WORKFLOW_STATE_001 (:231-233)g1 or end

Case delete is DELETE /compliance/cases/{id} (hasAnyRole('SUPER_ADMIN','ADMIN','COMPLIANCE_OFFICER'), ComplianceCaseController.java:316-340). A linked SAR or an active legal hold refuses it with 409 CASE_DELETE_BLOCKED (B/service/lifecycle/LifecycleGuardService.java:87-101).

g. SAR/STR and CTR filing​

stateDiagram-v2
[*] --> DRAFT: create from case (legal hold placed)
DRAFT --> PENDING_REVIEW: submit (creator)
REJECTED --> PENDING_REVIEW: submit (creator)
PENDING_REVIEW --> APPROVED: approve (not the creator)
PENDING_REVIEW --> REJECTED: reject (reason)
APPROVED --> FILED: file (MLRO / compliance officer, Edge pull)
FILED --> [*]
#Who, wherePreconditionWhat happensSuccessFailure: what the user seesNext
g1Case worker, /cases/all CaseActionsPanel "Raise a SAR from this case" (CaseActionsPanel.tsx:256-305). Console sar.create (CREATE_SAR). API POST /compliance/sar (B/controller/ComplianceReportingController.java:107-111), class guard hasAnyRole('SUPER_ADMIN','ADMIN','PLATFORM_ADMIN','MLRO','COMPLIANCE_OFFICER') (:33)case in own PSP, narrative typedSarWorkflowService.createSarDraft (B/service/SarWorkflowService.java:95-224) links the case, copies its transaction refs by id, links its alerts, sets the deadline, and places a legal hold: legal_holds rows in the same transaction, then hold.place to every Edge site over the tunnel after commit (B/service/legalhold/LegalHoldService.java:134-174,304-330)DRAFT404 case of another PSP; 403 role not in the class guard (see section 4: PSP_ADMIN and CASE_MANAGER see the button but are refused)g2
g2SAR register /transaction-monitoring/sars (F/pages/TransactionMonitoring/TransactionMonitoringSars.tsx; route needs VIEW_TRANSACTION_DETAILS). Narrative template GET /compliance/sar/{id}/narrative-template (ComplianceReportingController.java:129-145); edit PUT /compliance/sar/{id} (narrative only, DRAFT or REJECTED)—template rendered; narrative saved—404 SAR_TEMPLATE_NOT_CONFIGURED; 409 locked SARg3
g3Creator, SarWorkflowActions (F/components/compliance/SarWorkflowActions.tsx:71-84). API POST /compliance/sar/workflow/submit (B/controller/compliance/SarWorkflowController.java:71-77, class roles SUPER_ADMIN, ADMIN, MLRO, COMPLIANCE_OFFICER, INVESTIGATOR + CREATE_SAR)DRAFT or REJECTED→ PENDING_REVIEW—403 "Only the SAR creator may submit it for review"; wrong state → 409 ERR_WORKFLOW_STATE_001g4
g4Second person, sar.approve / sar.reject (APPROVE_SAR). API POST /compliance/sar/workflow/approve / reject (SarWorkflowController.java:79-94)PENDING_REVIEWAPPROVED with approvedBy, or REJECTED with a reason (SarWorkflowService.java:268-319)APPROVED403 ERR_ACCESS_DENIED_002 "SAR creator cannot approve their own report"; 400 reject without reason; wrong state → 409 ERR_WORKFLOW_STATE_001. The Console disables both buttons for the creatorg5 or back to g2
g5MLRO or compliance officer of the PSP. Console filing.file (compliance role + FILE_SAR). API POST /sar/{id}/file (B/controller/compliance/RegulatoryFilingController.java:85-90, class guard compliance roles :44)APPROVED, approver ≠ creator, transaction refs presentRegulatoryFilingService.fileSar (B/service/filing/RegulatoryFilingService.java:163-210): (1) read-only checks; (2) pulls the transaction detail from the Edge with filing.extract over the tunnel (B/service/filing/FilingDataService.java:67-145); (3) one transaction: re-check, render goAML XML, store, mark FILED, end the hold's workflow, audit200 "SAR filed. Filing reference …"platform staff 403 FILING_NOT_ALLOWED; another PSP 404 FILING_NOT_FOUND; 409 ALREADY_FILED; 409 FILING_STATE_CONFLICT (not APPROVED, approver = creator, no refs, bad regulator ref); EDGE_PULL_FAILED with the tunnel's status (503 offline, 429 busy, 504 timeout); FILING_INCOMPLETE 503 retryable (partial Edge answer) or 409 not retryable (refs without decision-record id, missing amount/currency); 409 FILING_CONCURRENT_CHANGEg6
g6Filed-document store (B/service/filing/FiledDocumentStore.java:65-133; M/V269__regulatory_filed_documents_and_legal_holds.sql:30-86)—encrypted (enc:v1:), SHA-256 sealed, immutable (trigger refuses UPDATE and early DELETE), retain_until = filed + max(5, PSP retention) years; goAML XML (B/service/filing/GoAmlFilingRenderer.java:34-37)stored500 FILING_STORE_FAILED, FILED_DOCUMENT_INTEGRITYg7
g7Compliance roles, /filed-documents (Console VIEW_SAR and a compliance role). API GET /filed-documents, `/filed-documents/export?format=csvndjson, /filed-documents/{docId}, /{docId}/content, /sar/{id}/filed-document, /ctr/{id}/filed-document (RegulatoryFilingController.java:101-154`)—paged register, audited export and downloadfile404 other PSP; platform staff need support access and a reason, else 403 SUPPORT_ACCESS_DISABLED; 403 TENANT_MISMATCH

Filing a SAR does not close its case. CLOSED_SAR_FILED is set by hand (hop f10).

CTR. Deadlines come from Edge decision records with ctrRequired (hop e9) and, until R12, also from the legacy daily CtrFilingBridgeService over cloud transactions (B/service/compliance/CtrFilingBridgeService.java:40-66).

#Who, wherePreconditionWhat happensSuccessFailureNext
c-1Compliance roles, /regulatory-reports "filings" tab, CtrFilingsPanel (F/pages/RegulatoryReports/tabs/RegulatoryFilingsTab.tsx:376; route VIEW_SAR). API `GET /ctr/deadlines?status=openfiledall (RegulatoryFilingController.java:71-79, B/service/filing/CtrRegisterService.java:68-108`)—register with OVERDUE computed per rowlist
c-2MLRO or compliance officer, "File". API POST /ctr/{id}/file (RegulatoryFilingController.java:93-98; RegulatoryFilingService.java:257-347)open CTR_FILING deadlineno approval stage; Edge pull of the anchor and the day's aggregate, goAML XML stored, deadline completed, holds placed, audit FILE_CTRfiled409 CTR_BELOW_THRESHOLD, ALREADY_FILED; the g5 Edge codes; a legacy-sourced deadline for an Edge PSP gives 409 FILING_INCOMPLETEend
flowchart LR
CFG["Edge Config<br/>retention.years 5-25"] --> PURGE["RetentionPurger<br/>daily at EDGE_PURGE_TIME"]
HOLD["hold.place / hold.release<br/>over the tunnel"] --> HELD[("edge_legal_holds<br/>edge_held_evaluations")]
PURGE -->|"copy held rows first"| HELD
PURGE -->|"drop monthly partitions<br/>past the cutoff"| LEDGER[("Edge Ledger")]
#Who, wherePreconditionWhat happensSuccessFailureNext
h1PSP admin sets retention (journey c)—the value reaches the Edge inside the sealed Edge ConfigEdge config version accepted—h2
h2Edge RetentionPurger (EH/residency/RetentionPurger.java), daily at EDGE_PURGE_TIME (default 02:00 local)a config acceptedretention = max(config, EDGE_RETENTION_MIN_YEARS), clamped 5–25 (EH/residency/RetentionPolicy.java:33-72). Rows under an active hold are copied to edge_held_evaluations, then whole monthly partitions past the cutoff are dropped (:250-337), so rows can stay up to one month longer. Also purged: released held copies, access log, shipped outbox rows, superseded config, finished back-fill runs (:355-407)purge log rowno config yet → SKIPPED_NO_CONFIG; nothing is purged. Unshipped outbox rows are never purged—
h3Control Plane LegalHoldService (B/service/legalhold/)SAR drafted, filed, or case ESCALATEDholds placed by edgeAssessmentIds; sent as hold.place, resent on tunnel hello.ack; the reconciler runs every 5 minutes (LegalHoldReconciler.java:133-187). Release after max(5, retention) years from filing (FILING_RETENTION_ELAPSED) or from case closure (CASE_CLOSED); deleting an unfiled SAR releases as PLACED_IN_ERROREdge answers hold.ack (EH/tunnel/EdgeTunnelClient.java:1107-1160)deleting a FILED SAR → 409. A REJECTED SAR that is never re-filed or deleted keeps its hold—
h4Platform operator, /edge-backfill (Console MANAGE_PSP, or a PSP role in BACKFILL_PSP_ROLES). API /psps/{pspId}/edge-backfill (B/controller/residency/LedgerBackfillController.java:42-119)PSP support access on; reason 10–500 charactersstart (POST .../runs, 202) and resume are platform-only; pause, abort and rejections also for the PSP's PSP_ADMIN, ADMIN, MLRO, COMPLIANCE_OFFICER (B/service/backfill/LedgerBackfillAccess.java). The worker sends legacy rows to the Edge over the tunnel op ledger.backfill (B/service/backfill/TunnelBackfillTransport.java:22-30); the Edge imports them with origin=BACKFILL and verifies a digest (EH/tunnel/BackfillImporter.java:33-52)run VERIFIED403 support access off or no reason; Edge BACKFILL_NOT_ALLOWED when its config forbids it; states PAUSED, STALLED, FAILED, ABORTEDh5
h5R12 purge gate (B/service/backfill/BackfillCoverageService.java:100-143)—a PSP may have its legacy rows purged only when it has no legacy rows, or a VERIFIED run with no uncovered rows; legacy rows with a NULL psp_id block the global purgereadiness shown read-only in the back-fill reportthe gate is not called by any production purge yet, because the R12 purge itself is not built—

A PSP cannot be terminated while it has active legal holds or an open back-fill run (journey k).

i. Support access​

sequenceDiagram
actor M as PSP admin (maker)
actor K as PSP checker
actor O as Platform operator
participant CP as Control Plane
participant E as Edge
M->>CP: PUT /psps/{id}/support-access {enabled:true}
CP-->>M: 202 pending
K->>CP: POST /psps/{id}/data-settings/changes/{cid}/approve
CP->>E: new sealed Edge Config (supportAccess.enabled)
O->>CP: GET /transactions?pspId&supportReason
CP->>E: tunnel read (purpose SUPPORT_ACCESS)
E-->>CP: rows (Edge also checks its config)
CP->>CP: edge_data_access_log row
M->>CP: GET /edge-data/access-log
#Who, wherePreconditionWhat happensSuccessFailureNext
i1PSP admin (PSP_ADMIN or ADMIN of the PSP), /settings data-sharing panel (F/pages/Settings/tabs/SupportAccessTab.tsx). API PUT /psps/{pspId}/support-access (B/controller/residency/PspDataSettingsController.java:90-96)—enabling creates a pending change (202); disabling applies at once (200)pending403 platform staff or other roles (B/service/residency/PspDataSettingsAccess.java:27,58-68)i2
i2A different PSP user with PSP_ADMIN, ADMIN, MLRO or COMPLIANCE_OFFICER, DataSettingChangesPanel. API POST /psps/{id}/data-settings/changes/{cid}/approve or /reject (PspDataSettingsController.java:122-135)pending changepsps.support_access_enabled set; the Edge Config is re-issued (B/service/residency/EdgeConfigService.java:125-131)enabled403 the maker cannot approve (PspDataSettingsService.java:455-459)i3
i3Platform operator, any Edge-backed page with EdgeSupportGate (hop f8a); cloud workflow pages through PlatformSupportAccessGate (B/service/security/PlatformSupportAccessGate.java:60-74)support access ontunnel reads need purpose SUPPORT_ACCESS, a 10–500 character reason and a platform role (B/edge/tunnel/EdgeDataGateway.java:244-261); the Edge refuses platform actors itself when its config has support access off (EH/tunnel/TunnelOps.java:193-197). Withdrawal cancels in-flight pulls on both sidesdata showntunnel: 403 SUPPORT_ACCESS_DISABLED; cloud pages: 403 ERR_ACCESS_DENIED_002 with a message starting SUPPORT_ACCESS_DISABLED:i4
i4Audit—tunnel pulls → edge_data_access_log; cloud reads → audit_logs_enhanced SUPPORT_ACCESS_READ / SUPPORT_ACCESS_REFUSED (B/service/AuditLogService.java:166-191)——i5
i5PSP staff, /data-access-log (Console VIEW_AUDIT_LOGS; F/pages/DataAccessLog/DataAccessLogPage.tsx). API GET /edge-data/access-log, /access-log/export (B/controller/edgedata/EdgeDataController.java:30-83, roles SUPER_ADMIN, ADMIN, PLATFORM_ADMIN, APP_CONTROLLER, PSP_ADMIN, COMPLIANCE_OFFICER, MLRO, AUDITOR)—paged and exportable list of tunnel pullslistthe page shows tunnel pulls only; cloud support-access reads are in /auditend

j. Billing and payments​

Billing​

#Who, wherePreconditionWhat happensSuccessFailureNext
j1Platform operator, /billing (F/pages/Billing/BillingPage.tsx; PSP users are redirected to /settings, F/App.tsx:63-70). Plans: /admin/billing/plans (B/controller/admin/BillingPlanAdminController.java:58-93, MANAGE_PSP + platform). Assign: Subscriptions tab "Assign plan" → POST /subscriptions (B/controller/psp/SubscriptionController.java:91-107, platform roles); also POST /admin/billing/psps/{pspId}/planPSP existsBillingPlanAdminService.assignPlan (B/service/billing/BillingPlanAdminService.java:185-218): TRIAL or ACTIVE; the previous assignment ends the day beforeplan in force400 "PSP not found", "Unknown plan", "Plan X is inactive", bad trial date (the /subscriptions path returns an empty 400 body)j2
j2Metering, automatic (B/service/billing/BillingUsageMeter.java:44-101)Edge decision records arrivingcounts v2 decision records by evaluation time; duplicates are never counted twice. Live usage for the plan card and alerts comes from decision_stats_hourly——j3
j3Period close, BillingCycleScheduler daily 02:00 UTC (B/scheduler/BillingCycleScheduler.java:55-115)month ended + billing.period.close-grace-days (3)usage snapshot written once (B/service/billing/BillingUsageSnapshotService.java:93-121). Late records are a true-up priced on their own month's plan (B/service/billing/PlanChargeCalculator.java:342-360). Trial months are not chargedsnapshotmanual issue before the close → 409 (POST /billing/invoices/generate, B/controller/psp/BillingController.java:111-128)j4
j4Pricing (PlanChargeCalculator.java:296-384)—fee line; overage PER_TRANSACTION (over × unit price) or PER_BLOCK (ceil(over / N) × block price); VAT = subtotal × rate (B/service/billing/VatPolicy.java:237-261: reverse charge 0 %, else the PSP's rate, else billing.vat.default-rate = 16.00 in application.properties, else 0 % with an internal note). The VAT number does not change the rate; it is printed on the PDF when set. Platform sets rate and number with PUT /admin/billing/psps/{pspId}/billing-profile/vat—400 rate outside 0–100, number longer than 64j5
j5Invoice issue (B/service/psp/BillingService.java:111-188)snapshotidempotent; status SENT, due = issue + payment terms (30), number HKK-YEAR-NNNNNN; a delivery is queuedinvoice—j6, j9
j6Delivery (InvoiceDeliveryListener, InvoiceDeliveryRetryJob, B/service/billing/)mail configuredfirst attempt after commit; retries every 5 min with doubling backoff up to 24 h; FAILED after 6 attempts. Recipients: billing_email or contact email plus CC, edited by the PSP admin (PUT /billing/profile/recipients, B/controller/psp/PspBillingProfileController.java:41-43). Resend: POST /billing/invoices/{id}/resend (B/controller/psp/InvoiceDeliveryController.java:45-55)email sentnotifications.email-enabled defaults to false: every attempt is NOT_CONFIGURED and the row ends FAILED—
j7Usage alerts, UsageAlertJob hourly at :15 (B/scheduler/UsageAlertJob.java:30-48)ACTIVE or SUSPENDED PSP with included volumeone email each at 80 % and 100 % per period (B/service/billing/UsageAlertService.java:28,63-93); PSP sees usage on PlanUsageCard (GET /entitlements/me)———
j8Dunning, DunningScheduler daily 09:00 UTC (B/scheduler/DunningScheduler.java)SENT invoice past due→ OVERDUE; reminder every 7 days; after 45 days a single escalation email to billing.dunning.admin-email; weekly notice after 30 days. No automatic suspension: nothing in billing calls a status change——k (manual)
j9PSP admin, /settings Billing tab (F/pages/Settings/SettingsPage.tsx:639-645, F/pages/Psps/tabs/BillingTab.tsx)—invoices, PDF (GET /billing/invoices/{id}/pdf), bank details, contacts——payments

Payments​

L6b (merge f42c0936b, V277) is on the base this page describes.

#Who, wherePreconditionWhat happensSuccessFailureNext
p1Platform operator, /billing Payment Gateways tab (F/pages/Billing/BillingPage.tsx:1071). API /admin/billing/gateways (list, create, PUT, /{id}/credentials, /enable, /disable, /default, /test; B/controller/admin/PaymentGatewayAdminController.java:27-97, MANAGE_PSP + platform)Pesawise apiKey, apiSecret, balanceIdPaymentGatewayRegistry (B/service/billing/gateway/PaymentGatewayRegistry.java): credentials encrypted; new gateways start disabled; enable needs complete config; production needs https; shows the webhook URL /api/v1/billing/gateways/{id}/webhook. No gateway is seededenabled default gateway400 "Cannot enable: missing …"; 404; 503p2
p2PSP user with PSP_SETTINGS_EDIT (Console billing.pay: tenant user, not platform), /settings Billing tab → Pay → InvoicePayDialog (F/pages/Psps/tabs/InvoicePayDialog.tsx). API POST /billing/payments/invoices/{id}/checkout (B/controller/psp/InvoicePaymentController.java:70-75, own PSP only)invoice SENT/OVERDUE, payer phone knownInvoicePaymentService.startCheckout (B/service/billing/payment/InvoicePaymentService.java:162-212): PENDING attempt committed, Pesawise e-com order created, valid 60 min. The dialog shows the hosted checkout in a sandboxed iframe, with an "Open in a new tab" button (InvoicePayDialog.tsx:236-250); return page /billing/payment-returnattempt PENDING422 METHOD_NOT_SUPPORTED, CURRENCY_NOT_SUPPORTED, BILLING_PHONE_MISSING; 409 INVOICE_NOT_PAYABLE, PAYMENT_IN_PROGRESS; 503 GATEWAY_UNAVAILABLE; 502 GATEWAY_ERROR; 403 platform staff or other PSPp5
p3Same user, M-PESA. API POST /billing/payments/invoices/{id}/mpesa (InvoicePaymentController.java:78-83)KES invoicePesawise STK push to the payer's phone (KES, whole amounts), valid 5 minprompt on the phoneas p2p5
p4Anyone holding the emailed pay link: public /pay/:token (F/App.tsx:112). API /billing/payments/link/{token}, /checkout, /attempts/{id}/refresh (InvoicePaymentController.java:111-146, public)mail configured, invoice payabletoken 256 bits, only its SHA-256 stored; valid until 90 days after the due date (B/service/billing/payment/InvoicePaymentLinkService.java:24-68). The link offers hosted checkout onlyattempt PENDING404 unknown or expired linkp5
p4aAutomatic payment requests, AutoPayService (B/service/billing/payment/AutoPayService.java)invoice SENT and not yet due, PSP billing_auto_pay on, billing phone set, KESSTK requests on days 0, 3 and 7 after issue (billing.autopay.schedule-days), the first right after issue, then daily job 07:00 UTC; each day claimed once (SENT, SKIPPED, FAILED). The payer must still approve on the phone. Phone and auto-pay switch: PUT /billing/profile/payment (PSP_SETTINGS_EDIT)prompt senta day after the due date is skippedp5
p5Settlement: webhook POST /billing/gateways/{gatewayId}/webhook (B/controller/psp/PaymentGatewayWebhookController.java:37-51, public, x-secret-hash compared in constant time, PesawiseGatewayAdapter.java:196-204) and the reconcile job every 2 min (B/service/billing/payment/PaymentJobs.java:50-78), which also settles checkout orders (Pesawise sends no webhook for them)PENDING attemptstatus re-checked with Pesawise payment-status; PaymentAttemptLedger (:165-255) under row locks: success → invoice PAID, receipt emailed, then reactivateIfDuesCleared; short or wrong currency → UNDERPAID; failed → DECLINED; past expiry → EXPIREDPAID; a SUSPENDED PSP with nothing else outstanding becomes ACTIVEwebhook 401 bad secret, 404 unknown gateway, 400 bad payloadk

There is no saved-card or automatic card debit. V277 dropped psp_payment_methods; Capability.SAVED_CARD exists but Pesawise does not declare it (B/service/billing/gateway/PesawiseGatewayAdapter.java:48,84-94). The legacy Daraja path (POST /billing/payments/initiate with MPESA or BANK_TRANSFER) is still mapped.

k. Suspension, reactivation and termination​

ObjectActionWho, APIEffectFailure codes
PSPsuspend, reactivate, terminateplatform roles, /psps row menu → PUT /psps/{id}/status {status, reason} (PspController.java:192-201); DELETE /psps/{id} with a reason also terminates (:155-166)transitions `PENDING→ACTIVETERMINATED, ACTIVE→SUSPENDED
PSPautomatic reactivationreactivateIfDuesCleared (PspService.java:96-120) after a payment or an admin marking an invoice PAIDany SUSPENDED PSP with no outstanding invoice becomes ACTIVE, whatever the reason for the suspension—
Userdisable / enableMANAGE_USERS, /users toggle → PATCH /users/{id}/toggle (UserController.java:343-363)sessions expired403 other PSP
UserdeleteDELETE /users/{id} (UserController.java:186-197)hard delete (UserService.java:113-119)—
Merchantcloseplatform roles, DELETE /merchants/{id} with a reason (B/controller/MerchantController.java:378-391)soft close to TERMINATED (B/service/merchant/MerchantUpdateService.java:202-238)400 MERCHANT_REASON_REQUIRED; 409 MERCHANT_ALREADY_CLOSED, MERCHANT_CLOSURE_BLOCKED (open cases, unfiled SARs, legal holds)
Edge nodesuspendplatform or the PSP's admin, POST /edge/nodes/{id}/suspendACTIVE → SUSPENDED; next poll 403, the Edge answers HOLD; tunnel closed with 4403 within 30 s409 not ACTIVE
Edge nodereinstateplatform only, POST /edge/nodes/{id}/reinstate (EdgeAdminController.java:212-221)SUSPENDED → ACTIVE (pinned keys and an ACTIVE PSP needed); audited EDGE_NODE_REINSTATED; the Edge recovers at its next successful poll409
Edge noderevokeplatform or the PSP's admin, POST /edge/nodes/{id}/revoketerminal; certificates revoked and the CRL re-issued (GET /edge/ca/crl, nginx ssl_crl). Re-enrolment needs a new node and a new edge id—

What still works while a PSP is not ACTIVE (PspActivationFilter.java:75-127):

  • always: /auth/** (sign-in, invites, reset), the Edge machine paths, /actuator/**, the public pay link and the gateway webhook;
  • PENDING or SUSPENDED: /users/me, /users/me/**, /psps/me;
  • SUSPENDED only: /billing/invoices/**, /billing/payments/**, /billing/bank-details, /billing/payment-methods/**, /billing/profile/**. The Console lets a SUSPENDED PSP open /settings and shows "Access suspended" with a "Pay outstanding invoices" link (PspAccessStatePage.tsx:19,51-58);
  • everything else: 403 PSP_AWAITING_APPROVAL (PENDING), PSP_ACCESS_REVOKED (SUSPENDED or TERMINATED) or PSP_NOT_ACTIVE.

l. Platform admin operations​

Full steps: operations/PLATFORM_ADMIN_RUNBOOK.md. In short:

  1. Bootstrap login and password rotation: journey a.
  2. PSP lifecycle (register, invite first admin, approve, suspend, terminate): journeys b and k.
  3. Edge fleet (approve, reject, reinstate, reissue code, CA and CRL): journeys d and k.
  4. Support access pulls with a reason, and where the PSP sees them: journey i.
  5. Legacy back-fill (R15): journey h.
  6. Billing plans, VAT, invoices, payment gateways: journey j.
  7. Health GET /api/v1/actuator/health; Runtime Errors /runtime-errors (CONFIGURE_SYSTEM, platform only, GET /admin/runtime-errors); Edge Sites status GET /edge-data/status on /edge-nodes.

m. Website demo bookings​

#Who, wherePreconditionWhat happensSuccessFailure: what the user seesNext
m1Visitor, website "Book a demo" (website/src/sections/DemoBookingSection.tsx). API GET /public/website/demo-slots, POST /public/website/demo-bookings (B/controller/marketing/PublicWebsiteController.java)—slots are computed, never trusted from the client: business days, DEMO_OPEN_HOUR–DEMO_CLOSE_HOUR, on a DEMO_SLOT_INTERVAL_MINUTES (120) grid, DEMO_LEAD_HOURS ahead, and at least that interval from every requested or confirmed demo (B/service/marketing/DemoBookingService.java availability). The booking is stored REQUESTED (V291), the operators are emailed and the visitor gets a calendar filereference shown409 slot no longer available (also the partial unique index)m2
m2Control Plane, every demo event—DemoBookingNotifier sends an in-app message to every Hokeka team member (enabled, not-removed platform staff of any role; DEMO_NOTIFY_ROLES narrows it; PSP users never). The Console shows each new unread message as a pop-up (F/components/notifications/NotificationPopups.tsx, checked every 20 s) and on the bellpop-up for new request, claim, acceptance, reschedule, daily reminder—m3
m3Any Hokeka staff member, /demo-requests Requests and Schedule views (route platformStaffOnly). API GET /marketing/demo-bookings, GET /marketing/demo-bookings/schedule (B/controller/marketing/DemoBookingAdminController.java, @pspIsolationService.isPlatformStaff())—queue and the 14-day schedule: demos and free slots per day in the meeting timezoneread-only unless an operator403 for a PSP's usersm4
m4Operator (platform admin with MANAGE_PSP): Assign to me, Accept, Decline, Reschedule. API POST /marketing/demo-bookings/{id}/claim, /confirm, /decline, GET /{id}/reschedule-options, POST /{id}/reschedulelead unclaimed or claimed by this operatorthe claim is atomic (V292); accept/decline/reschedule email the visitor automatically. A reschedule offers only free slots at least the interval from every other demo and sends an updated calendar entry (same UID, higher SEQUENCE)visitor emailed, team notified409 claimed by a colleague, or slot not free; 400 not requested/confirmed—

n. Demo PSP​

A PSP with environment = DEMO (V293) is Hokeka's demonstration tenant: never billed or licence-suspended, never sent to CBK or another regulator (filings record DEMO-NOT-SUBMITTED), never in cross-PSP sharing, left out of platform totals, mail only to DEMO_PSP_EMAIL_SINK. Platform admins set it with PUT /psps/{id}/environment (reason, audited; 409 for a PSP with invoices). Setup, its own Edge instance and synthetic traffic: operations/DEMO_PSP.md.

3. Role × journey matrix​

Legend: Y = allowed by both Console and server; own = only for the user's own PSP; SA = only while the PSP has support access on, with a reason; — = refused. Platform = SUPER_ADMIN, ADMIN (sentinel), PLATFORM_ADMIN. Derived from the guards cited in section 2 and the role templates.

HopPlatformPSP_ADMINMLROCOMPLIANCE_OFFICERCASE_MANAGERPSP_ANALYSTSCREENING_ANALYSTAUDITORPSP_USER
b1 register PSP, b5 approveY————————
b2 invite first PSP adminY————————
b6 invite staff, b7 rolesYown———————
c1/c4 retention, cross-PSP, support access propose—own———————
c2 approve a data-setting change (not the maker)—ownownown—————
c9 rules (MANAGE_RULES; c10 approve: a different reviewer)SUPER_ADMIN/ADMIN (only platform reviewers approve global rules)own———————
d1 create Edge node, d2a reissue, k suspend/revoke nodeYown———————
d2 approve node, k reinstate nodeY————————
f1 view alerts / casesSAownownownownownownownown
f3–f5 dispose alertSAownownownownown———
f6 escalate alert to case—ownownownown————
f7 case notes—ownownownownown——own
f8 live Edge pullsSAownownownownownownownown
f9 escalate case—ownownownown————
f10 close case—ownownownown————
g1 draft SAR (server role guard)Y (no PSP data without SA)— (Console shows it, server refuses)ownown— (Console shows it, server refuses)————
g3 submit SAR (creator)Y—ownown—————
g4 approve / reject SARY— (has APPROVE_SAR, server role guard refuses)ownapprove own, no reject—————
g5 / CTR file— (FILING_NOT_ALLOWED)—ownown—————
g7 filed-document registerSA—ownown—————
h4 start / resume back-fillSA————————
h4 pause / abort / rejectionsSAownownown—————
i5 Data Access LogYownownown———own—
j1 plans, p1 gatewaysY————————
j9 / p2–p3 pay invoice—own———————
k suspend / terminate PSPY————————

ADMIN on a PSP (not the sentinel) is treated as a PSP role by PspDataSettingsAccess and LedgerBackfillAccess.

4. Known restrictions and open owner decisions​

Each item below restricts a client today and was checked in the code.

Sanctions screening​

  • No list source is on by default. OpenSanctions needs a data licence for any for-profit use, so SANCTIONS_OPENSANCTIONS_LICENSED is false and nothing is fetched. Until a source is enabled, no list is published, every Edge answers HOLD SCREENING_NO_LIST, and first activation of a PSP is refused with 409 (contract §6.6). Publisher feeds will become the enabled sources.
  • A PSP that sends no names is held. With a list loaded and no counterparty_name, cardholder_name or beneficiary_name, the answer is HOLD SCREENING_NO_NAME. Change it per PSP or channel through PUT /psps/{id}/edge-config/settings (screeningMissingNameAction, screeningMissingNameByChannel).
  • A cloud outage past 72 hours holds named payments at every PSP, after an alert at 26 hours (§6.6).
  • Merchant blocks need the PSP's reference. A known merchant with no pspMerchantRef is flagged at /lists and on the merchant page, because the Edge cannot match its block.
  • Cloud merchant screening still treats a sanctioned PEP as PEP. aml-microservice does not carry the sanctioned flag; the Edge does.

Architecture still pending​

  • Legacy cloud ingest runs until R12. POST /transactions/ingest and /ingest/batch are still mapped (B/controller/TransactionController.java:45,106,243) and run the cloud DecisionEngine (B/service/FraudDetectionOrchestrator.java:69). New PSPs must use the Edge.
  • R13 simulation rerun pending. R12, R13 and R14b are pending in the work package status. LIVE stays blocked until the rerun passes.
  • CBK aggregates read legacy cloud transactions. CbkSubmissionOrchestrator builds its reports from TransactionRepository (B/service/cbk/CbkSubmissionOrchestrator.java:412-602) with no Edge path, so an Edge PSP's CBK reports are empty. Owner decision needed before R12.
  • Market surveillance, Mobile Money, Wallet Intelligence and Customer 360 use cloud-held stores (market_orders, market_executions, multi_asset_transactions, mobile_money_*; B/service/market/MarketSurveillanceService.java:26-30, B/service/multiasset/MultiAssetService.java:226-250) fed by direct cloud POSTs, not the Edge. Owner decision needed before R12.
  • CTR/LCTR/IFTR reports under /regulatory-reports read cloud rows and are refused with 409 for a PSP with an Edge (B/service/reporting/RegulatoryReportingService.java:66-81); Edge PSPs use the CTR register (hop c-1).
  • Global search (F/hooks/useGlobalSearch.ts:26 → B/service/search/GlobalSearchService.java:63-64) searches cloud transactions only; Edge transactions are not found by it.
  • Cross-PSP intelligence is only partly built. CrossPspFraudIntelligenceService raises flags from an alert's cloud transaction row (merchant id, PAN hash, terminal) and is read only by the cloud DecisionEngine (B/service/fraud/CrossPspFraudIntelligenceService.java:110-222). Edge alerts have no cloud transaction, so they contribute only the merchant id. There are no SAR-derived indicators and no Edge-side matching. The opt-in itself works (journey c).
  • Travel Rule (VASP) exists only as a cloud workflow on multi_asset_transactions (B/service/crypto/VirtualAssetComplianceService.java:286-346); the gateway client is off by default (travel-rule.gateway.enabled=false) and there is no receive endpoint. Owner decision open: Edge-to-Edge or a cloud relay that stores nothing.

Owner decisions on filing and payments​

  • CTR filing has no approval stage (B/service/filing/RegulatoryFilingService.java:334-347), unlike SAR filing. Owner decision pending.
  • Filing a SAR does not close its case; documents are goAML XML only; filed documents are stored encrypted in Postgres. These are the R9 defaults awaiting confirmation.
  • Pesawise has no saved-card or tokenised recurring charge, so there is no automatic card debit. Automatic collection is the scheduled M-PESA STK request, which the payer approves.
  • Payment reactivates only a NON_PAYMENT suspension with nothing outstanding; a COMPLIANCE or OTHER suspension needs a platform admin (B/service/psp/PspService.java reactivateIfDuesCleared).
  • Unpaid invoices suspend the Edge licence, not the Console: once the oldest is billing.licence.suspend-after-days (14) past due, Edge Nodes answer HOLD LICENCE_SUSPENDED until it is paid (B/service/licence/PspLicenceService.java; docs/operations/BILLING_LICENCE.md). Dunning emails continue.

Configuration a client needs before a hop works​

  • CONSOLE_BASE_URL is not written by the installer; without it invite and reset emails carry no link (b2, a).
  • notifications.email-enabled is false by default; invoices, receipts, alerts and dunning are then not emailed (j6).
  • An Edge without Aerospike answers HOLD FEATURE_STORE_UNAVAILABLE for every request with a pan_hash (e3).
  • A Pesawise gateway must be created and enabled at /billing before anyone can pay (p1).

Other compliance gaps found in code​

  • Underwriting vendors. Owner identity (Smile ID Enhanced KYC) and the company registry (Smile ID Business Verification, Kenya BRS and others) have live clients: set UNDERWRITING_SMILEID_PARTNER_ID, UNDERWRITING_SMILEID_API_KEY, UNDERWRITING_SMILEID_ENVIRONMENT (sandbox / production) and enable UNDERWRITING_SMILEID_ENABLED / UNDERWRITING_REGISTRY_ENABLED. An owner or entity clears only on a verified answer whose name and number match; anything else forces REVIEW. MATCH Pro, VMSS and the bank account-name check need card-scheme / bank agreements and their API specifications; until then they answer UNAVAILABLE and onboarding goes to REVIEW. An owner sanctions match also forces REVIEW.
  • CSRF is disabled (B/config/SecurityConfig.java:173); session cookies are SameSite=Strict in production.
  • Demo PSPs DEMO_VELOCITY, DEMO_MWANANCHI, DEMO_APEX are still created by V127; V253 disables only their users.

Blocking defects found and fixed​

Dead ends found while walking the journeys, each now fixed in code (tests named where they cover it).

  1. Control Plane keys reach the Edge through enrolment. An ACTIVE /edge/enroll answer's controlPlaneSigningKey / controlPlaneIngestKey are pinned to controlplane-keys.json (EH/activation/ActivationService.java, ControlPlaneKeyPinningTest); configured keys still win, and both installers take --controlplane-signing-key / --controlplane-ingest-key.
  2. Enrolment before approval waits. An unapproved node gets 202 PENDING_APPROVAL and its single-use code is kept (B/service/edge/EdgeEnrollmentService.java); the Edge retries until a platform admin approves it.
  3. SAR guards use permissions. ComplianceReportingController and SarWorkflowController check VIEW_SAR / CREATE_SAR / AMEND_SAR / FILE_SAR / APPROVE_SAR like the Console (ComplianceSarAccessTest).
  4. Platform staff no longer see "File" (F/lib/consoleRbac.ts filing.file).
  5. A SAR without a case can be filed. It is created from Edge decision records (edgeAssessmentIds), and its transactions can be set while DRAFT or REJECTED (POST /compliance/sar/workflow/{id}/transactions; Console SarTransactionsEditor).
  6. Wrong-state workflow errors are 409 ERR_WORKFLOW_STATE_001 (B/exception/WorkflowStateException.java).
  7. Legacy CTR deadlines file through their back-filled decision record; one not yet back-filled is refused with a message pointing to /edge-backfill (B/service/filing/RegulatoryFilingService.java legacyAnchor).
  8. Finance can pay. PAY_INVOICES (V285; FINANCE_OFFICER, PSP_ADMIN) pays; VIEW_FINANCE opens a Billing-only Settings tab and invoice PDFs. A suspended PSP can load its plan, usage and rate panels (PspActivationFilter).
  9. The awaiting-approval and suspended pages re-read the status every 30 s (F/pages/Auth/PspAccessStatePage.tsx).
  10. Merchants can be suspended, reactivated and closed from the Console (POST /merchants/{id}/suspend, /reactivate, DELETE /merchants/{id}; MERCHANT_EDIT, own PSP).
  11. Outbox quarantine. A record the Control Plane refuses on its own (400/413/415/422) five times in a row is set aside as QUARANTINED_HTTP_<status> (Edge Ledger V5) so later records keep shipping; /edge/status reports outboxQuarantined (OutboxShipperIntegrationTest).
  12. Platform settings tabs need their endpoints' permissions; APP_CONTROLLER sees none (F/lib/settingsTabs.ts).
  13. Alert maker-checker compares dispositions. The proposal is stored (V286); the checker approves the same disposition or the alert is returned for investigation (RETURN_ALERT_DISPOSITION). Escalating to a case is always final; moving the alert back to a working status withdraws the proposal.
  14. CLOSED_REJECTED is a closed status (B/service/CaseWorkflowService.java isClosed).
  15. /limits guards match the Console: MANAGE_RULES or CONFIGURE_SYSTEM; rows stay PSP-scoped.
  16. Closing or suspending a merchant blocks it on the Edge (PSP_TERMINATED, from the next config pull); reactivation lifts only the block its suspension placed (B/service/merchant/MerchantUpdateService.java).
  17. Removing a user is a soft delete (V287 removed_at): sign-in is blocked, the row keeps audit and foreign-key references, and the user is hidden from lists and cannot be re-enabled.

Found by the live walk of 2026-10-30 and fixed:

  • Every confirmed true positive failed (500): the CBK fraud incident got a disposition name in its VARCHAR(16) fraud_category_flag. It is now EXT, as the CBK return expects, and an Edge alert without a known merchant records the incident for the alert's own PSP (AlertFraudIncidentBridgeTest).
  • A case created from an alert rolled the disposition back when automatic escalation found no MLRO in the PSP; the escalation is now skipped with a warning (CaseEscalationService).
  • No SAR with transactions could change status (Hibernate HHH000479): Envers walked the lazy transactionRefs collection during flush. The collection is no longer audited; each SarTransaction row still is.
  • Filing a decision-record SAR for a PSP with no activated Edge Node said its transactions "no longer exist"; it now answers 503 retryable, naming the missing Edge.