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.

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:
- install steps:
install/README.md; - the normative data-residency rules:
EDGE_DATA_RESIDENCY_CONTRACT.md; - platform operator tasks:
operations/PLATFORM_ADMIN_RUNBOOK.md; - secrets:
operations/KEY_MANAGEMENT.md; - the Console route and role matrix:
CONSOLE_ROLE_MATRIX.md.
Conventions
-
Paths.
B/isBACKEND/src/main/java/com/posgateway/aml/,EH/isedge-host/src/main/java/com/hokeka/edge/,F/isFRONTEND/src/andM/isBACKEND/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_RULESorcanPerformAction). 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 exception HTTP Code IllegalArgumentExceptionwith "not found"404 ERR_NOT_FOUND_001other IllegalArgumentException400 ERR_BAD_REQUEST_001SecurityException403 ERR_ACCESS_DENIED_002, with the server's messageResponseStatusException/ConflictExceptionits status ERR_HTTP_<status>IllegalStateExceptionor otherRuntimeException500 ERR_INTERNAL_001, generic messageDomain 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
| Runtime | Who runs it | What it does |
|---|---|---|
Edge Node (edge-host/, edge-engine/) | the PSP, on its own premises | The 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/) | Hokeka | Users, 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-microservice | Hokeka, inside the cloud stack | Aerospike-backed sanctions lookups for the Control Plane. Not exposed to the Console |
Platform scope and PSP scope
- Platform operators belong to the
HOKEKA_PLATFORMsentinel PSP, or have no PSP (pspId0;M/V127__seed_demo_accounts.sql:27;/auth/mereturnspspId: 0,B/controller/auth/AuthenticationController.java:473).- Server rule (
B/service/security/PspIsolationService.java:96-123):SUPER_ADMIN,PLATFORM_ADMINandAPP_CONTROLLERalways count as platform;ADMINcounts only on the sentinel PSP. - Console rule (
F/lib/userAccess.ts:25-33):APP_CONTROLLERalways;SUPER_ADMIN,ADMIN,PLATFORM_ADMINonly withpspId0 orHOKEKA_PLATFORM. - Platform operators see a PSP's data only while that PSP has support access on (journey i).
- Server rule (
- 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.
| Role | Scope | Main permissions (abridged) |
|---|---|---|
SUPER_ADMIN, ADMIN | platform | every permission |
PLATFORM_ADMIN | platform | MANAGE_PSP, CONFIGURE_SYSTEM, PSP_SETTINGS_VIEW, view permissions; MANAGE_USERS and MANAGE_ROLES from M/V224__platform_user_management_permissions.sql:1-9 |
APP_CONTROLLER | platform | no permissions; counted as platform |
PSP_ADMIN | PSP | all 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 |
MLRO | PSP | all case permissions; SAR view, create, approve, reject, file, amend; OVERRIDE_SCREENING_MATCH, MANAGE_WATCHLISTS, VIEW_AUDIT_LOGS, EXPORT_DATA |
COMPLIANCE_OFFICER | PSP | as MLRO without REJECT_SAR, EXPORT_DATA, OVERRIDE_SCREENING_MATCH |
CASE_MANAGER | PSP | all case permissions; VIEW_SAR, CREATE_SAR |
PSP_ANALYST | PSP | VIEW_CASES, ASSIGN_CASES, case notes and evidence, VIEW_SAR |
SCREENING_ANALYST | PSP | VIEW_CASES, VIEW_SAR, screening overrides, watchlists |
AUDITOR | PSP | read-only, plus VIEW_AUDIT_LOGS |
PSP_USER | PSP | VIEW_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_PSPorCONFIGURE_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, where | Precondition | What happens | Success | Failure: what the user sees | Next |
|---|---|---|---|---|---|---|
| a1 | Hokeka ops, on the Control Plane host: scripts/install/hokeka-install.sh (role control-plane, scripts/install/roles/control-plane.sh:24) | Linux host with Docker | hokeka_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 compose | stack healthy (:298-310) | compose refuses to start without SEED_ADMIN_PASSWORD (docker-compose.prod.yml:233) | a2 |
| a2 | Control Plane boot | .env present | DataSeeder 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 up | a blank seed password on prod/production stops startup (DataSeeder.java:113-121) | a3 |
| a3 | Operator, Console /login; POST /auth/login (AuthenticationController.java:157, public) | the seed password, read from .env as root | session created (one per user, SecurityConfig.java:280); the response carries mustChangePassword and psp.status | /dashboard | 400 missing fields; 401 "Invalid username or password"; 403 "User account is disabled" | a4 |
| a4 | Operator, /profile | signed in | The 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 admin | rotated | 400 PASSWORD_CHANGE_REFUSED (wrong current password, policy, or same password) | b1 |
| a5 | Any user with a temporary password (set by an admin, by POST /auth/register, or by a reset) | must_change_password true | PasswordChangeRequiredFilter 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 Console | 400 PASSWORD_CHANGE_REFUSED | back 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):
- Console
/forgot-passwordcallsPOST /auth/password-reset/request. It always answers 200. The email link is built fromCONSOLE_BASE_URL(app.console-base-url;B/service/auth/ConsoleLinkBuilder.java:42-88). The installer does not writeCONSOLE_BASE_URL, and with it unset no email is sent; the request is only logged and audited (B/service/auth/PasswordResetService.java:171-183). /password-reset/confirmcallsPOST /auth/password-reset/confirm. A temporary password is emailed andmust_change_passwordis 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, where | Precondition | What happens | Success | Failure: what the user sees | Next |
|---|---|---|---|---|---|---|
| b1 | Platform 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 email | PspService.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 list | 400 ERR_BAD_REQUEST_001 "PSP Code already exists" (:144), shown in the dialog | b2 |
| b2 | Platform 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 over | b3 |
| b3 | Invitee, 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 token | redeem (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_REDEEMED | signed in automatically | 400 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-59 | b4 |
| b4 | PSP admin, any Console page | PSP is PENDING | PspActivationFilter (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 |
| b5 | Platform operator, /psps row menu, reason dialog. API PUT /psps/{id}/status {status:"ACTIVE", reason} (PspController.java:192-201), platform roles | PSP PENDING | applyStatusChange (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 ACTIVE | 400 PSP_STATUS_INVALID, PSP_REASON_REQUIRED; 409 PSP_STATUS_TRANSITION_INVALID | b6, c, d |
| b6 | PSP 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_USERS | PSP ACTIVE | invite pinned to the admin's own PSP; only that PSP's roles or tenant templates (RoleAssignmentPolicy.java:78-95) | invite emailed | 403 INVITE_PSP_FORBIDDEN (other PSP), 403 INVITE_ROLE_INVALID (platform role), plus the b2 codes | b3 for the staff member |
| b7 | PSP 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 PSP | RoleAssignmentPolicy applied; a role change expires the user's sessions (B/service/UserService.java:82-107) | role changed | 403 for another PSP's user or a forbidden permission | the 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_ADMINorADMIN. Platform operators can never propose; - the checker is a different user with
PSP_ADMIN,ADMIN,MLROorCOMPLIANCE_OFFICERin 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, where | Precondition | What happens | Success | Failure | Next |
|---|---|---|---|---|---|---|
| c1 | Maker, /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, 202 | pending | 400 out of range or same value; 403 platform or non-maker; 409 change already pending | c2 |
| c2 | Checker, 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 change | an 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 effectiveAt | 403 the maker cannot approve or reject; 409 not pending | h2 |
| c3 | Support access: journey i | |||||
| c4 | Maker, "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 in | as c1–c2 | — |
| c5 | Merchants, /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 k | merchant ACTIVE / UNDER_REVIEW / BLOCKED | 400 (empty body) on bad input; PUT returns every validation error as 404 (:364-366) | — |
| c6 | Platform 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 Edge | 400 bad reason or id; 409 "Merchant is already blocked"; 404 unblock of a merchant not blocked | e2 |
| c7 | Same 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 active | 400 invalid; 404 retire of a missing limit | e4 |
| c8 | Blacklist 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 Edge | 409 duplicate | e5 |
| c9 | Rule 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-deployable | every change is a proposal in RuleGovernanceService (B/service/rules/RuleGovernanceService.java) | pending version | 400 "Rule is not edge-deployable" (RulesController.java:74-83); 409 a proposal is already pending | c10 |
| c10 | A different reviewer. API GET /rules/governance/pending, POST /rules/governance/versions/{versionId}/approve or /reject (RulesController.java:417-445) | pending version; review reason | activation (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/bundle | rule live on the Edge | 403 the maker cannot approve or reject (:168-169,192-193); only platform reviewers approve global rules (:385-389); 409 not pending | e5 |
| 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 > 0 | stored as GlobalLimit AML_TRANSACTION / AML_DAILY. Not sent to the Edge: only the legacy cloud DecisionEngine enforces them. Edge PSPs use c7 | saved | 400; 403 for roles outside the class guard | — |
| c12 | PSP 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) | saved | 400 {"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, where | Precondition | What happens | Success | Failure: what the user sees | Next |
|---|---|---|---|---|---|---|
| d1 | PSP 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_ADMIN | PSP exists | EdgeEnrollmentService.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 command | code shown once with a 24 h warning (SetupWizard.tsx:322-327) | 400 "pspId is required" / displayName missing; 403 other PSP; 404 PSP not found | d2 |
| d2 | Platform 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 roles | node PENDING, PSP ACTIVE | PENDING → APPROVED (or REJECTED, terminal) (EdgeEnrollmentService.java:126-153) | APPROVED | 409 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 |
| d2a | PSP 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 APPROVED | old code stops working; audited EDGE_ENROLLMENT_CODE_REISSUED | new code once | 409 for any other status (EdgeEnrollmentService.java:324-339) | d3 |
| d3 | PSP 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 artifacts | Edge starts | refuses 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 |
| d4 | Edge, 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 valid | Edge 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 ACTIVE | 400 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/status | d5 |
| d5 | Edge, every EDGE_BUNDLE_POLL_INTERVAL (15 s) over mTLS | ACTIVE, Ed25519 key set | Rule 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 Config | bundle loaded | 401/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_BUNDLE | d6 |
| d6 | Edge tunnel client (EH/tunnel/EdgeTunnelClient.java) to wss://<host>/api/v1/edge/tunnel | ACTIVE, tunnel enabled in config | nginx 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 s | Console 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 backoff | e |
| d7 | Edge, every 60 s: POST /api/v1/edge/metrics (EdgeDistributionController.java:222-262) | ACTIVE | sealed metrics, replay guard; lastSeenAt updated by every Edge call | 204 | 400/403/413 | — |
| d8 | Edge, every 6 h: POST /api/v1/edge/certificate/renew (B/controller/edge/EdgeCertificateController.java:90-109) | inside the 30-day renewal window | new certificate; the old one stays valid until it expires | renewed | 403 node not ACTIVE; 400 CSR CN/OU wrong | — |
| d8a | Control Plane, every minute: EdgeLivenessMonitor.check (B/service/edge/EdgeLivenessMonitor.java, ShedLock edge.livenessMonitor) | node ACTIVE, PSP ACTIVE | a 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_silent | at most one alert per outage | mail not configured or no recipient: logged warning, the audit and silent_since still stand | — |
| d9 | Suspend / 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, where | Precondition | What happens | Success | Failure: what the caller sees | Next |
|---|---|---|---|---|---|---|
| e1 | PSP 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 key | Edge enrolled | txn_id checked first | — | 422 TXN_REF_LOOKS_LIKE_PAN or TXN_REF_INVALID; nothing is stored (:300-310) | e2 |
| e2 | Edge: 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 |
| e3 | Edge: 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 this | e4 |
| e4 | Edge: 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_UNAVAILABLE | e5 |
| e5 | Edge: rules (EH/EdgeEngine.java:278-307, EH/EdgeRuleInterpreter.java:289-311) | bundle loaded | every 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 |
| e6 | Edge: 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 |
| e7 | Edge ledger (EH/ledger/EdgeLedger.java:68-89,308-326) | — | a UUID edgeAssessmentId; one statement writes edge_evaluations, edge_txn_refs and edge_outbox | 200 {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 |
| e8 | Edge 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 set | batch 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-256 | 200 ack; rows marked shipped | 400 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, DUPLICATE | e9 |
| e9 | Control Plane DecisionRecordStore + DecisionRecordWorkflowService (B/service/edge/records/) | record accepted | one 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 webhook | alert 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, where | Precondition | What happens | Success | Failure: what the user sees | Next |
|---|---|---|---|---|---|---|
| f1 | Investigator, /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) | list | 403 | f2 |
| 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 transaction | 403, 404; banner "The PSP's Edge is offline." | f3–f5 |
| f3 | Status / 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 disposed | f4 |
| f4 | Resolve 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) | 200 | 400 "disposition is required" | f5 or f7 |
| f5 | Maker-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 closed | 403 ERR_HTTP_403 "Second reviewer must differ from the first reviewer (maker-checker)" | f7 |
| f6 | Escalate 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 |
| f7 | Case worker, /cases/all → CaseActionsPanel (F/components/Cases/CaseActionsPanel.tsx). Console VIEW_CASES | case NEW | assign 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 / updated | 403; 409 | f8, f9 |
| f8 | Live 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 up | the 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_log | live rows | EDGE_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 usable | f9 |
| f8a | Platform staff on f8 | PSP support access on | EdgeSupportGate (F/components/EdgeData/EdgeSupportGate.tsx) asks for the PSP and a 10–500 character reason, held for 60 minutes; sent as X-Support-Reason | audited pull | 403 SUPPORT_ACCESS_DISABLED | f9 |
| f9 | Escalate. Console case.escalate (ESCALATE_CASES). API POST /cases/{id}/escalate (CaseWorkflowController.java:165-188) | reason | CaseEscalationService (: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 MLRO | case ESCALATED | 400 blank reason; 403 no ESCALATE_CASES; 409 nobody to escalate to | f10, g1 |
| f10 | Close / 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 / reopened | 403; 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, where | Precondition | What happens | Success | Failure: what the user sees | Next |
|---|---|---|---|---|---|---|
| g1 | Case 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 typed | SarWorkflowService.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) | DRAFT | 404 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 |
| g2 | SAR 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 SAR | g3 |
| g3 | Creator, 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_001 | g4 |
| g4 | Second person, sar.approve / sar.reject (APPROVE_SAR). API POST /compliance/sar/workflow/approve / reject (SarWorkflowController.java:79-94) | PENDING_REVIEW | APPROVED with approvedBy, or REJECTED with a reason (SarWorkflowService.java:268-319) | APPROVED | 403 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 creator | g5 or back to g2 |
| g5 | MLRO 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 present | RegulatoryFilingService.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, audit | 200 "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_CHANGE | g6 |
| g6 | Filed-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) | stored | 500 FILING_STORE_FAILED, FILED_DOCUMENT_INTEGRITY | g7 |
| g7 | Compliance roles, /filed-documents (Console VIEW_SAR and a compliance role). API GET /filed-documents, `/filed-documents/export?format=csv | ndjson, /filed-documents/{docId}, /{docId}/content, /sar/{id}/filed-document, /ctr/{id}/filed-document (RegulatoryFilingController.java:101-154`) | — | paged register, audited export and download | file | 404 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, where | Precondition | What happens | Success | Failure | Next |
|---|---|---|---|---|---|---|
| c-1 | Compliance roles, /regulatory-reports "filings" tab, CtrFilingsPanel (F/pages/RegulatoryReports/tabs/RegulatoryFilingsTab.tsx:376; route VIEW_SAR). API `GET /ctr/deadlines?status=open | filed | all (RegulatoryFilingController.java:71-79, B/service/filing/CtrRegisterService.java:68-108`) | — | register with OVERDUE computed per row | list |
| c-2 | MLRO or compliance officer, "File". API POST /ctr/{id}/file (RegulatoryFilingController.java:93-98; RegulatoryFilingService.java:257-347) | open CTR_FILING deadline | no approval stage; Edge pull of the anchor and the day's aggregate, goAML XML stored, deadline completed, holds placed, audit FILE_CTR | filed | 409 CTR_BELOW_THRESHOLD, ALREADY_FILED; the g5 Edge codes; a legacy-sourced deadline for an Edge PSP gives 409 FILING_INCOMPLETE | end |
h. Retention purge, legal holds and back-fill
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, where | Precondition | What happens | Success | Failure | Next |
|---|---|---|---|---|---|---|
| h1 | PSP admin sets retention (journey c) | — | the value reaches the Edge inside the sealed Edge Config | Edge config version accepted | — | h2 |
| h2 | Edge RetentionPurger (EH/residency/RetentionPurger.java), daily at EDGE_PURGE_TIME (default 02:00 local) | a config accepted | retention = 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 row | no config yet → SKIPPED_NO_CONFIG; nothing is purged. Unshipped outbox rows are never purged | — |
| h3 | Control Plane LegalHoldService (B/service/legalhold/) | SAR drafted, filed, or case ESCALATED | holds 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_ERROR | Edge 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 | — |
| h4 | Platform 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 characters | start (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 VERIFIED | 403 support access off or no reason; Edge BACKFILL_NOT_ALLOWED when its config forbids it; states PAUSED, STALLED, FAILED, ABORTED | h5 |
| h5 | R12 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 purge | readiness shown read-only in the back-fill report | the 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, where | Precondition | What happens | Success | Failure | Next |
|---|---|---|---|---|---|---|
| i1 | PSP 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) | pending | 403 platform staff or other roles (B/service/residency/PspDataSettingsAccess.java:27,58-68) | i2 |
| i2 | A 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 change | psps.support_access_enabled set; the Edge Config is re-issued (B/service/residency/EdgeConfigService.java:125-131) | enabled | 403 the maker cannot approve (PspDataSettingsService.java:455-459) | i3 |
| i3 | Platform operator, any Edge-backed page with EdgeSupportGate (hop f8a); cloud workflow pages through PlatformSupportAccessGate (B/service/security/PlatformSupportAccessGate.java:60-74) | support access on | tunnel 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 sides | data shown | tunnel: 403 SUPPORT_ACCESS_DISABLED; cloud pages: 403 ERR_ACCESS_DENIED_002 with a message starting SUPPORT_ACCESS_DISABLED: | i4 |
| i4 | Audit | — | tunnel pulls → edge_data_access_log; cloud reads → audit_logs_enhanced SUPPORT_ACCESS_READ / SUPPORT_ACCESS_REFUSED (B/service/AuditLogService.java:166-191) | — | — | i5 |
| i5 | PSP 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 pulls | list | the page shows tunnel pulls only; cloud support-access reads are in /audit | end |
j. Billing and payments
Billing
| # | Who, where | Precondition | What happens | Success | Failure | Next |
|---|---|---|---|---|---|---|
| j1 | Platform 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}/plan | PSP exists | BillingPlanAdminService.assignPlan (B/service/billing/BillingPlanAdminService.java:185-218): TRIAL or ACTIVE; the previous assignment ends the day before | plan in force | 400 "PSP not found", "Unknown plan", "Plan X is inactive", bad trial date (the /subscriptions path returns an empty 400 body) | j2 |
| j2 | Metering, automatic (B/service/billing/BillingUsageMeter.java:44-101) | Edge decision records arriving | counts 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 |
| j3 | Period 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 charged | snapshot | manual issue before the close → 409 (POST /billing/invoices/generate, B/controller/psp/BillingController.java:111-128) | j4 |
| j4 | Pricing (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 64 | j5 |
| j5 | Invoice issue (B/service/psp/BillingService.java:111-188) | snapshot | idempotent; status SENT, due = issue + payment terms (30), number HKK-YEAR-NNNNNN; a delivery is queued | invoice | — | j6, j9 |
| j6 | Delivery (InvoiceDeliveryListener, InvoiceDeliveryRetryJob, B/service/billing/) | mail configured | first 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 sent | notifications.email-enabled defaults to false: every attempt is NOT_CONFIGURED and the row ends FAILED | — |
| j7 | Usage alerts, UsageAlertJob hourly at :15 (B/scheduler/UsageAlertJob.java:30-48) | ACTIVE or SUSPENDED PSP with included volume | one email each at 80 % and 100 % per period (B/service/billing/UsageAlertService.java:28,63-93); PSP sees usage on PlanUsageCard (GET /entitlements/me) | — | — | — |
| j8 | Dunning, 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) |
| j9 | PSP 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, where | Precondition | What happens | Success | Failure | Next |
|---|---|---|---|---|---|---|
| p1 | Platform 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, balanceId | PaymentGatewayRegistry (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 seeded | enabled default gateway | 400 "Cannot enable: missing …"; 404; 503 | p2 |
| p2 | PSP 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 known | InvoicePaymentService.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-return | attempt PENDING | 422 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 PSP | p5 |
| p3 | Same user, M-PESA. API POST /billing/payments/invoices/{id}/mpesa (InvoicePaymentController.java:78-83) | KES invoice | Pesawise STK push to the payer's phone (KES, whole amounts), valid 5 min | prompt on the phone | as p2 | p5 |
| p4 | Anyone 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 payable | token 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 only | attempt PENDING | 404 unknown or expired link | p5 |
| p4a | Automatic payment requests, AutoPayService (B/service/billing/payment/AutoPayService.java) | invoice SENT and not yet due, PSP billing_auto_pay on, billing phone set, KES | STK 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 sent | a day after the due date is skipped | p5 |
| p5 | Settlement: 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 attempt | status 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 → EXPIRED | PAID; a SUSPENDED PSP with nothing else outstanding becomes ACTIVE | webhook 401 bad secret, 404 unknown gateway, 400 bad payload | k |
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
| Object | Action | Who, API | Effect | Failure codes |
|---|---|---|---|---|
| PSP | suspend, reactivate, terminate | platform 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→ACTIVE | TERMINATED, ACTIVE→SUSPENDED |
| PSP | automatic reactivation | reactivateIfDuesCleared (PspService.java:96-120) after a payment or an admin marking an invoice PAID | any SUSPENDED PSP with no outstanding invoice becomes ACTIVE, whatever the reason for the suspension | — |
| User | disable / enable | MANAGE_USERS, /users toggle → PATCH /users/{id}/toggle (UserController.java:343-363) | sessions expired | 403 other PSP |
| User | delete | DELETE /users/{id} (UserController.java:186-197) | hard delete (UserService.java:113-119) | — |
| Merchant | close | platform 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 node | suspend | platform or the PSP's admin, POST /edge/nodes/{id}/suspend | ACTIVE → SUSPENDED; next poll 403, the Edge answers HOLD; tunnel closed with 4403 within 30 s | 409 not ACTIVE |
| Edge node | reinstate | platform 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 poll | 409 |
| Edge node | revoke | platform or the PSP's admin, POST /edge/nodes/{id}/revoke | terminal; 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/settingsand 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) orPSP_NOT_ACTIVE.
l. Platform admin operations
Full steps: operations/PLATFORM_ADMIN_RUNBOOK.md. In short:
- Bootstrap login and password rotation: journey a.
- PSP lifecycle (register, invite first admin, approve, suspend, terminate): journeys b and k.
- Edge fleet (approve, reject, reinstate, reissue code, CA and CRL): journeys d and k.
- Support access pulls with a reason, and where the PSP sees them: journey i.
- Legacy back-fill (R15): journey h.
- Billing plans, VAT, invoices, payment gateways: journey j.
- Health
GET /api/v1/actuator/health; Runtime Errors/runtime-errors(CONFIGURE_SYSTEM, platform only,GET /admin/runtime-errors); Edge Sites statusGET /edge-data/statuson/edge-nodes.
m. Website demo bookings
| # | Who, where | Precondition | What happens | Success | Failure: what the user sees | Next |
|---|---|---|---|---|---|---|
| m1 | Visitor, 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 file | reference shown | 409 slot no longer available (also the partial unique index) | m2 |
| m2 | Control 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 bell | pop-up for new request, claim, acceptance, reschedule, daily reminder | — | m3 |
| m3 | Any 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 timezone | read-only unless an operator | 403 for a PSP's users | m4 |
| m4 | Operator (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}/reschedule | lead unclaimed or claimed by this operator | the 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 notified | 409 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.
| Hop | Platform | PSP_ADMIN | MLRO | COMPLIANCE_OFFICER | CASE_MANAGER | PSP_ANALYST | SCREENING_ANALYST | AUDITOR | PSP_USER |
|---|---|---|---|---|---|---|---|---|---|
| b1 register PSP, b5 approve | Y | — | — | — | — | — | — | — | — |
| b2 invite first PSP admin | Y | — | — | — | — | — | — | — | — |
| b6 invite staff, b7 roles | Y | own | — | — | — | — | — | — | — |
| c1/c4 retention, cross-PSP, support access propose | — | own | — | — | — | — | — | — | — |
| c2 approve a data-setting change (not the maker) | — | own | own | own | — | — | — | — | — |
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 node | Y | own | — | — | — | — | — | — | — |
| d2 approve node, k reinstate node | Y | — | — | — | — | — | — | — | — |
| f1 view alerts / cases | SA | own | own | own | own | own | own | own | own |
| f3–f5 dispose alert | SA | own | own | own | own | own | — | — | — |
| f6 escalate alert to case | — | own | own | own | own | — | — | — | — |
| f7 case notes | — | own | own | own | own | own | — | — | own |
| f8 live Edge pulls | SA | own | own | own | own | own | own | own | own |
| f9 escalate case | — | own | own | own | own | — | — | — | — |
| f10 close case | — | own | own | own | own | — | — | — | — |
| g1 draft SAR (server role guard) | Y (no PSP data without SA) | — (Console shows it, server refuses) | own | own | — (Console shows it, server refuses) | — | — | — | — |
| g3 submit SAR (creator) | Y | — | own | own | — | — | — | — | — |
| g4 approve / reject SAR | Y | — (has APPROVE_SAR, server role guard refuses) | own | approve own, no reject | — | — | — | — | — |
| g5 / CTR file | — (FILING_NOT_ALLOWED) | — | own | own | — | — | — | — | — |
| g7 filed-document register | SA | — | own | own | — | — | — | — | — |
| h4 start / resume back-fill | SA | — | — | — | — | — | — | — | — |
| h4 pause / abort / rejections | SA | own | own | own | — | — | — | — | — |
| i5 Data Access Log | Y | own | own | own | — | — | — | own | — |
| j1 plans, p1 gateways | Y | — | — | — | — | — | — | — | — |
| j9 / p2–p3 pay invoice | — | own | — | — | — | — | — | — | — |
| k suspend / terminate PSP | Y | — | — | — | — | — | — | — | — |
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_LICENSEDis false and nothing is fetched. Until a source is enabled, no list is published, every Edge answers HOLDSCREENING_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_nameorbeneficiary_name, the answer is HOLDSCREENING_NO_NAME. Change it per PSP or channel throughPUT /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
pspMerchantRefis flagged at/listsand 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/ingestand/ingest/batchare still mapped (B/controller/TransactionController.java:45,106,243) and run the cloudDecisionEngine(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.
CbkSubmissionOrchestratorbuilds its reports fromTransactionRepository(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-reportsread 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.
CrossPspFraudIntelligenceServiceraises flags from an alert's cloud transaction row (merchant id, PAN hash, terminal) and is read only by the cloudDecisionEngine(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_PAYMENTsuspension with nothing outstanding; aCOMPLIANCEorOTHERsuspension needs a platform admin (B/service/psp/PspService.javareactivateIfDuesCleared). - Unpaid invoices suspend the Edge licence, not the Console: once the oldest is
billing.licence.suspend-after-days(14) past due, Edge Nodes answer HOLDLICENCE_SUSPENDEDuntil 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_URLis not written by the installer; without it invite and reset emails carry no link (b2, a).notifications.email-enabledis false by default; invoices, receipts, alerts and dunning are then not emailed (j6).- An Edge without Aerospike answers HOLD
FEATURE_STORE_UNAVAILABLEfor every request with apan_hash(e3). - A Pesawise gateway must be created and enabled at
/billingbefore 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 enableUNDERWRITING_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_APEXare 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).
- Control Plane keys reach the Edge through enrolment. An ACTIVE
/edge/enrollanswer'scontrolPlaneSigningKey/controlPlaneIngestKeyare pinned tocontrolplane-keys.json(EH/activation/ActivationService.java,ControlPlaneKeyPinningTest); configured keys still win, and both installers take--controlplane-signing-key/--controlplane-ingest-key. - Enrolment before approval waits. An unapproved node gets 202
PENDING_APPROVALand its single-use code is kept (B/service/edge/EdgeEnrollmentService.java); the Edge retries until a platform admin approves it. - SAR guards use permissions.
ComplianceReportingControllerandSarWorkflowControllercheckVIEW_SAR/CREATE_SAR/AMEND_SAR/FILE_SAR/APPROVE_SARlike the Console (ComplianceSarAccessTest). - Platform staff no longer see "File" (
F/lib/consoleRbac.tsfiling.file). - 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; ConsoleSarTransactionsEditor). - Wrong-state workflow errors are 409
ERR_WORKFLOW_STATE_001(B/exception/WorkflowStateException.java). - 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.javalegacyAnchor). - Finance can pay.
PAY_INVOICES(V285;FINANCE_OFFICER,PSP_ADMIN) pays;VIEW_FINANCEopens a Billing-only Settings tab and invoice PDFs. A suspended PSP can load its plan, usage and rate panels (PspActivationFilter). - The awaiting-approval and suspended pages re-read the status every 30 s (
F/pages/Auth/PspAccessStatePage.tsx). - Merchants can be suspended, reactivated and closed from the Console (
POST /merchants/{id}/suspend,/reactivate,DELETE /merchants/{id};MERCHANT_EDIT, own PSP). - 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/statusreportsoutboxQuarantined(OutboxShipperIntegrationTest). - Platform settings tabs need their endpoints' permissions;
APP_CONTROLLERsees none (F/lib/settingsTabs.ts). - 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. CLOSED_REJECTEDis a closed status (B/service/CaseWorkflowService.javaisClosed)./limitsguards match the Console:MANAGE_RULESorCONFIGURE_SYSTEM; rows stay PSP-scoped.- 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). - 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 nowEXT, 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
transactionRefscollection during flush. The collection is no longer audited; eachSarTransactionrow 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.