previews / audits / demo env readiness

Demo environment · Cross-team pre-read · 2026-07-23

Standing up a demo env: what actually bites

Pierre wants a demo environment. The compute stamps out in a day, exactly as the working assumption says. The product services are the hard part, and this audit says how hard, in numbers, verified against the code across five codebases. The whole thing turns on four decisions taken before any Terraform runs.

Status: Decided — demo firm in prodFor: Aaron George's demo-env working sessionAttendees: Daniele Esposti, George, Sean Daley, Adolfo
TL;DR. The environment exists in a day. The services work in weeks, unless scope is cut. The compute side is genuinely pattern-driven (lhctl auto-enrols a flagged env, dm is a clean 2-char name like gb/us), which matches the working assumption that infra is simple. The product-services surface is not: three env registries, roughly ninety env-keyed Terraform map entries across repos, about forty hand-authored SOPS secret files, ~143 SSM params if ingestion stays on Flightcontrol, and 22+ vendor consoles with no IaC coverage at all — a fourth pillar in its own right. prd-gb sitting half-provisioned at one of thirty-two secret files is the existence proof of where this stalls. Four decisions collapse or explode that surface, and they are decisions, not config. The meeting has since decided (see the addendum): no separate env — an isolated demo firm in production instead, which the code says is roughly three small patches plus one real payment decision.
3
env registries that must move in lock-step before anything plans
~71 / ~90
env-keyed Terraform maps in platform-v3, of ~90 across all repos
~40
hand-authored, KMS-encrypted SOPS secret files across repos
~143
SSM params per env, if ingestion stays on Flightcontrol
22+
vendor consoles with no IaC coverage — a fourth pillar
1 / 32
secret files provisioned for prd-gb — a stalled env, frozen

Two tracks, and only one of them is easy

The invite's working assumption is that compute infrastructure is fairly simple and the product services will be the hard part. The audit confirms that split and puts numbers on it. Worth stating both sides plainly, because the risk is entirely on one of them.

stamps outCompute

lhctl auto-enrols any env flagged in its registry. dm has no naming collisions and the same 2-char shape as gb and us, so VPC, cluster and service stamping is pattern-driven. This is the part that matches "fairly simple".

fans outProduct services

Every app service reads env-keyed maps, hand-authored secrets, per-env vendor state and hardcoded backend URLs. None of that is stamped. It is enumerated, one entry at a time, and much of it lives in vendor dashboards no Terraform touches.

The four decisions that determine everything else

These are the load-bearing part of the pre-read. Each one either collapses the surface above to something dev-shaped and cheap, or explodes it into new app code and a second production-grade estate. They are choices about the demo story, so they belong to this room, not to whoever runs the Terraform afterwards.

1
What does account_env become for *-dm?

The shared lawhive.core.enums.Environment only accepts LOCAL, TEST, DEV and PROD. Every Python service — ingestion (×5), lawrence-engine, agents (×3) — crashes on boot with a Pydantic validation error if ENVIRONMENT is anything else. So ENVIRONMENT has to stay dev or prd, which means the demo env is invisible to application code: it inherits whichever env's behaviour it collapses into.

dev-dm collapses to

Indistinguishable from dev-gb at the app layer. Safe, because dev behaviour is safe.

prd-dm collapses to

Behaves like real production. Isolating it needs a new enum value plus code branches — not config.

A prd-dm sends real things to real people

platform-api gates side-effects on DEPLOYED_ENVIRONMENT == FC-PRD (matterCreate.ts, sendFeedback.ts, the Slack integrations, customerio.ts). A demo env that reads as prod fires every one of those for real. A demo matter created on stage becomes a real Slack notification, a real Intercom ticket, and hits prod Customer.io routing.

Real Slack notificationsReal Intercom ticketsProd Customer.io routing
2
Which backends does the demo env's AI stack talk to?

The vendored lawhive.content_retrieval library hardcodes the platform-v3, case-context, retrieval and search URLs per enum value (config.py:68-137) and ignores the Terraform-injected URL env vars entirely. All matter, VFS and case-context traffic from agents and lawrence-engine routes to shared dev1 or prd1 regardless of what config says.

A demo env cannot point its AI services at a demo platform-v3 without changing that shared library. Until it is changed, the demo AI stack reuses dev (or prd) backends by design. The decision is whether that is acceptable for the demo story, or whether the library change is in scope.

3
What does the demo env track — develop, main, or manual promotion?

Deploys are branch-triggered: a push to develop runs the dev phase (dev-gb), a push to main runs the prd phase (prd-us) (platform-v3/.github/workflows/deploy.yml:8-12,244,292). A demo env has to pick which branch it rides, and every choice carries a cost.

track develop

Always the latest work, but inherits dev's instability — every merge deploys straight into a live demo.

track main

Stable, but features lag. Demos usually exist to show unreleased work, which lives on develop or behind a flag.

manual promotion

Pin a known-good build before each demo. The generated phase workflows already accept workflow_dispatch, so it is a new phase in lhctl.deploy.yaml + a CI regen, not new machinery.

Manual promotion is probably the right answer, but it is not free: someone owns "promote before the demo" forever. That is one of two recurring ownership costs this room should put a name against — the other is feature-flag maintenance, in the silent-failures column below.

4
Which deploy stack does ingestion run on?

Ingestion is live on Flightcontrol: flightcontrol.prd.json is the real config surface (~143 SSM params per env). The new-infra Terraform exists but is scaled to zero. Flightcontrol envs are hard-listed dev/prd only, and Flightcontrol-era services map to new-infra via a hardcoded { dev = "dev-gb", prd = "prd-us" } per service (infrastructure-v3/services/*/main.tf).

So a demo env is invisible to everything still on Flightcontrol or Vercel. The fork: duplicate the whole FC config (a new env object, ~143 SSM params, IAM policies by ARN), or accelerate the new-infra migration first and stamp ingestion the same way as everything else.

The pitfall inventory, by how it fails

Everything below is verified against code. It sorts into three failure classes, and the middle one is the dangerous one. Hard failures stop you at plan or boot — loud, and you fix them before anything ships. Silent failures deploy green and do the wrong thing, so they surface as a confusing incident days later. The third class is work no Terraform can do for you: someone clicks through a vendor dashboard.

Silent failures are the scariest class because a green deploy reads as success. The column is emphasised for that reason.

Hard failuresWon't plan · won't boot · loud
Three env registries, lock-step. lhctl/cli/src/envs.ts (compiled into the CLI, needs republish), infra/shared/tf/modules/core/constants/envs.tf, and the VPC CIDR table infra/network/tf/modules/ip_allocations/allocations.tf (no /20 block = no VPC). Each fails at a different layer.
platform-v3~71 env-keyed TF maps hard-fail at plan on an unknown key — service.tf, third_parties.tf, every third-party module (Clerk, Stripe, Twilio, Knock, PostHog, Connect, Upstash, Inngest, Nutrient). One map already ships an unfilled REPLACE-ME placeholder. Only one uses lookup(..., null) gracefully.
ai-platformingestioninfraagentsSame pattern elsewhere: ai-platform main.tf, ingestion ingestion.tf:110, legacy.tf:41, search.tf:803, infra service.tf:82, agents chat / message-drafting lawrence lookups.
SOPS secrets don't exist yet. 32 files in platform-v3, 6 in ingestion, 3 in agents, 1 in ai-platform — each hand-authored and KMS-encrypted. .sops.yaml may need a new creation rule for a new account.
Cross-repo ordering. agents TF apply fails unless v3-lawrence has published its values first (module.lawrence_api_values). v3's first deploy must create DBs and write <env>_ci DSNs before the first migrate.
CI matrices are hardcoded. Add the env to lhctl.deploy.yaml in each repo and re-run lhctl ci generate; ai-platform gates ci_migrate.sh on dev|prd and pins OIDC role ARNs per account.
Silent failuresDeploys green · does the wrong thing
Langfuse labels are binary. production iff env is literally prd, else development/latest. A prd-dm that isn't literally "prd" silently pulls non-prod prompts; agents' compaction prompt label is hardcoded production the other way.
Wrong INNGEST_ENV = silent event-routing loss (in ai-platform's own docs). App IDs are hardcoded and shared, so a demo env in the same Inngest environment collides with real workers — and even a separate env still shares the account budget (see the third-parties pillar).
Shared observability tenancy. Axiom dataset is v3-<account_env>; agents don't set a per-env dataset at all (defaults agents-dev). Demo logs mix into dev/prd; Langfuse host and public keys are hardcoded shared.
Feature flags are a permanent per-flag tax. PostHog projects are per app-env combo (e.g. "[dev] [v3] Legal OS"), and flags are scoped purely by the project API key the env carries. Share the dev project and a demo flag toggle is a dev flag toggle. Give the demo its own project and every future flag is created and maintained in triplicate — and the state a demo wants (unreleased features ON) is exactly what neither the dev nor the prd project encodes. This is ongoing cost, not one-time setup: one of the two recurring ownership items (with "promote before the demo" from decision 3).
S3 name collisions on account_env. lawhive-ingestion-${account_env} and lawhive-artifact-generation-${account_env} resolve to the real dev/prd buckets if the env collapses — data bleed into the warehouse.
ingestionIngestion hardcodes the matter buckets it reads. The Textract region map (pdf_extractor_textract.py:101-106) only knows v3-matters-prd/dev, v3-platform-emails-prd and lawhive-platform-cases-*, and silently falls back to eu-west-2 — a demo bucket gets OCR'd in the wrong region without erroring. Below it, the s3-handler library is written against "the v3-matters bucket" by design (s3_handler/handler.py:1), DEFAULT_BUCKET defaults to v3-matters-local (env.py:101-103), and source buckets are readable only when tagged ingestion-access-granted. A demo env's buckets need four things: creation, the tag, region-map entries, and the SSM param.
Queue schema strips -{ENVIRONMENT}. queues/queue.py:66-85 — if names carry -prd-dm but ENVIRONMENT is prd, schema lookup raises "No schema found for queue". Naming must match ENVIRONMENT.
PgBouncer & graphile RLS. Agent worlds need a direct session connection; a txn-mode pooler makes UI streams hang deployed-only. graphile _private_tasks RLS needs the root-migrates / rw-runs split or runs stick Pending. Health-check grace of 0 rolls slow-boot services back on day one.
Manual, no IaCA dashboard, not a plan
DNS + CloudFront + ACM for every app/API hostname (all hardcoded per env), including *.dm.v3.lawhive.co.uk for ingestion.
OpenSearch. New domain plus a manual bun os:bootstrap (no deploy runs it), and the case-context-summary index has no mapping file — create it out-of-band or that route 404s on a fresh cluster.
Databases. Infra team writes provider/users/cdc.json to s3://.../values/<env>/db/; lawrence-engine needs its 4 logical DBs + BigQuery CDC; ingestion needs its DB + Alembic migrate; matter buckets need the ingestion-access-granted tag.
Repoint downstream consumers. ingestion's SNS topic and service URLs are consumed by lawrence-api, case-quality and content-retrieval from other repos — all rewired per env.
Every third-party vendor. 22+ consoles, each a per-env tenant / key / webhook decision — big enough that it gets its own pillar below.

A hard failure costs you an afternoon and a clear error message. A silent failure costs you a demo that quietly notified a real customer, or an AI answer served from the wrong corpus, discovered only when someone asks why. Budget the review time against the middle column, not the left one.

Third parties, the fourth pillar

The three registries, the TF maps and the SOPS files are all inside our repos. The vendors are not. Every vendor v3 touches needs a per-env decision — new tenant, shared tenant, or stub — and the Terraform only ever references these; it never creates them. That is a whole class of work no plan can stamp, big enough to sit alongside the three failure classes rather than as a footnote inside them. The verified v3 inventory alone is 22 vendors, before the AI stack adds its own.

Grouped by what a new env actually needs from each, so the shape of the work is visible rather than a flat list of logos.

New per-env tenant in the console
Create a workspace / environment / dataset; wire its keys into SOPS.
Inngest envDoppler projectSentry projectPostHog projectUpstash RedisAxiom datasetsSanity datasetKnock envSendbird appAmazon Connect ·~14 maps
Keys + webhook repointing
Test-vs-live keys, and ~16 inbound webhooks re-pointed at per-env Hookdeck endpoints.
Clerk ·domainsStripeTwilioNylasSavvyCalCustomer.ioTallyThereforeElevenLabsIntercomHookdeck
Shared API keys — the AI stack
Mostly account-level keys, decided shared vs per-env; Langfuse needs prompts under the right label.
Langfuse ·3 projectsOpenAIAnthropicGeminiExaCohereOpenLawsCourtListenerLiteLLMDeepgram
The assumption trap
The vendor people wave through as "fine" — and why it isn't. Worked below.
Inngest env: freeInngest budget: sharedInngest priority: below prod
Worked exampleInngest — free to create, not free to run

Inngest is the vendor everyone assumes is handled, because creating an environment is trivial. The catch is that "environment" and "budget" are separate axes: you get the first for free and share the second with production.

Free · per env

Custom environments are unlimited and free to create — a dashboard op. Each gets its own event key + signing key, so two new SOPS secrets.

Capped · account-wide

Concurrency and event volume are account-level plan limits (Pro = 100+ concurrent), shared across every environment. A demo env spends the same pool as prod.

Deprioritised

Custom environments execute at lower priority than production. A demo run competes with prod for that shared budget, and loses.

So a separate Inngest environment fixes the silent routing collision (the INNGEST_ENV item above), but does nothing for throughput: a heavy demo can still be starved by prod, or starve it. That is a plan-tier and scheduling question, not a provisioning one.

Sources: inngest.com/docs/platform/environments · inngest.com/pricing

Two items are lead-time, not config — start them first
Nutrient / PSPDFKit licence is domain-bound. A new demo domain likely needs a licence amendment, and the amendment lead time is unknown. This is a procurement conversation, not a Terraform change, so it wants starting before anything else.
Twilio numbers are procurement, not config. Provisioning numbers and their callbacks is a purchasing and setup step per env, not a value in a map. Everything downstream that expects a working number waits on it.

The order it has to happen in

A few of the hard failures are ordering constraints, not missing pieces — they pass in isolation and fail if run out of sequence. The three registries move together before anything else can plan. Within a repo, the deploy phase has to create databases and write the <env>_ci DSNs before the first migrate. Across repos, v3-lawrence has to publish its values before an agents apply can read them.

flowchart TB
  R["3 env registries in lock-step
lhctl envs.ts · core constants.tf · VPC CIDR allocations.tf"]:::gate D["v3 deploy phase
create DBs · write <env>_ci DSNs"] M["First migrate"] V["v3-lawrence publishes values
module.lawrence_api_values"] A["agents TF apply"] R --> D D --> M R --> V V --> A classDef gate fill:#FBE7D6,stroke:#B5491F,color:#5A2A12,stroke-width:2px;
Three ordering gates. The registries are the root: nothing downstream plans until all three carry dm. Then two independent chains — v3's deploy-before-migrate, and v3-lawrence-values-before-agents-apply.

Where I'd land, and the honest fork

Two shapes are worth costing. They are not equivalent, and neither dodges the four decisions above — decisions 1 and 2 constrain both equally, because the enum coupling and the hardcoded content_retrieval URLs sit below the deployment topology.

dev-dm — the cheap one
dev account · test-mode keys · FC-DEV gating

Because it collapses to dev behaviour, there are no real-world side-effects: test-mode third-party keys, FC-DEV gating, dev observability tenancy. It is dramatically cheaper to stand up and safe to demo on. The cost is that it is indistinguishable from dev-gb at the app layer, and its AI stack talks to shared dev backends.

prd-dm — the expensive one
needs app-code changes before it is safe

Anything that reads as prod fires real Slack, Intercom and Customer.io. Making a prd-dm safe is not a config task; it needs a new enum value and side-effect branches in app code before the first deploy. If the demo story requires production-grade isolation, that engineering is the price of entry.

The EC2 / all-in-one alternative
trades the fan-out for a stubbing project

Packing the estate onto one box dodges the multi-repo fan-out — the ninety TF maps, the forty SOPS files, the registry lock-step. It does not come free: it replaces that with stubbing Clerk, Stripe, Twilio, Inngest and Hookdeck locally, which the v3 app estate has no equivalent for. Ingestion's localstack setup shows part of this exists for dev, but only part.

So it is a different project, not a shortcut — worth costing head-to-head against dev-dm. The one thing it does not change: the Environment enum and the content_retrieval URL couplings bind it exactly as they bind the deployed options.

The meeting outcome

This pre-read is the "list of potential pitfalls" the session set out to produce, sorted so the room can spend its time on the four decisions rather than re-deriving the surface. Two costs outlive setup and need an owner named today: feature-flag maintenance and "promote before the demo". Notes and follow-ups in the shared meeting doc.

The evidence

Every claim is anchored to a file and, where it matters, a line. Scanned across platform-v3, agents, ingestion, ai-platform and the infra repos (lhctl, infra, lawhive.infra.compute, infrastructure-v3). prd-gb at 1 of 32 secret files is the load-bearing existence proof.

Addendum · decided at the working session · 2026-07-23

The decision: a demo firm in production, not a demo environment

A separate env was rejected. The demo runs as an isolated firm in prod.

The room landed on the same economics this audit lays out: a separate environment's infrastructure cost and management overhead — the fan-out quantified above — was judged not worth it. The chosen shape is one or more isolated demo firms living in production, with their synthetic data excluded from analytics and reporting, and feature flags plus firm scoping controlling which features they see.

No new envDemo firm(s) in prodSynthetic data out of reportingFlags + firm scoping for access

Two things unlocked it. Demos originally moved to dev because creating a matter in prod required a real monetary payment; admins can now create shell matters directly, so that constraint is gone. And Lukas's worry that demos run on last-minute work was resolved by the release cadence: prod lags develop by only a few hours, so if a build is demo-ready it is prod-ready — no need to sit the demo on an unstable branch.

Daniele's mind map weighed three paths. The third won on cost; its open questions moved onto the chosen path.

Forked env from develop

Infra cost, its own release process, a resume-per-demo story, open hotfix questions. This audit's surface.

Develop + code freeze

Cheap, but blocks releasing while a demo is staged.

Production + demo accounts · chosen

No new estate. Open questions become payments, content prep, and reset.

What the demo-firm path actually costs (verified against platform-v3)

The "zero lines of code" framing is close but not literal. Most of the side-effect fan-out this page worried about already skips admin-created matters, so the real work is roughly three small patches and one genuine decision.

already safeThe side-effect fan-out skips admin creation

The admin direct-create path (createMatter.mutation.ts → matter-service createMatter.ts) never enters the fan-out: the Inngest matterCreate function triggers only on lead-api/lead.converted (matterCreate.ts:26), and the matter-service/matter.created event admin creation emits (createMatter.ts:160) has zero subscribers. No celebratory Slack, no work / assignment / ledger setup, no Customer.io (lead pipeline only), no Intercom.

one gateKnock: 49 sites, a single chokepoint

Admin creation still fires Knock invites (createMatter.ts:153), but all 49 Knock call sites funnel through one place (notifier.ts:56 + dispatch.ts:46) that is already firm-context-aware — the enrolledToMarketplace firm-segment gate at dispatch.ts:21-37. One gate suppresses everything, email and SMS included.

pattern existsPer-firm flag is a solved shape

Firm.caseAlertsEnabled (schema.prisma:184) is already a per-firm boolean gating a side-effect, and Firm.metadata JSON is a zero-migration home for an isDemoFirm flag. Nothing new to migrate to start.

~3 patchesThe realistic build

A firm flag, the Knock chokepoint gate, and a PostHog identify demo tag copying the existing impersonation / isActor exclusion in the three PosthogProvider.tsx files. Caveat: PostHog's firm group type exists but posthog.group() is never called — firm rides on person properties only, so exclusion must add the group call or filter on person properties.

the one hard itemStripe — demo payments need a real decision

Live-vs-test is keyed by market, not firm (stripe/client.ts:8), and no test-firm / discount / bypass mechanism exists anywhere; admin MOTO payments (createMOTOPayment.ts:94) also hit live Stripe. So demo firms either route to a test key or have their payment procedures blocked — a decision, not a config toggle. This is the concrete answer to the mind map's still-open "how to handle demo payments?", which the code does not answer today.

two gapsLoose ends for a usable demo

Outbound client email goes via Microsoft Graph (graph/client.ts), outside the Knock chokepoint, so it needs its own handling. And admin-created matters get no messaging channel — channel creation lives in the skipped lead fan-out (createChannel.ts:15) — so a usable demo probably needs that gap filled. Slack is 11 scattered but staff-facing sites: low priority.

Pre-demo "data massaging" straight in the database was tolerable in dev. In production it needs a far more careful stance — the practice to watch as the demo moves onto the real estate.

Open items and owners

All owned by Adolfo out of the session. Note the two recurring costs the pre-read flagged — "promote before the demo" and flag maintenance in triplicate — died with the separate-env decision: a single prod firm carries neither.

Open itemOwnerWhat it resolves
Decide demo payments: test key vs blocked payment proceduresAdolfoThe one code-hard item; the mind map's unanswered payments question
Exempt demo data from reporting metrics; assess descoping the demo firm from standard reportingAdolfoTest it, then brief the data team on the mechanism
Document demo-prep requirements and the reset storyAdolfoThe mind map's "prepare content" and "whether/how to reset" questions