MeridiansMeridians

Platform model — two products, three authorities, one daemon [Concept]

Source path: knowledge-base/diagrams/concepts/platform-model.md

# Platform model — two products, three authorities, one daemon `[Concept]`

The control plane around a running instance. Four ideas that are easy to conflate — kept apart here. Shipped code: `src/app/(gateway)`, `src/features/gateway`, `src/lib/server/gateway`, `supabase/migrations/`, `electron/`. Design doc: [GATEWAY.md](../../knowledge/operations/GATEWAY.md). Enforcement: [PERMISSIONS.md](../../knowledge/governance/PERMISSIONS.md).

## Two products, one account

A director can hold **License AND/OR Hosted**. Products are per-subscription rows, never a column — so "both at once" is a first-class case, not an edge.

```mermaid
flowchart LR
    D["**director**<br/>one per person<br/>· one Stripe customer"]
    D -->|"0..1"| L["subscription: **license**<br/>BYOK · self-hosted"]
    D -->|"0..1"| H["subscription: **hosted**<br/>VM · provider usage included"]
    L -->|"hasLicense"| App(["Electron app<br/>runs on your machine"])
    H -->|"hasHosted"| VM(["Fly VM<br/>always-on host + its own domain"])
    D -.->|"in platform_admins?"| Admin(["platform operator"])
```

## Three authorities — don't conflate them

"Director" names three related-but-separate things, and each answers a different question. Supabase authenticates the **person**; it says nothing about the other two.

```mermaid
flowchart LR
    subgraph Platform["Platform account · Supabase"]
        PR["**platform role**<br/>owner › admin › director › guest<br/>= WHAT you are on the platform"]
        PA["directors + subscriptions<br/>= which PRODUCT you own"]
    end
    subgraph Instance["In-instance people · access-context"]
        GM["instanceMembers<br/>Guest→Viewer→Contributor→Manager→Director"]
    end
    PA ==>|"the account owner IS<br/>the instance Director"| GM
    PR -.->|"gates: the cross-Director<br/>admin plane + owner SQL"| CP(["control plane"])
    PA -.->|"gates: can you run<br/>this instance type"| RT(["a running instance"])
    GM -.->|"gates: what you may<br/>DO inside it"| RT
```

| Authority | Question it answers | Cardinality |
|---|---|---|
| **Platform role** | may I administer the *platform*? | **one owner per gateway** — so one across the whole product; `admin` is the invitable tier |
| **Product entitlement** | may I *run* this instance type? | per-subscription, both products can be live at once |
| **In-instance people** | what may I *do* inside this instance? | **one Director per instance** — it is one person's record |

Platform role = *operator authority*; account = *ownership + billing*; in-instance people = *collaboration roles*. The gateway never touches `instanceMembers`; the instance never touches Supabase billing.

## Knowing the platform role

Because the role lives only on the gateway, an instance has to *ask* — and every headless caller must get the same answer. So one resolver writes the verdict through onto the daemon's copy of the Director session, and everything reads it from there.

```mermaid
flowchart LR
    SI["Director signs in<br/>(browser · Supabase)"] -->|"session push"| DS[("directorSession.json<br/>local-only, never replicated")]
    DS -->|"resolve once"| GW{{"GET /api/account/session<br/>role · admin · owner · entitled"}}
    GW -->|"cached with checkedAt"| DS
    DS --> MCP(["MCP scope gate<br/>director / admin / owner"])
    DS --> CR(["control-plane reads<br/>owner/admin only"])
    DS --> Diag(["session diagnostics<br/>never a token"])
```

The cache is **advisory and fresh-only** (5-minute TTL): a role that outlived a revocation is never re-readable, it survives token rotation (rotating a token is not a role change), and it never outlives the session it belongs to. It buys latency and good error messages — **never** authorization. The remote route re-checks every call.

## One daemon, two hosts

The server-of-record (**E1**) is one host-agnostic Node daemon — record + engine + scheduler + loops. License forks it locally; Hosted boots the *same* code on a VM. Only *where it runs* and *how the browser reaches it* differ.

```mermaid
flowchart LR
    E1["**E1** server-of-record<br/>host-agnostic daemon"]
    E1 -->|"Electron forks it<br/>(License)"| Local["localhost:41999<br/>record → userData"]
    E1 -->|"a Fly VM boots it<br/>(Hosted)"| Fly["&lt;slug&gt;.meridians.global<br/>record → /data volume"]
    Local --> LC(["local renderer<br/>always-connected"])
    Fly --> RC(["remote browser<br/>reconnect + sync"])
```

> **Shipped:** the managed Fly VM runtime, entitlement model, gateway app, control-plane schema, and Electron host. The hosted runtime lives in `src/lib/server/program/hosted-runtime.ts` + `src/lib/server/gateway/`; provisioning/fleet in the gateway console. Sign-in mechanics: [flows/hosted-signin](../flows/hosted-signin.md).
Open on GitHub

Raw Markdown source