Skip to content

Environments

Every org has two fixed environments, development and production. They are a partition, not a label: the same clerkId in both is two profiles, the same event name is two streams, and nothing reads across the boundary.

You choose an app’s environment at creation, and it never changes. Every source on that app inherits it, so ingestion resolves the source a call names, derives its app, and stamps that app’s environment on every write. There is no environment parameter to get wrong on identify, track, or batch: configure your development build with the development app’s source id and everything it sends is development data.

App ids are readable slugs derived from the name (storefront web becomes app_storefront-web), unique per org. A rename never changes the id, and a name whose slug is taken is a 409 asking for a different name. This is the one id in Cowliss that is not a TypeID; source ids are ordinary src_ TypeIDs.

Effectively everything a write touches:

  • Profiles and identifiers: the same userId/clerkId/email in both environments is two people.
  • Events (ClickHouse), including the feed, stats, and segment evaluation.
  • Segment membership, journey instances and their enable state, and destinations (see below).
  • Email addresses, suppressions, deliveries, journey runs, quarantine entries, violations, idempotency keys.
  • Settings: the ingestion policy (permissive/strict) and the event retention window are set per environment.
  • GDPR: erasure and export run inside one environment.

Retention has a default and a cap per environment: 18 months in production, 30 days in development; there is no “forever”. The ClickHouse expiry is computed at insert from the environment’s setting, and an event written with a timestamp already outside the window expires immediately, so historical-timestamp validation rejects anything older than the environment’s retention.

Segments and journeys are defined once for the org and carry an environments set saying where they work. It defaults to both, and listing one is how you ship to development first: one row, one file, so the promotion shows up in a diff instead of in a copy.

What a definition produces never crosses the boundary. A segment computes membership per environment, and only in the environments it declares; a journey keeps its instances, its enable state, and its stats per environment.

One product that runs in both environments is two apps, app_storefront-dev and app_storefront. An app id lives in exactly one environment, so an app filter is an environment filter too, and both places that take one also take a list:

  • Segment definition: "appId": ["app_storefront-dev", "app_storefront"]
  • Journey trigger: appId: ["app_storefront-dev", "app_storefront"]

Name both of a product’s app ids and the definition matches it in whichever environment it is being evaluated in. Name one and it matches in that environment only, whatever environments says. Omit the filter to span every app in the environment at hand.

environments sits beside the definition on create and update, and membership rows are keyed by segment, environment, and profile. A write recomputes only the segments declared for that profile’s environment.

Creating a segment computes initial membership over existing history in every environment it declares, and widening the set does the same for the environments you added, so entries fire there for data that predates the change. Narrowing drops the membership rows in the environments you removed.

Journeys are deployed per environment. cow deploy --env development makes a release the development environment’s code and leaves production on whatever it was running; the same release is promoted with a second cow deploy --env production, so the two never share a deployment. Executions stay pinned to the release they started on, so a redeploy never changes a journey out from under a user mid-run. See Projects.

environments in defineJourney is the author’s rollout gate, in code: a journey that omits an environment is deployed there but never active. The enable toggle is the operator’s kill switch, and it is per environment: it acts on the environment the caller has selected, so a journey can run in development while it is switched off in production, and it survives redeploys because it belongs to the journey key rather than to a release. A journey is live in an environment only when it declares that environment and is enabled there.

A destination belongs to one environment, chosen at creation and immutable, exactly like an app. Names are unique per org and environment, so crm can exist in both and point at a different receiver in each: a call to api.webhook.send({ destination: "crm", … }) resolves the name in the environment its execution is running in. The sender identity follows the same rule, one per org and environment, so development mail can leave from a different address than production mail.

Admin reads and writes select an environment with the X-Cow-Environment header; absent means production, and a value outside the enum is a 400 (a typo must never silently read production). Ingestion endpoints ignore the header: the app behind the source a call names decides.

Every surface sets the same header for you:

  • Dashboard: a header toggle, production by default, with a persistent banner while development is selected. Every list, feed, overview, and the settings page follow the selection.
  • CLI: cow --env development … or COW_ENVIRONMENT=development.
  • MCP server: COW_ENVIRONMENT at startup; one environment per server process.

A journey that runs in development behaves exactly like production up to the provider’s door, and then stops.

  • Email is captured, not sent. The send activity evaluates every gate (journey enabled, sending pause, suppression, credit, frequency cap, consent, verified domain) exactly as in production, and a refused send records the same reason with a would_skip_* status. When every gate passes, the template is rendered and the delivery settles as would_send with the rendered subject, html, and text stored under payload.rendered, beside the template data. Nothing reaches SES, so the row has no provider id, receives no feedback, and is terminal. The dashboard labels these rows Captured and shows the rendered message on the delivery detail page, so the link in a verification or opt-in email can be opened straight from the log.
  • Webhooks deliver for real. A development destination URL is POSTed to, signed, with retries and the auto-disable strike counter, exactly as in production. That is what a local receiver is for.
  • A development run is free. Captures use the dry-run statuses, so the frequency cap, the stale-send sweep, the sending-protection rates, and the meter never see them, and a completed development instance records no journey run. Development events still meter like production ones.

External BI should filter on the environment column every events row carries.