Two Tenants, One ECO
Why Osprey Strike needs a two-dimensional tenancy model instead of a generic SaaS checkbox · ~17 min read · Suggested by Q technicalbusinessoperations
Most SaaS products have one obvious tenant: the customer. Osprey Strike does not have that luxury. In an emergency callout workflow, the NOC that creates the work and the OSP contractor that performs the work are both first-class actors, and the architecture has to treat them that way from the beginning.
Most multitenant software starts with a pleasant fiction. There is a customer. The customer has users. The users log in, do work, and stay inside the neat little boundary called a tenant.
Osprey Strike does not live in that world. An emergency callout begins with a Network Operations Center noticing something has gone wrong, but the field response often belongs to an Outside Plant contractor using different tools, different staff, different credentials, and different operating assumptions. The software is not merely hosting one organization. It is mediating work between organizations.
That distinction matters because the wrong tenancy model doesn’t fail in an obvious way. It fails by smearing responsibilities together: the wrong people can see the wrong records, job-number sequences become nonsense, integrations assume one upstream system, and the architecture starts leaking private operational context across organizational boundaries. Slow poison.
This cairn walks through why Strike’s tenancy model is intentionally two-dimensional, what that buys us, and where the sharp edges still are.
The usual SaaS tenant model breaks surprisingly fast here
In ordinary B2B SaaS, the default mental model is simple: one customer equals one tenant. Microsoft and AWS both document variants of this tradeoff between cost, isolation, and operational complexity. That guidance is useful, but only up to the point where your product is not simply serving one organization’s internal workflows.
Strike has two organizations attached to nearly every meaningful unit of work:
- the NOC tenant that creates and tracks an ECO, and
- the OSP tenant that receives and works that ECO through Render-backed field operations.
Those are not subordinate concepts. They are not departments inside a single account. They are independent entities that may each have their own users, credentials, phone resources, policies, and reporting needs. ADR-003 is explicit on this point: even when the same business participates in both roles, the roles remain distinct because the operational responsibilities and data surfaces are different.
The key architectural move is not “support multitenancy.” It is “define the tenant correctly.” In Strike, the tenant is role-shaped, not company-shaped.
The business relationship is many-to-many, not parent-child
A NOC can work with multiple OSPs. An OSP can serve multiple NOCs. That means the core business relationship is not a single ownership tree; it is an explicit link table with status, defaults, and room for future metadata.
That is why ADR-003 models noc_osp_links separately instead of pretending an ECO can infer the relationship from one shared customer account. The link is where future business rules live: approved partnerships, suspended relationships, default dispatch preferences, and eventually things like SLA or pricing metadata.
A simplified view looks like this:
graph TD N[NOC Tenant] --> L[noc_osp_links] O[OSP Tenant] --> L L --> E[ECO] E --> R[Render Instance] E --> P[Pager List]
The diagram is deceptively simple. The important part is what it rules out:
- no assumption that one NOC always maps to one OSP,
- no assumption that an OSP’s resources belong to a single customer,
- no assumption that historical work should disappear when a business relationship changes.
That last point is doing more work than it appears.
Historical visibility is a product decision disguised as a schema choice
One of the better decisions in ADR-003 is that ECOs store direct references to both noc_tenant_id and osp_tenant_id rather than deriving visibility through the current link table.
This means an ECO remains visible to both parties even if the NOC-OSP relationship is later suspended or removed. For an emergency-work system, that is the sane choice. Historical operational records should not vanish because a vendor relationship changed on a Tuesday.
If the architecture had routed visibility entirely through current links, severing a relationship could accidentally orphan legitimate history. That would be terrible for auditing, post-incident review, invoice disputes, and the basic human act of asking, “what happened on that outage?”
The link table should control future eligibility for new work, not historical truth about old work.
This distinction is subtle enough that teams often miss it on first pass. Then they discover six months later that “current authorization” and “historical visibility” are not the same problem at all.
Per-tenant resources are not optional decoration
Once you accept that NOCs and OSPs are separate actors, resource partitioning follows naturally.
The internal architecture docs split resources along the operational boundary:
- NOC-specific concern: Twilio subaccount identity and phone-number context.
- OSP-specific concerns: Render credentials, pager lists, job numbering, and task execution context.
This is not busywork. It is the difference between an integration model that scales and one that becomes a pile of dangerous exceptions.
For example, each OSP tenant may have:
- its own Render API credentials,
- its own polling configuration,
- its own regional pager lists,
- and its own job-number format and sequence.
Each NOC tenant may need its own telephony context and customer-facing status view.
A single global Render client or a single global job sequence works only until the moment you onboard a second operationally distinct contractor. Then every shortcut becomes a migration. The architecture guide calls out one of the early system assumptions directly: the polling service originally assumed a single Render instance. That kind of assumption is survivable in a prototype and poisonous in a product.
Request context has to carry both user identity and working tenant
Azure’s guidance on request mapping makes a point that lands cleanly here: user identity and tenant context are separate concerns. Strike’s auth model follows that logic.
A request needs to answer at least three questions:
- Who is this user?
- Which tenant are they currently acting within?
- What role do they have in that tenant?
The architecture guide captures this as an auth context with a user identity, optional system role, and active tenant selection. That sounds obvious until you imagine the alternatives.
If the tenant is inferred loosely from email domain, you are in for pain. If it is inferred from a default customer account, multi-membership users become ambiguous. If it is buried only in front-end state, bookmarked URLs and direct navigation become brittle.
The user journeys document shows the cleaner path: the active NOC is expressed in the route, authorization is enforced explicitly, and system administrators are handled as an intentional exception instead of a magic bypass.
A tenant-aware system is not one that stores a tenant ID somewhere. It is one that requires tenant context everywhere a business decision depends on it.
Isolation is more than rows in a table
The phrase “tenant isolation” tempts people into database-only thinking. Yes, row-level controls matter. PostgreSQL’s row-security documentation is a good reminder that table access and row visibility are not the same thing. But Strike’s isolation story has to cover more than SQL filters.
Isolation here spans at least four layers:
- Data isolation — NOC and OSP scoping on ECOs, events, and related views.
- Credential isolation — per-OSP Render credentials, encrypted at rest.
- Operational isolation — per-instance polling, pager configuration, and job sequences.
- UI isolation — users only seeing the tenants and actions they should see.
That is why the architecture uses explicit tenant fields in projections, repositories that filter by tenant, encrypted Render secrets, and user-to-tenant membership tables. It is also why the docs defer row-level security as a future option rather than treating it as the whole answer.
For this stage of the product, application-enforced tenant scoping plus clear repository boundaries is the pragmatic choice. Later, if compliance or blast-radius requirements tighten, database-level RLS becomes a reinforcement layer rather than a heroic retrofit.
If you add row-level security after the codebase has already normalized “fetch first, filter later,” you are not adding defense in depth. You are cleaning up after a design mistake.
Job numbers are part of the contract, not a cosmetic field
One of the most domain-specific choices in ADR-003 is the per-OSP job sequence and templated job format.
That might look like a formatting detail. It is not. Job identifiers sit at the boundary between software, field crews, customer communication, and external systems. They are operational language.
An OSP contractor may need continuous numbering that never resets, a custom prefix, and perhaps a format that embeds the NOC code for at-a-glance recognition. The design supports templates like {prefix}-{noc}-{num:5}-{year} for exactly this reason.
The architectural implication is straightforward: a global sequence is incorrect because it ignores who owns the work execution model. The same is true for pager lists and Render instances. Once the OSP is the operational executor, OSP-scoped resources become the natural unit of partitioning.
This is the sort of domain constraint generic multitenancy articles rarely cover well. They help you choose between shared and isolated infrastructure. They do not tell you which business actor should own the job number. The product has to answer that itself.
The design must survive real users, not just clean diagrams
The user journeys are where the architecture proves it understands reality. A single-NOC operator, a multi-NOC operator, a sysadmin with no memberships, and a user with no access all behave differently. Good. They should.
A tenancy model is only credible if it can answer annoying but ordinary questions:
- What does the URL mean for a user who belongs to two NOCs?
- What happens when a sysadmin has no tenant membership at all?
- How does the UI respond to a direct URL for a tenant the user should not access?
- How does a future dual-membership user switch between NOC and OSP views without nonsense leakage?
The user-journey doc does something quietly valuable: it treats “no access” and “forbidden” as first-class states instead of edge cases to be hand-waved away. That is usually a sign the authorization model has been thought through by adults.
Where this model still has sharp edges
No architecture worth taking seriously is finished the day its diagram looks elegant.
Strike’s current model solves the first-order problem well, but several second-order questions remain interesting:
OSP-facing experience
The current user journeys focus primarily on NOC and sysadmin paths. Future OSP admin and OSP agent flows will need equal clarity: what is visible, what is editable, and how cross-tenant provenance is explained in the UI.
Compliance escalation
Today the product can rely on application-level scoping, explicit repository filters, and careful auth context propagation. Tomorrow, a larger customer or compliance requirement may justify adding stricter database-level guarantees such as PostgreSQL row-level security.
Tenant-switch ergonomics
Explicit tenant context is correct, but users still need a low-friction way to understand where they are acting. Switching tenants is an authorization problem wrapped in a navigation problem.
Relationship metadata
The noc_osp_links table leaves room for future business detail: SLA terms, pricing tiers, dispatch preferences, and approval states. That flexibility is wise, but it will need discipline to avoid turning into a junk drawer.
A good tenancy model does not eliminate future complexity. It ensures future complexity has somewhere honest to live.
What the team should take away
The larger lesson here is not merely about Strike. It is about product architecture in any domain where software mediates work across organizational boundaries.
If you choose your tenant model too generically, the domain will punish you later. If you define the tenant around the actual responsibilities in the system, a surprising number of downstream decisions become clearer:
- which IDs should be scoped where,
- which credentials belong to which actor,
- which history must remain visible,
- which UI states are legitimate,
- and which shortcuts are actually bugs wearing a prototype badge.
- Strike is not single-axis multitenant. Each ECO belongs simultaneously to a creating NOC and an executing OSP, and the model has to preserve both truths.
- Historical records should outlive current business relationships. Link tables govern whether new work may be created, not whether old work remains visible.
- Operational resources follow the executor. Render credentials, pager lists, and job-number sequences belong with the OSP tenant because that is where work execution lives.
- Tenant context must be explicit in requests and UI flows. Identity alone is insufficient once users can belong to multiple tenants or act with system-wide privileges.
- Isolation is layered. Data scoping, credential handling, operational boundaries, and UI behavior all contribute; none is sufficient alone.
- For Strike's future OSP-facing UI, what actions should be tenant-scoped by default, and which ones need explicit cross-tenant provenance shown in the interface?
- At what customer or compliance threshold should we add PostgreSQL row-level security as a second isolation layer instead of relying solely on application-enforced tenant filters?
- Which future metadata on `noc_osp_links` would materially improve dispatch quality first: SLA windows, preferred regions, pricing, or approval workflow state?
- Tenancy Models for a Multitenant Solution — Microsoft's overview of shared-versus-isolated tenancy tradeoffs, useful as a baseline before you account for Strike's more unusual two-actor domain.
- Map Requests to Tenants in a Multitenant Solution — Clear guidance on separating user identity from tenant context, which maps directly to Strike's active-tenant request model.
- Architectural Approaches for Storage and Data in Multitenant Solutions — Useful for thinking through isolation, scale, and noisy-neighbor tradeoffs in shared storage systems.
- Tenant Isolation - AWS SaaS Lens — AWS's framing of tenant isolation as foundational infrastructure discipline rather than a single implementation trick.
- REST API: Subaccounts — Twilio's subaccount model is relevant because Strike's NOC-side telephony context naturally wants separation without requiring fully independent billing roots.
- PostgreSQL Row Security Policies — The canonical explanation of row-level security, helpful for evaluating when database-enforced tenant boundaries should augment application-level filters.
Generated by Cairns · Agent-powered with Claude