MSO Cloud · Documentation

Extension points for a new customer

Source: docs/architecture/extension-points.md Updated 2026-09-21
On this page

Onboarding an organization must be a set of rows, not a branch in the source. This page names every seam that carries a customer-specific answer as data, the door that reads it, and the seams that still need an engineer.

The rule this page exists to keep#

A platform that special-cases one tenant stops being a platform. The failure is rarely an if (orgSlug === …) — no such branch exists in product code. It takes four quieter shapes: one customer's code book applied to every tenant's ingest, customer-pinned operational scripts shipped inside a production image, a process-wide flag carrying a hardcoded term list, and one customer's initials frozen into platform vocabulary.

Two ratchets in audit:all hold the line, and both may only shrink:

Audit What it pins
no-per-customer-hardcodes.py Six pattern families: named identities, an org slug pinned in a worker constant, a vendor account id, a social account id, a customer code-book symbol outside its one home, and a string .default(…) inside a connector config schema
as-unknown-as-ratchet.py Unchecked as unknown as casts, which is how a wrong row shape reaches money math without a type error

The second family is worth naming on its own: a connector config schema that DEFAULTS a customer-shaped string silently applies one customer's answer to every tenant that leaves the field blank. The fix is to make the field required and let validation refuse, which is what happened to the social-listening mentions tab.

Provisioning shape#

An organization's shape is composed, not coded. ORG_BLUEPRINT_REGISTRY holds the base shapes — standard commerce, standard dashboard pack, market-intelligence only, marketing analytics, content intelligence, dataset workspace — and an alias layer turns a base shape plus a domain layer into another offered shape: the HR, finance and warehouse workspaces are all the dataset workspace plus one industry layer.

Three doors matter:

  • listProvisionableBlueprintShapes() is what the operator picker reads, so an alias that carries a domain layer appears as its own shape rather than hiding behind its base.
  • planOrgBlueprint() is a pure dry run: zero reads, zero writes, and it produces the same summary the apply path later produces, so plan and apply share one derivation instead of two.
  • RequestedBlueprintKey is a branded type producible only from operator input. The RESOLVED key of an already-applied blueprint no longer compiles into the apply path, which is what previously dropped the domain layer off an alias on every re-apply.

Adding a NEW shape is still a code change plus a registry-validity gate. Provisioning an existing shape for a new customer is not.

Ingest that reads the customer's own vocabulary#

A stock-keeping code book used to live as a constant and was consumed ungated by two writers, so any eight-digit code with a matching prefix wrote another customer's brand into the product row.

The code book is now an installed table. sku_brand_rules is org-scoped, row-level-security enforced, and read by loadOrgSkuCodeBook(db, orgId); the decoder takes it as an argument, decodeSku(sku, codeBook). The pure connector package holds no database client — only the Database TYPE crosses that boundary — so the loader takes the caller's own handle.

With an empty book, a code decodes to brand null AND category null, never to a guess. The reference book that used to be the default survives as seed and QA material with no tenant reader, and a static check refuses that symbol anywhere outside its one home file.

Connector configuration#

Per-connector configuration is a JSON document on the credential row, validated by that connector's own Zod schema. The schema's top-level keys split into two sets, and an audit refuses a key in neither:

  • Source-key fields — the values that IDENTIFY which external source this row points at. They compose a generated source_key column, which is what makes one organization's two accounts on the same platform distinguishable.
  • Operational keys — sync toggles, intervals, lookback windows, and the house-page id list. They tune HOW a source is read, not WHICH source it is.

Two examples of the same lesson. The social-listening connector's mentions tab is now REQUIRED rather than defaulted, so a new customer cannot silently inherit another's tab name; validation refuses on the server even though the create form does not mark the field. The house-page id list makes a rollup that used to carry a customer's page ids in shared promoter SQL read them from configuration instead, and a rollup with no ids configured writes nothing rather than writing something wrong. That rollup is invoked by a maintenance run rather than by connector sync, so it is a repair path, not an ingest path.

The customer's own numbers#

An organization brings its own operational records in without an engineer, and the semantics of each column are declared as data rather than compiled:

Seam Mechanism Door
Dataset definition org_datasets, org_dataset_fields — role, temporality, expression, filters resolveOrgDatasetBinding in the org dataset resolver
Starter mapping templates One per RECORD dataset in a shipped pack DATASET_PACK_STARTER_TEMPLATES
Nav labels, order, hiding org_nav_config, one row per organization and brand resolveNavConfig
Skin and brand ink site_themes rows, plus brands.theme_overrides The theme scope predicate
White-label host org_domains, verified by a TXT record locateOrgByCustomDomain, and the edge configuration route
Prompt wording ai_prompt_templates, keyed by slot and optional industry resolvePromptSlot
Identity terms to mask org_masking_terms and org_masking_settings resolveMaskPosture

Two safety rules travel with those seams. A dataset field with status suggested can never feed a rollup, a binding or reconciliation, so a proposed column is never load-bearing before a human confirms it. And a starter template strips the sheet.constants block of the pack it derives from — that block once carried a reference tenant's company name and would have been stamped into every new organization of that shape.

Nav configuration is cosmetic by construction: it can never grant or revoke access. The access answer comes from the plugin and role layers, which is the rule that keeps a menu edit from becoming a privilege edit.

Prompt resolution, and one gap#

A prompt slot resolves in three steps: the organization's override for the exact industry, then the organization's industry-agnostic override, then the neutral default shipped in code. The code default names no vertical, so an organization that sets nothing gets no industry framing rather than someone else's.

The industry value itself lives on the organization's chat-assistant settings row, nullable, and five ids are supported. One gap is worth stating rather than leaving for a reader to discover: the default MSO playbook library responds to a single industry value that is NOT in the supported set, so no validated write path can set it and the library resolves empty for every organization. The industry-driven prompt slots are unaffected; only that default library is unreachable.

The PDF footer brand is deliberately environment-only. The resolver comments say why: a per-organization footer is a commercial decision, and when it is taken, one function is the seam because it resolves all three footer values in one place.

Request identity: the host decides the tenant#

An organization's tenancy comes from the request, never from what a session cookie last remembered. On an organization's own subdomain or verified domain, the host wins; a caller who is neither a member nor a super admin gets a null organization and the route refuses; an unknown host resolves to nothing at all. Only on a platform host does the session's organization stand.

  • resolveRequestOrg(session) is the one door for a route handler. It returns the organization, brand, brand access, user and super-admin flag, all already pinned to the host.
  • requestBaseUrl(headers) is the one door for building a URL that must come back to the same place. It derives the origin from the forwarded host and the request scheme, falling back to the configured platform URL only when tenant hosts are switched off.

Two audits keep both doors the only doors:

Audit Scope Exception, and its one site
no-session-active-org.py app/admin, app/(dashboard)/settings, app/api audit-ok-session-org — the brand-switch route, which needs to tell an unknown host apart from a platform host, a distinction the neutral resolver collapses to null
middleware-request-origin.py every source under app/**, plus pinned files audit-ok-middleware-origin — the OAuth connector redirect, because a provider whitelists one fixed URI and a host-derived one would be rejected

The class of bug this closes is concrete: a data-subject erasure request used to resolve its organization from the session, so on a tenant host it could anonymise another organization's shoppers. Three more routes carried the same read and now route through the neutral resolver.

What still needs an engineer#

Listing the seams without listing their edges would overstate the case.

  • A new IDENTITY KIND for masking. Kinds are declared per domain in the vertical registry; a new customer inherits kinds by activating a domain, but a new kind is a code edit.
  • A new INDUSTRY id. The per-industry prompt defaults are a code map; only the wording per existing id is data.
  • A new CONNECTOR TYPE. A manifest, a driver and a sync registration. Configuring an existing type is codeless.
  • A new BLUEPRINT SHAPE or DATASET PACK. Repository content plus a registry-validity gate; an operator cannot add one from inside the product.
  • A per-organization PDF footer. The seam is documented; the lookup is not written.