4E Control Plane
Source code:
backend/Applications/4EOrganizationsManager/Projects:FourE.OrganizationsManager.{Utility, Entity, Common, Api, Workers, Host, Tests}Runtime: .NET 10 + EF Core + PostgreSQL. Its own database (four_e_organizations, schemafour_e), its own Redis, its own RabbitMQ, its own deployable process.
In plain words
Imagine a big apartment building full of separate flats. Each flat is one customer's ERP system — their own private space with their own furniture (their data) that nobody else can see. 4E is the building's management office. It is not one of the flats. It is the front desk and the maintenance room.
When a new family wants to move in, the office:
- signs them up and writes them into the tenant register,
- decides which wing of the building they go in (a "cell"),
- hands them their keys and access cards (their login accounts and API keys),
- ticks off which rooms/amenities they paid for (which ERP "modules" they may use),
- collects the rent every month (billing through Stripe),
- keeps a logbook of everything that happens (events, incidents, failures),
- and lets a building superintendent step into a flat for a short, recorded visit when the family needs help (impersonation).
The families never walk into the management office to run their own home, and the office never lives inside anyone's flat. It just keeps the whole building organised.
The business problem 4E solves. A SaaS ERP has to create, route, meter, bill and govern many isolated customer tenants from one operator console — without ever leaking one customer's data or configuration into another, and without each customer's ERP needing to know how it was created or how it gets paid for. 4E is the one place that does all of this. It feeds the customer's actual ERP runtime only two things: "yes, this tenant is allowed to be online right now" (routability) and "here are the modules they are entitled to use" (entitlements).
A company would use this to: sign a customer up (self-serve public signup → Stripe Checkout → automated provisioning, or operator-created), give that customer a working tenant ERP in the right region, turn modules on and off as their subscription changes, suspend / restore / archive / move / clone / delete tenants, invite the first admin user, watch the health of the whole fleet, and let support staff issue a short-lived, read-only "look but don't touch" token into a customer's system.
- Tenant = one customer's running ERP instance (one "flat"). The unit of isolation.
- Organization = the legal customer entity that owns one or more tenants (the "family name on the lease").
- Cell = a physical placement target / wing of the building where tenants run (a region + a database target).
- Module entitlement = a record saying "this tenant may run this ERP module" (which amenities are paid for).
- Provisioning = the automated process of standing up a brand-new tenant runtime.
- Control plane = the management layer that governs tenants but does not run the tenants' business logic. (The thing that runs the business logic is the runtime, a separate product.)
- RLS (Row-Level Security) = a database safety net where the database itself hides rows that don't belong to your tenant. 4E deliberately has none — see the gaps section.
What it is responsible for
4E is the operator-global control plane. "Operator-global" means it sees every tenant at once (it is the building manager, not a resident), so it has no per-tenant session and no Row-Level Security. Isolation is enforced by an explicit TenantId foreign key on every row plus authorization policies — never by a database query filter.
Owns (it is the authoritative source of truth)
| Area | Tables |
|---|---|
| Organization registry | organizations |
| Cell registry + runtime routing | cells |
| Tenant registry + host routing | tenants, tenant_domains, tenant_api_keys |
| Module entitlements (runtime SSOT) | module_entitlements |
| Subscription catalog + pricing | subscription_plans, subscription_plan_modules, pricing_tiers |
| Provisioning saga | provisioning_jobs, provisioning_job_steps |
| External-binding evidence | keycloak_provisioning_records, migration_runs |
| Operation audit | tenant_operations |
| Control-plane spine | control_plane_events, control_plane_health_snapshots, control_plane_dead_letters |
| Self-serve billing | billing_prospects, billing_subscriptions, billing_invoices, billing_dunning_cases, billing_webhook_deliveries, billing_checkout_sessions |
| Support impersonation | impersonation_grants |
| Owner identity + executive portal | owner_profiles, company_groups, company_group_members, owner_company_launch_tokens |
| Tenant-user invitations | tenant_user_invitations |
Delegates (it does NOT own these)
- The ERP runtime itself (
ERP.Api+ its modules) — 4E only resolves routing + entitlements for it. - Auth / record-rule evaluation / company-scope — Auth stays the runtime record-rule authority. 4E integrates no record-rule evaluator; it provides routability + entitlement evidence that Auth consumes.
- Credential storage — Keycloak is the credential authority.
owner_profilesis an identity projection only, with no secrets. - Actual module DB migrations — executed by a reviewed-bundle runner against the runtime DBs; 4E only records evidence and pins catalog heads.
- Payment processing — Stripe charges the card; 4E persists and mirrors, it never charges directly.
- Runtime owner company-access reads — delegated to the runtime via
IRuntimeOwnerAccessProbe(4E has no tenant RLS session, so it cannot read the runtime'sUserCompanyAccessdirectly).
Code structure
The module follows the mandated ERP.Next layering. Reading bottom-up: Utility is a dependency-free leaf, Entity is EF Core only, Common holds all the CQRS application logic, Api is thin HTTP boundaries, Workers runs background processors + external clients, Host is the deployable composition root, and Tests covers them all.
4EOrganizationsManager/
├── FourE.OrganizationsManager.Utility/ # leaf: constants, errors, options, money, redactor (no deps)
│ ├── OrganizationsManagerConstants.cs # Schema="four_e", max-lengths, ProvisioningSagaSteps, event/op-type names
│ ├── OrganizationsManagerErrors.cs # canonical Error codes
│ ├── OrganizationsManagerPolicies.cs # operator/executive/runtime authorization policy names
│ ├── OrganizationsManagerRateLimitPolicies.cs # PublicSignup / StripeWebhook / PublicCatalog / IdentityInvite limits
│ ├── MinorUnitMoney.cs # the ONLY major↔minor currency conversion site
│ └── ProvisioningSecretRedactor.cs # redacts secrets out of failure messages
│
├── FourE.OrganizationsManager.Entity/ # EF Core: POCO models, configs, DbContext, migrations, seeds
│ ├── Models/ # 29 tables' POCOs + status enums
│ ├── Configurations/ # 30 IEntityTypeConfiguration classes (mapping/index/check-constraint)
│ ├── Context/OrganizationsManagerDbContext.cs # 30 DbSets; NO query filters / NO RLS (by design)
│ ├── Migrations/ # 25 reviewed migrations + model snapshot (never auto-applied)
│ └── Seeds/SubscriptionPlanSeeder.cs # seeds System plans + backfills entitlement PlanId
│
├── FourE.OrganizationsManager.Common/ # application logic: CQRS feature folders + service facades
│ ├── Services/OrganizationsManagerService.cs # the big typed facade dispatching to handlers
│ ├── Organizations/ Cells/ Tenants/ ModuleEntitlements/ Plans/ PricingTiers/ # operator CQRS
│ ├── ProvisioningJobs/Saga/Steps/ # 9 step executors + ModuleMigrationCatalog + ControlPlaneMessages
│ ├── Billing/ Checkout/ PublicSignup/ PublicKyc/ PublicCatalog/ # self-serve revenue path + Stripe handlers
│ ├── TenantUsers/ # invite / provision-primary-admin / grant-verified-admin-role
│ ├── Impersonation/ # issue / revoke / validate / search + token signer
│ ├── DeadLetters/ Monitoring/ TenantOperations/ # audit, monitoring, DLQ reads + writes
│ ├── Owners/ Portal/ # cross-tenant owner identity + executive portal + launch tokens
│ ├── ApiKeys/ MigrationRuns/ Keycloak/ # API-key issue/revoke; migration-run record; Keycloak ports
│ └── Behaviors/ Shared/ # validation pipeline behavior + shared dispatcher helpers
│
├── FourE.OrganizationsManager.Api/Controllers/ # 18 thin HTTP boundary controllers
│
├── FourE.OrganizationsManager.Workers/ # background processors + external clients
│ ├── Provisioning/ # ProvisioningSaga*, DeadLetterIntake, PendingAdminRoleGrantSweep,
│ │ # RabbitMq publisher/replayer/topology, RedisTenantRuntimeResolutionCache
│ ├── Keycloak/ # KeycloakAdminClient / KeycloakUserAdminClient + token providers
│ ├── Stripe/ # HttpStripeBillingClient + StripeWebhookEventDispatchWorker
│ └── RuntimeAccess/HttpRuntimeOwnerAccessProbe.cs # config-gated 4E→runtime owner-access probe
│
├── FourE.OrganizationsManager.Host/ # composition root: the deployable API + worker process
│ ├── Program.cs # builds host; wires auth/rate-limit/workers/Stripe/OTel; --seed-billing-catalog CLI
│ └── FourEHostSecurityExtensions.cs # multi-audience auth + the operator policy ladder
│
└── FourE.OrganizationsManager.Tests/ # Api / Application / Architecture / Money / Persistence / Security / Workers
Every write goes Controller → IOrganizationsManagerService facade (or a feature service) → Command + Handler + FluentValidation Validator. Every read is a Query + Handler + Validator. Controllers never contain business logic — they are HTTP plumbing only.
How it works
The headline runtime flow is standing up a brand-new paid tenant: a stranger on the public website turns into a fully provisioned, online ERP instance, with nobody from the operator team touching a keyboard. It chains the self-serve revenue path (signup → Stripe Checkout → webhook) into the provisioning saga.
Walkthrough for a beginner.
- A visitor fills in the public signup form (
POST /api/public/signup, anonymous + rate-limited). 4E creates a checkout session row and asks Stripe to make a hosted payment page. - The visitor pays on Stripe's page. 4E never sees the card.
- Stripe calls back over HTTPS to
POST /api/public/webhooks/stripe. 4E first verifies the HMAC signature (proves it's really Stripe), then drops the raw event into thebilling_webhook_deliveriesinbox with statusPending. The HTTP request returns immediately — no business work happens on the web thread. - A background worker,
StripeWebhookEventDispatchWorker, polls the database forPending/Failedrows and hands each to the dispatcher. (It is a DB poller, not a broker consumer — important for understanding retries.) - For
checkout.session.completed, the dispatcher routes toProvisionPaidTenantHandler, which is idempotent on the Stripe subscription id: it creates the organization + tenant + entitlement graph and enqueues a provisioning job. Running it twice cannot create two tenants. - The provisioning saga (
ProvisioningSagaRunner, driven byProvisioningSagaBackgroundService) leases the nextPendingjob using the lease-poll index, then runs the 9 ordered steps below. The lease is re-checked before and after every step; a stolen/expired lease fails loud with a Conflict.
- Each step is an
IProvisioningStepExecutorrecorded inprovisioning_job_steps. Succeeded steps are skipped on resume — so a crashed-and-restarted job picks up where it left off instead of redoing Keycloak work. A step failure marks the stepFailedand aborts the job. - The
rabbit-event-publishstep emitstenant-runtime-provisioned.v1on RabbitMQ; the runtime's entitlement feed consumes it. The finaltenant-activationstep flips the tenant toActive(only if it is stillProvisioning/Restoring). From then on, the runtime can resolve and route the tenant.
Two reasons. The inbox (step 3) makes Stripe webhooks idempotent and replayable — Stripe retries aggressively, and the unique index on (Provider, ProviderEventId) means a duplicate is recorded as Duplicate instead of double-charging anyone. The saga (step 6) makes a multi-system operation (Keycloak + migrations + Redis + RabbitMQ + DB) crash-safe and resumable, because each step's success is durably recorded before the next begins.
Database design
The control-plane database is four_e_organizations, schema four_e, 29 tables, and — uniquely among ASAB modules — zero Row-Level Security. Common conventions: PK Id uuid; a uint RowVersion mapped to PostgreSQL xmin for optimistic concurrency on almost every mutable table; CreatedAtUtc / UpdatedAtUtc as timestamptz; enums persisted as strings via HasConversion<string> (a small number as int).
Registry & routing
organizations — the customer / legal entity.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| Name | varchar(200) | not null | Legal/display name |
| Slug | varchar(80) | not null, unique | URL-safe handle |
| CountryCode | varchar(8) | not null | ISO country code |
| CountryName | varchar(120) | not null | Display country |
| Status | varchar(32) | not null, default Active | Active / Suspended / Archived |
| CreatedAtUtc | timestamptz | not null | Created |
| UpdatedAtUtc | timestamptz | not null | Last changed |
| RowVersion | xmin | concurrency | Optimistic lock |
cells — an infra placement target ("wing").
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| Code | varchar(80) | not null, unique | Stable cell code |
| Name | varchar(160) | not null | Display name |
| RegionCode | varchar(32) | not null | Geographic region |
| RuntimeDatabaseTarget | varchar(500) | not null | Routing metadata (no secrets) |
| Status | varchar(32) | not null, default Active | Active / Draining / Offline |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
tenants — the ERP instance unit ("flat").
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK (also alt key (Id, TenantId)) | Identity |
| OrganizationId | uuid | FK→organizations, not null, RESTRICT | Owning org |
| CellId | uuid | FK→cells, not null, RESTRICT | Placement |
| Name | varchar(200) | not null | Display name |
| Slug | varchar(80) | not null, globally unique | Routing handle |
| VerticalType | varchar(32) | nullable, col vertical_type | factory / insurance-broker (NOT NULL deferred) |
| Status | varchar(32) | not null, default Provisioning | Provisioning / Active / Suspended / Restoring / Failed / Archived |
| ObjectStoragePrefix | varchar(160) | not null | Storage namespace |
| PrimaryContactEmail | varchar(254) | nullable | Contact |
| PrimaryContactName | varchar(200) | nullable | Contact |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
tenant_domains — host → tenant resolution.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, not null, CASCADE | Owning tenant |
| HostName | varchar(255) | not null, globally unique | Hostname routed to this tenant |
| IsVerified | bool | not null | Ownership verified |
| CreatedAtUtc | timestamptz | not null | Created |
| RowVersion | xmin | concurrency | Optimistic lock |
tenant_api_keys — runtime API credentials (hash + prefix only).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, not null, CASCADE | Owning tenant |
| Name | varchar(160) | not null | Key label |
| KeyHash | varchar(128) | not null, unique | Hash of key (plaintext never stored) |
| KeyPrefix | varchar(32) | not null, partial-unique | Visible prefix for lookup |
| RequestedBy | varchar(200) | not null | Issuer |
| IdempotencyKey | varchar(160) | not null, UX(TenantId, IdempotencyKey) | Idempotent issue |
| ExpiresAtUtc / RevokedAtUtc / LastUsedAtUtc | timestamptz | nullable | Lifecycle stamps |
| CreatedAtUtc | timestamptz | not null | Created |
| RowVersion | xmin | concurrency | Optimistic lock |
Entitlement & catalog
module_entitlements — which modules a tenant may run (the runtime's source of truth).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, not null, CASCADE | Owning tenant |
| ModuleCode | varchar(80) | not null, UX(TenantId, ModuleCode) | Module key |
| PlanCode | varchar(80) | not null | Legacy plan code |
| PlanId | uuid | FK→subscription_plans, nullable, RESTRICT | Plan link (backfilled; NOT NULL deferred) |
| Status | varchar(32) | not null, default Enabled | Requested / Migrating / Enabled / Failed / Suspended / Disabled |
| EffectiveFromUtc | timestamptz | not null | Window start |
| EffectiveToUtc | timestamptz | nullable | Window end (termination = Disabled + EffectiveToUtc) |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
subscription_plans — the catalog plan.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| Code | varchar(80) | not null, unique | Plan code |
| Name | varchar(160) | not null | Display name |
| Description | varchar(500) | nullable | Marketing copy |
| VerticalType | varchar(32) | nullable | Vertical, or null = agnostic |
| Status | varchar(32) | not null | Draft / Active / Retired |
| PlanKind | varchar(32) | not null, default Public | Public / System (System = seeder-owned, never public) |
| TrialDays | int | not null, CHECK 0..365 | Free-trial length |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
subscription_plan_modules — plan ↔ module mapping.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| SubscriptionPlanId | uuid | FK→subscription_plans, not null, CASCADE | Plan |
| ModuleCode | varchar(80) | not null, UX(PlanId, ModuleCode) | Module key |
| IsIncluded | bool | not null, default true | In the plan? |
| DisplayOrder | int | not null, IX(PlanId, DisplayOrder) | Sort |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
pricing_tiers — a priced tier of a plan (money in minor units, e.g. cents).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| SubscriptionPlanId | uuid | FK→subscription_plans, not null, CASCADE | Plan |
| Code | varchar(80) | not null, UX(PlanId, Code) | Tier code |
| Name | varchar(160) | not null | Display name |
| CurrencyCode | varchar(3) | not null | ISO currency |
| BillingInterval | varchar(32) | not null | Monthly / Yearly |
| UnitAmountMinor | bigint | not null, CHECK ≥0 | Base price in minor units |
| StripePriceId | varchar(255) | nullable, partial-unique | Stripe price for webhook mapping |
| PerSeatAmountMinor | bigint | nullable, CHECK null or ≥0 | Per-seat add-on |
| IncludedSeats / MinimumSeats | int | not null, CHECK bounds | Seat policy |
| MaximumSeats | int | nullable, CHECK null or ≥ Minimum | Seat cap |
| IsActive | bool | not null, default true | Sellable? |
| DisplayOrder | int | not null | Sort |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
Index of note:
UX(PlanId, BillingInterval, CurrencyCode) WHERE IsActive— exactly one active tier per plan/interval/currency.
Provisioning
provisioning_jobs — the leased, idempotent saga job.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK (also alt key (Id, TenantId)) | Identity |
| TenantId | uuid | FK→tenants, not null, CASCADE | Target tenant |
| OperationType | varchar(80) | not null | e.g. create-tenant-runtime / move / enable-module-runtime |
| RequestedBy | varchar(200) | not null | Requestor |
| Reason | varchar(500) | not null | Audit reason |
| IdempotencyKey | varchar(160) | not null, UX(TenantId, IdempotencyKey) | Dedupe key |
| Status | varchar(32) | not null, default Pending | Pending / Leased / Succeeded / Failed / Cancelled |
| AttemptCount | int | not null | Retry counter |
| LeaseId | varchar(80) | nullable | Active lease token |
| LeasedBy | varchar(160) | nullable | Worker id |
| LeaseExpiresAtUtc | timestamptz | nullable | Lease deadline |
| CurrentStepCode / FailureCode | varchar(80) | nullable | Progress / failure tag |
| FailureMessage | varchar(500) | nullable | Redacted failure text |
| CompletedAtUtc | timestamptz | nullable | Finished |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
Lease-poll index:
IX(Status, LeaseExpiresAtUtc, CreatedAtUtc).
provisioning_job_steps — the resumable per-step ledger.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| ProvisioningJobId + TenantId | uuid | composite FK→jobs (Id, TenantId), CASCADE | Owning job |
| Sequence | int | not null, UX(JobId, Sequence) | Order |
| StepCode | varchar(80) | not null, UX(JobId, StepCode) | One of the 9 step codes |
| Status | varchar(32) | not null, default Pending | Pending / Succeeded / Failed |
| FailureCode / FailureMessage | varchar(80) / varchar(500) | nullable | Failure detail |
| StartedAtUtc | timestamptz | not null | Step start |
| CompletedAtUtc | timestamptz | nullable | Step end |
| UpdatedAtUtc | timestamptz | not null | Last change |
| RowVersion | xmin | concurrency | Optimistic lock |
keycloak_provisioning_records — external-resource bindings (IDs / fingerprints only, never secrets).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, not null, RESTRICT | Tenant |
| ResourceType | varchar(80) | not null, UX(TenantId, ResourceType, ResourceKey) | org / client / role / user |
| ResourceKey | varchar(200) | not null | Deterministic key |
| ExternalId | varchar(200) | not null | Keycloak id |
| Status | varchar(32) | not null | Succeeded / Replayed / PendingVerification / Provisioned / Skipped |
| Fingerprint | varchar(128) | not null | Drift detection (fails loud, no silent rebind) |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
migration_runs — reviewed module-migration evidence (no RowVersion; append-only).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, not null, CASCADE | Tenant |
| ModuleCode | varchar(80) | not null | Module |
| MigrationAssembly | varchar(160) | not null | Bundle assembly |
| MigrationId | varchar(160) | not null, UX(TenantId, ModuleCode, MigrationAssembly, MigrationId) | Migration id |
| ScriptHash | varchar(128) | not null | Reviewed-bundle hash (drift fails loud) |
| TargetDatabase / TargetSchema | varchar(200) | not null | Where it ran |
| Outcome | varchar(40) | not null | Result |
| StartedAtUtc | timestamptz | not null | Start |
| CompletedAtUtc | timestamptz | nullable | End |
| RequestedBy | varchar(200) | not null | Requestor |
| JobId | uuid | nullable | Linked provisioning job |
| FailureReason | varchar(200) | nullable | Failure text |
| AppliedAtUtc | timestamptz | not null | Applied |
tenant_operations — lifecycle/op audit ledger (Status stored as int).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | not null (row-scope key, no nav FK) | Subject tenant |
| CellId | uuid | FK→cells, nullable, SetNull | Cell context |
| TargetTenantId | uuid | nullable | For move / clone |
| OperationType | varchar(80) | not null | Operation name |
| ModuleCode | varchar(80) | nullable | Module context |
| IdempotencyKey | varchar(160) | nullable, partial-UX WHERE not null | Dedupe |
| ProvisioningJobId | uuid | composite FK→jobs, nullable, SetNull | Linked job |
| LeaseId | varchar(80) | nullable | Lease |
| Attempt | int | nullable | Attempt |
| MigrationId | varchar(160) | nullable | Migration ref |
| FromStatus / ToStatus | varchar(32) | nullable | State transition |
| Status | int | not null | Pending=0 / Succeeded=1 / Failed=2 |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
Identity / invitation
tenant_user_invitations — control-plane invite intent + idempotency anchor.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, not null, RESTRICT | Target tenant |
| varchar(254) | not null (normalized lowercase) | Invitee | |
| FirstName / LastName | varchar(100) | nullable | Name |
| Status | varchar(32) | not null | Pending / Accepted / Revoked / Expired |
| KeycloakUserExternalId | varchar(200) | nullable | Keycloak user id once created |
| RequestedByOperator | varchar(200) | not null | Operator |
| IdempotencyKey | varchar(128) | not null, unique | Dedupe |
| CreatedAtUtc / UpdatedAtUtc / ExpiresAtUtc | timestamptz | not null | TTL default 7 days |
| RowVersion | xmin | concurrency | Optimistic lock |
Plus a partial-unique
(tenant_id, lower(email)) WHERE status='Pending'(raw SQL) — one live invite per address per tenant.
Monitoring / audit / DLQ
control_plane_events — the audit-spine event ledger.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, nullable, RESTRICT | Tenant scope |
| CellId | uuid | FK→cells, nullable, RESTRICT | Cell scope |
| ModuleCode | varchar(80) | not null | Module |
| EventType | varchar(120) | not null | Event name |
| Severity | varchar(32) | not null | Information / Warning |
| Summary | varchar(500) | not null | Human summary |
| CorrelationId | varchar(160) | not null | Trace id |
| OccurredAtUtc | timestamptz | not null | When it happened |
| MetadataJson | varchar(4000) | nullable | Structured detail |
| CreatedAtUtc | timestamptz | not null | Recorded |
| RowVersion | xmin | concurrency | Optimistic lock |
CHECK
CK_control_plane_event_scope: TenantId or CellId must be set — EXCEPT whenModuleCode='executive-portal-identity'(owner events are legitimately cross-tenant, both null).
control_plane_health_snapshots — fleet health projection.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId / CellId | uuid | FK nullable, RESTRICT, CHECK one set | Scope |
| ComponentCode | varchar(80) | not null | Component |
| ComponentInstance | varchar(160) | not null | Instance id |
| Status | varchar(32) | not null | Health status |
| RegionCode | varchar(32) | not null | Region |
| LatencyMilliseconds | int | nullable, CHECK null or ≥0 | Latency |
| Message | varchar(500) | not null | Detail |
| ObservedAtUtc / CreatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
control_plane_dead_letters — RabbitMQ DLQ intake (Status stored as int).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| MessageId | uuid | not null, unique | Idempotent intake key |
| TenantId / CellId | uuid | FK, not null, RESTRICT | Scope |
| OperationId / CorrelationId | uuid | not null | Trace ids |
| IdempotencyKey | varchar(160) | not null | Original dedupe key |
| ModuleCode | varchar(80) | not null | Module |
| EventName | varchar(120) | not null | Failed event |
| RoutingKey | varchar(160) | not null | Routing key |
| FailureReason | varchar(500) | not null | Why it dead-lettered |
| PayloadJson | varchar(8000) | not null | Original payload |
| Status | int | not null | Pending=0 / Retried=1 / Resolved=2 |
| ReplayCount | int | not null, CHECK ≥0 | Replays |
| LastReplayedAtUtc / ResolvedAtUtc | timestamptz | nullable | Stamps |
| ResolvedBy | varchar(200) | nullable | Operator |
| ResolutionNote | varchar(500) | nullable | Note |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
Billing (Stripe-aligned, minor-unit money)
billing_prospects — billing-customer projection per tenant.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, RESTRICT | Tenant |
| BillingEmail | varchar(254) | not null | Billing contact |
| DisplayName | varchar(200) | not null | Customer name |
| CurrencyCode | varchar(3) | not null | Currency |
| ProviderCustomerId | varchar(200) | nullable, partial-UX | Stripe customer id |
| Status | varchar(32) | not null | Active / Archived |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
billing_subscriptions — Stripe subscription mirror.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, RESTRICT | Tenant |
| ProspectId | uuid | FK→prospects, nullable, RESTRICT | Billing customer |
| PlanCode | varchar(80) | not null | Plan |
| BillingInterval | varchar(32) | not null | Monthly / Yearly |
| Quantity | int | not null, CHECK >0 | Seats |
| CurrencyCode | varchar(3) | not null | Currency |
| Status | varchar(32) | not null | PendingProviderActivation / Active / PastDue / Canceled |
| Provider | varchar(32) | not null | Payment provider |
| ProviderSubscriptionId | varchar(200) | nullable, partial-UX | Stripe sub id |
| IdempotencyKey | varchar(160) | not null, unique | Dedupe |
| StartedAtUtc | timestamptz | not null | Start |
| CurrentPeriodEndsAtUtc / TrialEndsAtUtc | timestamptz | nullable | Period ends |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
billing_invoices — invoice (minor units).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, RESTRICT | Tenant |
| SubscriptionId | uuid | FK→subscriptions, RESTRICT | Subscription |
| InvoiceNumber | varchar(80) | not null, unique | Number |
| Status | varchar(32) | not null | Draft / Open / Paid / Void / Uncollectible |
| CurrencyCode | varchar(3) | not null | Currency |
| SubtotalMinor / TaxMinor / TotalMinor | bigint | not null, CHECK ≥0 | Amounts (minor units) |
| IssuedAtUtc | timestamptz | not null | Issued |
| DueAtUtc / PaidAtUtc | timestamptz | nullable | Due / paid |
| Provider | varchar(32) | not null | Provider |
| ProviderInvoiceId | varchar(200) | nullable, partial-UX | Stripe invoice id |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
billing_dunning_cases — failed-payment retry case.
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, RESTRICT | Tenant |
| SubscriptionId | uuid | FK→subscriptions, RESTRICT | Subscription |
| InvoiceId | uuid | FK→invoices, nullable, RESTRICT | Invoice |
| Status | varchar(32) | not null | Open / Paused / Resolved |
| AttemptCount | int | not null, CHECK ≥0 | Retries |
| OpenedAtUtc | timestamptz | not null | Opened |
| NextAttemptAtUtc / ResolvedAtUtc | timestamptz | nullable | Schedule |
| LastFailureCode | varchar(120) | not null | Code |
| LastFailureMessage | varchar(500) | not null | Message |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
billing_webhook_deliveries — the Stripe webhook inbox (no TenantId — keyed by provider event).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| Provider | varchar(32) | not null | Provider |
| ProviderEventId | varchar(200) | not null, UX(Provider, ProviderEventId) | Provider event id |
| EventType | varchar(120) | not null | e.g. invoice.paid |
| SignatureHeaderHash | varchar(128) | not null | Verified signature hash |
| PayloadHash | varchar(128) | not null | Body hash |
| PayloadJson | text | nullable | Verified raw body |
| ProcessingStatus | varchar(32) | not null | Accepted / Duplicate / Rejected + Pending / Processed / Skipped / Failed / DeadLettered |
| FailureCode / FailureMessage | varchar(120) / varchar(500) | nullable | Failure |
| DispatchAttemptCount | int | default 0 | Async dispatch attempts |
| ReceivedAtUtc / ProcessedAtUtc / CreatedAtUtc | timestamptz | mixed | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
billing_checkout_sessions — pre-payment self-serve intent (no tenant yet).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| SignupRequestId | varchar(160) | not null, unique | Client idempotency UUID |
| PlanCode | varchar(80) | not null | Chosen plan |
| PricingTierId | uuid | FK→pricing_tiers, RESTRICT | Chosen tier |
| BillingInterval | varchar(32) | not null | Monthly / Yearly |
| Seats | int | not null, CHECK >0 | Seats |
| FounderEmail | varchar(254) | not null | Signup email |
| VerticalType | varchar(32) | not null | Vertical |
| CompanyName | varchar(200) | not null | Company |
| TenantSlug | varchar(80) | not null | Desired slug |
| CurrencyCode | varchar(3) | not null | Currency |
| Status | varchar(32) | not null | CheckoutPending / Completed |
| StripeCheckoutSessionId | varchar(200) | nullable, partial-UX | Stripe checkout id |
| CheckoutUrl | varchar(~1020) | nullable | Hosted payment URL |
| StripeSubscriptionId | varchar(200) | nullable, partial-UX | Becomes ProvisionPaidTenant idempotency key |
| TenantId | uuid | nullable | Set once provisioned |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
Impersonation / owner identity
impersonation_grants — signed, read-only support grant (Status stored as int).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TenantId | uuid | FK→tenants, RESTRICT | Target tenant |
| OperatorSubject | varchar(256) | not null | Operator subject |
| OperatorDisplayName | varchar(256) | not null | Operator name |
| Reason | varchar(1024) | not null | Audit reason |
| TokenId | uuid | not null, unique | Signed-token jti |
| Status | int | not null | Issued=1 / Revoked=2 / Expired=3 |
| IssuedAtUtc / ExpiresAtUtc | timestamptz | not null, CHECK Expires > Issued | Validity window |
| RevokedAtUtc | timestamptz | nullable | Revoked |
| CreatedAtUtc | timestamptz | not null | Created |
| RowVersion | xmin | concurrency | Optimistic lock |
owner_profiles — cross-tenant human owner (identity projection only, no secrets).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| KeycloakSubjectId | varchar(64) | col keycloak_subject_id, unique | Per-request hot lookup |
| varchar(254) | lowercase, lower(email) unique (raw SQL) | Owner email | |
| DisplayName | varchar(200) | not null | Name |
| Status | varchar(32) | not null | Active / Suspended / Archived |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
company_groups — an owner's portfolio (V1 invariant: one group per owner).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| OwnerProfileId | uuid | FK→owner_profiles, RESTRICT, unique | One group per owner |
| Name | varchar(200) | not null | Portfolio name |
| GroupVersion | int | not null | Monotonic cache-invalidation token |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
company_group_members — the explicit, auditable owner→tenant link (the ONLY ownership marker).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| CompanyGroupId | uuid | FK→company_groups, RESTRICT | Owner's group |
| TenantId | uuid | FK→tenants, RESTRICT | Linked tenant |
| LinkSource | varchar(32) | not null | OperatorLinked / SignupClaimed |
| LinkStatus | varchar(32) | not null | PendingVerification / Active / Revoked |
| LinkedByActor | varchar(200) | not null | Who linked |
| VerifiedAtUtc | timestamptz | nullable | Verified |
| IdempotencyKey | varchar(128) | not null | Dedupe |
| CreatedAtUtc / UpdatedAtUtc | timestamptz | not null | Timestamps |
| RowVersion | xmin | concurrency | Optimistic lock |
Plus partial-unique
(tenant_id) WHERE link_status='Active'— one active owner per tenant.
owner_company_launch_tokens — single-use portal → tenant SSO handoff (no RowVersion; single-use).
| Column | Type | Key/Constraints | Meaning |
|---|---|---|---|
| Id | uuid | PK | Identity |
| TokenHash | varchar(64) | col token_hash, SHA-256 hex, unique | Hash of launch token (raw never stored) |
| OwnerProfileId | uuid | FK→owner_profiles, RESTRICT | Owner |
| TenantId | uuid | FK→tenants, RESTRICT | Target tenant |
| KeycloakSubject | varchar(64) | col keycloak_subject | Subject |
| ExpiresAtUtc | timestamptz | not null, IX | TTL 120s |
| RedeemedAtUtc | timestamptz | nullable | Single-use marker |
| CreatedAtUtc | timestamptz | not null | Created |
Endpoints are guarded by escalating operator policies defined in OrganizationsManagerPolicies.cs + FourEHostSecurityExtensions.cs:
OperatorRead < OperatorSupport < OperatorManage < OperatorAdmin < OperatorOwner (scheme FourEOperator); the executive portal uses ExecutivePortalOwner / FourEPortalExecutive (scheme FourEExecutive); and the tenant runtime authenticates with FourERuntimeService.
Features
Organization lifecycle
Plain: create a customer, edit it, and suspend / restore / archive it.
- CQRS:
CreateOrganizationHandler,GetOrganizationHandler,SearchOrganizationsHandler,UpdateOrganizationHandler,OrganizationLifecycleHandler,SearchOrganizationTenantsHandler. - API:
POST/GET /api/4e/organizations,GET/PUT /api/4e/organizations/{id},POST /api/4e/organizations/{id}/lifecycle(Admin),GET /api/4e/organizations/{id}/tenants. - Files:
Common/Organizations/**,Api/Controllers/OrganizationsController.cs. Lifecycle writes acontrol_plane_event.
Cell management
Plain: register the "wings" where tenants live and toggle whether they accept traffic.
- CQRS:
CreateCellHandler,GetCellHandler,SearchCellsHandler,UpdateCellHandler,CellStatusHandler. - API:
POST /api/4e/cells(Admin),GET /api/4e/cells,GET /api/4e/cells/{id},PUT /api/4e/cells/{id}(Admin),POST /api/4e/cells/{id}/status(Admin). - Files:
Common/Cells/**,Api/Controllers/CellsController.cs. A status change emitscell.status_changed.
Tenant lifecycle & operations
Plain: the big one — create a tenant; search/get it; move it through its lifecycle; move it to a different cell; clone it; delete it; turn its modules on/off; record migration evidence; issue/revoke API keys.
- CQRS:
CreateTenantHandler,SearchTenantsHandler,GetTenantHandler,TenantLifecycleHandler,MoveTenantHandler,CloneTenantHandler,DeleteTenantHandler,EnumerateTenantsHandler(Common/Tenants/**); module handlersModuleEntitlementLifecycle*+UpdateModuleEntitlementHandler(Common/ModuleEntitlements/**);RecordMigrationRunHandler;IssueTenantApiKeyHandler/RevokeTenantApiKeyHandler/GetTenantApiKeysHandler;SearchTenantOperationsHandler. - API (all on
TenantsController.cs):POST/GET /api/4e/tenants,GET /api/4e/tenants/{id},GET .../operations,POST .../impersonation-sessions(Support),PUT .../modules/{moduleCode}(enable),POST .../modules/{moduleCode}/suspend|disable(Manage),.../revoke(Admin),POST .../migration-runs,POST/GET .../api-keys,DELETE .../api-keys/{id}(Admin),POST .../move|clone(Admin),DELETE /api/4e/tenants/{id}(Admin),POST .../lifecycle(Admin),GET .../modules. - Flow: module-lifecycle writes publish RabbitMQ events (
module.enabled/disabled/suspended/revoked.v1) and invalidate the runtime resolution cache. Enabling a module on an Active tenant auto-enqueues anenable-module-runtimeprovisioning job. Every lifecycle op appends atenant_operationsrow.
Tenant runtime resolution (consumed by the ERP runtime)
Plain: the runtime asks 4E "is this tenant allowed online, and which modules?" — this is the read the whole platform leans on.
- A tenant is routable only when tenant + org + cell are all Active (
TenantRuntimeComposition); enabled modules come only fromEnabledentitlements. - Services:
ResolveTenantRuntime*/EnumerateCellTenantRuntimesAsyncon the facade; cache viaRedisTenantRuntimeResolutionCache(cache-aside, invalidated after committed entitlement/lifecycle writes). - API:
GET /api/4e/tenants/runtime,GET /api/4e/cells/{cellId}/tenant-runtimes— policyFourERuntimeService(TenantRuntimeController.cs).
Subscription catalog & pricing
Plain: the price list. Operators build plans, modules, and priced tiers; the public website reads a safe projection of it.
- CQRS:
Common/Plans/**(Create/Get/Search/Update/Retire/Modules),Common/PricingTiers/**(Create/Get/Search/Update),Common/PublicCatalog/Search(SearchPublicPlansHandler). - API:
POST/GET /api/4e/catalog/plans(Admin/Read),GET/PUT .../{id},POST .../{id}/retire|modules|pricing-tiers,GET/PUT .../{id}/pricing-tiers[/{id}](PlansController.cs); publicGET /api/public/plans(anonymous +PublicCatalograte limit,PublicCatalogController.cs). - Seeding:
SubscriptionPlanSeederseeds System plans (legacy-core,system-dependency) and backfillsmodule_entitlements.PlanId; run via Host--seed-billing-catalog(dev/ops only, fail-loud on unmapped plan codes).
Self-serve signup → Checkout → paid provisioning
Plain: the first-revenue path — an anonymous visitor becomes a paying, provisioned tenant with no operator in the loop. (Detailed sequence is in How it works.)
- CQRS/services:
PublicSignupCommand+LivePublicSignupService/IPublicSignupService;CreateCheckoutSessionHandler;ProvisionPaidTenantHandler(idempotent on Stripe subscription id);PublicKycApplicationCommand+ KYC service. - API (anonymous + rate-limited):
POST /api/public/signup,GET /api/public/signup/status(PublicSignupController.cs);POST /api/public/kyc/applications(PublicKycController.cs).
Stripe webhook ingress + dispatch
Plain: verify the signature, store the event, then process it asynchronously so the lifecycle keeps in sync (paid → record invoice, failed → suspend, cancelled → revoke entitlements).
- Services:
StripeWebhookSignatureVerifier/IStripeWebhookSignatureVerifier;LiveStripeWebhookIngressService;StripeWebhookEventDispatcher+StripeWebhookDispatchRouting/StripeWebhookEventTypes; handlersRecordInvoiceFromStripeHandler,SyncSubscriptionFromStripeHandler,CancelSubscriptionFromStripeHandler(Common/Checkout/Subscriptions/**). - API:
POST /api/public/webhooks/stripe(anonymous +StripeWebhookrate limit,StripeWebhookController.cs); legacy operatorPOST /api/4e/billing/webhooks/{provider}.
Billing reads + operator subscription create
Plain: operators can create a subscription directly and read a tenant's billing snapshot.
- CQRS:
CreateBillingSubscriptionHandler,GetTenantBillingSnapshotHandler,RecordBillingWebhookHandler(Common/Billing/**). - API:
POST /api/4e/billing/subscriptions(Manage),GET /api/4e/billing/tenants/{tenantId}(Read) (BillingController.cs).
Tenant-user invitations (Keycloak)
Plain: invite the first (and later) users into a tenant, then resend / revoke, and grant the verified-admin role once they confirm their email.
- CQRS/services:
ITenantUserProvisioningService+Common/TenantUsers/**(InviteTenantUserHandler,ResendInvitationHandler,RevokeInvitationHandler,SearchInvitationsHandler,GetInvitationHandler,ProvisionPrimaryAdminHandler,GrantVerifiedAdminRoleHandler,SkipPrimaryAdminHandler). - API:
POST .../users/invitations(Manage +IdentityInviterate limit),GET .../invitations[/{id}](Read),POST .../invitations/{id}/revoke(Manage),.../resend(Manage + rate limit) (TenantUsersController.cs).
Impersonation (signed, read-only)
Plain: a support person gets a time-boxed, read-only token to look inside a tenant. The token is HMAC-signed and the runtime validates it server-side; every attempt is logged.
- CQRS/services:
IImpersonationTokenSigner/ImpersonationTokenSigner;Common/Impersonation/**(IssueImpersonationTokenHandler,RevokeImpersonationGrantHandler,SearchImpersonationGrantsHandler,ValidateImpersonationTokenHandler,CreateImpersonationGrantHandler). - API:
POST /api/4e/impersonation(Support, returns token once),GET /api/4e/impersonation(Read),DELETE /api/4e/impersonation/{grantId}(Support),POST /api/4e/impersonation/{tokenId}/validate(Read — runtime handshake) (ImpersonationController.cs). Every attempt writes onecontrol_plane_event.
Provisioning jobs (enqueue + evidence)
Plain: the operator API to enqueue a provisioning job and read its progress / evidence.
- CQRS:
CreateProvisioningJobHandler,GetProvisioningJobHandler,GetProvisioningEvidenceHandler,UpdateProvisioningJobHandler(Common/ProvisioningJobs/**). - API:
POST /api/4e/provisioning-jobs(Manage),GET /api/4e/provisioning-jobs/{id},GET .../{id}/evidence(Read) (ProvisioningJobsController.cs).
Control-plane monitoring + dead letters
Plain: the logbook and incident desk — record health snapshots and events, roll up fleet health, and triage / retry / resolve failed messages from the dead-letter queue.
- CQRS:
Common/Monitoring/**(CreateHealthSnapshotHandler,SearchHealthSnapshotsHandler,CreateEventHandler,SearchEventsHandler,HealthRollupHandler);Common/DeadLetters/**(SearchDeadLettersHandler,GetDeadLetterHandler,RetryDeadLetterHandler,ResolveDeadLetterHandler). - API:
POST/GET /api/4e/monitoring/health-snapshots,POST/GET /api/4e/monitoring/events,GET /api/4e/monitoring/rollup,GET /api/4e/monitoring/dead-letters[/{id}],POST .../dead-letters/{id}/retry|resolve(MonitoringController.cs).
Cross-tenant owner identity
Plain: one human can own a portfolio of several tenants. Operators link / claim / unlink / verify those ownership links.
- CQRS/services:
IOwnerPortfolioService+Common/Owners/**(CreateOwnerProfileHandler,UpdateOwnerProfileHandler,SearchOwnersHandler,GetOwnerProfileHandler,LinkCompanyHandler,ClaimCompanyHandler,UnlinkCompanyHandler,VerifyCompanyLinkHandler,GetOwnerCompaniesHandler). - API (
OwnersController.cs):POST/GET /api/4e/owners,GET/PUT .../{ownerId},POST .../{ownerId}/companies(link),.../companies/claim,.../companies/{tenantId}/unlink,.../company-links/{memberId}/verify,GET .../{ownerId}/companies.
Executive portal
Plain: the owner-facing side. An owner reads their own portfolio, sees a company directory, and mints a single-use launch token to single-sign-on into one of their tenant ERPs.
- CQRS/services:
IOwnerPortfolioService(ResolvePortalContextHandler,GetCompaniesHandler,GetCompanyHandler,IssueLaunchTokenHandler,UpdateMyProfileHandler,VerifyLaunchTokenHandler);ILaunchTokenIssuer/LaunchTokenIssuer;IRuntimeOwnerAccessProbe(config-gated, fail-loud default). - API:
GET /api/4e/portal/me,GET .../me/companies[/{tenantId}],POST .../me/companies/{tenantId}/launch-token,PUT .../me— policyExecutivePortalOwner(OwnerPortalController.cs);GET /api/4e/portal/access-probe,GET /api/4e/portal/company-directory— policyFourEPortalExecutive(PortalController.cs);POST /api/4e/portal/launch-token/verify— policyFourERuntimeService(PortalLaunchVerifyController.cs).
How it talks to other modules
4E is the upstream control plane. It does not consume business events from any ERP runtime module; instead it produces entitlement/routing evidence the runtime consumes, plus it integrates with the external systems Keycloak, Stripe, Redis and RabbitMQ.
Events produced (4E → RabbitMQ). SSOT is FourEControlPlaneEventNames (Common/ProvisioningJobs/Saga/Steps/ControlPlaneMessages.cs). Every message is published under a per-cell key 4e.cell.{cellId}.{moduleCode} and a stable second key 4e.entitlements.v1, so the runtime feed binds one queue without knowing per-cell keys.
| Event | Produced by | Consumer |
|---|---|---|
tenant-runtime-provisioned.v1 | saga rabbit-event-publish step | runtime entitlement feed |
module.enabled.v1 | entitlement enable handler | runtime entitlement feed |
module.disabled.v1 | disable handler | runtime entitlement feed |
module.suspended.v1 | suspend handler | runtime entitlement feed |
module.revoked.v1 | revoke handler (also Stripe cancel path) | runtime entitlement feed |
tenant.suspended.v1 | tenant lifecycle suspend | runtime entitlement feed |
tenant.resumed.v1 | tenant lifecycle restore | runtime entitlement feed |
Events consumed (RabbitMQ → 4E). Only the control-plane Dead Letter Queue — ControlPlaneDeadLetterIntakeBackgroundService is the sole RabbitMQ consumer. 4E does not consume business events from any ERP runtime module.
External webhook (HTTP). Stripe → POST /api/public/webhooks/stripe → inbox → DB-poll dispatcher. Handled types: checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated / deleted.
Boundaries.
- → ERP runtime: the runtime calls
GET /api/4e/tenants/runtime+GET /api/4e/cells/{id}/tenant-runtimes(FourERuntimeService) and consumes4e.entitlements.v1; it redeems portal launch tokens viaPOST /api/4e/portal/launch-token/verify. 4E calls into the runtime only via the config-gatedHttpRuntimeOwnerAccessProbe. - → Auth: Auth stays the record-rule / company-scope authority; 4E integrates no record-rule evaluator. The session bridge writes
OwnerProfile.KeycloakSubjectIdinto the runtime AuthUserProfile.KeycloakSubject. - → Keycloak / Stripe / Redis / RabbitMQ: typed admin clients, config-gated Stripe client, cache-aside resolution cache, and the control-plane publish + DLQ topology, respectively.
- Shared kernel: uses
ERP.SharedKernel.Results,ERP.SharedKernel.Security.RuntimeVerticalTypes, andERP.Host.Common. It references no other module'sEntityproject. - Separate product: its own DB (
four_e_organizations/ schemafour_e), Redis, RabbitMQ and deploy, fronted (like the runtime) byERP.ApiGateway. Vertical packs (Alfawood / InsuranceBroker) own no 4E behavior.
The current code diverges from the design/BRD in several honest ways. Trust the code over the older docs.
DATABASE.mdis stale. It documents only 11 "foundation" tables and an unchecked "remaining work" list; the live schema is 29 tables and most mutable tables now carry anxminRowVersion. Trust the migrations overdocs/applications/4EOrganizationsManager/DATABASE.md. The wiki notedocs/wiki/modules/4e-organizations-manager.mdis closer but predates the billing/owner/portal expansions.- No RLS anywhere — intentional, but flag it. Unlike every ERP runtime module, 4E has zero Row-Level Security. Isolation rests entirely on the explicit
TenantIdFK + authorization policies. A handler bug that omits a tenant filter is not caught by a database safety net. - Expand-phase nullable FKs not yet contracted.
tenants.vertical_typeandmodule_entitlements.PlanIdare still nullable, with the NOT NULL step explicitly deferred. Until backfill is verified in every environment, a tenant/entitlement can exist without a vertical / plan link. - Executive portal probe is partly a placeholder.
GET /api/4e/portal/access-probeproves auth-scheme separation only; it does not complete the later L4 executive-portal feature set. The richerOwnerPortalControllerendpoints are real, but the runtime owner-access probe is config-gated and fail-loud-stubbed (FailLoudRuntimeOwnerAccessProbe→ HTTP 503) untilFourE:RuntimeOwnerAccessProbe:BaseAddressis configured. - Stripe billing is config-gated and ships unconfigured. Without
FourE:StripePlatformBilling:ApiKey+ Checkout URLs, theFailClosedStripeBillingClientstays bound (fail loud). The live catalog today is effectively the two seeded System plans with ~zero real invoices, so billing-money read paths are correct but exercised against near-empty data. - Billing webhook money / email correctness — recently fixed, was real.
docs/quality/erp-next/audit/findings-x-4e.mdrecords four genuine defects, all RESOLVED 2026-06-14: (a) a clean Stripe cancel left entitlementsEnabled(revenue leakage) → now routes through the sharedRevokeModuleEntitlementCommand; (b) the dispatcher swallowed non-retryable failures asProcessed, silently dropping paid-tenant provisioning → now fail-loud withDispatchAttemptCount+ aDeadLetteredcutoff (migrationAddBillingWebhookDispatchAttemptCount); (c) invoice money fields persisted as 0 → now read from the payload; (d) the founder-email guard was bypassed whencustomer_emailwas null → now reads nestedcustomer_details.email. - Legacy/duplicate impersonation + webhook paths coexist. The signed-grant impersonation path (
/api/4e/impersonation) is additive and separate from the legacy support-session path (POST /api/4e/tenants/{id}/impersonation-sessions). LikewisePOST /api/4e/billing/webhooks/{provider}coexists with the livePOST /api/public/webhooks/stripe. Two flows per concept — consolidation candidates, currently kept for migration stability. FourEOrganizationsAdminlegacy policy alias retained (with the migration-onlyerp_4e_admin=trueclaim) even though no current controller references it directly; graded operator policies are used instead. Kept for backward compatibility.- Migration evidence has no
RowVersion.migration_runsand the single-useowner_company_launch_tokensdeliberately omitxmin; they rely on unique-key idempotency / an atomic null-predicate update instead of optimistic concurrency. Correct for append/single-use rows, but inconsistent with the rest of the model — note this when reasoning about concurrency.