Platform / Product Composition
Core is two halves that are composed, not merged — and since the platform/product split they
are two packages: opentremor-platform (its own repo, see
Platform Base) and opentremor-core, which depends on it.
- Platform — multi-tenant identity and org management: users, organizations, memberships, local/SSO/SCIM authentication, teams, invitations, service-account API keys, platform admin, pricing/billing/metering, background job tracking, outbound org webhooks, telemetry and the audit log. Nothing here knows what the product analyses.
- Product — the security-analysis engine: analyzers, resources and namespaces, findings, rules, rule categories, report templates and generation, the review workflow.
The boundary is drawn by what a thing is, not by who happens to use it. Background jobs and outbound webhooks both started in core and moved up once it was clear that nothing about “org-scoped async work with a stuck-job sweep” or “HMAC-signed best-effort delivery to an org’s endpoint” is specific to analysing infrastructure. What stayed behind in each case is the genuinely product-shaped part: the job fields an analysis run needs, and which finding data belongs in a notification payload.
Two application factories reflect that:
create_platform_app() # the multi-tenant base alone — no analysis surface
# opentremor_core.controllers.appcreate_app() # = create_platform_app() + _install_product() — 123 routescreate_app() is what server.py and every deployment use. create_platform_app() is not a
convenience wrapper: it lives in a separate distribution whose test suite runs with
opentremor-core absent from the environment entirely. Its route surface is a strict subset of
the composed app’s — the product’s paths are genuinely not there.
Both modules also expose an app attribute for uvicorn to target
(opentremor_core.controllers.app:app, or opentremor_platform.controllers.app:app for a
deployment that mounts no product). Each is built lazily, on first attribute access, via a
module-level __getattr__ — importing either module runs no factory. That matters because
composition works by import: core imports create_platform_app from the platform module, so a
factory call at module scope would build a complete, orphaned platform app — storage connection,
MCP mount and all — inside every core process, on an app nothing ever serves. Import a factory,
get a factory; ask for app, get an app.
The same seam runs through the type system: AppConfig (core) is PlatformConfig +
ProductConfig, StorageBackend (core) is PlatformStorageBackend + ProductStorageBackend
(see Storage layer), and JobStatus (core) widens the platform’s
JobRecord. All compose by inheritance, so
attribute access and every call site are identical to a single flat class — the split costs
callers nothing.
How the product plugs into the platform
Section titled “How the product plugs into the platform”The platform never enumerates product-specific names. Seven registration points carry everything that used to be hardcoded:
| Registry | What the product contributes | Module (in opentremor-platform) |
|---|---|---|
| Settings sections | its own admin-editable config sections and fields | services/settings_registry.py |
| Sweep steps | the work a platform-owned sweep should actually do | services/sweep_registry.py |
| Org purge steps | the rows it keys by org_id, deleted when an org is purged | services/org_purge_registry.py |
| Index declarations | the collections and indexes it needs created | storage/index_registry.py |
| Org bootstrap hooks | what a newly-created organization needs seeded | shared/org_bootstrap.py |
| Startup hooks | one-time seeding at process start | shared/startup.py |
| Storage classes | backends spanning both halves, for live storage switching | storage/mongo_admin.py |
Org purge is the newest of these and the clearest illustration of why they exist. The cascade
in services/org_offboarding.py used to name every collection itself, including eight that
only ProductStorageBackend declares — which worked in a composed process, where the combined
backend implements both halves, and raised AttributeError partway through a purge in a
platform-only one. It is now split the same way the retention sweep is: the platform finds what
is due, runs each registered step, then cascades its own collections children-before-parents and
deletes the organization document last. A failing step aborts that one org with its document
still present and pending_deletion_at still set, so the next sweep retries rather than
stranding rows behind a deleted parent.
Two further pieces are inherited rather than registered — a product subclasses or calls them, but contributes no wiring:
| Inherited | What you get | Where |
|---|---|---|
| Background jobs | JobRecord, 9 storage methods, the stuck-job sweep and its admin endpoints | models/jobs.py, services/jobs_sweep.py |
| Outbound org webhooks | send_org_webhook() — lookup by (org_id, provider), HMAC over the exact bytes sent, best-effort | services/webhooks.py |
The settings registry is the load-bearing registration point. A section is declared once with
its Pydantic model, and the flat override key map, the managed-section list, the
excluded-section list and the PATCH /admin/settings body model all derive from it —
including the field bounds, which are reused from the config model rather than restated.
verify_complete() then asserts that every section of whichever config model the deployment
actually uses declared a disposition (editable, managed, or excluded-with-a-reason) and fails
if one didn’t, so a section can never be silently uneditable-and-unexplained.
Two config sections deliberately straddle the boundary rather than being forced onto one side:
retention (platform TTLs plus product purge windows) and server (public URLs plus ingest
limits). Each is one section whose halves are declared by their respective domains. Keeping
retention whole is what keeps it one sweep with one state document, one endpoint and one
task-scheduler entry, rather than two of each.
This mirrors the analyzer plugin system below, which has always worked this way — core defines the interface, installed packages register against it.