System Context — the Acme Platform¶
What this covers: the outermost C4 level for Acme, Initech's B2B commodity-trading
platform. It names the human roles that use the system, the external systems it depends on,
the trust boundaries those dependencies cross, and the coexistence relationship with the
legacy stack that Platform is replacing. Everything here was verified against
apps/platform/*, charts/, and the service config schemas in the source repository —
where a claim could not be verified from source, the prose says so explicitly rather than
guessing.
1. The system in one diagram¶
C4Context
title System Context — Acme Platform
Person(trader, "Trader A-E", "Creates deals, purchases and sales. Day-to-day trading operations. Role TRADER.")
Person(finance, "The FD and finance staff", "Approves and voids invoices, manages accounting months and exchange rates. Roles FINANCE and MD.")
Person(admin, "Admin A", "Manages users, invitations and roles inside one tenant. Role ADMIN.")
Person(superadmin, "Platform superadmin", "Cross-tenant administration via /api/v1/platform/*. Role SUPERADMIN.")
Person(viewer, "Viewer and agent", "Read-only and partial-read access. Roles VIEWER and AGENT.")
System(acme, "Acme Platform", "Multi-tenant trading, inventory, accounting and commission platform. NestJS microservices behind a single API gateway.")
System(legacy, "Legacy stack", "legacy-web plus legacy-api plus domain-api. Being strangled by Acme Platform.")
System_Ext(erp, "ERP accounting system", "Sales and purchase invoice posting, nominal codes, customers, bank accounts, currencies. OAuth2 REST.")
System_Ext(idp, "External identity provider", "Per-tenant OIDC or SSO. PKCE authorization-code flow.")
System_Ext(email, "Transactional email provider", "Outbound invoice, commission, invitation and reset email.")
System_Ext(blob, "Object storage", "Generated PDFs and tenant branding assets.")
System_Ext(tax, "Government tax service", "Published FX rates, one of three accepted rate sources.")
Rel(trader, acme, "Creates and edits deals, purchases, sales", "HTTPS")
Rel(finance, acme, "Approves invoices, closes months, sets rates", "HTTPS")
Rel(admin, acme, "Invites and manages users", "HTTPS")
Rel(superadmin, acme, "Creates, suspends and configures tenants", "HTTPS")
Rel(viewer, acme, "Reads trading and stock data", "HTTPS")
Rel(acme, erp, "Posts invoices, syncs reference data", "HTTPS REST plus OAuth2")
Rel(acme, idp, "Delegates authentication per tenant", "OIDC plus PKCE")
Rel(acme, email, "Sends transactional mail", "Provider SDK")
Rel(acme, blob, "Stores and serves documents and assets", "Provider SDK")
Rel(acme, tax, "Records published FX rates", "Manual or import")
Rel(legacy, erp, "Posts invoices on the existing path", "HTTPS REST")
UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1")
What this shows¶
The platform is a single logical system with one front door. Every human actor reaches it through the same API gateway; there is no second ingress for admins or for finance. The external dependency surface is deliberately small — five outbound integrations, four of which are pluggable behind a port.
Takeaways¶
- Role, not persona, is the access primitive. The system defines 7 roles and 65 permissions (SUPERADMIN, ADMIN, FINANCE, MD, TRADER, VIEWER, AGENT). Permission keys are
{resource}:{action}—deal:create,invoice:approve,commission:view:all. SUPERADMIN holds a wildcard; every other role is tenant-scoped. A user may hold several roles, and the JWT carries the union of their permissions. - SUPERADMIN is a different lane, not a bigger key. Cross-tenant administration only
happens on
/api/v1/platform/*routes, and the gateway forwards thex-platform-scopeclaim only on routes flaggedplatformScope: truein the routing table. On any business endpoint the same platform-scoped session behaves as tenant-scoped. See ADR-0029 Superadmin platform scope auth. - Every external integration is behind a port with a mock adapter. ERP posting
(
ERP_MOCK_MODE,mock-erp.adapter.tsvsreal-erp.adapter.ts), email (NOTIFICATION_EMAIL_PROVIDER: 'mock' | 'acs'), and document storage (DOCUMENT_STORAGE_PROVIDER: 'mock' | 'azure') all select the adapter from validated config. A dev cluster can run the entire platform with zero third-party calls. - Identity is federated but tokens are local. The platform accepts per-tenant OIDC, but after a successful SSO callback it mints its own RS256 JWT. Downstream services never see an external token and cannot distinguish an SSO login from a password login. See ADR-0053 Platform identity JWT RS256 JWKS.
- The legacy stack still owns the live ERP posting path. Acme Platform and the legacy
legacy-apiboth integrate with the same ERP tenant. This is a strangler-fig migration, not a big-bang cutover, so the context diagram has two systems reaching one external dependency for the duration.
Invariant encoded¶
No request reaches a bounded context without a platform-issued identity. The gateway strips inbound
x-tenant-id,x-user-id,x-user-roles,x-permissionsandx-platform-scopeheaders before authentication, then re-injects them from verified JWT claims. A client cannot assert its own identity.
2. Trust boundaries¶
flowchart TB
subgraph internet ["Untrusted — public internet"]
Browser["Browser SPA"]
IdPExt["External IdP"]
ErpExt["ERP accounting system"]
MailExt["Email provider"]
BlobExt["Object storage"]
end
subgraph edge ["Edge — TLS termination, out-of-band Helm release"]
Ingress["Ingress controller<br/>public LetsEncrypt cert<br/>HTTP-01 ACME"]
end
subgraph dmz ["Authentication boundary — cluster ingress namespace"]
GW["API gateway<br/>JWT verify, header strip and re-inject,<br/>rate limit, circuit breaker, routing"]
end
subgraph app ["Trusted-after-gateway — per-BC namespaces"]
Svcs["12 bounded-context services"]
end
subgraph data ["Data plane — private endpoints only"]
PG[("PostgreSQL 16<br/>per-BC schemas")]
RDS[("Redis<br/>cache and revocation")]
MQ[("RabbitMQ<br/>topic and fanout exchanges")]
end
Browser -->|"HTTPS 443"| Ingress
Ingress -->|"cluster-internal HTTP"| GW
GW -->|"HTTP plus injected identity headers"| Svcs
Svcs --> PG
Svcs --> RDS
Svcs --> MQ
Svcs -->|"egress"| ErpExt
Svcs -->|"egress"| MailExt
Svcs -->|"egress"| BlobExt
GW -->|"OIDC redirect and callback"| IdPExt
What this shows¶
Four concentric trust zones. The gateway is the only authentication boundary; once past it, identity travels as unsigned HTTP headers on a network the platform treats as trusted. That trust is enforced by network policy, not by cryptography.
Takeaways¶
- The identity headers are unsigned.
x-tenant-id,x-user-id,x-permissionsandx-platform-scopeare plain headers. Their integrity rests entirely on the NetworkPolicy that restricts cross-namespace HTTP ingress on each bounded-context service to the gateway pod only. This is a known, documented constraint, not an oversight — it is why the bundle values files pinnamespaceSelectorrules to the gateway namespace. - The gateway does not forward the bearer token by default.
ServiceRoute.forwardAuthorizationdefaults tofalse: re-sending the JWT to every bounded context would needlessly widen token exposure. It is opted into only for an upstream that re-verifies the token itself. Cookies are likewise withheld (forwardCookiesdefaults tofalse) except on the OIDC callback route, which needs the__oidc_statecookie for CSRF state-binding. - The edge is outside the GitOps loop. TLS termination runs as a standalone Helm
release with an HTTP-01 ACME resolver, deliberately not reconciled by the GitOps
controller. Config changes there require a manual
helm upgrade— a trap worth knowing before debugging "why did my ingress change not apply". - Data services are reachable only over private endpoints. PostgreSQL, Redis and the
broker have no public listener. Service-to-PostgreSQL enforces
sslmode=require. - Path-smuggling is rejected at the router, not the proxy.
resolveService()short-circuits on NUL bytes, on.., and on%2ebefore any upstream URL is built, becausenew URLwould otherwise normalise a traversal into a path the matched route never intended.
Invariant encoded¶
The routing decision and the privilege decision use one input.
platformScopeis a property of the route object that routing already resolved — not a second, independently normalised path check. Two separate checks could disagree; a double-encoded traversal could route to a business service while re-parsing to a platform path and leak the cross-tenant header. One matcher, one decision.
3. External systems, verified¶
| External system | Bounded context that owns the integration | Adapter selection | Notes |
|---|---|---|---|
| ERP accounting | accounting-service (modules/erp/) |
ERP_MOCK_MODE picks mock-erp.adapter.ts or real-erp.adapter.ts |
OAuth2 with a token-refresh scheduler, a throttle service, retry policy, and an encryption subscriber for tokens at rest. Reference data (customers, nominal codes, currencies, bank accounts, companies) is synced into accounting.erp_* tables. ADR-0024 ERP integration strategy, ADR-0044 ERP CSV import for onboarding. |
| External identity provider | auth-service (modules/oidc/) |
Per-tenant OidcProvider rows |
PKCE authorization-code flow with state for CSRF and nonce for replay protection. Auto-provisioning when autoProvision=true. SSO-only accounts are refused at /auth/login with SSO_ONLY_ACCOUNT. ADR-0067 SSO session handoff one-time code. |
| Email provider | notification-service |
NOTIFICATION_EMAIL_PROVIDER: 'mock' \| 'acs' — fail-loud validation rejects acs without connection string and sender address |
Delivery status and retry are exposed at /api/v1/deliveries for operators. NOTIFICATION_DELIVERY_CONCURRENCY and NOTIFICATION_MAX_RETRY_ATTEMPTS are tunables. |
| Object storage | document-service (DOCUMENT_STORAGE_PROVIDER) and tenant-service (TENANT_ASSET_STORAGE_*, container defaults to tenant-assets) |
'mock' \| 'azure' |
Two distinct concerns: generated PDFs vs tenant branding assets. ADR-0074 Platform tenant asset storage. |
| Government tax service | accounting-service (modules/exchange-rate/) |
n/a | The ExchangeRateSource enum is exactly TAX_AUTHORITY \| MANUAL \| ERP. Not verified: whether any automated fetch from the tax service exists in Platform — the entity records the source of a rate, and the use case accepts an operator-supplied DTO. Treat automated FX ingestion as unconfirmed. ADR-0023 Exchange rate temporary ownership. |
Takeaways¶
- Two of the five integrations are financial and audited. ERP posting writes a
erp_posting_auditrow per attempt; ERP tokens are encrypted at rest by a MikroORMEventSubscriberkeyed onERP_TOKEN_ENCRYPTION_KEY. - Provider selection is fail-loud, not fail-soft. The notification config schema rejects a partially configured provider at boot rather than silently degrading to mock — a deliberate reaction to an earlier incident where a required variable defaulted quietly.
- The ERP integration is credentialed at the platform level, not per tenant. Tenant onboarding does not collect per-tenant ERP credentials; connection testing runs against platform-held credentials.
- Storage is split by lifecycle. Documents are immutable artefacts of a business event; tenant assets are mutable branding. They use different containers and different services so a branding change cannot touch an invoice PDF.
Invariant encoded¶
Every external call is a port with an in-repo test double. No integration test, CI job, or local
docker composestack may require a third-party credential to pass.
4. Actors and what they can actually do¶
flowchart LR
SA["SUPERADMIN"] -->|"wildcard"| ALL["All tenants"]
AD["ADMIN"] -->|"65 perms"| T1["Own tenant, incl. user management"]
FI["FINANCE"] -->|"17 perms"| T2["Read trading plus invoice approve and void plus commission"]
MD["MD"] -->|"27 perms"| T3["Trading plus deal:lock plus commission own only"]
TR["TRADER"] -->|"24 perms"| T4["Core trading CRUD, no lock, no invoices"]
VW["VIEWER"] -->|"12 perms"| T5["Read-only across resources"]
AG["AGENT"] -->|"4 perms"| T6["Base read: deal, purchase, sale, stock"]
What this shows¶
Privilege is not a ladder with one rung per job title — FINANCE and MD are orthogonal
slices, not "more than TRADER". FINANCE can approve an invoice but cannot lock a deal; MD
can lock a deal but sees only its own commission, never commission:view:all.
Takeaways¶
deal:lockis the hinge permission of the whole platform. Locking a deal is what triggers commission calculation and FX-rate snapshotting downstream, which is why it sits with MD and ADMIN rather than with every trader.commission:viewandcommission:view:allare separate permissions precisely so an MD can see personal earnings without seeing the team's.- AGENT exists for external partners — 4 read permissions and nothing else.
- Roles are assigned through a
UserRolejoin table with no unique constraint onuserId; multi-role is a first-class case, and the JWT carries the union. - Tenant lifecycle state (
PROVISIONED,ONBOARDING,ACTIVE,SUSPENDED,DELETED) is derived from entity columns byTenant.getState(), never stored. There is no state column to drift out of sync withsuspendedAtanddeletedAt.
Invariant encoded¶
Tenant isolation is an ORM-level default, not a query-level discipline. Every tenant-scoped entity extends
TenantBaseEntityand inherits a MikroORM@Filterdeclareddefault: true. There is no opt-out. The small set of exempt entities —Tenant,TenantConfig,SuperadminAuditLog,PlatformSetting— must carry an explicit@TenantExempt()decorator, which makes exemption grep-able and reviewable.
5. Deciding ADRs at this level¶
| ADR | Title | Why it matters here |
|---|---|---|
| 0013 | Per-BC PostgreSQL schema isolation | Defines what "one system" means internally — shared cluster, isolated schemas, no cross-schema joins. |
| 0014 | Microservices separate binaries day one | Each bounded context ships as its own deployable from the start. |
| 0017 | RabbitMQ unified messaging | One broker for both domain events and background jobs; Redis is cache-only. |
| 0024 | ERP integration strategy | Shape of the accounting external boundary. |
| 0029 | Superadmin platform scope auth | Why cross-tenant access is a separate route lane, not a bigger role. |
| 0031 | GDPR-compliant audit trail | Constrains what the audit feed may retain about people. |
| 0051 | Cloud-native provider-neutral secrets and identity | Keeps the external-dependency set portable rather than vendor-welded. |
| 0053 | Platform identity JWT RS256 JWKS | Platform mints its own tokens; external IdPs terminate at the auth boundary. |
| 0066 | Hostname-based tenant resolution | How a request is bound to a tenant before authentication. |
| 0069 | Pre-auth tenant slug resolution | The public, enumeration-safe discovery lane at the apex domain. |
6. What could not be verified¶
Honest gaps, stated rather than invented:
- Automated FX ingestion from the government tax service. The
ExchangeRateSourceenum includesTAX_AUTHORITY, and acreate-exchange-rate.use-case.tsaccepts a DTO with asourcefield — but no scheduled fetcher for that source was found inaccounting-service. The arrow in the context diagram is drawn as "Manual or import" for that reason. ai-serviceexternal model dependencies. The service exposessignals,predictions,agent-registrationandagent-sessionsmodules, but its event consumers bind routing keys and exchanges that do not yet exist, and the financial ingestion streams are gated on FD sanction (ADR-0047). It is drawn in the container view as deployed but inert; no external model-provider edge is claimed.- Whether the legacy stack and Platform share one ERP tenant or two. Both integrate with the ERP; the diagram shows two arrows to one system, which is the safe reading.
Next: 01-container-view.md — the gateway, the 12 services, and the data and messaging plane.