Skip to content

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:

opentremor_platform.controllers.app
create_platform_app() # the multi-tenant base alone — no analysis surface
# opentremor_core.controllers.app
create_app() # = create_platform_app() + _install_product() — 123 routes

create_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.

The platform never enumerates product-specific names. Seven registration points carry everything that used to be hardcoded:

RegistryWhat the product contributesModule (in opentremor-platform)
Settings sectionsits own admin-editable config sections and fieldsservices/settings_registry.py
Sweep stepsthe work a platform-owned sweep should actually doservices/sweep_registry.py
Org purge stepsthe rows it keys by org_id, deleted when an org is purgedservices/org_purge_registry.py
Index declarationsthe collections and indexes it needs createdstorage/index_registry.py
Org bootstrap hookswhat a newly-created organization needs seededshared/org_bootstrap.py
Startup hooksone-time seeding at process startshared/startup.py
Storage classesbackends spanning both halves, for live storage switchingstorage/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:

InheritedWhat you getWhere
Background jobsJobRecord, 9 storage methods, the stuck-job sweep and its admin endpointsmodels/jobs.py, services/jobs_sweep.py
Outbound org webhookssend_org_webhook() — lookup by (org_id, provider), HMAC over the exact bytes sent, best-effortservices/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.