Acasuso.
← All work

Live-pivoting a prototype into a multi-tenant SaaS

Took a single-user LinkedIn generator and pivoted it in-repo – openly, phase by phase – into a multi-tenant blog-first content service with database-enforced tenant isolation, atomic Stripe-driven quota, and an inline Vercel pipeline, now live at `ghostpost.fun`.

My contribution

Started the product with the blog structure, visualisations, integrations and automated publishing schedules. Then scoped the move from a single-user prototype to a multi-tenant live product, designed the database-side tenancy boundary, implemented the atomic quota engine and Stripe webhook idempotency, migrated the generation pipeline, and carried the product into production.

Ghostpost create-blog flow: idea, refine, generate, review, with a LinkedIn companion post drafted from the blog.Open full-size image ↗
Ghostpost create-blog flow: idea, refine, generate, review, with a LinkedIn companion post drafted from the blog.

Situation

Ghostpost v1 was a single-user LinkedIn-post generator built in a few weekends. It worked – but as a product, it had three problems that compounded:

  1. One user per deployment. No sense of an "organisation", no tenancy boundary, no way to sell it to a client without spinning up a copy. The buyer who would pay for this was a content team, not a solo operator.
  2. Trigger.dev for pipelines that should have been synchronous. The generation pipeline was broken out into Trigger.dev tasks because that was the pattern elsewhere in the fleet. For a single blog draft on a user's request, the orchestration overhead cost more than it bought.
  3. No concept of quota, trial, or billing. The architecture had no idea what "run out of generations" meant. Any paid version would have to retrofit that into the write path.

The honest version of the task wasn't "polish v1". It was "take the ideas and the component library and build v2 as a multi-tenant SaaS, in the open, in the same repo".

Task

Three decisions we owned:

  1. How to add multi-tenancy without a rewrite. Options: tenant-filter in app code (every query gets a where orgId = clause, trusted to be there), a tenant-aware ORM wrapper, or a database-side boundary via a constrained Postgres role.
  2. Whether to keep Trigger.dev or inline the pipeline. Trigger was good when steps were durable and long-running; this product's steps are seconds-long and tied to a single user request.
  3. How to make quota and the Stripe webhook survive retries. Both idempotency problems: double-decrement on a retried generation, double-credit on a retried Stripe event.

Action

Architecture

Multi-tenant RLS via a constrained Postgres role. Prisma 5 connects as ghostpost_app, a role that can only see rows that match the current tenant context, set via set_config('app.org_id', ..., true) at request start. Per-row RLS policies on every tenant-scoped table do the enforcing. No per-query filter in app code – the database refuses to leak. The one place this fails cleanly (admin / cross-org reads from the Stratas side) is a separate ghostpost_admin role used only by internal admin surfaces.

Inline pipeline on Vercel via waitUntil. Trigger.dev was removed in Phase 1C-2c. The generation pipeline now runs attached to the request via @vercel/functions.waitUntil, with withLangfuseFlush + .finally(flushLangfuse) ensuring Langfuse traces survive the lambda freeze that happens the moment the HTTP response is sent. Simpler, cheaper, and the right fit for request-scoped work.

Multi-provider LLM router under src/lib/llm/. Anthropic Claude Sonnet 4.6 primary, OpenAI fallback, picked per tier (e.g. tier: "drafting"). Perplexity Sonar-Pro for research-grounded prompts. The router carries model name and token counts through to Langfuse so per-provider cost is attributable.

Alternatives considered and rejected:

  • Tenant-filter in app code. The trust surface is "every developer writing every query, forever". One missing clause leaks the whole org. The database-side version is the one you can rely on.
  • Fork the single-user version into a multi-tenant branch. Rejected because the pivot's whole point was to be honest about the direction change in the repo history. GHOSTPOST_PIVOT_BUILD_PLAN.md is dated 2026-10-01 and sits beside the Phase 1–3 PR arc for anyone – buyer, partner, or the team later – to read.
  • Keep Trigger.dev "just in case". Rejected because unused infrastructure is still a bill, a dependency, and a thing that breaks.
  • One LLM provider. Rejected because a provider outage would silently fail the product; the router costs almost nothing to maintain and buys continuity.

Implementation

  • Atomic quota engine (PR #75). A pg_advisory_xact_lock(orgId) wraps the "consume one generation" write so two concurrent requests from the same org cannot both see N remaining and both decrement to N-1. Fairness is not the point; correctness is.
  • Stripe webhook claim-before-work (PR #78). The webhook handler first inserts an idempotency claim row keyed on the Stripe event id, then does the work. A retried event's insert fails on the unique constraint and the handler short-circuits. No double-credit on Stripe retries.
  • withLangfuseFlush + .finally(flushLangfuse). Vercel freezes the lambda the moment the HTTP response is sent. Without a flush call, any in-flight Langfuse span is lost. Both the HTTP route and the waitUntil-detached pipeline flush in finally, so every generation call is costed, including the ones that fail halfway.
  • Trial-gated generation. Phase 3E re-targets Stripe trials so generation quota is live from signup; trial expiry bounces the user to the plan selector with the correct quota state preserved.
  • Phase plan + build log + migration-named deploys. GHOSTPOST_PIVOT_BUILD_PLAN.md for the direction, CHANGELOG.md for the deployed phases, and migration names like 20261002120000_phase_3c_generation_consumption so a deploy can be traced to the phase that produced it.

Verification

  • 170 Post rows, 175 PostVersion rows, 20 BlogDraftRun rows on the founding Stratas org at Phase 2A backfill. Backfill was itself the integration test of the new multi-tenant model – the real data had to end up readable under the constrained role.
  • Migrations applied to prod with verifiable timestamps. 20261002120000_phase_3c_generation_consumption on 2026-10-02 is one of several; the deploy log and the migration timestamp agree.
  • Langfuse per-provider split visible on the Agent Fleet dashboard, project stratas-products, trace tag ghostpost. The product's cost is attributable the day it goes live, not retrofitted later.
  • Phase 2B regression: when Prisma was switched to the constrained role, three API routes that had been quietly reading cross-org data (admin surfaces that hadn't been gated) failed in staging. Correct behaviour; promoted the admin role, kept the regular role fully constrained.

Result

Live in production at ghostpost.fun. Phases 0–3 shipped between July and October 2026 across PRs #58–#78. Dependabot drawdown is active (~52 alerts as of 2026-10-03). Not yet onboarding external customer orgs – pending Phase 5 (CMS connectors: WordPress + Webflow + webhook) and Phase 4 pricing confirmation.

Second-order effects:

  • Database-side tenancy stays correct even as new developers touch the code. The guarantee lives in ghostpost_app's permissions, not in a convention everyone has to remember.
  • The inline-pipeline migration freed the fleet's Trigger.dev budget for the products that actually need durable orchestration (Blogpost, Preservation, Saga). Not every product needs the same backbone.
  • The pivot is readable. A buyer, a hire, or a future contractor can read GHOSTPOST_PIVOT_BUILD_PLAN.md + the Phase PR arc and reconstruct why the product looks the way it does – rather than guessing from git blame.

What we would do differently: add the constrained role on day one of v2, not in Phase 2B. The three admin-surface regressions caught in staging were costless because of the pre-production catch, but they would have been easier to design against if the role boundary had been enforced before the first write path shipped.

Skills evidenced

  1. Multi-tenant RLS via constrained Postgres role – Prisma connects as ghostpost_app; tenant context set via set_config; policies do the enforcing. No per-query filter in app code to forget.
  2. Atomic quota engine – pg_advisory_xact_lock(orgId) around the consume write; Stripe webhook claim-before-work on retries; both idempotency problems solved in SQL, not in retry logic.
  3. Inline pipeline + Langfuse flush discipline – @vercel/functions.waitUntil + withLangfuseFlush + .finally(flushLangfuse) keeps traces across lambda freeze. Simpler than Trigger for request-scoped work, with correct cost telemetry.
  4. Multi-provider LLM router – Anthropic → OpenAI by tier, token counts carried through to Langfuse for per-provider cost split, Perplexity Sonar-Pro for research prompts.
  5. Live-pivot discipline in the repo – phase plan + build log + migration-named deploys; the pivot is a readable artifact, not a hidden reset.

Portable lessons

  1. Multi-tenancy belongs in the database, not in the ORM. The trust surface of "every developer, every query, forever" is too wide. A constrained role costs you one admin-role split and buys you a tenancy boundary you can defend.
  2. Not every pipeline wants to be durable. Trigger.dev is excellent when steps are long and need to survive restarts. For a request-scoped generation path, inlining with waitUntil and flushing telemetry in finally is simpler, cheaper, and correct.
  3. Pivots are either readable or they are mythology. A dated plan + a phase PR arc + migration names that encode the phase is one more hour of work than a quiet reset, and it's the only version a future reader can audit.

Want to discuss the decisions behind it?

Over fifteen years of consulting and growth, and hands-on AI delivery from discovery to daily operations. I can share my CV and talk through how I connect business outcomes with systems that run.

Available for a chat. In person in Stavanger, or remotely worldwide.