← Projects

MealMarkOS 2026

MealMarkOS

Co-founder & engineer · piloting · 10+ universities

MealMarkOS landing page hero showing the headline "One wellness platform to replace them all," multi-tenant platform copy for universities, and a MealMark AI assistant mockup reasoning across food, training, and weight data. mealmarkos.com — a multi-tenant platform, not a logo swap on a shared database.

DukeDine started as a nutrition tracker for one school — scrape NetNutrition, log meals, ship to real students. By spring 2026 it had 100+ active Duke users and a scraper we’d reverse-engineered from CBORD’s undocumented HTML API.

Then I noticed what we’d actually built.

The scraper pointed at CBORD’s nn-prod backend. Every other CBORD school runs the same API with different unit IDs. Schools not on CBORD cluster around a handful of menu providers — Nutrislice, Dine On Campus — with simpler JSON surfaces we could adapter-wrap. The single-campus app was sitting on a multi-campus data engine.

That reframed the product: not “DukeDine for Duke,” but MealMarkOS — a platform where each university gets its own branded subdomain, its own isolated data plane, and a dining adapter matched to its provider, on top of a shared wellness engine. Duke became duke.mealmarkos.com, the first live tenant, not the whole product.

Before-and-after comparison: scattered stack of vendor PDFs, generic consumer apps, and siloed Strava data versus one MealMarkOS timeline with normalized menu feed, campus meal logging, Iron Logic training, and institutional branding. The pitch in one frame — replace the stack, keep the brand.

This is how I designed the multi-tenancy.

Schema-per-tenant, not database-per-tenant

The first hard call was isolation shape. Database-per-tenant gives the cleanest boundary — separate connection pools, backups, credentials, blast radius. I rejected it anyway. On a student-team budget, every migration, pool, backup policy, and secret multiplies by N schools. Ten pilot universities means ten of everything operational, before you’ve sold one contract.

Schema-per-tenant on one Aurora Postgres cluster was the compromise I could actually run:

Each school gets real isolation at the Postgres schema level without operating N databases. Migrations run once against the cluster; tenant schemas apply modularly so a school only gets the feature bundles it opts into (core, iron, strava, coach, signals, etc.).

The tradeoff is honest: you’re trusting application code to never leak search_path. I accepted that because the alternative was operational bankruptcy, and because I could enforce tenancy at a single middleware chokepoint instead of sprinkling tenant filters across hundreds of queries.

Control plane vs. data plane

Splitting platform from tenant schemas wasn’t just naming — it was how procurement and demos would work.

The tenant registry row carries what varies per school: subdomain, schema name, status, feature flags, theme JSONB, adapter type and config, billing tier (pilot / standard / enterprise), institutional email domains for signup gating. When a dining director asks “who changed our settings on March 3rd,” the answer lives in platform.config_audit_log, not in a grep through application logs.

Background work has no HTTP Host header to resolve from. Scrape runs, Strava syncs, webhook deliveries look up tenancy through platform.user_tenant_map via getTenantClientForUser(user_id). Same isolation guarantee as the request path, different entry point.

Onboarding a new school is one CLI transaction — provisionTenant.js: validate subdomain, create schema from the modular bundles, register the tenant, write the audit entry. Not a fork. Not a manual runbook. Configuration, not code.

Trust section on mealmarkos.com: "Institutions trust what they control" with cards for isolated environments, institutional branding, real menu feeds, and SSO-gated access. What procurement cares about — isolation and brand — mapped to what the control plane stores.

How a request stays in its lane

The canonical API path on a tenant subdomain:

  1. Request hits duke.mealmarkos.com → ALB routes by host header to the backend service (wildcard ACM cert on *.mealmarkos.com, plus per-tenant custom domains when needed).
  2. attachTenantDb middleware resolves the tenant — explicit X-Tenant-Id wins, then subdomain against PLATFORM_BASE_DOMAIN, then custom-domain lookup — and pins a Postgres client to that tenant’s schema with SET search_path.
  3. Route handlers call getDb(req); queries are additionally scoped by req.user.id from the Supabase JWT verified upstream. No Postgres RLS — explicit per-request scoping is the boundary we chose over policy magic.
  4. Every query runs in the resolved tenant schema unless it’s intentionally hitting platform.* or public.*.

I rejected “one shared schema + RLS policies” for the same reason I rejected DB-per-tenant: at our scale and team size, debugging a leaked row because a policy was wrong felt harder than debugging middleware that forgot to attach. One chokepoint, one place to audit.

Showcase mode as a framework guarantee

Selling to institutions means demos with real product density and zero data risk. Showcase mode is an access_mode on the tenant row: read-only end to end.

Enforcement isn’t per-route trust. The MCP tool invoker blocks any tool with mutates: true at a single chokepoint. The web UI wraps write CTAs in <LockedFeature>. A prospect can click through a fully populated Duke-shaped tenant without touching student data or leaving audit ambiguity about who wrote what.

That only works because tenancy and mutation policy are framework-level — not “remember to check showcase in this handler.”

Adapter-as-configuration

Multi-tenancy isn’t just data isolation — it’s different upstream systems per tenant. Duke runs NetNutrition (CBORD HTML we already scraped). Other pilots run Nutrislice or Dine On Campus JSON APIs.

All three conform to one ScrapeAdapter interface. Adapter choice and config live on the tenant row. Onboarding a Nutrislice school is adapter configuration plus provisioning, not a codebase fork. Per-restaurant selector overrides are validated so they can’t override the school-level base URL — a small detail that prevents one misconfigured scraper from hitting the wrong campus’s data.

Four platform modules on the landing page: Dining intelligence with nightly menu scrape, Meal logging, Iron Logic training journal, and Connector ingest from Apple Health and Strava. Same shared engine modules — dining adapters, logging, Iron Logic, connectors — provisioned per tenant schema.

From one EC2 box to a tenant-routed cluster

The deployment had to match the data model. Legacy DukeDine still ran on a single EC2 instance during transition; the platform moved to ECS Fargate — four services (backend API, web SPA, marketing landing, scrape worker) behind an ALB with host-header routing, Terraform-managed in deploy/ecs/, GitHub OIDC keyless deploys to ECR, Secrets Manager rotating the DB password every seven days.

Duke’s 100+ users migrated in as tenant_duke, so the platform launched with a live pilot tenant rather than an empty shell. As of June 2026 we’re piloting at 10+ universities with active student users (provisioned at runtime beyond the two tenants visible in seed migrations). No signed paying customers yet — pre-revenue, Colab-grant funded — but the architecture is built for N schools, not for Duke.

What it taught me

Three things I’d carry to the next multi-tenant system:

The tenancy decision is a budget decision. DB-per-tenant isn’t “more correct” — it’s more expensive to operate. Schema-per-tenant with a hard middleware boundary was the shape we could afford to run while still selling isolation to procurement.

Control plane tables are product features. Audit logs and tenant registry aren’t infra trivia — they’re what a university asks about before signup.

Provision in one transaction or don’t provision. If onboarding a school takes a runbook, you’ll onboard three schools and stop.

MealMarkOS is what happened when we realized the engine underneath was already built for everyone else’s dining hall too.