john mark lowry
§ ai tools · 2026

MERIDIAN

The AI-visibility prototype had grown to ~40 overlapping screens. Meridian is the clean-room successor: multi-tenant on migration one, provenance enforced by the database and CI, and a queue that is also the database.

team one internal tool, deployed in production on the agency's internal aws/eks platform. the scoring model, gap engine, brand-truth library, vendor specifics, and any lexus figures stay off this page on purpose — the infrastructure is the story.

01 · the moment

A colleague's AI-visibility prototype (the instrument in the GEO case study) had proven the domain and produced some genuinely good solves — and had grown to 24 routable modules and eight settings tabs. Gap prioritization lived in three modules sharing one hook; “share of mentions” appeared in three places under three names; and the product's crown-jewel concept, provenance, had a three-way name collision between what the chips said and what the code meant. Underneath: the brand was a free-text string, the tenant id was nullable (“legacy, don't scope” when null), the vendor account id leaked into request params and cache keys, and SQLite and Postgres were hand-mirrored table for table under an invariant enforced only by comments.

Decision: don't fork it. Leave it live and read-only for salvage, spend two days on docs before a line of code — seven locked ADRs, a reuse catalog (the “do-not-lose” list), a data model, a six-area information architecture, a design standard — then rebuild from an empty repo, porting only the parts that earn their place.

02 · the reframe

Three, all visible in the ADRs. From screens to a spine: six areas with one job each (home, setup, visibility, diagnose, produce, ship); time is a toggle, not a module; nav label = route = page title, enforced by a parity test. From conventions to constructions: tenancy is row-level security plus one query wrapper plus a lint gate; provenance is a single origin-class enum rendered one-to-one by the chip and pinned by a test; fabricated claim language fails CI via a grep gate; synthetic data is stamped so it can never feed a headline KPI. Multi-tenant on migration one: the real client is row one and a synthetic second tenant is seeded in the same migration, because row-level-security policies are only proven by a second tenant existing.

03 · the routes

Postgres only. Killing the SQLite + Postgres hand mirror — the single biggest maintenance tax in the old persistence layer — and gaining RLS, JSONB, and real constraints. Tenant id is denormalized onto leaf tables so RLS policies are join-free, with a composite foreign key so the denormalized value can't drift. No ambient bypass: even the schema owner is under FORCE ROW LEVEL SECURITY; elevated access sets the tenant per call, auditable at each call site instead of everywhere at once.

Three roles from one managed master. The container entrypoint runs bootstrap → migrate → serve: create the admin, app, and scheduler roles from the master once, migrate as admin under an advisory lock, serve exclusively as the app role with no bypass. The managed database's master isn't a true superuser and can't create a bypass role, so the scheduler's one cross-tenant read (which clients to refresh) became a single-table policy plus a security-definer function — the platform constraint forced the tighter design.

The queue is the database. pg-boss over a separate worker system so there is exactly one datastore: a daily cron pod enqueues one job and exits (scheduling is platform-guaranteed and independent of the worker's health), the worker fans out one job per tenant target under RLS, and failed vendor fetches don't auto-retry because rate-limit budgets are shared — the next cron is the retry path. A pull profile is the only way to build a pull plan, with a per-profile call budget, so a caller physically cannot widen the weekly cron into the 168-call history trap.

The API is the sole session issuer. The web tier forwards the cookie verbatim, holds no session state and no service credential — no confused deputy — and a grep gate bans any cookie or token parsing in the web package. A scoped-read test proves a viewer session already equals the future client-portal shape: reads 200, mutations 403, other tenant 404.

Measure, never synthesize. The live-engine probe asks two providers' grounded-search APIs a brand's tracked prompts and stores the verbatim answer, de-duplicated citation URLs, and mention flags — classed measured, capped at fifteen prompts × two engines per brand per cycle, failures stored as honest error rows. The LLM insight cards get a catalog of already-measured numbers and may only cite numbers in it; the server validates every number in every card against the catalog and labels the result simulated — never KPI-eligible.

04 · the architecture

A pnpm/Turborepo monorepo — web, api, worker, and contract / core / db / ingest packages — with dependencies pointing inward only. Four workloads on the platform: the public web (server rendered), the private API on cluster DNS, the worker, and the enqueue-and-exit cron. 28 tables over 18 hand-written SQL migrations; twelve tenant tables under forced RLS; 33 API endpoints; nine web routes; ~440 tests including six end-to-end flows. The honesty chokepoint is one module in core: data origin → origin class → KPI-eligibility allowlist (only measured and derived) → chip, with parity tests pinning the database enum, core, contract, and web vocabulary to each other. A kpi_version on the derived cache separates “the formula changed” from “the inputs changed” when last week's number moves.

Flow: daily cron enqueues into the Postgres job queue; the worker pulls the vendor API and live-engine probes, writing under row-level security; the honesty chokepoint routes measured and derived to headline KPIs and simulated or unknown to 'not measured'; the private API serves the public web tier
one datastore, one query path, one chokepoint — the chip is a rendering of the enum, not an opinion
05 · learned the hard way

A throwaway spike before the build found three platform facts the docs didn't mention: a ~60-second ingress cap (504 at 60.6 s, unrelated to the load balancer's hour-long idle timeout); a 40-second boot migration reports ready and then crash-loops (entrypoint migrations must take seconds; slow ones need their own job); and an injected plaintext connection URL that the managed database rejects — so the client builds TLS from components and refuses to boot otherwise. The first real deploy added three more, each now a CI gate: a missed copy in every Dockerfile (a build-only job), the job library's “create the queue before you send or jobs silently no-op” (an end-to-end test asserts a non-zero enqueue), and a web shell that shipped unstyled (a style guard).

Then production. Once model keys landed, the home page put live provider calls on its critical path and loaded in 15–30 seconds; the fix was a derived-cache read (instant on hit) with the deterministic tier served immediately on miss and enrichment in the background — stale-while-revalidate, about a second. And the hardening audit found that the job library's dedup key was a silent no-op on default-policy queues: the “weekly” probe had fired on every daily cron — seven times the model spend — and the double-click guard on the probe endpoint was fiction. A queue key can't express “once per week”; the probe-run archive became the ledger and dedup moved into the application.

06 · the outcome

Live on the internal platform, auto-deploying on push, with real client data flowing from the vendor source on the daily cron and weekly live-engine probes scheduled. 155 commits in 23 calendar days, one author, built agentically against the ADRs; ~23.6k lines across three apps and four packages, a quarter of it tests. What it didn't do, stated plainly: it did not replace the prototype in place (that stays live with its author), and adoption isn't something the repo can claim — the hub that indexes it still described it as “planning complete” when I last looked, which is the kind of docs rot the system itself was built to refuse.

From conventions to constructions. Every trust rule the prototype enforced by discipline — tenancy, provenance, no fabricated claims — became something the database or CI enforces. Tenancy is a database guarantee, not a per-query if.

the insight
ran onHono API (private) + React Router 7 web (public) + pg-boss worker · PostgreSQL + Drizzle · FORCE ROW LEVEL SECURITY · 3 DB roles · pg-boss (queue = the database) · daily enqueue-and-exit cron · live-engine probes (two providers' grounded search) · pnpm + Turborepo · Vitest + Playwright (~440 tests) · production on AWS

← all work