# Decision records

https://exo.now/docs/adr

> Every architecture decision record, rendered directly from docs/adr.



The pages in this section are compiled straight from the Markdown in
[`docs/adr/`](https://exo.now/docs/adr/0001-reversible-content-backend). The ADR files are the single
source of truth: they carry no docs-specific frontmatter, and their title, status, and date are
read from each record's own heading and bullet list. Links between records resolve to docs pages,
and links to other repository files point at the file on `staging`.

When records conflict, the newest accepted ADR wins. For example, ADR 0003 (Convex Auth v2)
superseded ADR 0002 (Clerk identity boundary), ADR 0012 records that Clerk is fully removed, and
ADR 0013 (EXO is mission control; apps pick components) supersedes ADR 0011 (forge, Bases and Ships).

<AdrIndex />

<Callout title="Not yet on staging" type="info">
  ADRs 0005–0007 were accepted on `main` on 2026-09-27 and will appear here automatically once
  they reach `staging`. Until then, read them on `main`:
  [0005 EXO Verify](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0005-exo-verify-verification-service.md) ·
  [0006 Exo Analyze](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0006-exo-analyze-product-analytics.md) ·
  [0007 Exo Deploy (was exo-mobilize)](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0007-exo-mobilize-mobile-release-toolkit.md).
  Summaries are on the [platform services](https://exo.now/docs/platform) pages.
</Callout>

## Writing a new ADR [#writing-a-new-adr]

1. Add `docs/adr/NNNN-short-slug.md` with a `# ADR NNNN: Title` heading and `- Status:` and
   `- Date:` bullets, following the existing records.
2. Open it through the normal canary → `staging` path. The docs pick it up at build time with no
   further change.


---

# Maintaining these docs

https://exo.now/docs/contributing

> How the Exo docs are built, where the content lives, and how to add or change a page.



## Framework: Fumadocs [#framework-fumadocs]

The docs are built with [Fumadocs](https://fumadocs.dev) (MIT), a documentation framework native
to the Next.js App Router. They are served by the existing Exo Next.js app at `/docs`, so they
share its build, Docker image, Railway service, and staging → production path. There is no second
deployment.

| Piece                | Location                                                                            |
| -------------------- | ----------------------------------------------------------------------------------- |
| Authored pages (MDX) | `content/docs/**`                                                                   |
| Sidebar order        | `meta.json` in each folder of `content/docs`                                        |
| Decision records     | `docs/adr/*.md`, rendered in place under `/docs/adr/*`                              |
| Roadmap board        | `roadmap/roadmap.json`, rendered on `/docs/roadmap`                                 |
| Source wiring        | `src/lib/docs/source.ts` (Fumadocs MDX macro API, no codegen step)                  |
| Route and layout     | `src/app/(docs)/`, a root layout separate from the landing page                     |
| Exo theme            | `src/app/(docs)/docs.css`, which maps Fumadocs tokens onto the canonical Exo tokens |
| MDX components       | `src/components/Docs/`                                                              |
| Search               | `GET /api/docs-search`, a static index over every page including the ADRs           |

## Adding or changing a page [#adding-or-changing-a-page]

1. Add or edit an `.mdx` file under `content/docs/`. Frontmatter needs `title` and should include
   a one-sentence `description`.
2. List new pages in the folder's `meta.json` to control their position in the sidebar.
3. Link to other pages or repository files with **relative paths**, for example
   `../../../docs/ai-routing.md`. Links to docs pages become in-site links, and links to other repo
   files open on `staging`, so the same link works in the repository browser.
4. Cite the source for every fact. If the repository does not record something, write **TBD**.
5. Merge to `staging` and review the page at [https://staging.exo.now/docs](https://staging.exo.now/docs). Docs are only ever reviewed on Staging.

## Components available in MDX [#components-available-in-mdx]

| Component            | Use                                                                                     |
| -------------------- | --------------------------------------------------------------------------------------- |
| `<Callout>`          | Notes, warnings, and known inconsistencies between sources                              |
| `<Cards>` / `<Card>` | Section overviews                                                                       |
| `<Steps>` / `<Step>` | Ordered procedures                                                                      |
| `<Tabs>` / `<Tab>`   | Alternatives, such as staging versus production values                                  |
| `<StackFacts>`       | The platform panel on each stack page, read from `src/platform/integrations/catalog.ts` |
| `<AdrIndex>`         | The list of decision records                                                            |
| `<RoadmapBoard>`     | The roadmap board                                                                       |

## Design rules [#design-rules]

The docs follow the [Structured Liquidity design standard](https://github.com/Interspace-Ventures/exo/blob/staging/docs/design-standard.md). The
theme introduces **no new colors, radii, or fonts**: `docs.css` points Fumadocs' semantic tokens at
the Exo tokens defined in `src/app/(frontend)/globals.css`, and the navigation uses the Exo layer
mark from `src/components/Logo`. Light is the default and dark is the alternate, sharing the
landing page's theme preference.

## Checks [#checks]

`pnpm check` covers the docs: `tsc` type-checks the MDX wiring, `pnpm lint` runs ESLint plus the
Structured Liquidity and visual-vocabulary gates, and `pnpm build` compiles every page statically. A broken
MDX file fails the build.


---

# Overview

https://exo.now/docs

> What Exo is, what it owns, and how these docs are organized.



EXO is mission control for launching apps. Its products are **components**: Exo ID, Exo Key, Exo Deploy,
EXO Verify, Exo Analyze, Exo Rank, Exo Inform and the other services in the
[platform](https://exo.now/docs/platform) and [stack](https://exo.now/docs/stack) docs. People build **apps**, and
each app picks the components it needs. It is modular: no app uses everything. EXO is infrastructure, not
a general-purpose application generator and not a CMS. Its reusable core is schemas, access
controls, provider adapters, provisioning, deployment contracts, evidence, and documentation
([architecture](https://github.com/Interspace-Ventures/exo/blob/staging/docs/architecture.md),
[ADR 0013](https://exo.now/docs/adr/0013-exo-is-mission-control-apps-pick-components)).

Exo lives at [exo.now](https://exo.now). Its shared staging environment is
`https://staging.exo.now`, and every managed app stages under it at `staging.exo.now/{app}`.

## The product hierarchy [#the-product-hierarchy]

EXO is mission control for launching apps. Its components are its products. People build apps,
and each app picks its components.
Construct, one of those apps, publishes Surfaces, and Surfaces carry Cards.

| Term             | Meaning                                                                                                                                                                                                                          |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Organization** | Ownership and collaboration: a person or a team. Legacy Payload `tenants` records are migration input, not a synonym for anything here.                                                                                          |
| **Account**      | A walled-off space on EXO with its own users, data, secrets, environments and audit trail. Anything that needs walled-off money or data (for example Foundation's donor money) gets a separate account, not a separate platform. |
| **Component**    | One EXO product an app can pick, declared in the app's `project.yaml` as a top-level `components` list. Components are either core or specialty (see below).                                                                     |
| **App**          | What people build on EXO. It picks the components it needs and owns its persistent runtime state, privileged workflows and operating responsibility. The artwork shows an app as a rocket ship; the prose always says app.       |
| **Starter kit**  | A saved set of components that new apps start from. Each app still declares its own components.                                                                                                                                  |
| **Version**      | One iteration of an app. **Launch** means a version reaching Production; Staging is the launch pad and EXO Verify is the preflight.                                                                                              |
| **Surface**      | An interaction layer for users, published by Construct: a web surface (website), a mobile surface (PWA), a phone surface (iOS or Android app), or any combination. Surfaces are **published**, not launched.                     |
| **Card**         | A UI unit that sits on a Surface and gives it functionality. Cards inherit the Surface's identity and release boundary, and are published with it.                                                                               |
| **Page**         | A routable content unit inside a web Surface, owned by Construct. Exo sees Pages only as typed resources for grants, routing, deployment evidence, and aggregate policy.                                                         |
| **UI component** | A reusable implementation or UI unit inside an app's code, with no identity responsibility. Not the same thing as an EXO component.                                                                                              |

### Core components and specialty components [#core-components-and-specialty-components]

**Core components** are the ones most apps need:

* `source`: code and its history (Entire); recommended, but an app may opt out
* `identity`: sign-in for the app's users (Exo ID); an app with no users who sign in sets
  `signIn: false` and skips it
* `preflight`: checks before a launch (EXO Verify Level 1)
* `runtime`: where the app runs
* `secrets`: keys and credentials
* `evidence`: the record of what was checked and launched
* `data`: the app's database (Convex)

**Specialty components** are the ones only some apps need (not every app needs comms and support,
or model routing through Exo Rank):

* `comms`: email, SMS and messaging (Bird)
* `payments`
* `models`: AI model routing (Exo Rank)
* `support`: help for the app's users (Exo Ask, proposed)
* `analytics`: product analytics (Exo Analyze)
* `surfaces`: screens published through Construct

The control plane (Exo, driven through Hark, with Hermes as the open-source option; see the
[stack](https://exo.now/docs/stack/control-plane)) is recommended in the same way as source control, and an app
may opt out of it too.

EXO Verify requires preflight, runtime, secrets and evidence, plus identity unless `signIn` is
false. Source is recommended, not required. Data is core because almost every app uses it, but an app without a database
is still valid. A **starter kit** is a saved set of components, so a family of similar apps can
start from the same picks.

### Surface, app, or account? [#surface-app-or-account]

* **A Surface in Construct** when it is mostly screens over Construct's components.
* **Its own app** when it has its own backend and data.
* **Its own account on EXO** only when it needs walled-off money or data, for example regulated
  money or a different owner.

Applied: Hadron is an app in the Interspace account. Foundation's donor money lives in its own
account on EXO, while its landing page stays a Surface in Construct. If Hadron is sold, each
customer gets their own account on EXO and their own Hadron app, the same pattern as Parlour's
Construct.

## Five responsibilities, kept separate [#five-responsibilities-kept-separate]

Exo is easiest to understand as five connected responsibilities rather than one large admin
panel. They are deliberately separate: a green build is not a deployment, a provider deployment is
not a review, and a completed issue is not a Production approval.

| Responsibility | Question it answers                                                    | Primary interface               | Source of truth                                         |
| -------------- | ---------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------- |
| Work           | What are we trying to change, and who owns the next action?            | Exo portfolio                   | Exo work item                                           |
| Product        | What app, account, environment, grant, and release state do we manage? | Exo control plane               | Convex records and versioned contracts                  |
| Source         | What exact code and agent context produced the result?                 | Entire                          | Commit, branch, session, checkpoint, and tag            |
| Runtime        | What is configured and running now?                                    | Railway or another OCI provider | Live provider state and secrets                         |
| Evidence       | Is the declared version healthy, reviewable, and approved?             | Exo portfolio                   | Provider observations, manifests, checks, and approvals |

## What Exo owns, and what it does not [#what-exo-owns-and-what-it-does-not]

* **Exo owns** app registration, accounts, organizations and access, environments, provider provisioning,
  deploy/release/rollback state, runtime-dependency metadata, policy and security controls, audit
  evidence, orchestration, health, and bounded usage and cost aggregates
  ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)).
* **Construct owns** Page and website editing, Puck, publishing, Page authorization, and
  authenticated Surfaces.
* **Independent apps own** their domain records. Exo provisions and observes their resources and
  accepts only bounded operational observations. It does not centralize their prompts, content,
  user data, datasets, or financial ledgers.

## Platform services [#platform-services]

Exo provides its services as components that each app picks, so apps skip basic plumbing:

* **[Exo Key](https://exo.now/docs/platform/exo-key)**: the single identity provider and OAuth client. Apps are
  relying parties.
* **[EXO Verify](https://exo.now/docs/platform/exo-verify)**: code verification as a service, with a standard
  verify contract, sandboxed runs, live staging checks, and AI review.
* **[Exo Analyze](https://exo.now/docs/platform/exo-analyze)**: Exo-owned product analytics, from visits through
  cost to serve.
* **[Exo Deploy](https://exo.now/docs/platform/exo-deploy)**: one release path for every surface (web on Railway, mobile on Expo).
* **[Exo Rank](https://exo.now/docs/platform/exo-router)** (code and URLs still say router): Exo's smart model
  routing, one metered AI egress. Apps never hold provider keys.
* **Exo Inform**: docs and READMEs that agents and people can read (`/llms.txt`, the docs MCP tool
  and `exo docs` CLI); offering it to every app is planned
  ([roadmap](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/exo-inform.md)).

## How these docs are organized [#how-these-docs-are-organized]

<Cards>
  <Card title="Tenets" href="https://exo.now/docs/tenets" description="The four principles every decision is checked against." />

  <Card title="The stack" href="https://exo.now/docs/stack" description="Twelve dimensions: platform, boundary, replaceability, cost, and status." />

  <Card title="Cost and unit economics" href="https://exo.now/docs/stack/economics" description="Every cost figure recorded in the ADRs, in one place." />

  <Card title="Platform services" href="https://exo.now/docs/platform" description="The components apps pick: Exo Key, EXO Verify, Exo Analyze, Exo Deploy, Exo Rank and more." />

  <Card title="How work ships" href="https://exo.now/docs/shipping" description="Canary branch → Staging → Release Candidate → Production." />

  <Card title="Decision records" href="https://exo.now/docs/adr" description="The ADRs, rendered straight from docs/adr." />

  <Card title="Roadmap" href="https://exo.now/docs/roadmap" description="Rendered from roadmap/items." />
</Cards>

<Callout title="Conventions">
  Every statement here is drawn from the repository: `docs/`, `docs/adr/`, `roadmap/`, and the
  code. Each page links to its sources. Anything the repository does not record is marked
  **TBD**, not estimated. When a source document and these pages disagree, the source document
  (and the newest accepted ADR) wins, and the page should be fixed.
</Callout>


---

# Exo Analyze

https://exo.now/docs/platform/exo-analyze

> Exo-owned product analytics — one funnel from visit to margin, with PostHog as a swappable adapter.



Source: ADR 0006, accepted (owner-approved) on 2026-09-27. It is on `main` and not yet on
`staging`: [read it on main](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0006-exo-analyze-product-analytics.md).
Roadmap: [product-analytics](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/product-analytics.md).

## Why [#why]

RevenueCat and Stripe only report payments. Without visits, sign-up conversion, and cost to serve,
Exo cannot compute per-App unit economics or margin. Letting each App wire its own analytics
platform would repeat the integration per App and scatter the data.

## Decision [#decision]

* **Ingestion and schema live in Exo's Convex backend**, partitioned per App and per environment.
  Apps send events to one Exo endpoint through a thin SDK (web and Expo).
* **Exo stores the funnel end to end:**

  ```text
  visits → sign-ups (Exo Key) → paid (RevenueCat / Stripe webhooks)
         → cost to serve (exoAiBrokerJobs + exo-verify + infra)
  ```

  That join gives per-App acquisition, conversion, revenue per user, and margin.
* **PostHog sits behind Exo as a swappable adapter** for dashboards and session replay. Plausible
  and GA4 remain possible adapters.
* **Apps never integrate an analytics platform directly.**

## Unit economics [#unit-economics]

* Today's event volume fits PostHog's free tier (\~1M events/month). The incremental cost is Convex
  storage and function usage for the event table, expected to be a few dollars per month.
* Each App's dashboard reports **cost per active user** and **gross margin per paying user**.
* Beyond the free tier, Exo can sample or aggregate before forwarding to the adapter, while keeping
  full-fidelity funnel counts in Convex.

## Alternatives considered [#alternatives-considered]

| Option                             | Why not                                                                                          |
| ---------------------------------- | ------------------------------------------------------------------------------------------------ |
| Per-App PostHog, Plausible, or GA4 | No cross-App funnel, no join to Exo Key or Exo Router cost, and vendor lock-in in every codebase |
| PostHog as system of record        | Ties the revenue and cost-to-serve joins to a vendor schema                                      |
| RevenueCat and Stripe only         | The status quo: payments without the top of the funnel or cost                                   |

## Consequences [#consequences]

* Exo ships and versions the analytics SDK, and Exo Deploy bakes it into the mobile template.
* Event schema changes are platform decisions, reviewed like any other Exo contract.
* Privacy and consent handling (cookie banners, data deletion) is centralized in Exo.

## Status [#status]

Accepted; planned. Event schema, SDK, and endpoint: **TBD**.


---

# Exo Deploy

https://exo.now/docs/platform/exo-deploy

> One release path for every surface. Web on Railway, mobile on Expo (config, signing, TestFlight and store listings, Exo Key native sign-in, Exo Analyze built in).



Source: ADR 0007, accepted (owner-approved) on 2026-09-27. It is on `main` and not yet on
`staging`: [read it on main](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0007-exo-deploy-mobile-release-toolkit.md).
Roadmap: [mobile-delivery-pipeline](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/mobile-delivery-pipeline.md).

**Renamed 2026-10-02:** Exo Deploy became **Exo Deploy** and now covers every surface (web on
Railway, mobile on Expo, pages and embedded app surfaces). ADR 0007 keeps its original filename.
RevenueCat moved to [Payments](https://exo.now/docs/stack/payments).

## Why [#why]

Shipping to iOS and Android involves the same setup every time: app config, signing, store
listings, TestFlight, native sign-in redirects, and analytics. Exo's stack is TypeScript, React,
Convex, and Exo Key, and most code is written by agents. Universe already ships on Expo and is the
reference.

## Decision [#decision]

* **Expo is the standard mobile framework** for Exo Apps. Expo is an MIT-licensed framework on
  React Native.
* **EAS is optional and swappable** for build, submit, and OTA updates. Start on EAS Free, move to
  Starter when several Apps ship, and run an own Mac builder (`eas build --local` or fastlane) only
  at high volume.
* **Exo Deploy owns the release template:** app config, signing, TestFlight and store listings,
  Exo Key sign-in via a native redirect scheme (for example `universe://auth/callback`), and the
  Exo Analyze SDK built in.
* **The PWA-required-for-mobile gate** in the repository contract stays.

## Unit economics [#unit-economics]

| Stage                   | Fixed cost             | Marginal cost                                 |
| ----------------------- | ---------------------- | --------------------------------------------- |
| Apple Developer Program | $99/year (required)    | —                                             |
| EAS Free                | $0                     | Capped at 15 + 15 builds/month, OTA to 1K MAU |
| EAS Starter             | $19/month              | Usage beyond the $45 build credit             |
| Own Mac builder         | Hardware or hosted Mac | \~$0 per build; pays off only at high volume  |

At current volume the toolkit costs **\~$8/month** (Apple fee amortized). Move up a tier only when
build counts or OTA MAU exceed the current tier.

## Alternatives considered [#alternatives-considered]

| Option                         | Why not                                                         |
| ------------------------------ | --------------------------------------------------------------- |
| Bare React Native              | Same runtime without Expo's build, config, and OTA tooling      |
| Flutter                        | Dart rewrite, no official Convex client, OTA only via Shorebird |
| Capacitor / Ionic              | Webview feel                                                    |
| Kotlin / Compose Multiplatform | Kotlin rewrite                                                  |
| Lynx (ByteDance)               | Too young                                                       |
| Native Swift + Kotlin          | Two codebases                                                   |

## Consequences [#consequences]

* New mobile Apps start from the Exo Deploy template.
* EAS is a service choice, not a dependency: the template keeps a local-build path.
* Each App registers its native redirect scheme with Exo Key during onboarding.

## Status [#status]

Accepted; planned. Template location: **TBD**.


---

# Exo Key

https://exo.now/docs/platform/exo-key

> Exo is the only OAuth client and identity provider; Apps are relying parties that receive app-scoped RS256 tokens.



Sources: [ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider) and the
[Exo Key integration guide](https://github.com/Interspace-Ventures/exo/blob/staging/docs/exo-id.md). The guide is the integration reference; this
page summarizes it.

> **Naming:** Exo Key was called Exo ID until 2026-09-29. Technical identifiers, including the
> `/exo-id` endpoint paths, the `exo-id` ADR filename and the code, keep the old name until the
> code rename lands.

**Exo is the only OAuth client** registered with Google, GitHub, and (later) Apple. An App never
mounts Convex Auth or OAuth provider components, never holds `AUTH_PRIVATE_KEY`, `AUTH_JWKS`, or
provider client secrets, and never touches a provider console. &#x2A;*Onboarding an App means adding it
to `convex/lib/exoIdApps.ts`**, and that is the whole provider-side setup.

## Endpoints (staging) [#endpoints-staging]

| Purpose             | URL                                                                          |
| ------------------- | ---------------------------------------------------------------------------- |
| Issuer (`iss`)      | `https://frugal-eel-930.convex.site/exo-id`                                  |
| Authorize (browser) | `https://staging.exo.now/id/authorize`                                       |
| Token               | `POST https://frugal-eel-930.convex.site/exo-id/v1/token`                    |
| Revoke              | `POST https://frugal-eel-930.convex.site/exo-id/v1/revoke`                   |
| JWKS                | `https://frugal-eel-930.convex.site/exo-id/.well-known/jwks.json`            |
| Discovery           | `https://frugal-eel-930.convex.site/exo-id/.well-known/openid-configuration` |

Read the endpoints from the discovery document instead of hard-coding them. Production endpoints:
**TBD** (not recorded in the guide).

## Flow: authorization code + PKCE (S256) [#flow-authorization-code--pkce-s256]

<Steps>
  <Step>
    The App generates a `code_verifier`, its S256 `code_challenge`, and an opaque `state`.
  </Step>

  <Step>
    It redirects to `/id/authorize?app=…&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256`.
    Exo validates the `app` and `redirect_uri` pair server-side. An invalid pair renders an error page
    and never redirects.
  </Step>

  <Step>
    After sign-in, Exo mints a one-time code (valid for 60 seconds, single use, stored only as a
    SHA-256 hash) and redirects back with `code` and `state`.
  </Step>

  <Step>
    The App checks `state` and exchanges the code at the token endpoint. It refreshes before
    `expires_in`; refresh tokens rotate. Replaying the previous token within 30 seconds returns the
    same new token; any other reuse of a rotated token signs the whole session out.
  </Step>

  <Step>
    Sign-out posts the refresh token to the revoke endpoint, which always returns 200.
  </Step>
</Steps>

## Tokens [#tokens]

* **Access token:** RS256 JWT, 900 seconds. Claims: `iss`, `aud` (the App name), `sub` (the Exo
  user id, stable across Apps), `email`, `email_verified`, `name`, `iat`, and `exp`.
* **Refresh token:** opaque, 30 days, rotating.
* **Errors:** HTTP 400 with `invalid_request`, `invalid_grant`, or `unsupported_grant_type`. Any
  redemption attempt consumes the code. Too many token requests return HTTP 429 with
  `Retry-After`.

Relying Apps verify tokens in their own Convex backend with a `customJwt` provider that points at
the Exo Key issuer and JWKS. `ctx.auth.getUserIdentity()` then returns the Exo user id, email, and
name.

## Registered Apps [#registered-apps]

| App           | Redirect URIs (summary)                                                 |
| ------------- | ----------------------------------------------------------------------- |
| `observatory` | staging subpath, `observatory.bio`                                      |
| `construct`   | `staging.construct.page`, `construct.page` (server-side cookie session) |
| `rampart`     | staging subpath, `rampart.fit`                                          |
| `dropship`    | staging subpath, `dropship.wtf`                                         |
| `universe`    | staging subpath, native `universe://auth/callback`                      |

Redirect URIs match **exactly**, with no prefix, wildcard, query, or trailing-slash tolerance.
Origins control CORS on the token and revoke endpoints.

## Boundaries [#boundaries]

* **Authorization stays in the App (v1).** Exo Key proves who the person is. Allowlists, roles, and
  tenant grants are enforced by the App on every request.
* **Separate signing keys.** Exo Key signs with `EXO_ID_PRIVATE_KEY` and `EXO_ID_JWKS`, independent
  of Exo's own session keys. Rotation publishes the new key alongside the old one, waits at least
  the JWKS cache time (5 minutes) plus the access-token lifetime, then drops the old key.
* **Hard dependency.** Exo's Convex deployment is on the sign-in path for the whole portfolio.
  Its availability and key rotation are platform responsibilities.

## Cost notes [#cost-notes]

No license fee (Convex Auth is open source). Marginal Convex usage: **TBD**. The decision to leave
Clerk cited its per-MAU pricing
([ADR 0003](https://exo.now/docs/adr/0003-convex-auth-v2-identity-plane)).

## Status [#status]

Live on staging for the five registered Apps. Clerk retirement is done: EXO carries no Clerk code,
package, or variable, and its own sign-in and staging gateway run on Exo Key
([ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere),
[roadmap: convex-auth](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/convex-auth.md)). Centralized grants are future
work; staging tester grants are declared in `EXO_STAGING_GRANTS`.


---

# Exo Router

https://exo.now/docs/platform/exo-router

> Exo's smart model routing — one metered AI egress where an assessor scores each task, the utility function weighs capability against cost, and the Convex AI Gateway executes. Apps never hold provider keys.



Sources: [AI routing](https://github.com/Interspace-Ventures/exo/blob/staging/docs/ai-routing.md), `convex/aiBroker.ts`, `convex/ai/*`,
[services/arch-router](https://github.com/Interspace-Ventures/exo/blob/staging/services/arch-router/README.md), and the roadmap item
[ai-gateway-jev-routing](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/ai-gateway-jev-routing.md).

**Exo Router** is the name for Exo's own smart model routing: the whole path from an App's prompt
to a metered model call. Older code and docs call the HTTP half the "broker" (`convex/aiBroker.ts`,
`EXO_AI_BROKER_TOKEN`, `exoAiBrokerJobs`); those technical names are unchanged.

## Two pieces [#two-pieces]

### The endpoint (`POST /v1/ai/generate`) [#the-endpoint-post-v1aigenerate]

An HTTP action on the Exo Convex deployment. Construct, and later every Exo App, posts generation
jobs here instead of holding provider credentials. Exo Router owns model routing, budgets, and
provider keys, and records every job for unit economics.

* **Auth:** an Exo Key token (`exo_tk_…`) issued per tenant, optionally bound to one app, with
  scopes (`ai`, `usage:read`) and a 90-day default expiry. Only the token's SHA-256 hash is stored.
  The legacy shared token (`EXO_AI_BROKER_TOKEN`) still works and bills to the `legacy-broker`
  tenant. Phase 1 of ADR 0008 ([proposal](https://github.com/Interspace-Ventures/exo/pull/145)); see
  [Metering and usage](https://exo.now/docs/platform/exo-router#metering-and-usage).
* **Job types:** `page.draft` only, today.
* **Provider:** with `EXO_AI_MODEL` set, jobs go to the Convex AI Gateway, which holds the provider
  credentials. Without it, a deterministic local composer (`exo-local-composer`) serves the
  request so the loop works end to end.
* **Failure policy:** gateway failures fail the job loudly. A silent fallback would hide a spend or
  configuration problem.
* **Metering:** every job writes `exoAiBrokerJobs` (job type, status, provider, model, source
  count, duration, input and output tokens), plus tenant, vendor cost, markup, and price.

### The router (`convex/ai/router.ts`) [#the-router-convexairouterts]

```text
surface (help desk / chat)
        │
        ▼
convex/ai/router.ts  →  assessor (Arch-Router or Jev)
        │  utility = intelligence^w / blended cost
        ▼
convex/ai/chat.ts  →  Convex AI Gateway (OpenAI-compatible, service-token auth)
        │
        ▼
chosen model from aiModelPool
```

* **Fast path:** jobs that already know their category and difficulty (every broker job type:
  `page.draft` is writing, `classify.choice` extraction, `design.review` visual-review) skip the
  assessor; the value score still picks from the live pool. Decisions are memoised for 10 minutes
  per pool version, and every call still writes its own `aiRoutingLog` row. Routing adds a few
  milliseconds before the model call starts.
* **Assessors** (`EXO_AI_ASSESSOR`, for prompts without a caller category): `jev` = TypeSafe's Jev
  through the Convex AI Gateway Decisions API (same gateway token, 600 ms hard timeout), `arch` =
  self-hosted Arch-Router (`ARCH_ROUTER_URL`), `none` = pure utility at difficulty weight 0.5 with
  every decision flagged `fallback: true`. Unset defaults to `arch` when `ARCH_ROUTER_URL` is set.
* **Guardrail:** `utility = intelligence^w / blendedCost^(1-w)` per pool model, where `w` is the
  assessed difficulty (0–1) and `blendedCost` blends input and output prices 3:1. The assessor's choice gets a confidence-scaled bonus
  (`1 + 0.25 × confidence`), utility keeps it honest against cost, and the highest score wins.
* **Hot-swappable pool:** `aiModelPool` rows carry intelligence (0–100) and per-million-token
  costs. Edits take effect on the next request, with no redeploy.
* **Audit:** every decision lands in `aiRoutingLog`: surface, task, chosen model, utility,
  assessor provider, raw assessor response, the fallback flag, decision latency, and memo hits.
* **Surfaces today:** apps reach the router only through the broker endpoint
  `POST /v1/ai/generate` (decisions log as `surface: "broker:<jobType>"`). `ai/chat:chat` (routed)
  and `ai/chat:chatWithModel` (pinned) are internal actions for trusted server-side Exo code; they
  are not callable from clients.
* **Endpoint jobs:** with `EXO_AI_MODEL=auto` (or a per-request `routing: "auto"`), `/v1/ai/generate`
  jobs go through the router, and every job records its tier, routing-log id, and estimated cost
  in `exoAiBrokerJobs`. Unset `EXO_AI_MODEL` keeps the $0 local composer.

## What is Arch-Router? [#what-is-arch-router]

**Arch-Router** is a small open-weights model from Katanemo (Arch-Router-1.5B, 1.5 billion
parameters) built for one job: reading a prompt and choosing which route, and so which model,
should handle it. It does not answer the prompt itself.

In Exo Router it is the difficulty assessor. Exo runs it on its own Railway service from
`services/arch-router/`, served by llama.cpp as an OpenAI-compatible endpoint
(`POST /v1/chat/completions`). It is CPU-only, runs comfortably in \~1 GB RAM, and needs no
external account, so there is no per-call cost. The router sends it the task plus one route per
pool model and it answers `{"route": "<model>"}`. Arch-Router does not score difficulty directly,
so on `staging` the router derives `w` from the model it reaches for (that model's intelligence ÷
100, in `convex/ai/assessors.ts`), then applies the utility guardrail before picking a model. It replaced the
dormant Jev integration when TypeSafe closed signups.

## Model benchmarks [#model-benchmarks]

The catalog names **Artificial Analysis** as the source of independent intelligence, speed, and
price benchmarks for scoring models. The code does not use it yet: the five seeded pool models
(GPT-5 mini, Claude Haiku 4.5, Gemini 2.5 Flash, Claude Sonnet 4.5, GPT-5) carry intelligence
estimates of 72–94 that `seedPool` describes as "ours and meant to be calibrated from aiRoutingLog
over time". How and when Artificial Analysis scores feed `aiModelPool`: **TBD**.

## Privacy boundary [#privacy-boundary]

Exo may receive aggregate quality, latency, token, cost, reliability, and rollback findings. It
must not receive prompts, responses, conversation content, user identifiers, provider keys, or
application source ([Convex framework](https://github.com/Interspace-Ventures/exo/blob/staging/docs/convex-framework.md)).

## OpenAI-compatible endpoint (`POST /v1/chat/completions`) [#openai-compatible-endpoint-post-v1chatcompletions]

Apps that speak the OpenAI chat API (LiteLLM, PR-Agent, Minutes) call Exo here with an Exo Key or
the broker token. The app comes from the token, the `x-exo-app` header, `body.app`, or an
`exo-app/<app>/<model>` model prefix.

* **JSON (default):** the gateway's response is returned unchanged.
* **Streaming (`"stream": true`):** the response is `text/event-stream` with OpenAI
  `chat.completion.chunk` events, ending in `data: [DONE]`. Exo asks the Convex AI Gateway for
  `stream_options.include_usage` and meters the usage on the final chunk; the usage chunk is only
  forwarded when the caller also sets `stream_options.include_usage`. Auth, the spend cap and body
  limits are checked before the stream opens, and errors before the first byte keep the normal
  JSON error. An error after streaming starts arrives as one redacted
  `data: {"error": {...}}` event followed by `data: [DONE]`. If the gateway sends no usage, the
  job's tokens are estimated (about 4 characters per token, priced from the model pool when the
  model is pooled) and the job is flagged `usageEstimated`. If the gateway answers a streaming
  request with a plain JSON completion, Exo relays it as a single chunk plus `[DONE]`.

## Metering and usage [#metering-and-usage]

Every AI call is attributed to the tenant that owns the calling token. If the token is bound to
an app, that app is recorded and the app the request claims is ignored.

| Field              | Meaning                                                                   |
| ------------------ | ------------------------------------------------------------------------- |
| `vendorCostMicros` | What Exo pays upstream: the gateway-reported cost, or pool price × tokens |
| `markupBps`        | The tenant's markup in basis points (default 2000 = 20%)                  |
| `priceMicros`      | What the tenant is charged: vendor cost × (1 + markup), rounded up        |

* **Ledger:** each metered call appends one `usageLedger` row (tenant, dimension `ai`, app,
  model, tokens, cost, price). A running monthly total per tenant makes spend checks a single read.
* **Usage API:** `GET /v1/usage?month=YYYY-MM` with a `usage:read` token returns the tenant's
  events, tokens, vendor cost and price for the month, broken down by app and model.
* **Spend cap:** a tenant can have a hard monthly AI cap. Once spend reaches it, calls return
  HTTP 402 (`spend_cap_exceeded`) until the cap is raised or the month rolls over.
* **Issuing tokens (phase 1):** an Exo operator mints and revokes tokens with the deployment key
  (`npx convex run exoKeyTokens:mint`, `exoKeyTokens:revoke`, `exoKeyTokens:setBilling`). The
  plaintext token is shown once. Self-serve issuing comes with the Exo Key account UI.

## Cost notes [#cost-notes]

* The assessor has no per-call cost.
* Vendor cost and price are recorded per call and per tenant (see above). Fixed costs (Convex
  plan, Arch-Router hosting) are not yet allocated per tenant: **TBD**.

## Status [#status]

* **Live:** the `/v1/ai/generate` endpoint, used by Construct.
* **Built:** router, assessors, routing log, and model pool.
* **Live on staging:** Exo Key tokens, per-tenant metering, the usage ledger, `/v1/usage`, and
  monthly spend caps (ADR 0008 phase 1).
* **TBD:** Artificial Analysis benchmarks in the model pool.


---

# Exo Secure

https://exo.now/docs/platform/exo-secure

> Secrets load from each app's own 1Password vault at boot; platform env vars hold only one bootstrap token.



Source: [ADR 0014](https://exo.now/docs/adr/0014-exo-secure-secrets-from-1password), proposed on 2026-10-21 after
Railway's [security disclosure](https://blog.railway.com/p/security-disclosure-wednesday-october-21st).

## The rule [#the-rule]

Exo Secure never stores secrets as platform env vars. Railway, Vercel and other hosts hold one
bootstrap token per app (`OP_SERVICE_ACCOUNT_TOKEN`) and non-secret config. Everything else comes
from the app's own 1Password vault when the server starts.

## How it works [#how-it-works]

* **One vault per app environment.** EXO uses `Exo Staging` and `Exo Production`. Each has one
  read-only 1Password service account that can see only that vault.
* **References, not values.** Code lists what it needs as `op://<vault>/<item>/<field>` in
  `src/lib/exo-secure/manifest.ts`. References are safe to commit.
* **Loaded once, kept in memory.** At boot, `loadSecrets()` resolves every reference with the
  official 1Password SDK and keeps the values in memory. They are never written back to the
  environment. Server code reads them with `secret(name)`.
* **Fails closed in Production.** If the token, the vault or a required secret is missing, the new
  deploy refuses to start and the previous one keeps serving.
* **Local development** uses `op run -- pnpm dev` or a gitignored `.env.secrets.local` with
  `EXO_SECURE_SOURCE=file`.

## Turning it on for an app [#turning-it-on-for-an-app]

1. Create the vault and a read-only service account scoped to it.
2. Copy each secret into the vault under the item and field the manifest names.
3. On the host, set `OP_SERVICE_ACCOUNT_TOKEN`, `EXO_SECURE_SOURCE=1password` and
   `EXO_SECURE_VAULT=<vault name>`.
4. Redeploy, confirm the boot log says the secrets loaded from 1Password, then delete the old
   variables. The server warns by name about any that are still set.

## Exo Key gateway tokens [#exo-key-gateway-tokens]

Apps behind `staging.exo.now` used to trust one shared header secret. Exo Key now signs a short-lived
token for each app (`aud = exo-gateway:<app>`, five minutes), and each app checks it against the
public Exo Key keys. Apps hold no gateway secret, and a token for one app does nothing at another.
The `EXO_GATEWAY_TOKENS` flag (`off`, `dual`, `only`) runs old and new side by side while each app
moves over.

## Checked on every build [#checked-on-every-build]

EXO Verify's `security` command includes a `platform-env` check. Give it an export of the host's
variables (for example `railway variables --json`) and it fails when any value looks like a raw
secret, naming the variable but never printing the value. Without an export it skips.

```bash
railway variables --json | pnpm verify:platform-env -
```

## Where secrets live [#where-secrets-live]

| Kind                                                                            | Home                                                 |
| ------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Next server secrets (Twenty, gateway)                                           | The app's 1Password vault                            |
| Convex-only secrets (Bird, Exo Key signing keys, OAuth client secrets, AI keys) | Convex deployment env, for now (ADR 0014, section 3) |
| URLs, hosts, flags                                                              | Plain env; not secrets                               |

## Cost [#cost]

1Password service accounts come with the plan. Exo Secure makes one batched read per boot, so it
costs nothing per app, deploy or request and stays well inside 1Password's
[rate limits](https://developer.1password.com/docs/service-accounts/rate-limits/). It is included in
every plan, unmetered.


---

# EXO Verify

https://exo.now/docs/platform/exo-verify

> Code verification as a platform service — a verify contract, sandboxed runs, live staging checks, and an AI review pass.



Source: ADR 0005, accepted (owner-approved) on 2026-09-27. It is on `main` and not yet on
`staging`: [read it on main](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0005-exo-verify-verification-service.md).

## Why [#why]

Each App carries ad-hoc unit tests, and staging failures slip through. The motivating example: the
AI router passed all unit tests and review but fell back 100% of the time on staging, because a
fix never merged. Only a live behavioral check against the deployed environment catches that class
of failure.

Two constraints shape the design:

* The owner is moving from GitHub to Entire, so GitHub Actions and GitHub-app bots are out.
* Most commits are made by agents (\~300/month across the portfolio), so per-seat pricing is the
  wrong unit.

## Four layers [#four-layers]

<Steps>
  <Step>
    ### Standard verify contract [#standard-verify-contract]

    Every repository declares `verify` commands (`typecheck`, `lint`, `test`, `smoke`) in
    `project.yaml`. Exo runs what is declared, and a missing command is a visible gap, not a silent
    pass.
  </Step>

  <Step>
    ### Sandboxed runs on Railway workers [#sandboxed-runs-on-railway-workers]

    The preferred trigger is an Entire webhook, if Entire exposes one. The fallbacks are an
    `exo verify` CLI that agents run before pushing, then Exo polling. Inside the sandbox, an agent may
    also write and run targeted tests for the change.
  </Step>

  <Step>
    ### Post-deploy live checks against staging [#post-deploy-live-checks-against-staging]

    Health, auth (Exo Key JWKS and OAuth callbacks), and two or three declared user journeys per App,
    driven by Playwright or a browser agent.
  </Step>

  <Step>
    ### AI review pass [#ai-review-pass]

    An open-source engine (in the style of PR-Agent or Kodus) or Exo's own prompts. Every model call
    goes through [Exo Router](https://exo.now/docs/platform/exo-router), so cost is logged per App and the router keeps easy
    diffs on cheap models.
  </Step>
</Steps>

Results are stored in Convex as release gates, attached to the Entire trail, and gate the
staging → production promotion.

## Unit economics [#unit-economics]

| Option               | Pricing                                                                                     | Est. monthly at \~300 agent commits |
| -------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------- |
| **EXO Verify (own)** | \~$0.007 compute per 5-min 2 vCPU / 2 GB Railway run + $0.01–0.10 model tokens per run      | **\~$6–30**                         |
| Greptile Pro         | $30/seat/mo, 50 credits/seat, $1 per extra credit; non-GitHub/GitLab forges Enterprise-only | \~$300–900                          |
| CodeRabbit           | $24–30/user; CLI free at 3 reviews/hr on local git                                          | Per seat; CLI usable as an interim  |
| Graphite             | $40/user                                                                                    | Per seat, GitHub-bound              |

Cost per verified commit is roughly **$0.02–0.10**, recorded per App so that it shows up in each
App's cost to serve.

## Consequences [#consequences]

* Every App gains a `verify` block in `project.yaml`, and onboarding includes declaring its two or
  three golden journeys.
* Third-party review engines start out better tuned. Exo tracks catch rate against false positives per
  layer and will revisit buying the review layer if the gap persists.
* Live checks need a staging-only sign-in path for bots (seeded Exo Key test identities), because
  browser agents cannot pass Google's bot detection.
* **Open question:** does Entire expose webhooks or a checks API?

## Levels [#levels]

EXO Verify groups its checks into levels. **Level 1** is the baseline every managed App runs (it was
called the base test tier until 2026-10-12). Higher levels are reserved for later and not built.

* **Level 1 checks**: `exo-verify contract` (managed-repository contract), `exo-verify static` (the
  repo's own typecheck, lint, and UI-system scripts), `exo-verify playtest` (Playwright on phone and
  desktop: page loads, console errors, accessibility, overflow, images, screenshots), and
  `exo-verify review --level 1` (PR-Agent on the diff). `exo-verify all` runs them in order.
* **Security scanning (opt-in)**: `exo-verify security` runs gitleaks (committed secrets),
  osv-scanner (dependencies with known vulnerabilities) and Semgrep CE (risky code patterns) as
  plain CLIs. Turn it on with `"security": { "enabled": true }` in `exo-verify.config.json`. It reads
  files, never git, so it runs in Railway builds, and a scanner that is not installed is skipped
  with a warning rather than counted as a pass. A built-in `platform-env` check also fails when a
  host's variables export holds a raw secret ([Exo Secure](https://exo.now/docs/platform/exo-secure)); with no
  export it skips.
* **Config**: `exo-verify.config.json` in the App repo, with `"level": 1` (the default). Reports go
  to `.exo-verify/reports/`.
* **Any runner**: the same command runs on a laptop, in the pre-push hook, in a Railway build, in
  Entire, or in a thin GitHub Actions wrapper. No source host is the source of truth.
* **Package**: `@interspace/exo-verify` (one CLI for the contract and Level 1). Apps vendor the
  tarball from `pnpm verify:pack`. The vendored single file from `exo-verify vendor` has only the
  contract commands, because Level 1 needs Playwright and axe.

## Status [#status]

Built in part on `staging` (2026-10-01). What runs today:

* **Contract + CLI** (`packages/exo-verify`, zero dependencies): a `verify:` block in `project.yaml`
  (typecheck, lint, a list of test commands, build, smoke checks per env, `mode: enforce|report`).
  `exo-verify run [--gate] [--json <path>]` and `exo-verify smoke --env staging|production`. A missing
  command counts as a **gap**, never a pass. Only enforce mode exits non-zero.
* **Results**: `POST /v1/verify/runs` (service token) stores rows in the `verifyRuns` Convex table.
  `GET /v1/verify/status` returns the latest status for each app.
* **Build gates**: Exo's and Observatory's Railway builds run `exo-verify run --gate` in enforce mode. Construct
  (12 existing lint errors) and Rampart (tests assume a root base path) run it in report mode.
  Apps use a vendored copy (`scripts/exo-verify.mjs`) from `exo-verify vendor`.
* **Live checks**: a Convex cron runs every 15 minutes. It makes HTTP GETs to each app's declared
  smoke routes on staging and production. For drift, it records whether production's
  `/api/health` commit differs from staging's and since when. It also checks production domains
  over DNS-over-HTTPS.
* **Delivery guardrails** (2026-10-18): every test file now belongs to the gated Vitest run (a guard
  test fails on an orphaned suite). Smoke routes now include
  `/og/exo.png` and `/llms-full.txt`. `exo-verify drift` reports staging ↔ production drift from the
  two health endpoints, and `exo-verify smoke --wait-for-commit <sha>` smokes a deploy once it is live.
* **Pre-push hook**: `exo-verify install` turns on a versioned `.githooks/` for one worktree. The
  hook runs the gate and then hands off to the existing `.git/hooks` (Entire). To skip once, set
  `EXO_VERIFY_SKIP=1`. The skip is always written to a local log. It is also posted as a `skip` run
  (best effort) when `EXO_VERIFY_URL` and `EXO_VERIFY_TOKEN` are set.
* **AI review**: `exo-verify review --base <ref>` runs PR-Agent (MIT, pinned `pr-agent==0.46.0`,
  plain-diff mode) through Exo Router's OpenAI-compatible `POST /v1/chat/completions`, which logs
  per-app tokens and cost in `exoAiBrokerJobs`. **Off by default**: the Convex AI gateway is not
  enabled for the team, so the endpoint returns 503 `exo_router_unavailable`, records the failed
  job, and the CLI prints `review skipped: <reason>`.

Measured cost per run:

| Check                                                                                                      | Time measured                                                                                   | Cost                                                                                    |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Exo Railway build gate (typecheck 15.4s, lint 12.7s, Vitest 14.0s)                                         | 42 s added to each deploy                                                                       | No new service. If billed at Railway's 2 vCPU / 2 GB list rate: about $0.001 per deploy |
| App gates on Railway (Observatory: typecheck 8.7s, lint 9.7s, tests 1.1s) and locally (Construct, Rampart) | 20 s on Railway for Observatory; 7–9 s locally                                                  | About $0.0005 per deploy at the same rates                                              |
| Live sweep (4 apps, both envs)                                                                             | One Convex action plus about 40 GETs (smoke, health, and DNS-over-HTTPS; Exo alone declares 18) | About 96 sweeps a day, well inside the Convex plan's function-call quota                |
| Exo security scan in the gate (gitleaks about 1 s, osv-scanner about 2 s, Semgrep about 35 s)              | About 40 s added to each deploy; 0 MB added to the runtime image                                | No new service; under $0.001 per deploy at the same rates                               |
| AI review                                                                                                  | Not measured: the gateway is not enabled                                                        | Recorded per app once it is                                                             |

Not built yet: browser journeys (Playwright, Apache-2.0), Entire triggers, and sandboxed Railway
workers. Vitest (MIT) is the test runner for Exo itself.


---

# Platform services

https://exo.now/docs/platform

> EXO's components. Each app picks the ones it needs, so apps skip basic plumbing.



These services are EXO's components. EXO is mission control for launching apps: people build apps
on EXO, and each app picks the components it needs. It is modular, and no app uses everything. An
app declares its components in `project.yaml` as a top-level `components` list ([ADR 0013](https://exo.now/docs/adr/0013-exo-is-mission-control-apps-pick-components)),
and EXO Verify Level 1 checks that each declared component is wired. Core components are the
ones most apps need; specialty components are the ones only some apps need
([overview](https://exo.now/docs#core-components-and-specialty-components)). A **starter kit** is a saved set
of components that new apps can start from. Each service follows the same
pattern: Exo owns the platform relationship, keys, and schema, and apps call one Exo contract.
Swapping the platform behind a service is an Exo change, not an app change
([tenet 3](https://exo.now/docs/tenets#3-integrated-not-locked-in)).

| Service                                     | Dimension              | Decision                                                               | Status                             | Platform behind it                                                        |
| ------------------------------------------- | ---------------------- | ---------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- |
| [Exo Key](https://exo.now/docs/platform/exo-key)                    | Identity               | [ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider) | Live on staging                    | Convex Auth v2                                                            |
| [EXO Verify](https://exo.now/docs/platform/exo-verify)              | Continuous integration | ADR 0005 (on `main`)                                                   | Accepted; not built                | Vitest, Playwright, Railway workers, open-source review engine            |
| [Exo Analyze](https://exo.now/docs/platform/exo-analyze)            | Analytics              | ADR 0006 (on `main`)                                                   | Accepted; planned                  | PostHog adapter                                                           |
| [Exo Deploy](https://exo.now/docs/platform/exo-deploy)              | Deploy                 | ADR 0007 (on `main`)                                                   | Web live; mobile accepted, planned | Railway; Expo; EAS optional                                               |
| [Exo Rank](https://exo.now/docs/platform/exo-router) (code: router) | AI                     | [AI routing](https://github.com/Interspace-Ventures/exo/blob/staging/docs/ai-routing.md)                              | Endpoint live; routing built       | Convex AI Gateway, Arch-Router; Artificial Analysis TBD; TypeSafe dormant |

## How they join up [#how-they-join-up]

The services are designed to compose into one per-App cost-to-serve record:

* **Exo Key** supplies the sign-up step of the Exo Analyze funnel.
* **Exo Rank** records every model call in `exoAiBrokerJobs`.
* **EXO Verify** sends its AI review calls through Exo Rank and records its cost per App.
* **Exo Deploy** bakes the Exo Analyze SDK and Exo Key native sign-in into the mobile template.
* **Exo Analyze** joins visits, sign-ups, payments, and cost to serve into per-App unit economics.

See [cost and unit economics](https://exo.now/docs/stack/economics) for which of these joins exist today.


---

# Roadmap

https://exo.now/docs/roadmap

> What Exo is building now, next, and later, rendered from roadmap/items.



The roadmap lives in the repository as one Markdown file per item in
[`roadmap/items/`](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/README.md). `scripts/build-roadmap.mjs` compiles those files into
`roadmap/roadmap.json`, and the board below renders that file, so this page never restates roadmap
content by hand. Items link to their Entire trail for execution evidence.

To change the roadmap, edit or add an item file, run `node scripts/build-roadmap.mjs` (it fails
loudly on invalid fields), and commit both the Markdown and the regenerated `roadmap.json`, as
described in the [roadmap README](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/README.md).

<RoadmapBoard />

<Callout title="Staging vs. main" type="info">
  This board reflects the `staging` copy of `roadmap/`. The `main` branch also carries an
  `exo-verify` item and updated items that accompany ADRs 0005–0007. They will appear here once
  the branches are reconciled.
</Callout>


---

# Approval boundaries

https://exo.now/docs/shipping/approvals

> The consequential actions that never follow implicitly from a validated commit, a green check, or a merged review.



Source: [How Exo builds and ships](https://github.com/Interspace-Ventures/exo/blob/staging/docs/build-and-deploy.md#approval-boundaries).

The following **never** follow implicitly from a validated commit, staging integration, a green
check, a merged change review, a completed Exo item, or a `project.yaml` declaration:

* creating or replacing a Release Candidate;
* Production promotion or a public release;
* domain or DNS changes;
* adding or upgrading a paid service, or changing billing controls;
* database or persistent-data migration;
* credential creation, rotation, or provider-account linking; and
* external email or other communication.

These boundaries are deliberate. Routine code should move quickly to private Staging, and material
business and production actions should stay unmistakably human decisions. This is the
[fourth tenet](https://exo.now/docs/tenets#4-automated-not-unsupervised) in practice.

## Related gates [#related-gates]

| Gate                                   | What it checks                                                                                                           | Source                                                      |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| projects.dev credential and spend gate | Environment match, credential inventory (names only), recent spend, a configured global spend limit that is not exceeded | [Repository contract](https://github.com/Interspace-Ventures/exo/blob/staging/docs/repository-contract.md) |
| `pnpm check:ui-system`                 | Structured Liquidity foundation and visual vocabulary                                                                    | [Design standard](https://github.com/Interspace-Ventures/exo/blob/staging/docs/design-standard.md)         |
| Staging evidence                       | Exact commit, `/api/health`, access boundary, environment label, browser pass                                            | [Environments](https://exo.now/docs/shipping/environments)                          |


---

# Environments

https://exo.now/docs/shipping/environments

> Preview, Staging, Release Candidate, and Production — one purpose, one artifact, and one approval boundary each.



Source: [managed environment lifecycle](https://github.com/Interspace-Ventures/exo/blob/staging/docs/environment-lifecycle.md). These are
deployment stages, not product lifecycle labels. A project can be in private beta while running a
production environment, and setting `project.lifecycle` to `stable` never authorizes a deployment.

## Canonical names [#canonical-names]

| Stage             | Persistent   | Default Git source                 | Release channel |
| ----------------- | ------------ | ---------------------------------- | --------------- |
| Preview           | No, optional | Remote-canary or pull-request head | Internal        |
| Staging           | Yes          | `staging`                          | Internal        |
| Release Candidate | Yes          | `release`                          | Candidate       |
| Production        | Yes          | `main`                             | Stable          |

Human-readable names follow the pattern `{Project} · Staging`. Provider names and generated URLs
are separate operational metadata.

## Preview (optional) [#preview-optional]

An ephemeral, server-backed environment for the exact pushed canary or pull-request head. It
inherits staging configuration, uses isolated data when mutations are possible, and is removed
when the pull request closes. It must never inherit production customer data or credentials.

## Staging [#staging]

The persistent integration environment, showing the combined state of upcoming work.

* **Source:** `staging`. Agents never develop directly on it; they integrate validated commits from
  canary branches.
* **Deployment:** every update automatically deploys after the projects.dev credential and spend
  gate passes. No owner approval sits between the branch update and the deployment.
* **Access:** private by default, routed by the [staging topology](https://exo.now/docs/shipping/staging).
* **Optional services:** integrations such as outbound email may stay disabled when
  staging-specific credentials are absent. Health and read-only routes must keep working.

## Release Candidate [#release-candidate]

The production-like environment for the exact version proposed for the next release. It accepts
fixes only after the candidate is cut, promotes the same verified build onward, and applies to any
release worth freezing, including patches. Creating or replacing it needs explicit owner
direction.

## Production [#production]

The live environment. `stable` is its release channel label, not a name for the environment. It
deploys only the approved Release Candidate artifact after explicit owner approval on the
`release` → `main` pull request.

## Promotion rules [#promotion-rules]

1. No stage implies approval for the next stage.
2. The deployed commit, build checksum, configuration class, and approval evidence are recorded at
   every promotion.
3. Secrets and persistent data are isolated by stage. Production values never flow backward.
4. Rollback promotes a previously verified artifact or reverts through the normal approval path.
5. Provider state is observed separately from `project.yaml`. Drift does not rewrite the contract.
6. A provider capability is not configured until its environment, URL, access boundary, and
   lifecycle behavior are verified live.
7. Every deployment needs fresh projects.dev evidence: credential inventory checked, billing
   visible, a spend limit configured, and no limit exceeded.
8. Staging integration is not an approval gate. Domain, billing, destructive data, credential, and
   production migration actions keep their own approvals.
9. Ordinary staging code delivery is automatic. Changing the provider binding, credentials,
   domain, billing configuration, or persistent data is a separately authorized mutation.
10. Apps stage under `staging.exo.now` and Construct Pages under `staging.construct.page`. An App's
    production domain never owns its staging hostname.


---

# How work ships

https://exo.now/docs/shipping

> The one promotion path every Exo-managed App uses — canary branch → Staging → Release Candidate → Production.



This section summarizes the canonical operating guide,
[How Exo builds and ships](https://github.com/Interspace-Ventures/exo/blob/staging/docs/build-and-deploy.md). That guide stays authoritative, and
specialist contracts must not describe a different path.

```text
prompt → validated prompt-linked commit → Staging → Release Candidate → Production (Stable)
             remote canary └→ optional PR Preview ┘
```

## The path in one minute [#the-path-in-one-minute]

| Stage             | Plain-language meaning                                           | Git source                                      | Who moves it forward                        | Required evidence                                                     |
| ----------------- | ---------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------- |
| Plan              | Decide what outcome matters and who owns the next action.        | Exo portfolio item                              | Product owner or operator                   | Scope, owner, priority, and acceptance criteria                       |
| Build             | Make one coherent change without disturbing shared environments. | Short-lived remote canary branch from `staging` | Developer or agent                          | Pushed prompt-linked commit and relevant checks                       |
| Staging           | Show the integrated change in a private, realistic environment.  | `staging`                                       | Automatic after validation                  | Deployed commit, passing gate, health, access, and browser review     |
| Release Candidate | Freeze the exact version proposed for release.                   | `release`                                       | Owner explicitly directs candidate creation | Reviewed staging evidence, manifest, migration and rollback readiness |
| Production        | Put the approved candidate in front of its intended audience.    | `main`, channel `stable`                        | Owner explicitly approves the release       | Identical artifact, approval record, health, and live behavior        |

Preview is optional and temporary. It never replaces Staging or shortens the release path. A local
checkout is an implementation tool, never a review surface.

## Every target, one path [#every-target-one-path]

Every environment has one purpose, one artifact, and an unmistakable human approval boundary.
Responsive websites, PWAs, and iPhone apps share one versioned delivery manifest, release
identity, approval model, and evidence contract, while keeping platform-specific configuration
([cross-platform delivery](https://github.com/Interspace-Ventures/exo/blob/staging/docs/platform-delivery.md)).

* **Web** runs this path on [Railway](https://exo.now/docs/stack/deploy). The Staging → Release Candidate →
  Production path is live for web.
* **Mobile** apps follow the same path through Exo Deploy on Expo, from TestFlight to the app
  stores ([Deploy](https://exo.now/docs/stack/deploy)). Exo Deploy is accepted (ADR 0007) and planned.
* **Delivery targets are typed.** Shared release identity and evidence live at the Exo layer, and
  provider specifics stay inside target adapters.
* **Drift never rewrites the contract.** Provider observations report whether the live target
  agrees with the declared manifest
  ([environment lifecycle](https://github.com/Interspace-Ventures/exo/blob/staging/docs/environment-lifecycle.md)).

<Steps>
  <Step>
    ### Contributor loop [#contributor-loop]

    1. Start from the latest `staging`, confirm `entire status`, and create a short-lived remote
       canary branch.
    2. Make one coherent, prompt-linked change, and push checkpoints promptly.
    3. Run `pnpm check` while working: TypeScript, lint with the UI-system gates, and
       repository-safe integration tests.
    4. Run `pnpm verify` before integration: it repeats the checks and creates the production build.
    5. Open a change review, keep the Entire session context, and integrate into `staging`. Ordinary
       staging integration needs no owner approval.
    6. Verify the staging deployment: exact commit, deployment result, `/api/health`, private access
       boundary, environment label, and browser behavior.
  </Step>

  <Step>
    ### Release loop [#release-loop]

    1. The owner reviews the staging outcome and explicitly directs creation of the Release
       Candidate.
    2. Freeze the reviewed commit on `release` and promote the identical artifact.
    3. Verify migrations, rollback, release notes, health, authorization, and user-visible behavior.
    4. Present the [approval packet](https://exo.now/docs/shipping/releases#approval-packet).
    5. Only explicit approval of the `release` → `main` release authorizes Production.
    6. Verify the deployed commit or checksum, health, and live behavior before marking the release
       complete.
  </Step>
</Steps>

## Commands by intent [#commands-by-intent]

| Intent                                 | Command                   | Changes external state?                         |
| -------------------------------------- | ------------------------- | ----------------------------------------------- |
| Run the app locally                    | `pnpm dev`                | No provider change                              |
| Check an implementation                | `pnpm check`              | No                                              |
| Verify an integration candidate        | `pnpm verify`             | No                                              |
| Inspect credential and spend readiness | `pnpm delivery:gate`      | Read-only evidence only                         |
| Inspect provider resources and health  | `pnpm services:status`    | Read-only evidence only                         |
| Prepare a service change               | `pnpm services:plan-add`  | Creates a non-mutating local plan               |
| Apply an approved service plan         | `pnpm services:apply-add` | Yes; exact approval and execution gate required |

## In this section [#in-this-section]

<Cards>
  <Card title="Environments" href="https://exo.now/docs/shipping/environments" description="Preview, Staging, Release Candidate, and Production, and the promotion rules between them." />

  <Card title="Staging topology" href="https://exo.now/docs/shipping/staging" description="Apps under staging.exo.now; Pages under staging.construct.page." />

  <Card title="Releases and changelogs" href="https://exo.now/docs/shipping/releases" description="Stable N, internal versions, and the approval packet." />

  <Card title="Approval boundaries" href="https://exo.now/docs/shipping/approvals" description="What never follows implicitly from a green build." />
</Cards>


---

# Releases and changelogs

https://exo.now/docs/shipping/releases

> Every deployment gets an internal version and, when stable, a tenant-specific public "Stable N" release.



Source: [tenant releases and changelogs](https://github.com/Interspace-Ventures/exo/blob/staging/docs/releases.md).

## Two version identities [#two-version-identities]

| Identifier       | Example        | Audience                 | Purpose                                                    |
| ---------------- | -------------- | ------------------------ | ---------------------------------------------------------- |
| Internal version | `2.4.0+abc123` | Operators and developers | Precisely identifies the build, commit, or migration state |
| Public release   | `Stable 18`    | Site users and clients   | A simple, memorable indication of the stable version       |

Public numbers are independent for each tenant. The first stable release is `Stable 1`, and each
later one increments by one. Drafts, previews, and internal releases don't consume a number, and a
published number is never reused. **Stable N** is the default human-facing name in product emails,
changelogs, dashboards, and updates. The exact technical version stays available as secondary
evidence.

## Publishing workflow [#publishing-workflow]

1. Create a release draft for the tenant and record its internal version.
2. Add a title, summary, highlights, and optional detailed notes.
3. Link the prompt-linked commits, change review, Entire checkpoint, deployment, changelog, and
   roadmap item.
4. Review the release against the staging build.
5. Send the approval packet to an authorized owner.
6. After explicit owner approval on the release pull request, merge the candidate into `main` and
   publish it on the `stable` channel.
7. Exo assigns the next public release number and publication date.
8. Verify the live version, tenant health checks, and changelog entry.
9. Send the configured changelog email, first to the owner-preview address, and keep non-secret
   delivery evidence.

Publishing a changelog entry records a release. It does not bypass the release pull-request gate
or deploy code by itself.

## Approval packet [#approval-packet]

```text
Changed
- ...

Removed
- Nothing.

Pressure-test / concerns
- No specific concerns.

Validation
- ...

Deployment impact
- ...

Rollback
- ...
```

Call out data migrations, authentication or authorization changes, destructive operations,
provider changes, and tenant-specific behavior explicitly. Don't invent a concern when there is
none. Approval to merge code is not approval to deploy it unless the owner explicitly grants both.

## Cadence [#cadence]

* Integrate task-sized commits into staging as soon as they pass validation.
* Consolidate reviewed staging commits into a release pull request instead of approval-gating
  every staging update.
* Don't let approved work sit unreleased without a stated reason.
* Keep staging on the same commit and configuration intended for production.
* Never report a release as live until the deployed version and health checks are verified.

## Public changelog [#public-changelog]

Off by default. When a tenant enables it, the built-in renderer exposes `/changelog`, and external
renderers can read `GET /api/content/:tenant/changelog`, which returns only published, public,
stable releases.


---

# Staging topology

https://exo.now/docs/shipping/staging

> Exo owns staging for Apps; Construct owns staging for Pages. Review routes follow ownership, not production hostnames.



Source: [Exo staging topology](https://github.com/Interspace-Ventures/exo/blob/staging/docs/staging-topology.md).

## Canonical routes [#canonical-routes]

| Resource       | Canonical staging route                 | Example                              |
| -------------- | --------------------------------------- | ------------------------------------ |
| Exo platform   | `https://staging.exo.now`               | The Exo control plane                |
| App            | `https://staging.exo.now/{app}`         | `https://staging.exo.now/rampart`    |
| Construct Page | `https://staging.construct.page/{page}` | `https://staging.construct.page/dan` |

Construct is an App, so it stages at `staging.exo.now/construct`. It is also the Page platform,
so the Pages it manages stage on `staging.construct.page`.

## Routing and authorization [#routing-and-authorization]

The canonical route is a gateway address. A provider-generated hostname is an operational
coordinate and a source of deployment evidence, never the review URL.

1. The shared origin authenticates the session.
2. The gateway resolves the first path segment to a declared App or Construct Page.
3. The server validates an active, environment-specific Exo grant for that resource.
4. The gateway forwards only to the declared provider target and keeps the canonical browser URL.

Apps share one non-production identity plane without every signed-in user getting access to every
App. Health checks and signed webhooks keep narrow, documented exceptions.

<Callout title="Identity" type="info">
  Exo Key (Exo ID) authenticates the shared staging origin; Clerk is removed
  ([ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere)). The gateway checks
  an Exo ID session and an exact staging grant before it opens an app path
  ([staging access](https://github.com/Interspace-Ventures/exo/blob/staging/docs/staging-access.md)).
</Callout>

## Adoption inventory [#adoption-inventory]

The last verified observations recorded in the topology doc:

| Resource                | Route                           | Status                                                                      |
| ----------------------- | ------------------------------- | --------------------------------------------------------------------------- |
| Exo                     | `staging.exo.now`               | Verified live                                                               |
| Construct               | `staging.exo.now/construct`     | Verified live (Construct commit `875ac2e`)                                  |
| Rampart                 | `staging.exo.now/rampart`       | Verified live; removal of `staging.rampart.fit` is a separate domain action |
| Observatory             | `staging.exo.now/observatory`   | Verified live                                                               |
| Cosmograph              | `staging.exo.now/cosmograph`    | Verified live                                                               |
| Vector                  | `staging.exo.now/vector`        | Provisioning required                                                       |
| Universe                | `staging.exo.now/universe`      | Provisioning required                                                       |
| Dropship                | `staging.exo.now/dropship`      | Verified live                                                               |
| Construct-managed sites | `staging.construct.page/{page}` | Per-Page migration                                                          |

A route is never marked live from configuration alone. It needs provider health, exact-commit
evidence, and a browser pass.

## Per-resource migration checklist [#per-resource-migration-checklist]

1. Classify the resource as `platform`, `app`, or `construct-page`, and reserve its slug.
2. Record the current provider service, source branch, provider URL, and verified commit.
3. Configure gateway routing for the target path without changing Production.
4. Configure the non-production authorized origin and the resource-specific Exo grant.
5. Verify unauthenticated denial, authorized success, cross-resource denial, return paths, health,
   noindex headers, and the exact deployed commit.
6. Review the canonical URL in a browser.
7. Remove the old app-domain staging route, or make it redirect-only.
8. Attach evidence to the Exo work and release records.


---

# 12 · Artificial intelligence

https://exo.now/docs/stack/ai

> Apps send prompts to Exo Router, Exo's smart model routing. It scores how hard each task is and picks the least costly capable model through the Convex AI Gateway.



<StackFigure n="12" />

<StackFacts dimension="12 · Artificial intelligence" platforms="[&#x22;convex-ai-gateway&#x22;, &#x22;artificial-analysis&#x22;, &#x22;arch-router&#x22;]" status="Exo Router endpoint live on Convex; difficulty assessor and model pool built; Artificial Analysis benchmarks and per-App cost attribution TBD." />

## What it is [#what-it-is]

**Exo Router** is Exo's own smart model routing. Apps send prompts to Exo Router and never hold
provider keys. It scores how hard each task is, weighs model capability against cost, and sends
the call to the chosen model through the Convex AI Gateway. Every job is metered. In short: **an
assessor decides, and the gateway executes** ([AI routing](https://github.com/Interspace-Ventures/exo/blob/staging/docs/ai-routing.md)). The
design is on the [Exo Router](https://exo.now/docs/platform/exo-router) page.

## Platform [#platform]

* **Convex AI Gateway** is the OpenAI-compatible egress with service-token auth. Convex holds the
  provider credentials.
* **Artificial Analysis** is named in the catalog as the source of independent intelligence,
  speed, and price benchmarks used to score models. It is **not wired into the code yet**: the
  `aiModelPool` intelligence scores (0–100) are Exo's own seed estimates in
  `convex/ai/router.ts`, "meant to be calibrated from aiRoutingLog over time". The benchmark
  import is **TBD**.
* **Arch-Router** (Katanemo Arch-Router-1.5B, open weights) is the difficulty assessor,
  self-hosted on Railway via llama.cpp from `services/arch-router/`. See
  [What is Arch-Router?](https://exo.now/docs/platform/exo-router#what-is-arch-router).
* **TypeSafe** (Jev, System One) is a dormant alternate assessor: TypeSafe closed new signups
  (verified 2026-09-23).

## Boundary and replaceability [#boundary-and-replaceability]

* **Hot-swappable model pool.** `aiModelPool` is a table, so pool changes are data edits, not
  deploys.
* **Pluggable assessors.** The first configured assessor wins (`ARCH_ROUTER_URL`, then
  `TYPESAFE_API_KEY`). With neither configured, Exo Router uses pure utility and flags every
  decision `fallback: true`.
* **No silent fallback.** Gateway failures fail the job and are recorded.
* **Aggregate findings only.** Exo may receive aggregate quality, latency, token, cost, and
  reliability findings from Apps. It never receives prompts, responses, user identifiers, or
  provider keys ([Convex framework](https://github.com/Interspace-Ventures/exo/blob/staging/docs/convex-framework.md)).

## Cost notes [#cost-notes]

* **Assessor:** no per-call cost; CPU-only, \~1 GB RAM on Railway.
* **Routing policy:** on `staging`, `convex/ai/router.ts` computes
  `utility = intelligence^w / blendedCost`, where `w` is the assessed difficulty (0–1) and
  `blendedCost` is the 3:1 input:output blend of the pool row's per-million-token prices. Open
  PR [#115](https://github.com/Interspace-Ventures/exo/pull/115) (against `main`) changes it to
  `intelligence^w / blendedCost^(1-w)`. Easy tasks let cost dominate, and hard tasks weight
  intelligence.
* **Metering:** each pool row carries input and output cost per million tokens (USD).
  `exoAiBrokerJobs` records tokens and duration per job. On `staging` the endpoint does not yet
  compute `costMicrosUsd` and rows carry no App identifier, so per-App AI spend is **TBD**.

## Status [#status]

* **Live:** the Exo Router HTTP endpoint on the Exo Convex deployment (`POST /v1/ai/generate`),
  used by Construct. With `EXO_AI_MODEL` unset, a deterministic local composer serves requests so
  the loop works without keys.
* **Built:** `convex/ai/router.ts`, `assessors.ts`, and `chat.ts` with the routing log.
* **In progress:** sending `/v1/ai/generate` jobs through the router (PR #115), and per-App cost
  attribution
  ([roadmap: ai-gateway-jev-routing](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/ai-gateway-jev-routing.md)).
* **TBD:** Artificial Analysis benchmark import into `aiModelPool`.


---

# 04 · App state & storage

https://exo.now/docs/stack/app-state

> Convex is the live backend for every app: data, functions, real-time state, and files. Object storage only when a workload justifies it.



<StackFigure n="04" />

<StackFacts dimension="04 · App state & storage" platforms="[&#x22;convex&#x22;, &#x22;convex-storage&#x22;]" status="App state: Live. Exo's platform data is in Convex; exceptions need an ADR-level justification. Storage: Convex file storage for small blobs; large media on S3-compatible storage (Garage) per the infrastructure split." />

## What it is [#what-it-is]

Convex is the default persistence, transaction, function, and reactive-state layer for every state
Exo owns ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)). It holds tenants,
content, roadmap, billing, releases, delivery targets, observations, and identity, and serves the
live queries behind dashboards and boards ([infrastructure](https://github.com/Interspace-Ventures/exo/blob/staging/docs/infrastructure.md)).

### Storage [#storage]

Media covers uploaded files, images, and attachments. Construct's Convex deployment owns Page
media metadata, checksums, lifecycle, and references. Exo uses Convex file storage for help-desk
attachments and similar small blobs ([infrastructure](https://github.com/Interspace-Ventures/exo/blob/staging/docs/infrastructure.md)).

## Platform [#platform]

**Convex.** Exo runs two deployments, `frugal-eel-930` (staging) and `befitting-bullfrog-619`
(production) ([`convex/README.md`](https://github.com/Interspace-Ventures/exo/blob/staging/convex/README.md)).

### Storage [#storage-1]

**Convex Storage** for small files and for the authoritative metadata. Large media libraries stay
on S3-compatible object storage, which is "Garage today" according to the infrastructure split.

## Boundary and replaceability [#boundary-and-replaceability]

* **Exceptions are explicit.** An exception to Convex needs a concrete workload constraint, a named
  authority, reconciliation and rollback rules, and an exit path. Exo runs no other
  application database.
* **Derived indexes stay derived.** Specialized analytical or search indexes may live outside
  Convex, but they must be rebuildable and never the sole authority.
* **Function discipline.** Every query and mutation validates arguments and returns, derives
  identity server-side, enforces the resource boundary, and bounds result size. Privileged
  functions are internal, and retryable mutations use atomic idempotency receipts
  ([architecture](https://github.com/Interspace-Ventures/exo/blob/staging/docs/architecture.md)).
* **No secrets in tables.** Convex stores only non-secret provider coordinates and the names of
  runtime environment variables.
* **Exit.** Tenant exports use versioned JSON Schema and newline-delimited JSON with stable IDs
  ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).

### Storage [#storage-2]

* Large binary objects never go in Convex documents. Blob bytes may use Convex file storage or an
  external object store when size, transformation, retention, egress, or interoperability
  requirements justify it. **Convex stays authoritative** for ownership, checksum, metadata,
  lifecycle, and the opaque object reference
  ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)).
* Because references are opaque, moving bytes between stores is a reconciliation job, not a schema
  change.
* Exports include original media files, metadata, alt text, checksums, and a media manifest
  ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).

## Cost notes [#cost-notes]

* Convex plan and usage: **TBD**.
* ADR 0006 expects Exo Analyze's event table to add "a few dollars per month" of Convex storage
  and function usage at current portfolio scale.

### Storage [#storage-3]

Convex storage and object-storage costs: **TBD**. The ADR names egress as one of the valid reasons
to move bytes out of Convex.

## Status [#status]

* **Done:** Exo's data layer moved from Payload/PostgreSQL to Convex
  ([exo-convex-backend](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/exo-convex-backend.md)).
* **Retired:** Payload and its PostgreSQL code path (removed from Exo and Construct, October
  2026; the old databases are kept untouched until their data is exported), Clerk as identity
  (fully removed, [ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere)), and
  bespoke API services ([infrastructure](https://github.com/Interspace-Ventures/exo/blob/staging/docs/infrastructure.md)).

### Storage [#storage-4]

* **Live:** Convex file storage for small blobs.
* **TBD:** which Apps currently keep large originals on Garage, and the measured constraint for
  each (the ADR requires one).


---

# 08 · Communications & support

https://exo.now/docs/stack/communications

> One Exo communications layer sends email, text, and voice through Bird; customer records live in Twenty, an open-source CRM.



<StackFigure n="08" />

<StackFacts dimension="08 · Communications & support" platforms="[&#x22;bird&#x22;, &#x22;twenty&#x22;]" status="Communications: Planned (roadmap horizon: now). Resend is still the required default until the migration lands. CRM: TBD — named standard in the catalog; no integration is documented in the repository yet." />

## What it is [#what-it-is]

One Exo communications layer sends email, SMS, WhatsApp, Apple Messages, and voice. It is
consent-aware, runs server-side, uses per-app sending domains, and stays separate from product
data. Apps never hold provider keys. They call Exo's send function with their tenant ID
([roadmap: bird-communications](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/bird-communications.md)).

### CRM [#crm]

Customer context stays in the CRM, while forms and signals synchronize through replaceable,
consent-aware adapters (the stack description on exo.now).

## Platform [#platform]

**Bird** will be the single communications provider, replacing Resend and any one-off messaging
integrations.

### CRM [#crm-1]

**Twenty**, an open-source CRM. Its catalog posture is **optional adapter**, and the **App** is the
authority: customer relationships belong to the App, not to Exo.

## Boundary and replaceability [#boundary-and-replaceability]

* **Exo owns the infrastructure:** the Bird account, keys, DNS verification, templates, analytics,
  and suppression lists.
* **Vendor-neutral subdomains.** Each App sends from its own custom domain (for example
  `mail.2daysearly.com` or `inbox.2daysearly.com`) for recipient trust and per-domain reputation.
  Names are never `bird.`, so a provider swap never touches DNS.
* **Exo speaks as Exo** from an Exo-owned subdomain (`ops.exo.now` or `notify.exo.now`) for
  billing, operations, and agent notifications.
* **Adapter shape.** A Convex `communications` layer (an `outboundMessages` table plus per-channel
  `send` actions), with the same adapter shape as the billing providers.

### CRM [#crm-2]

* The catalog describes the boundary as "open-source customer context synchronized through
  replaceable intake adapters". The adapter contract itself is **TBD**.
* Related intake that exists today: Exo's own contact inquiries are stored in the Convex `contactInquiries` table, and the portfolio
  dashboard counts inbound volume over 30 days
  ([revenue monitoring](https://github.com/Interspace-Ventures/exo/blob/staging/docs/revenue-monitoring.md)).
* Direct customer contact data is excluded from Exo's revenue ledger
  ([commerce contract](https://github.com/Interspace-Ventures/exo/blob/staging/docs/commerce-contract.md)).

## Cost notes [#cost-notes]

Bird per-channel pricing: **TBD**. The roadmap notes that SMS, WhatsApp, and voice "land free on
the same layer — no new vendor later", which means no added vendor, not no usage cost.

### CRM [#crm-3]

Twenty hosting (self-hosted or cloud) and cost: **TBD**.

## Status [#status]

* **Planned**, priority high, horizon now, trail `IV-170`.
* **Scope:** migrate Resend call sites (Construct waitlist, contact, and email library; the
  Interspace inbound route; the Observatory changelog email; Exo templates), add inbound email via
  Bird mailboxes, and add domain onboarding with warm-up sequencing to tenant provisioning.
* **Until then:** Resend remains the required default email provider for active managed projects,
  and outbound email fails closed unless explicitly enabled
  ([repository contract](https://github.com/Interspace-Ventures/exo/blob/staging/docs/repository-contract.md),
  [environment lifecycle](https://github.com/Interspace-Ventures/exo/blob/staging/docs/environment-lifecycle.md)).

### CRM [#crm-4]

**TBD.** The repository names Twenty as the standard, but no adapter, ADR, or roadmap item exists
yet. Adding one is a next step.


---

# 10 · Continuous integration

https://exo.now/docs/stack/continuous-integration

> EXO Verify type-checks and tests every change before it deploys and checks live Staging and Production routes every 15 minutes; Vitest runs the code checks. Playwright browser journeys on Staging are planned.



<StackFigure n="10" />

<StackFacts dimension="10 · Continuous integration" platforms="[&#x22;vitest&#x22;, &#x22;playwright&#x22;]" via="EXO Verify" status="EXO Verify partly built on staging (2026-10-01): contract, CLI, build gates, 15-min live checks, pre-push hook; AI review waits on the AI gateway." />

## What it is [#what-it-is]

Continuous integration means **code checks and AI evals**. The goal: every change is type-checked,
tested, and clicked through in a real browser on Staging before anyone approves it (the stack
description on exo.now). Today the type checks, tests, and live route checks run; the
browser click-through is planned. A release is healthy, reviewable, and approved, not merely green in a provider
dashboard. This dimension is about verifying code, not identity; sign-in lives under
[Identity](https://exo.now/docs/stack/identity). The service design is on the [EXO Verify](https://exo.now/docs/platform/exo-verify)
page.

## Platform [#platform]

* **Vitest** (open source) runs typed unit and integration checks. In this repository,
  `pnpm test:int` runs Vitest with `vitest.config.mts`.
* **Playwright** (open source) drives browser checks against Staging. In this repository,
  `pnpm test:e2e` runs Playwright with `playwright.config.ts`.

The rest of EXO Verify is first-party: a verify contract, Railway sandbox workers, live checks,
and an AI review pass whose model calls go through [Exo Router](https://exo.now/docs/platform/exo-router).

## Boundary and replaceability [#boundary-and-replaceability]

* The contract is the command list each repository declares (`typecheck`, `lint`, `test`,
  `smoke`). Exo runs what is declared, and a missing command is a visible gap, not a silent pass.
* The AI review layer uses an open-source engine or Exo's own prompts. Third-party review
  (Greptile Enterprise) stays a later option if Exo's review quality falls short.
* No GitHub Actions or GitHub-app bots: the owner is leaving GitHub.
* The AI eval format (what an eval declares, how it is scored, and where results are stored):
  **TBD**. ADR 0005 records AI review and agent-written targeted tests, not a separate eval
  contract.

## Cost notes [#cost-notes]

From ADR 0005 (2026-09-27), at \~300 agent commits per month:

| Option               | Pricing                                                                                            | Est. monthly           |
| -------------------- | -------------------------------------------------------------------------------------------------- | ---------------------- |
| **EXO Verify (own)** | \~$0.007 compute per 5-min 2 vCPU / 2 GB Railway run + $0.01–0.10 model tokens per run             | **\~$6–30**            |
| Greptile Pro         | $30/seat/month, 50 credits/seat, $1 per extra credit; non-GitHub/GitLab forges are Enterprise-only | \~$300–900             |
| CodeRabbit           | $24–30/user; CLI free at 3 reviews/hour                                                            | Per seat               |
| Graphite             | $40/user                                                                                           | Per seat, GitHub-bound |

Cost per verified commit is roughly **$0.02–0.10**, recorded per App so it appears in that App's
cost to serve. Vitest and Playwright are open source.

## Status [#status]

* **Today in this repo:** `pnpm check` (legacy-backend freeze, `tsc`, ESLint plus the UI-system
  gates, and Vitest integration tests), `pnpm verify` (adds the production build), and Playwright
  e2e via `pnpm test:e2e`.
* **Built on `staging` (2026-10-01):** the EXO Verify contract, CLI, and results store; Railway
  build gates (Exo and Observatory enforce, Construct and Rampart report); 15-minute live
  smoke, drift, and domain checks; the pre-push hook; and the PR-Agent review pass through
  Exo Router. Review is off until the Convex AI gateway is enabled. Measured: the Exo gate adds
  42 s to each deploy. See [EXO Verify](https://exo.now/docs/platform/exo-verify#status).
* **Open source parts:** PR-Agent (MIT), Vitest (MIT), Playwright (Apache-2.0; browser journeys not
  wired yet).
* **Open question:** does Entire expose webhooks or a checks API for triggering runs?


---

# 01 · Control plane

https://exo.now/docs/stack/control-plane

> Exo itself — the portfolio control plane for App registration, environments, releases, and evidence — with work run through Hark, and Hermes as the open-source option.



<StackFigure n="01" />

<StackFacts dimension="01 · Control plane" firstParty="[&#x22;Exo&#x22;]" platforms="[&#x22;hark&#x22;, &#x22;hermes&#x22;]" openSource="hermes" status="Exo is live on Convex (staging and production deployments); Payload removed (October 2026). Hark is the primary way work is driven; Hermes is the open-source option. Integration contract for both: TBD." />

## What it is [#what-it-is]

Exo is the application platform and control plane. It owns projects, organizations and access,
environments, provider provisioning, deploy/release/rollback state, runtime-dependency metadata,
policy and security controls, audit evidence, orchestration, health, and bounded usage and cost
aggregates ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)).

Priorities, dependencies, release gates, and verified delivery share one accountable operating
record. The public Exo shell explains the platform without exposing App data. The authenticated
workspace is the internal operator surface.

On exo.now the dimension reads: "Plans, releases, and approvals for every app live in one record.
Work runs through Hark, with Hermes as the open-source option. Recommended, not required."

The control plane is recommended but optional, like source control: an app may opt out of it
([overview](https://exo.now/docs#core-components-and-specialty-components)).

## Platform [#platform]

* **Exo** is first-party. Its state lives in [Convex](https://exo.now/docs/stack/app-state), its web app runs on
  [Railway](https://exo.now/docs/stack/deploy), and its source lives in [Entire](https://exo.now/docs/stack/source).
* **Hark** is the primary: the agent operators drive Exo work through, from plans and changes to
  reviews and approvals (integration catalog: ecosystem standard, authority Exo).
* **Hermes** (agents) is the open-source option: the open-source agent runtime from Nous Research
  (integration catalog: ecosystem standard, authority Exo). Every layer names an open-source option;
  this is the control plane's.
* **FluidVoice** is an optional, open-source, on-device dictation tool an operator can use to drive
  work by voice. It is not part of the control plane's primary stack (integration catalog:
  optional adapter).

| Environment | Convex deployment        | Web                       |
| ----------- | ------------------------ | ------------------------- |
| Staging     | `frugal-eel-930`         | `https://staging.exo.now` |
| Production  | `befitting-bullfrog-619` | `https://exo.now`         |

Source: [`convex/README.md`](https://github.com/Interspace-Ventures/exo/blob/staging/convex/README.md) and
`src/platform/integrations/catalog.ts`.

## Boundary and replaceability [#boundary-and-replaceability]

* **Authority rules.** Every record has exactly one authority during every cutover phase. External
  providers stay authoritative for live deployment, billing, source, and health facts. Exo owns
  portfolio and release coordination, Entire owns source and execution provenance, Construct owns
  Pages, and independent Apps own their domain data (ADR 0003).
* **Agents and voice are inputs, not authorities.** Hark (or Hermes, the open-source option) is how
  operators drive work; the accountable record stays in Exo. How Hark and Hermes sessions connect
  to Exo records is not documented in the repository yet: **TBD**.
* **No Payload.** Payload and its PostgreSQL adapter are gone from the code. The old Postgres
  database is kept, untouched, only until its records are exported to their new owners.
* **Exit.** Every tenant can obtain independently usable content, media, design, source, and
  deployment artifacts ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).

## Cost notes [#cost-notes]

* Convex and Railway plan costs for the control plane: **TBD**.
* Hark operating cost: **TBD**. Hermes and FluidVoice are open source (per the catalog); their
  operating cost: **TBD**.
* Exo stores bounded usage and cost aggregates for Apps, not their ledgers. See
  [cost and unit economics](https://exo.now/docs/stack/economics).

## Status [#status]

* **Done:** Convex backend for the platform, deployed to staging and production
  ([roadmap: exo-convex-backend](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/exo-convex-backend.md)).
* **In progress:** roadmap as repo files with a public board
  ([roadmap-as-repo](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/roadmap-as-repo.md)).
* **Data export:** where each legacy Postgres record family goes is described in
  [ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane) and the
  [legacy authority map](https://github.com/Interspace-Ventures/exo/blob/staging/docs/exo-legacy-authority-map.md).
* **Hark and Hermes:** named in the catalog and on exo.now (Hark as the primary, Hermes as the
  open-source option). No ADR or roadmap item records their rollout: **TBD**.


---

# 07 · Deploy

https://exo.now/docs/stack/deploy

> Exo Deploy ships every surface. Web apps and services run on Railway, and iOS and Android apps ship with Expo.



<StackFigure n="07" />

<StackFacts dimension="07 · Deploy" platforms="[&#x22;railway&#x22;, &#x22;expo&#x22;]" via="Exo Deploy" status="Web live on Railway. Mobile toolkit accepted (ADR 0007, 2026-09-27) and planned; Universe already ships on Expo." />

## What it is [#what-it-is]

The deploy dimension answers: how does each surface reach users, and what is running now?
**Exo Deploy** owns the release path for every surface:

| Surface                         | Platform             | Path                                                               |
| ------------------------------- | -------------------- | ------------------------------------------------------------------ |
| Web apps and services           | Railway              | `staging` auto-deploys; a release PR to `main` promotes production |
| Mobile apps (iOS, Android)      | Expo                 | TestFlight, then the App Store and Google Play                     |
| Pages and embedded app surfaces | Construct on Railway | Same path as web                                                   |

Every surface follows the same Staging → Release Candidate → Production path, described in
[How work ships](https://exo.now/docs/shipping). The service design is on the
[Exo Deploy](https://exo.now/docs/platform/exo-deploy) page. In-app purchases live under
[Payments](https://exo.now/docs/stack/payments), not here.

## Platform [#platform]

* **Railway** runs Next.js hosting for the Exo web app, cron workers that reach external systems
  (Entire trail sync, provider health sweeps, revenue snapshot pulls), and Valkey for rate limits
  and short-lived caches ([infrastructure](https://github.com/Interspace-Ventures/exo/blob/staging/docs/infrastructure.md)). The Exo web image is
  a standard `Dockerfile` (`node:22-bookworm-slim`, pnpm 11.15, `pnpm build`, port 3000,
  `output: 'standalone'`). The self-hosted Arch-Router assessor is a second Railway service built
  from `services/arch-router/`.
* **Expo**, an MIT-licensed framework on React Native, is the standard mobile framework for Exo
  apps. **EAS** is optional and swappable for build, submit, and OTA updates (ADR 0007).

## Boundary and replaceability [#boundary-and-replaceability]

* **Web: the contract is an OCI image:** a standard `Dockerfile`, an HTTP port, and environment
  variables. Railway and Coolify are the initial adapters, and the contract requires neither
  ([architecture](https://github.com/Interspace-Ventures/exo/blob/staging/docs/architecture.md),
  [cross-platform delivery](https://github.com/Interspace-Ventures/exo/blob/staging/docs/platform-delivery.md)).
* Railway holds runtime secrets. Exo stores only the names of environment variables.
* Leaving the managed runtime includes a documented handoff of the container contract, health
  checks, variable names, volumes, scheduled jobs, domains, and rollback
  ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).
* A provider-generated hostname is deployment evidence, never the review URL
  ([staging topology](https://github.com/Interspace-Ventures/exo/blob/staging/docs/staging-topology.md)).
* **Mobile:** EAS is a service choice, not a dependency. The Exo Deploy mobile template keeps a
  local-build path (`eas build --local` or fastlane). The repository contract keeps a
  **PWA-required-for-mobile** gate, and each app registers its native redirect scheme with Exo Key
  during onboarding.

## Cost notes [#cost-notes]

Web:

* One recorded unit cost: **\~$0.007 per 5-minute run on a 2 vCPU / 2 GB Railway worker**
  (ADR 0005, used to price EXO Verify).
* The Arch-Router service is CPU-only and runs comfortably in \~1 GB RAM, with no per-call cost
  ([arch-router](https://github.com/Interspace-Ventures/exo/blob/staging/services/arch-router/README.md)).
* Monthly Railway spend for Exo and the portfolio: **TBD**. Every deployment must first pass the
  projects.dev credential and spend gate.

Mobile (ADR 0007, 2026-09-27):

| Stage                   | Fixed cost             | Marginal cost                                             |
| ----------------------- | ---------------------- | --------------------------------------------------------- |
| Apple Developer Program | $99/year (required)    | —                                                         |
| EAS Free                | $0                     | Capped at 15 iOS + 15 Android builds/month, OTA to 1K MAU |
| EAS Starter             | $19/month              | Usage beyond the $45 build credit                         |
| Own Mac builder         | Hardware or hosted Mac | \~$0 per build; pays off only at high volume              |

At current volume the mobile toolkit costs about **$8/month** (Apple fee amortized).

## Status [#status]

* **Live:** Exo web on Railway, with staging at `staging.exo.now` and production at `exo.now`.
* **Planned:** the Exo Deploy mobile template
  ([roadmap: mobile-delivery-pipeline](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/mobile-delivery-pipeline.md)).
  Template location: **TBD**.
* **Reference:** Universe already ships on Expo, registered with Exo Key using the native
  redirect `universe://auth/callback`.
* **Evidence rule:** a push proves only that Git received the commit, and a provider success proves
  only that a deployment job finished. Staging is current only after the deployed commit,
  `/api/health`, access boundary, and browser behavior are verified
  ([build and deploy](https://github.com/Interspace-Ventures/exo/blob/staging/docs/build-and-deploy.md)).


---

# 06 · Design system

https://exo.now/docs/stack/design-system

> Every Exo app uses shadcn/ui components with the Structured Liquidity theme; websites and pages are built and published in Construct.



<StackFigure n="06" />

<StackFacts dimension="06 · Design system" platforms="[&#x22;shadcn&#x22;, &#x22;structured-liquidity&#x22;, &#x22;construct&#x22;]" status="Design system: Live. Enforced in this repository by pnpm check:ui-system, which runs inside pnpm lint. Pages: Construct is live on staging at staging.exo.now/construct; Pages stage at staging.construct.page." />

## What it is [#what-it-is]

Every app uses the same open-source components, styled with the Structured Liquidity theme (the
stack description on exo.now). The Structured Liquidity foundation is mandatory for every
Exo-managed application, Exo-owned interface, management surface, and first-party template
([design standard](https://github.com/Interspace-Ventures/exo/blob/staging/docs/design-standard.md)).

### Pages [#pages]

Landing pages, marketing sites, and comparison pages are written, laid out, and published in
Construct, Exo's content management system for websites. **Construct owns presentation and page
organization; Exo owns function** (identity, deployment, access, data, and
[Exo Router](https://exo.now/docs/platform/exo-router)). Components and theming are a separate dimension:
[Design system](https://exo.now/docs/stack/design-system).

## Platform [#platform]

* **shadcn/ui** is the open component foundation. Managed frontend work starts from the
  repository's shadcn configuration (`components.json`, `new-york` baseline) and composes
  primitives from `src/components/ui` instead of raw interactive controls.
* **Structured Liquidity** is the shared theme and tokens every Exo app and Construct page uses.
  Its three dimensions are Neobrutalist structure, Liquid motion, and Scientific modern clarity,
  and Construct is its canonical first-party expression.

### Pages [#pages-1]

* **Construct** is a first-party Interspace App and the Page platform (catalog layer: CMS,
  authority Construct).
* **Puck** is open-source visual page composition inside Construct.

## Boundary and replaceability [#boundary-and-replaceability]

* **One foundation, many expressions.** Tenants may replace palette, typography, imagery, and
  content density through their documented token and brand contracts, but they must keep the
  Structured Liquidity and shadcn component foundation and the platform accessibility,
  responsiveness, performance, and semantic-control rules.
* **Controlled visual vocabulary.** No more than two actual typefaces mapped to display, body, and
  detail roles, seven semantic size tiers, four weight tiers, and semantic color roles instead of
  component-local values. Exo-owned surfaces keep `--radius: 0px`.
* **Design exports as tokens.** Design is part of the tenant export
  ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).

### Pages [#pages-2]

* Construct owns Page and website editing, Puck, publishing, Page authorization, and authenticated
  Surfaces, on its own Convex-native model, with no Payload
  ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)).
* Exo knows Pages only as typed resources for grants, routing, deployment evidence, and aggregate
  policy ([architecture](https://github.com/Interspace-Ventures/exo/blob/staging/docs/architecture.md)).
* Portability: the tenant export includes Puck data plus the component contract and migrations
  needed to interpret it, and a framework-neutral static HTML fallback for published pages
  ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).

## Cost notes [#cost-notes]

shadcn/ui is open source. Structured Liquidity is first-party. No cost figure is recorded: **TBD**.

### Pages [#pages-3]

Construct operating cost per site: **TBD**. Puck is open source. Construct's AI generation goes
through [Exo Router](https://exo.now/docs/platform/exo-router) (endpoint `POST /v1/ai/generate`), so its model
spend is recorded in `exoAiBrokerJobs`.

## Status [#status]

* **Live and enforced:** `node scripts/check-structured-liquidity-ui.mjs` checks the shadcn
  configuration, required primitives, and foundation tokens, and
  `node scripts/check-visual-vocabulary.mjs` ratchets inherited raw font, size, weight, and color
  declarations against a baseline. Both run through `pnpm check:ui-system` inside `pnpm lint`.
* These docs use the same tokens: `src/app/(docs)/docs.css` maps Fumadocs tokens onto the Exo
  tokens ([maintaining these docs](https://exo.now/docs/contributing)).

### Pages [#pages-4]

* **Live:** Construct App at `https://staging.exo.now/construct`, verified at Construct commit
  `875ac2e`. Construct-managed Pages stage at `https://staging.construct.page/{page}`, migrating
  Page by Page ([staging topology](https://github.com/Interspace-Ventures/exo/blob/staging/docs/staging-topology.md)).


---

# Cost and unit economics

https://exo.now/docs/stack/economics

> Every cost figure recorded in the ADRs and docs, the unit-economics model Exo is building toward, and what is still TBD.



This page collects every cost figure the repository records. Figures are quoted from their source
with the date of the decision. Nothing here is a new estimate, and where the repository records no
figure, the cell says **TBD**.

## Recorded figures [#recorded-figures]

| Dimension              | Item                                      | Figure                                                                                                                                        | Source                                                                                            |
| ---------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Continuous integration | EXO Verify compute                        | \~$0.007 per 5-minute run on a 2 vCPU / 2 GB Railway worker                                                                                   | ADR 0005 (2026-09-27)                                                                             |
| Continuous integration | EXO Verify model tokens                   | $0.01–0.10 per run                                                                                                                            | ADR 0005                                                                                          |
| Continuous integration | EXO Verify per verified commit            | \~$0.02–0.10                                                                                                                                  | ADR 0005                                                                                          |
| Continuous integration | EXO Verify monthly at \~300 agent commits | \~$6–30                                                                                                                                       | ADR 0005                                                                                          |
| Continuous integration | Greptile Pro (alternative)                | $30/seat/month, 50 credits/seat, $1 per extra credit; est. \~$300–900/month at the same volume                                                | ADR 0005                                                                                          |
| Continuous integration | CodeRabbit (alternative)                  | $24–30/user; CLI free at 3 reviews/hour on local git                                                                                          | ADR 0005                                                                                          |
| Continuous integration | Graphite (alternative)                    | $40/user                                                                                                                                      | ADR 0005                                                                                          |
| Analytics              | PostHog free tier                         | \~1M events/month                                                                                                                             | ADR 0006 (2026-09-27)                                                                             |
| Observability          | Exo event table on Convex                 | "a few dollars per month" expected                                                                                                            | ADR 0006                                                                                          |
| Deploy (mobile)        | Apple Developer Program                   | $99/year (required)                                                                                                                           | ADR 0007 (2026-09-27)                                                                             |
| Deploy (mobile)        | EAS Free                                  | $0; 15 iOS + 15 Android builds/month, OTA updates to 1K MAU                                                                                   | ADR 0007                                                                                          |
| Deploy (mobile)        | EAS Starter                               | $19/month plus usage beyond the included $45 build credit                                                                                     | ADR 0007                                                                                          |
| Deploy (mobile)        | Own Mac builder                           | Hardware or hosted Mac; \~$0 per build; pays off only at high volume                                                                          | ADR 0007                                                                                          |
| Deploy (mobile)        | Exo Deploy at current volume              | \~$8/month (Apple fee amortized)                                                                                                              | ADR 0007                                                                                          |
| AI                     | Arch-Router assessor (Exo Router)         | No per-call cost; self-hosted, CPU-only, \~1 GB RAM                                                                                           | [ai-routing](https://github.com/Interspace-Ventures/exo/blob/staging/docs/ai-routing.md), [arch-router](https://github.com/Interspace-Ventures/exo/blob/staging/services/arch-router/README.md) |
| Identity               | Clerk (removed)                           | Charged per MAU (reason for leaving; no figure recorded). Removed from EXO 2026-10-12; the account and subscription are the owner's to cancel | [ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere)                        |
| Portability            | Exit                                      | No punitive exit fee; direct infrastructure egress may be passed through                                                                      | [portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)                                                       |

<Callout title="Where ADRs 0005–0007 live" type="info">
  ADRs 0005, 0006, and 0007 were accepted on `main` on 2026-09-27 and are not yet on `staging`.
  They will render under [Decision records](https://exo.now/docs/adr) once the branches are reconciled. Until
  then, read them on `main`:
  [0005](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0005-exo-verify-verification-service.md),
  [0006](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0006-exo-analyze-product-analytics.md),
  [0007](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0007-exo-mobilize-mobile-release-toolkit.md).
</Callout>

## Not yet recorded (TBD) [#not-yet-recorded-tbd]

The repository does not record plan or monthly figures for these. Treat each one as **TBD**
until someone adds an ADR or doc:

* Convex plan and usage (control plane, identity, app state, media) for both deployments
* Railway monthly spend for the Exo web services and the `arch-router` service
* Entire (source host) pricing
* Construct operating cost per site
* Bird per-message pricing for email, SMS, and voice
* Twenty hosting (self-hosted or cloud)
* Stripe and RevenueCat fees at portfolio volume
* AI model spend per App (see the Exo Router caveat below)
* Hark operating cost, and Hermes and FluidVoice operating cost
* Artificial Analysis benchmark access (not wired into `aiModelPool` yet)

## The unit-economics model [#the-unit-economics-model]

Exo Analyze (ADR 0006) defines the funnel Exo stores end to end, so that every App has computable
unit economics:

```text
visits → sign-ups (Exo Key) → paid (Stripe / RevenueCat webhooks) → cost to serve
                                                                   (exoAiBrokerJobs + exo-verify + infra)
```

Each App's dashboard is meant to report **cost per active user** and **gross margin per paying
user**, so a feature is judged on cost per unit and margin, not revenue alone.

### What is measured today [#what-is-measured-today]

* **AI jobs.** Every Exo Router job (`POST /v1/ai/generate`) writes a row to `exoAiBrokerJobs` with `jobType`, `status`,
  `provider`, `model`, `sourceCount`, `durationMs`, `inputTokens`, and `outputTokens`
  (`convex/schema.ts`). The table has a `costMicrosUsd` column, but on `staging` the endpoint does
  not compute it yet (the response returns `0`), and rows carry no App identifier. Per-App AI cost
  is therefore **TBD**; token counts are the current unit.
* **Routing.** `aiRoutingLog` records the chosen model, its utility score, the assessor, and a
  `fallback` flag for every routed decision. `aiModelPool` holds per-model input and output cost
  per million tokens (USD) and a 0–100 intelligence estimate.
* **Revenue.** Stripe and RevenueCat events land in an idempotent, normalized ledger with cached
  metric snapshots. Currencies are never summed silently, and unobserved metrics display as
  unknown, never zero ([revenue monitoring](https://github.com/Interspace-Ventures/exo/blob/staging/docs/revenue-monitoring.md)).

## Spend governance [#spend-governance]

* **projects.dev spend gate.** Before each deployment, Exo verifies the matching environment,
  reads the credential inventory without revealing values, inspects recent spend, and confirms a
  global spend limit exists and has not been exceeded. Free tiers are preferred, and any new or
  upgraded paid plan needs explicit owner approval
  ([repository contract](https://github.com/Interspace-Ventures/exo/blob/staging/docs/repository-contract.md)).
* **Buy vs. build at agent volume.** Third-party tools are priced per run at agent commit volume
  (hundreds per month), not per seat. That is why EXO Verify was built rather than bought.


---

# 02 · Development framework

https://exo.now/docs/stack/framework

> Every Exo app is written in strict TypeScript with React, on Next.js for the web and Expo for mobile. One opinionated path.



<StackFigure n="02" />

<StackFacts dimension="02 · Development framework" platforms="[&#x22;nextjs&#x22;, &#x22;typescript&#x22;, &#x22;react&#x22;]" status="Proposed (2026-10-02), pending team confirmation. Exo itself already runs on it; the repository contract still allows other frameworks until this is confirmed." />

## What it is [#what-it-is]

The framework dimension answers: what is every app written in? One answer for every app:
**TypeScript, React, and Next.js** for the web, and the same TypeScript and React on Expo for
mobile ([Deploy](https://exo.now/docs/stack/deploy)). There is no second path: no Svelte, Remix, Vue, or Angular.

One path means code, components, agents, and people move between apps without relearning a
stack, and every Exo service (EXO Verify, Exo Analyze, Exo Key, the design system) is built and
tested for exactly one target.

## Platform [#platform]

* **Next.js** (MIT, Vercel) for web apps: routing, server rendering, and API routes. Exo runs on
  Next.js 16.2 and React 19.2.
* **TypeScript** (Apache-2.0, Microsoft), strict mode, for all application code. JavaScript
  application sources are disabled (already required by the repository contract).
* **React** (MIT, Meta) for UI, shared by web (Next.js) and mobile (Expo / React Native).

All three are open source. Developers use them directly. Exo does not wrap or rename them.

## Boundary and replaceability [#boundary-and-replaceability]

* The framework is a convention, not a hosted service, so there is no account, key, or bill.
* Next.js runs on any OCI host: Exo apps build to a standard container
  ([Deploy](https://exo.now/docs/stack/deploy)) and do not depend on Vercel.
* Replacing the framework is a portfolio-wide migration, not a per-app choice. That is the point
  of an opinionated stack.
* **Open change:** the [repository contract](https://github.com/Interspace-Ventures/exo/blob/staging/docs/repository-contract.md) currently says
  "Application framework choice remains independent". It changes to Next.js + TypeScript + React
  once the team confirms this page.

## Cost notes [#cost-notes]

* License cost: **$0** (MIT and Apache-2.0).
* The cost of one path is the alternatives given up: a team that prefers Svelte or Remix must
  build outside Exo.

## Status [#status]

* **Live:** Exo, Construct, and the portfolio web apps already use Next.js, TypeScript, and React.
* **Proposed:** making it mandatory for every Exo app (pending team confirmation, 2026-10-02).


---

# 05 · Identity

https://exo.now/docs/stack/identity

> Exo Key on Convex Auth v2 — Exo is the only OAuth client and identity provider; Apps are relying parties.



<StackFigure n="05" />

<StackFacts dimension="05 · Identity" platforms="[&#x22;convex-auth&#x22;]" via="Exo Key" status="Exo Key live on staging for registered Apps; Clerk fully removed from EXO (ADR 0012)." />

## What it is [#what-it-is]

One sign-in for every property, with explicit App and Page scopes instead of a separate account
per property. Exo's Convex deployment mounts Convex Auth v2 (core plus Google and GitHub OAuth;
Apple when added) and holds the only provider registrations. It issues **app-scoped** RS256 access
tokens that Apps verify ([ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider)).

The full contract is on the [Exo Key](https://exo.now/docs/platform/exo-key) service page.

## Platform [#platform]

**Convex Auth v2**, which is open source and native to the Convex database
([roadmap: convex-auth](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/convex-auth.md)). It runs as a pinned alpha
(`@convex-dev/auth` `2.0.0-alpha.2` in `package.json`). Per the Convex team, v2 is stable enough
to deploy now and reaches GA by Q4
([ADR 0003](https://exo.now/docs/adr/0003-convex-auth-v2-identity-plane)).

## Boundary and replaceability [#boundary-and-replaceability]

* **Apps never touch providers.** Apps don't mount auth components, don't hold `AUTH_*` or
  provider secrets, and never register with Google, GitHub, or Apple. Onboarding an App is a
  reviewed code change to `convex/lib/exoIdApps.ts`.
* **Authentication and authorization are separate.** Exo proves identity (`sub`, `email`, `name`).
  Authorization (roles, tenant grants) stays with the App in v1.
* **Upgrade containment.** Convex Auth upgrades are contained to `convex/auth.ts`,
  `convex/auth.config.ts`, `convex/convex.config.ts`, and the client provider.
* **Portability.** A person has one Exo user id across Apps, and Apps key their records on it.
  Identity exports omit credentials.
* **Consequence.** Exo's Convex deployment is a hard dependency for sign-in across the portfolio.
  Its availability and key rotation are platform responsibilities.

## Cost notes [#cost-notes]

* The move off Clerk was partly economic: Clerk "charges per MAU and makes a proprietary vendor the
  default login surface" (ADR 0003). Clerk's actual spend: **TBD**. EXO no longer uses Clerk; the
  account and subscription are the owner's to cancel.
* Convex Auth has no license fee. Its marginal cost is Convex function and storage usage:
  **TBD**.

## Status [#status]

* **Live on staging:** authorize page `https://staging.exo.now/id/authorize`, and token, revoke,
  JWKS, and discovery endpoints on `frugal-eel-930.convex.site/exo-id`.
* **Registered Apps:** `observatory`, `construct`, `rampart`, `dropship`, and `universe`, plus
  `exo-staging` (EXO's own browser session) and the headless `exo` API audience
  ([Exo Key guide](https://github.com/Interspace-Ventures/exo/blob/staging/docs/exo-id.md)).
* **Done:** Clerk is fully removed from EXO
  ([ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere)). EXO's own `/sign-in`
  and the `staging.exo.now/{app}` gateway sign in through Exo Key as the `exo-staging` relying
  party, and staging access needs an exact non-production grant (`EXO_STAGING_GRANTS`).
* **In progress:** Apps outside EXO that still carry their own Clerk code move to Exo Key in their
  repositories ([convex-auth](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/convex-auth.md)).
* **Pending:** a version-bump pass before Convex Auth v2 GA (Q4).


---

# The stack

https://exo.now/docs/stack

> Twelve named dimensions, each with a preferred platform, a boundary, and a replacement path.



Exo names every dimension of an App's stack, the preferred platform for it, and the boundary that
keeps that platform replaceable. The table mirrors the stack on [exo.now](https://exo.now), whose
source of truth is the `dimensions` list in `src/components/ExoShell/index.tsx`. The platform
facts on each page are rendered from the integration catalog in
`src/platform/integrations/catalog.ts`, and a docs test checks every page against the landing
page's numbers, names, and platforms, so the landing page, the catalog, and these docs cannot drift
apart.

There are twelve dimensions. On 2026-10-05, Media moved into App state & storage, the CMS into
Design system (as pages), and CRM into Communications & support; Analytics is now Observability.

| #  | Dimension                                              | Exo platform              | Infrastructure                                        |
| -- | ------------------------------------------------------ | ------------------------- | ----------------------------------------------------- |
| 01 | [Control plane](https://exo.now/docs/stack/control-plane)                   | Exo                       | Hark (primary) + Hermes (open-source option)          |
| 02 | [Development framework](https://exo.now/docs/stack/framework)               | Exo                       | Next.js + TypeScript + React (proposed)               |
| 03 | [Source control](https://exo.now/docs/stack/source)                         | Exo                       | Entire                                                |
| 04 | [App state & storage](https://exo.now/docs/stack/app-state)                 | Exo                       | Convex + Convex Storage                               |
| 05 | [Identity](https://exo.now/docs/stack/identity)                             | Exo Key                   | Convex Auth                                           |
| 06 | [Design system](https://exo.now/docs/stack/design-system)                   | Exo Design                | shadcn/ui + Structured Liquidity + Construct (pages)  |
| 07 | [Deploy](https://exo.now/docs/stack/deploy)                                 | Exo Deploy (Exo Deploy)   | Railway (web) + Expo (mobile)                         |
| 08 | [Communications & support](https://exo.now/docs/stack/communications)       | Exo Broadcast             | Bird + Twenty (CRM)                                   |
| 09 | [Payments](https://exo.now/docs/stack/payments)                             | Exo Pay                   | Stripe + RevenueCat                                   |
| 10 | [Continuous integration](https://exo.now/docs/stack/continuous-integration) | EXO Verify (EXO Verify)   | Vitest + Playwright                                   |
| 11 | [Observability](https://exo.now/docs/stack/observability)                   | Exo Analyze (Exo Analyze) | PostHog + RevenueCat                                  |
| 12 | [Artificial intelligence](https://exo.now/docs/stack/ai)                    | Exo Router (Exo Router)   | Convex AI Gateway + Artificial Analysis + Arch-Router |

Where a layer's primary platform is not open source, its page names an **open-source option**
(the `openSource` field on the landing page's dimension and on the page's facts panel). Today the
control plane names Hermes; the other layers are filled in as their options are confirmed.

Source control and the control plane are recommended but optional: an app may opt out of either.

Each platform's catalog posture (core standard, ecosystem standard, optional adapter, or retiring
compatibility) and authority are shown in the panel at the top of its dimension page.

## How to read a dimension page [#how-to-read-a-dimension-page]

Each page answers the same five questions:

1. **What it is.** The job this dimension does for an App.
2. **Platform.** The platform, its catalog posture, and which system is authoritative.
3. **Boundary and replaceability.** What sits behind an Exo-owned contract, and what a swap would
   touch.
4. **Cost notes.** Only figures recorded in the ADRs and docs. Everything else is **TBD**. The
   [cost and unit economics](https://exo.now/docs/stack/economics) page collects every figure in one place.
5. **Status.** What is live, what is in progress, and what is only planned, with the evidence.

## Rule of thumb: who runs it, who remembers it [#rule-of-thumb-who-runs-it-who-remembers-it]

> Railway runs services; Convex holds state. If a piece of infrastructure primarily *runs*
> something, it belongs on Railway. If it primarily *remembers* something, it belongs in Convex.
> — [Infrastructure split](https://github.com/Interspace-Ventures/exo/blob/staging/docs/infrastructure.md)

## Retiring platforms [#retiring-platforms]

These stay in the catalog as `compatibility` entries until migration completes. They are not
choices for new work.

| Platform             | Replaced by            | Why                                                                                                                                                               |
| -------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Clerk                | Exo Key on Convex Auth | Per-MAU pricing and a second identity system beside the data; fully removed ([ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere))          |
| Resend               | Bird                   | One communications layer for email, SMS, and voice ([roadmap](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/bird-communications.md))                                                     |
| GitHub               | Entire                 | Entire is the primary source host; GitHub is a compatibility mirror                                                                                               |
| Payload + PostgreSQL | Convex                 | Removed from the code (October 2026); the old database is kept only until its data is exported ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)) |


---

# 11 · Observability

https://exo.now/docs/stack/observability

> Exo Analyze keeps visits, sign-ups, conversion, and cost to serve in one Exo event layer; PostHog is a swappable dashboard adapter.



<StackFigure n="11" />

<StackFacts dimension="11 · Observability" platforms="[&#x22;posthog&#x22;, &#x22;revenuecat&#x22;]" via="Exo Analyze" status="Accepted (ADR 0006, 2026-09-27); planned." />

Renamed from Analytics (2026-10-05): the same Exo Analyze layer, named for what it does.

## What it is [#what-it-is]

Visits, sign-ups, conversion, and cost to serve land in one Exo event layer beside payments.
Dashboards are an adapter, so the provider can change without touching an App. The service design
is on the [Exo Analyze](https://exo.now/docs/platform/exo-analyze) page.

## Platform [#platform]

* **PostHog** provides dashboards and session replay, sitting behind Exo as a swappable adapter.
  Plausible and GA4 remain possible adapters.
* **RevenueCat** supplies the paid step of the funnel for app-store purchases, beside Stripe for
  web payments (ADR 0006). Revenue events land in the ledger described on
  [Payments](https://exo.now/docs/stack/payments).

## Boundary and replaceability [#boundary-and-replaceability]

* Ingestion and schema live in Exo's Convex backend, partitioned per App and per environment. Apps
  send events to one Exo endpoint through a thin SDK (web and Expo).
* **Apps never integrate an analytics platform directly.** Changing platforms is an Exo adapter change.
* Event schema changes are platform decisions. Privacy and consent (cookie banners, data deletion)
  are centralized in Exo.

## Cost notes [#cost-notes]

From ADR 0006:

* At today's portfolio scale, event volume fits PostHog's free tier (\~1M events/month).
* The incremental cost is Convex storage and function usage for the event table, expected to be a
  few dollars per month.
* If volume outgrows the free tier, Exo can sample or aggregate before forwarding to the adapter
  while keeping full-fidelity funnel counts in Convex.

## Status [#status]

* **Planned** ([roadmap: product-analytics](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/product-analytics.md)).
* ADR 0006 is on `main` and is not yet on `staging`
  ([read on main](https://github.com/Interspace-Ventures/exo/blob/main/docs/adr/0006-exo-analyze-product-analytics.md)).


---

# 09 · Payments

https://exo.now/docs/stack/payments

> Stripe for web payments and RevenueCat for in-app purchases, normalized into one commercial record without making Exo a payment processor.



<StackFigure n="09" />

<StackFacts dimension="09 · Payments" platforms="[&#x22;stripe&#x22;, &#x22;revenuecat&#x22;]" status="Ingestion boundary and dashboard built; enabling real data per tenant needs human-approved provider configuration." />

## What it is [#what-it-is]

Payments and subscriptions are normalized into one commercial record and isolated from the
portable product core. Exo gives platform admins one private portfolio view while checkout,
entitlements, and provider-native subscription state stay with the tenant App
([revenue monitoring](https://github.com/Interspace-Ventures/exo/blob/staging/docs/revenue-monitoring.md)).

## Platform [#platform]

* **Stripe** is the payment, refund, dispute, and settlement source. It is Merchant of Record only
  for transactions through Stripe Managed Payments.
* **RevenueCat** is the app-store purchase and cross-platform entitlement source.

Both are **optional adapters** with the **App** as authority. A tenant that doesn't monetize
declares `commerce.profile: none` and stays fully conformant
([commerce contract](https://github.com/Interspace-Ventures/exo/blob/staging/docs/commerce-contract.md)).

## Boundary and replaceability [#boundary-and-replaceability]

* **Opt-in profiles:** `none`, `web-stripe` (Stripe Checkout and the Customer Portal own the web
  billing experience), `mobile-revenuecat`, and `hybrid`. `web-clerk-stripe` is accepted only in
  legacy schema 1.8 declarations.
* **Default governance for a monetized App:** `governanceModel: exo-managed`,
  `stripePaymentMode: managed-payments`, `merchantOfRecord: stripe`. This is a target policy, not
  evidence that billing is live.
* **What Exo stores:** non-secret provider coordinates, normalized events, source definitions,
  freshness, and reconciliation status. It never stores secrets, raw payment methods, full
  webhook payloads, or direct customer contact data.
* **Webhooks:** one URL per tenant and environment
  (`POST /api/billing/webhooks/{stripe|revenuecat}/:tenant?environment=staging`). HMAC
  verification uses a five-minute replay window, and tenant-scoped event keys make retries
  idempotent.
* **Commercial entitlements are not access grants.** An entitlement answers what a customer
  bought. An Exo Key grant answers whether an operator may access a resource.

<Callout title="Clerk Billing is gone" type="info">
  Clerk and Clerk Billing are removed from EXO
  ([ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere)). Web billing is
  Stripe-only: Stripe Checkout and the Customer Portal own plans and subscriptions, and accounts
  key on the Exo ID user.
</Callout>

## Cost notes [#cost-notes]

* Stripe and RevenueCat fees at portfolio volume: **TBD**.
* **Reporting rules that protect the numbers:** currency totals are never converted or summed
  silently. Portfolio totals include only snapshots explicitly marked `include`. A RevenueCat
  cross-platform aggregate is excluded until overlap with Stripe is reconciled. Metrics a source
  cannot establish (such as MRR) show as unknown, never zero. Snapshots older than the freshness
  target (36 hours in the scaffold) are marked stale.

## Status [#status]

* **Built:** schema, secure ingestion boundary, and portfolio dashboard.
* **Pending per tenant:** product and plan selection, scoped RevenueCat V2 keys, webhook
  destinations, runtime secrets, and test events. Production activation is a separate approval.


---

# 03 · Source control

https://exo.now/docs/stack/source

> Entire is the primary Git-compatible source host and keeps agent sessions with source history.



<StackFigure n="03" />

<StackFacts dimension="03 · Source control" platforms="[&#x22;entire&#x22;]" status="Primary source host. GitHub remains a compatibility mirror during migration." />

## What it is [#what-it-is]

The source dimension answers: what exact code and agent context produced this result? Entire is
the primary source host and implementation-context authority for every managed project. It captures
repositories, branches, sessions, checkpoints, source, and review context
([build and deploy](https://github.com/Interspace-Ventures/exo/blob/staging/docs/build-and-deploy.md)).

## Platform [#platform]

**Entire** ([entire.io](https://entire.io)). Work is tracked as Entire trails. For example, the
roadmap's platform items link to trail `IV-170`. `IV-###` identifiers persist from the retired
work register.

## Boundary and replaceability [#boundary-and-replaceability]

* The contract is a plain Git repository with **no host-only runtime dependency**
  ([architecture](https://github.com/Interspace-Ventures/exo/blob/staging/docs/architecture.md)). Switching source hosts moves Git history and loses
  only the captured session context.
* GitHub may mirror source and change objects for ecosystem compatibility, but it must not become
  a second workflow (AGENTS.md). Linear is retired and must not be updated.
* Exo owns portfolio intent and release state. Entire owns source and execution provenance
  ([ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)).

<Callout title="Known inconsistency" type="warn">
  [How Exo builds and ships](https://github.com/Interspace-Ventures/exo/blob/staging/docs/build-and-deploy.md) still describes GitHub Actions
  running `pnpm check` and `pnpm verify`. The accepted EXO Verify decision (ADR 0005, on `main`)
  rejects GitHub Actions for verification because the owner is leaving GitHub. Until EXO Verify
  ships, treat the Actions description as legacy.
</Callout>

## Cost notes [#cost-notes]

Entire pricing: **TBD** (not recorded in the repository).

## Status [#status]

* **Live:** Entire is the primary remote for this repository; GitHub is the mirror.
* **Open question (ADR 0005):** does Entire expose webhooks or a checks API? The answer decides
  how EXO Verify is triggered. The CLI and polling fallbacks keep the design unblocked either way.


---

# Tenets

https://exo.now/docs/tenets

> The four principles behind every Exo decision, and the concrete rules that enforce them.



The four tenets appear on [exo.now](https://exo.now). This page pairs each one with the
repository rules that turn it into behavior.

## 1. Opinionated, not restrictive [#1-opinionated-not-restrictive]

> A clear operating model and reusable primitives—then enough room for every property to remain
> unmistakably itself.

What it means in practice:

* **One design foundation, many expressions.** The Structured Liquidity foundation is mandatory
  for every Exo-managed application: shadcn primitives, square geometry, a controlled visual
  vocabulary, and visible focus. Tenants still choose their own palette, typography, imagery, and
  content density through documented tokens
  ([design standard](https://github.com/Interspace-Ventures/exo/blob/staging/docs/design-standard.md)). `pnpm check:ui-system` enforces the
  foundation, and it runs inside `pnpm lint`.
* **One promotion path.** Every managed App uses the same Preview → Staging → Release Candidate →
  Production order ([How work ships](https://exo.now/docs/shipping)).
* **One named standard per dimension.** Each of the [twelve stack dimensions](https://exo.now/docs/stack)
  names a preferred platform and the boundary it sits behind.

## 2. Open by preference [#2-open-by-preference]

> We start with inspectable, open-source foundations. Proprietary services must earn their place
> and stay behind a replaceable boundary.

What it means in practice:

* Open-source defaults where they exist: Convex Auth for identity, Expo (MIT) for mobile,
  Arch-Router (open weights, self-hosted) for Exo Router's difficulty scoring, Vitest and
  Playwright for continuous integration, Puck for composition, shadcn/ui for the design system, and Twenty for CRM.
* "Proprietary managed services may be used operationally, but a self-hosted path must remain
  documented" ([architecture](https://github.com/Interspace-Ventures/exo/blob/staging/docs/architecture.md)).
* For any framework or platform choice, state the open-source status and license up front, and keep
  the free framework separate from the paid hosted service. For example, Expo is MIT and EAS is an
  optional paid service ([Exo Deploy](https://exo.now/docs/platform/exo-deploy)).

## 3. Integrated, not locked in [#3-integrated-not-locked-in]

> Deeply integrated, never welded shut. Content exports as schema + NDJSON, media as files +
> checksums, design as tokens, routes with SEO and redirects, delivery as manifests, plus a
> deployable static archive. Leaving should be boring, complete, and documented.

What it means in practice:

* **Portability is a product contract.** Export is a normal authenticated action. It uses a
  documented, versioned format and needs no Exo account to read. There is no punitive exit fee;
  direct egress costs may be passed through
  ([portability](https://github.com/Interspace-Ventures/exo/blob/staging/docs/portability.md)).
* **Adapters, not integrations.** Platforms sit behind Exo-owned adapters so that swapping one is an
  Exo change, not an App change. Examples: PostHog behind Exo Analyze, Bird behind the
  communications layer, and the Convex AI Gateway behind Exo Router.
* **Vendor-neutral names.** Sending subdomains are `mail.` and `inbox.`, never `bird.`, so a
  provider swap never touches DNS
  ([Bird roadmap item](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/bird-communications.md)).

## 4. Automated, not unsupervised [#4-automated-not-unsupervised]

> Machines observe, compare, and prepare. Humans approve consequential production, billing,
> domain, and data changes.

What it means in practice:

* **Staging is automatic; consequential changes are not.** Validated commits deploy to Staging
  without an approval step. Creating a Release Candidate, promoting to Production, changing DNS,
  paid plans, data migrations, credentials, and external communications each keep an explicit
  human approval gate ([approval boundaries](https://exo.now/docs/shipping/approvals)).
* **Failures are loud.** Exo Router fails a job, and records the failure, rather than falling
  back silently, because a silent fallback would hide a spend or configuration problem
  ([Exo Router](https://exo.now/docs/platform/exo-router)). Routing decisions made without an assessor are flagged
  `fallback: true`.
* **Evidence over claims.** Nothing is called live until the deployed commit, health, access
  boundary, and browser behavior are verified.


---

# 0001: Reversible content backend

https://exo.now/docs/adr/0001-reversible-content-backend

> Status: superseded by ADR 0003 · Date: 2026-07-18



# ADR 0001: Reversible content backend [#adr-0001-reversible-content-backend]

* Status: superseded by [ADR 0003](https://exo.now/docs/adr/0003-convex-first-control-plane)
* Date: 2026-07-18

> Historical decision only. Do not use this ADR to justify new Payload, PostgreSQL, or Puck work
> in EXO. ADR 0003 makes Convex the default authority for EXO-owned state and assigns Page editing
> and Puck to Construct.

## Decision [#decision]

Exo will use Payload with PostgreSQL, hosted initially by Neon, as its canonical content system. Neon is an operational provider rather than a domain dependency.

Convex remains an intentional future option. The current choice can be revisited after feedback from the Convex team or once Exo has workloads where Convex's reactive state model materially improves the product.

## Why this is not a configuration toggle [#why-this-is-not-a-configuration-toggle]

Payload currently has official adapters for PostgreSQL, MongoDB, and SQLite. Convex exposes a different execution, query, identity, and realtime model. A full switch therefore requires either a production-grade Payload database adapter or a replacement CMS persistence implementation.

Exo must make that migration controlled and testable, but must not describe it as effortless until those missing components exist.

## Portability requirements [#portability-requirements]

1. Application rendering reads canonical DTOs through `ContentRepository`.
2. Provider-specific code stays under `src/platform/content/<provider>` or Payload configuration and migrations.
3. Canonical records use UUIDv7 identifiers. Provider-native identifiers are mapping details.
4. No feature may require raw SQL outside migrations or a provider adapter.
5. Tenant exports use a versioned, newline-delimited JSON format with media manifests and checksums.
6. Imports are idempotent and preserve canonical IDs, timestamps, slugs, relationships, and publication state.
7. Tenant isolation, drafts, versions, access rules, and referential integrity must pass the same contract suite for every backend.
8. A change-data or event interface must be introduced before adding a second live backend; ad hoc dual writes are prohibited.
9. Production cutover requires reconciliation counts, relationship validation, media verification, and a documented rollback window.

## Convex activation gates [#convex-activation-gates]

A Convex implementation may become canonical only after it supports:

* all `ContentRepository` behavior;
* Payload-equivalent editorial writes or a replacement admin workflow;
* tenant-scoped authorization on every query and mutation;
* drafts, publishing, versions, redirects, forms, and media references;
* complete export and restore;
* migration rehearsal against a production-shaped tenant;
* operational backup, observability, and incident procedures.

## Near-term use of Convex [#near-term-use-of-convex]

Convex may be added earlier as an optional realtime service for presence, deployment progress, preview state, notifications, or activity feeds. Durable CMS records remain in the canonical content backend until the activation gates are met.


---

# 0002: Clerk identity and Exo authorization boundary

https://exo.now/docs/adr/0002-clerk-identity-boundary

> Status: superseded by 0003-convex-auth-v2-identity-plane.md · Date: 2026-08-26



# ADR 0002: Clerk identity and Exo authorization boundary [#adr-0002-clerk-identity-and-exo-authorization-boundary]

* Status: superseded by [0003-convex-auth-v2-identity-plane.md](https://exo.now/docs/adr/0003-convex-auth-v2-identity-plane)
* Date: 2026-08-26

## Context [#context]

Exo needs strong multi-tenant identity, organization membership, MFA, passkeys, session
revocation, and future enterprise SSO. It must also avoid making a proprietary identity provider
the canonical owner of tenant authorization or data identity.

## Decision [#decision]

Use Clerk as the target authentication provider for an environment-specific Exo identity
plane. Exo-owned Apps share that plane so a person does not need a product-specific identity for
each repository or domain. Map one Clerk user to one Exo ID within the plane and authenticate
Clerk sessions through a Payload custom strategy or the App's Exo authorization adapter.

Exo remains responsible for authorization. Resource-local grants carry canonical scopes,
roles, explicit capabilities, validity, and revocation state. Clerk Organizations may mirror coarse
Workspace membership, but Organization membership is neither the canonical resource graph nor
sufficient authorization.

Production and non-production use isolated identity planes. A separate identity plane for a client
application's customers is allowed only when the application has an independently owned user
population, legal boundary, or identity adapter. Another Exo-owned App does not qualify merely
because it has a different repository or domain.

## Consequences [#consequences]

* Privileged users receive Clerk MFA, session management, recovery, and Organization context.
* Exo-owned Apps no longer provision separate Clerk identities per product.
* Exo must implement and test a custom Payload authentication strategy.
* Clerk webhooks are lifecycle synchronization, not synchronous authorization.
* The platform must maintain Exo identities, environment-specific external mappings,
  resource grants, account-linking safeguards, and audited revocation.
* Claims remain compact; resource authorization stays server-side.
* Privileged actions require recent factor verification and an audit event.
* Identity exports omit credentials and retain portable canonical user and membership IDs.
* A future provider adapter or self-hosted identity implementation remains possible.

## Migration gate [#migration-gate]

Payload local authentication remains active until Clerk staging proves:

1. account linking without duplicate or hijacked users;
2. production/non-production identity-plane isolation;
3. App, Construct, and Construct Page grant enforcement;
4. MFA and step-up flows;
5. cross-App session, grant-revocation, and membership-removal behavior;
6. webhook replay and outage behavior;
7. recovery and break-glass access;
8. rollback to the previous admin login path.

Disabling Payload's local strategy requires a separate approved production migration.


---

# 0003: Convex Auth v2 as the shared identity plane

https://exo.now/docs/adr/0003-convex-auth-v2-identity-plane

> Status: accepted · Date: 2026-09-23



# ADR 0003: Convex Auth v2 as the shared identity plane [#adr-0003-convex-auth-v2-as-the-shared-identity-plane]

* Status: accepted
* Date: 2026-09-23
* Supersedes: [0002-clerk-identity-boundary.md](https://exo.now/docs/adr/0002-clerk-identity-boundary)
* Amended by: [0004-exo-id-central-identity-provider.md](https://exo.now/docs/adr/0004-exo-id-central-identity-provider) — Exo is the sole OAuth client and identity provider; apps are relying parties.

## Context [#context]

ADR 0002 chose Clerk as the authentication provider for the Exo identity plane, with Exo
retaining authorization. Since then, two things changed:

1. Convex Auth reached a v2 architecture (components: a core that mints RS256 JWTs and owns
   sessions, plus provider components for password/username, OAuth, passkeys, email). Per the
   Convex team it is stable enough for deployment now and reaches GA by Q4; Interspace product
   scale stays small until then, so preview risk is acceptable.
2. Clerk charges per MAU and makes a proprietary vendor the default login surface for every
   Exo-owned app, while all of our app state already lives in Convex. Keeping identity in
   the same system that owns the data removes a vendor, a webhook surface, and a whole
   identity-mapping layer.

Rampart shipped Convex Auth v2 with Google and Apple OAuth in September 2026 and is the
reference implementation for the platform pattern.

## Decision [#decision]

Convex Auth v2 is the shared identity plane for every Interspace application. Exo-owned
apps use it for authentication; Exo retains authorization exactly as before (resource-local
grants, roles, capabilities, validity, revocation).

The platform pattern, proven in Rampart:

* ~~Mount the auth **core component** (`httpPrefix: "/auth"`, `AUTH_PRIVATE_KEY`/`AUTH_JWKS`
  env) plus whichever **provider components** the app needs (password/username, OAuth
  Google/Apple, passkeys) in the app's `convex/convex.config.ts`.~~ &#x2A;*Superseded by [ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider):** only Exo mounts the
  core and provider components; apps verify Exo ID tokens with `customJwt` and mount none.
* `convex/auth.config.ts` verifies core JWTs as `customJwt` against the deployment JWKS at
  `${CONVEX_SITE_URL}/auth/.well-known/jwks.json`.
* Each provider wires `setup*` with `attachUserCallbacks({ createUser })`; the app owns its
  `users` table and the profile mapping. Provider components own accounts/sessions.
* Clients use `@convex-dev/auth/react` (`ConvexAuthProvider` with
  `api: { refreshSession, signOut }`) and the provider hooks
  (`useSignInWithGoogle`, `useSignInWithApple`, password helpers). Route protection is
  client-side (`Authenticated`/`Unauthenticated`); v2 ships no Next.js middleware helper.
* Backend identity is always derived server-side (`getAuthUserId` or
  `ctx.auth.getUserIdentity().tokenIdentifier`); never accept a caller-supplied owner ID.

A separate identity plane for a client application's customers remains allowed under the same
conditions as ADR 0002 (independently owned user population, legal boundary, or identity
adapter) — implemented with Convex Auth v2, not Clerk.

## Consequences [#consequences]

* Clerk is no longer the platform authentication provider. Clerk dependencies are removed from
  each app as it migrates; apps with existing Clerk user populations (Construct, Observatory)
  need a per-app user-mapping or re-login plan before cutover.
* ~~OAuth provider approval is platform-level: the approved catalog (Google, GitHub; Apple per-app)
  is defined in `docs/exo-auth-contract.md` in Construct and mounted uniformly by every Exo app.
  A provider added for one app is added to the catalog and swept into every app.~~ &#x2A;*Superseded by [ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider):** the
  provider catalog (Google, GitHub; Apple when added) is mounted once, at Exo. There is no
  per-app Apple or any other per-app provider.
* ~~OAuth client registration (Google Cloud Console, GitHub, Apple Developer) is per-provider platform
  setup, recorded in the app's env contract; Convex Auth does not broker those registrations.~~ &#x2A;*Superseded by [ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider):**
  Exo holds the only OAuth client registrations; apps never touch Google Cloud Console, GitHub
  OAuth settings, or Apple Developer.
* v2 is pre-GA: pin the alpha version per app, track the `reboot` branch, and budget a
  version-bump pass before Q4. Upgrades are contained to `convex/auth.ts`,
  `convex/auth.config.ts`, `convex/convex.config.ts`, and the client provider.
* The ADR 0002 migration gate is retired with it; identity verification moves to per-app
  auth/isolation tests plus the Convex Auth component contract tests.
* Payload local authentication in EXO itself remains the admin fallback until the Convex-native
  frontend ships; this ADR governs the app identity plane, not Payload's internal admin login.


---

# 0003: Convex-first EXO control plane

https://exo.now/docs/adr/0003-convex-first-control-plane

> Status: accepted · Date: 2026-09-14



# ADR 0003: Convex-first EXO control plane [#adr-0003-convex-first-exo-control-plane]

* Status: accepted
* Date: 2026-09-14
* Historical work reference: IV-173 (retired)
* Supersedes: [ADR 0001](https://exo.now/docs/adr/0001-reversible-content-backend)

## Decision [#decision]

EXO is the application platform and control plane. It owns projects, organizations and access,
environments, provider provisioning, deploy/release/rollback state, runtime-dependency metadata,
policy and security controls, audit evidence, orchestration, health, and bounded usage and cost
aggregates.

Convex is the default persistence, transaction, function, and reactive-state layer for every state
EXO owns. An exception requires a concrete workload constraint, a named authority, reconciliation
and rollback rules, and an exit path. An inherited Payload or PostgreSQL implementation is not a
constraint.

Construct owns Page and website editing, Puck, publishing, Page authorization, and authenticated
Surfaces such as Interspace/ION. Construct persists those records against its own Convex-native
model. Payload is not moved into Construct by default; a future external-CMS adapter requires a
demonstrated integration need and remains optional rather than authoritative.

Independent Apps own their domain records. EXO provisions and observes their resources and accepts
only bounded operational observations. It does not centralize their prompts, content, user data,
scientific datasets, financial ledgers, or other domain records.

## Legacy freeze [#legacy-freeze]

Payload, PostgreSQL, and Puck in this repository are migration sources and rollback material only.
No new EXO capability may add a Payload collection, Payload/Puck dependency, or Puck implementation
file. `pnpm check:legacy-backend-freeze` enforces the checked-in baseline. Removing items from that
baseline is allowed; extending it requires changing this ADR and explicit architecture review.

Bug fixes needed to export, reconcile, secure, or retire the legacy path are allowed when they do
not increase its product scope. The normal approval gates still apply to data writes, provider
changes, Release Candidate, Production, domains, billing, and resource deletion.

## Authority rules [#authority-rules]

The collection-by-collection disposition is maintained in the
[EXO legacy authority and migration map](https://github.com/Interspace-Ventures/exo/blob/staging/docs/exo-legacy-authority-map.md). A record has exactly one
authority during every cutover phase:

1. External providers remain authoritative for live deployment, billing, source, and health facts.
2. EXO owns portfolio and release coordination; Entire owns source and execution provenance.
3. Clerk authenticates identities; EXO Convex owns resource grants and authorization state.
4. EXO Convex owns control-plane intent, orchestration state, bounded observations, and audit
   evidence.
5. Construct Convex owns Pages, content, revisions, Puck documents, publishing, and Page/Surface
   access.
6. Independent Apps own domain data and expose only versioned contracts and bounded observations to
   EXO.

## SQL, analytical index, and object-storage exceptions [#sql-analytical-index-and-object-storage-exceptions]

SQL is not retained merely because a legacy schema exists. A SQL exception must identify a workload
Convex cannot reasonably satisfy, such as a required external protocol or specialized query shape,
and must document synchronization and failure behavior. No current EXO control-plane collection has
such an approved exception.

Large binary objects do not belong in Convex documents. Blob bytes may stay in Convex file storage
or an external object store when size, transformation, retention, egress, or interoperability
requirements justify it. Convex remains authoritative for ownership, checksum, metadata, lifecycle,
and the opaque object reference.

Specialized analytical or search indexes may remain external when their workload requires them.
They are derived and rebuildable, never the sole authority. EXO stores only bounded aggregate state
and freshness/reconciliation evidence.

## Cutover shape [#cutover-shape]

The migration is staged and reversible:

1. freeze the legacy feature surface and inventory every record family;
2. widen Convex schemas, validators, indexes, auth checks, receipts, and bounded audit logs;
3. export deterministic, secret-free, versioned source bundles with counts and hashes;
4. dry-run transforms and reconcile identifiers, relationships, constraints, and totals;
5. import through idempotent receipts into isolated Staging after the normal data-write approval;
6. shadow-compare reads without making two systems authoritative;
7. cut Staging reads and writes to Convex, then prove auth, isolation, index, idempotency, audit,
   retention, rollback, and recovery parity;
8. separately approve any Production migration and retain the frozen source for the rollback window;
9. remove EXO Payload, PostgreSQL, and Puck code and dependencies; then retire provider resources
   only through their explicit approval gates.

Rollback restores the last single authority and replays only receipt-backed operations. Ad hoc
dual writes are prohibited.


---

# 0004: Exo ID — Exo is the central identity provider; apps are relying parties

https://exo.now/docs/adr/0004-exo-id-central-identity-provider

> Status: accepted · Date: 2026-09-25



# ADR 0004: Exo ID — Exo is the central identity provider; apps are relying parties [#adr-0004-exo-id--exo-is-the-central-identity-provider-apps-are-relying-parties]

* Status: accepted
* Date: 2026-09-25
* Amends: [0003-convex-auth-v2-identity-plane.md](https://exo.now/docs/adr/0003-convex-auth-v2-identity-plane)

## Context [#context]

ADR 0003 made Convex Auth v2 the shared identity plane but described it as a per-app pattern:
every app mounted its own auth core and OAuth provider components and registered its own OAuth
clients with Google, GitHub, and Apple. That gives each person a separate identity per app,
spreads provider secrets and callback registrations across deployments, and forces every app
team into Google Cloud Console and Apple Developer.

Owner directive (2026-09-25): "I'd love for you to do anything related to sign-on to exist at the
Exo level. There shouldn't be multiple apps that need access to the cloud console — Exo is the
platform provisioning IDs to apps like Construct or Observatory."

## Decision [#decision]

**Exo is the sole OAuth client and identity provider for Interspace apps ("Exo ID").** Apps
(Observatory, Construct, and every future app) are relying parties.

* Exo's Convex deployment mounts Convex Auth v2 (core + Google/GitHub OAuth; Apple when added)
  and holds the only provider registrations and callback URLs
  (`<exo-convex-site>/oauth/{google,github}/callback`).
* Exo issues **app-scoped** RS256 access tokens from a dedicated Exo ID signing key
  (`EXO_ID_PRIVATE_KEY`/`EXO_ID_JWKS`), separate from Exo's own session keys.
* Apps verify Exo ID JWTs with a `customJwt` provider in their `convex/auth.config.ts`:
  `issuer: ${EXO_SITE}/exo-id`, `applicationID: <app>`,
  `jwks: ${EXO_SITE}/exo-id/.well-known/jwks.json`, `algorithm: 'RS256'`.
* Apps **never** mount the auth core or OAuth provider components, never hold `AUTH_*` or
  provider client secrets, and never register with Google, GitHub, or Apple.
* Onboarding an app is a code change to Exo's registry (`convex/lib/exoIdApps.ts`), reviewed in
  this repo.
* Authorization (who may use which app, roles, tenant grants) stays with the app in v1. Exo
  proves identity and includes the user's email; it does not block issuance on app allowlists.

## Contract (v1) [#contract-v1]

Issuer: `ISSUER = ${CONVEX_SITE_URL}/exo-id` (staging:
`https://frugal-eel-930.convex.site/exo-id`).

Registry (`convex/lib/exoIdApps.ts`): per app, `redirectUris` (exact match) and `origins` (CORS).

1. **Authorize page** (Exo web): `GET /id/authorize?app&redirect_uri&state&code_challenge&code_challenge_method=S256`.
   Exo validates app and redirect URI server-side; invalid requests render an error and never
   redirect. Unauthenticated visitors sign in to Exo (Google/GitHub via Convex Auth v2, returning
   to the same URL). Authenticated visitors trigger mutation `exoId.authorize`, then
   `location.replace(redirect_uri?code&state)`.
2. **`exoId.authorize`** (mutation): requires an authenticated Exo user; validates app,
   redirect URI, and an S256 challenge; stores only `sha256(code)` in `exoIdCodes` with a
   60-second expiry; codes are single-use.
3. **`POST ${ISSUER}/v1/token`** (JSON):
   * `authorization_code`: `{app, code, redirect_uri, code_verifier}` — code must exist, be
     unused and unexpired, match app and redirect URI, and satisfy PKCE S256. Any redemption
     attempt consumes the code.
   * `refresh_token`: `{app, refresh_token}` — rotates the refresh token (hashes in
     `exoIdSessions`, 30-day lifetime, 30-second grace for the previous token).
   * 200: `{access_token, token_type: 'Bearer', expires_in, refresh_token, user: {id, email, name}}`.
   * 400: `{error: 'invalid_request' | 'invalid_grant' | 'unsupported_grant_type'}`.
   * CORS: preflight allowed for any registered origin; POST responses echo the Origin only
     when it belongs to the body's app.
   * Access token: RS256, header `kid` from `EXO_ID_JWKS`; claims `iss`, `aud=<app>`,
     `sub=<exo user id>`, `email`, `email_verified` (boolean), `name`, `iat`, `exp = iat + 900`.
4. **`POST ${ISSUER}/v1/revoke`** `{app, refresh_token}` → always 200.
5. **`GET ${ISSUER}/.well-known/jwks.json`** → public keys only, `Cache-Control: public, max-age=300`, CORS `*`.
6. **`GET ${ISSUER}/.well-known/openid-configuration`** → `issuer`, `jwks_uri`, `token_endpoint`,
   `authorization_endpoint` (from deployment env `EXO_ID_AUTHORIZE_URL`).

Integration guide: [docs/exo-id.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/exo-id.md).

## Consequences [#consequences]

* ADR 0003's per-app provider mounting, per-app OAuth registration, and "Apple per-app" bullets
  are superseded (marked in 0003). The rest of 0003 (Convex Auth v2 as the technology, alpha
  pinning, server-side identity derivation) stands.
* Apps that already mounted their own Convex Auth components (e.g. Observatory, Rampart,
  Construct's in-flight work) migrate to `customJwt` verification of Exo ID tokens and remove
  their provider components and secrets.
* Exo's Convex deployment becomes a hard dependency for sign-in across the portfolio; its
  availability and key rotation are platform responsibilities.
* A person has one Exo user id (`sub`) across apps; apps key their own records on it.
* Exo's Clerk-backed `/sign-in` stays in place until Construct leaves Clerk; Exo ID lives at
  `/id/authorize` and does not depend on Clerk.


---

# 0005: exo-verify — code verification as a platform service

https://exo.now/docs/adr/0005-exo-verify-verification-service

> Status: accepted (owner-approved 2026-09-27) · Date: 2026-09-27



# ADR 0005: exo-verify — code verification as a platform service [#adr-0005-exo-verify--code-verification-as-a-platform-service]

* Status: accepted (owner-approved 2026-09-27)
* Date: 2026-09-27
* Roadmap: [`exo-verify`](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/exo-verify.md)

## Context [#context]

Exo provisions shared platform services to every app so apps do not spend time on basic
plumbing. Verification is not one of them yet: each app carries ad-hoc unit tests, and
staging failures slip through. Example: the AI router passed all unit tests and review but
fell back 100% of the time on staging, because a fix never merged. Only a live behavioral
check against the deployed environment catches that class of failure.

Two constraints shape the design. The owner is moving off GitHub to Entire as the forge, so
GitHub Actions and GitHub-app PR bots are out. And most commits are made by agents
(\~300/month across the portfolio), so per-seat vendor pricing is the wrong unit.

## Decision [#decision]

Exo provides verification as a shared service, **exo-verify**, in four layers:

1. **Standard verify contract.** Every repo declares `verify` commands (`typecheck`, `lint`,
   `test`, `smoke`) in `project.yaml`. Exo runs what is declared; a missing command is a
   visible gap, not a silent pass.
2. **Sandboxed runs on Railway workers** (not GitHub Actions). Trigger order of preference:
   an Entire webhook if Entire exposes one; otherwise an `exo verify` CLI that agents run
   before pushing; otherwise Exo polling. In the sandbox an agent may also write targeted
   tests for the change and run them (TREX-style runtime validation).
3. **Post-deploy live checks against staging:** health, auth (Exo ID JWKS, OAuth callbacks),
   and 2–3 declared user journeys per app driven by Playwright or a browser agent.
4. **AI review pass** using an open-source engine (PR-Agent / Kodus-style) or our own
   prompts. All model calls go through the Exo AI broker, so cost is logged per app in
   `exoAiBrokerJobs` and the router keeps easy diffs on cheap models.

Results are stored in Convex as release gates, attached to the Entire trail, and gate
staging → production promotion.

## Unit economics [#unit-economics]

| Option               | Pricing                                                                                                                                        | Est. monthly at \~300 agent commits |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| **exo-verify (own)** | \~$0.007 compute per 5-min 2 vCPU / 2 GB Railway run + $0.01–0.10 model tokens per run                                                         | **\~$6–30**                         |
| Greptile Pro         | $30/seat/mo, 50 credits/seat, $1/extra credit; TREX runtime validation (beta) costs more credits; non-GitHub/GitLab forges are Enterprise-only | \~$300–900                          |
| CodeRabbit           | $24–30/user; CLI free at 3 reviews/hr on local git                                                                                             | per seat; CLI usable as interim     |
| Graphite             | $40/user                                                                                                                                       | per seat, GitHub-bound              |

Cost per verified commit for the own service is roughly $0.02–0.10, recorded per app so it
shows up in each app's cost-to-serve.

## Alternatives considered [#alternatives-considered]

* **Greptile** — strongest vendor review and has runtime validation, but priced per seat plus
  credits and requires Enterprise for a non-GitHub/GitLab forge. Kept as a later option for
  the review layer (Greptile Enterprise) if our review quality falls short.
* **CodeRabbit** — the free CLI reviews a local diff, so agents can use it pre-push as an
  interim second opinion while layer 4 is built. Not the system of record.
* **Graphite** — GitHub-centric stacking/review; does not fit the Entire forge.
* **GitHub Actions** — rejected; the owner is leaving GitHub.

## Consequences [#consequences]

* Every app gains a `verify` block in `project.yaml`; onboarding an app includes declaring
  its 2–3 golden journeys.
* Vendor review engines are more tuned than ours at first. We track catch rate against false
  positives per layer and revisit buying the review layer if the gap persists.
* Live checks need a staging-only sign-in path for bots (seeded Exo ID test identities),
  because browser agents cannot pass Google's bot detection.
* **Open question:** does Entire expose webhooks or a checks API? Verify before building the
  trigger; the CLI and polling fallbacks keep the design unblocked either way.


---

# 0006: exo-analyze — Exo-owned product analytics

https://exo.now/docs/adr/0006-exo-analyze-product-analytics

> Status: accepted (owner-approved 2026-09-27) · Date: 2026-09-27



# ADR 0006: exo-analyze — Exo-owned product analytics [#adr-0006-exo-analyze--exo-owned-product-analytics]

* Status: accepted (owner-approved 2026-09-27)
* Date: 2026-09-27
* Roadmap: [`product-analytics`](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/product-analytics.md) (exo-analyze)

## Context [#context]

Exo provisions shared platform services to every app so apps do not spend time on basic
plumbing. Today RevenueCat and Stripe tell us about payments only. We cannot see visits,
sign-up conversion, or what it costs to serve a user, so we cannot compute per-app unit
economics or margin. Letting each app wire its own analytics vendor would repeat the
integration per app and scatter the data.

## Decision [#decision]

Exo owns product analytics as a platform service, **exo-analyze**:

* **Ingestion and schema live in Exo's Convex backend**, partitioned per app and per
  environment. Apps send events to one Exo endpoint through a thin SDK (web + Expo).
* **Exo stores the funnel end to end:** visits → sign-ups (Exo ID) → paid (RevenueCat /
  Stripe webhooks) → cost-to-serve (`exoAiBrokerJobs`, plus exo-verify and infra cost). That
  join gives per-app unit economics: acquisition, conversion, revenue per user, and margin.
* **PostHog sits behind Exo as a swappable adapter** for dashboards and session replay (free
  tier \~1M events/month). Plausible and GA4 remain possible adapters.
* **Apps never integrate an analytics vendor directly.** Changing vendors is an Exo adapter
  change, not an app change.

## Unit economics [#unit-economics]

* At portfolio scale today, event volume fits PostHog's free tier; the incremental cost is
  Convex storage/function usage for the event table, expected to be a few dollars per month.
* Each app's dashboard reports cost per active user and gross margin per paying user, so
  feature design can be judged on cost per unit and margin, not revenue alone.
* If volume outgrows the free tier, Exo can sample or aggregate before forwarding to the
  adapter while keeping full-fidelity funnel counts in Convex.

## Alternatives considered [#alternatives-considered]

* **Per-app PostHog/Plausible/GA4** — fastest for one app, but no cross-app funnel, no join to
  Exo ID or broker cost, and vendor lock-in in every codebase.
* **PostHog as system of record** — good dashboards, but ties revenue and cost-to-serve joins
  to a vendor schema; kept as an adapter instead.
* **RevenueCat/Stripe only** — status quo; payments without the top of the funnel or cost.

## Consequences [#consequences]

* Exo ships and versions the analytics SDK; exo-mobilize bakes it into the mobile template.
* Event schema changes are platform decisions, reviewed like any other Exo contract.
* Privacy/consent handling (cookie banners, data deletion) is centralized in Exo.


---

# 0007: exo-mobilize — Expo mobile release toolkit

https://exo.now/docs/adr/0007-exo-mobilize-mobile-release-toolkit

> Status: accepted (owner-approved 2026-09-27) · Date: 2026-09-27



# ADR 0007: exo-mobilize — Expo mobile release toolkit [#adr-0007-exo-mobilize--expo-mobile-release-toolkit]

* Status: accepted (owner-approved 2026-09-27)
* Date: 2026-09-27
* Roadmap: [`mobile-delivery-pipeline`](https://github.com/Interspace-Ventures/exo/blob/staging/roadmap/items/mobile-delivery-pipeline.md) (exo-mobilize)

## Context [#context]

Exo provisions shared platform services to every app so apps do not spend time on basic
plumbing. Shipping to iOS and Android involves the same setup every time: app config,
signing, store listings, TestFlight, native sign-in redirects, analytics. Our stack is
TypeScript, React, Convex, and Exo ID, and most code is written by agents. Universe already
ships on Expo and is the reference.

## Decision [#decision]

* **Expo is the standard mobile framework for Exo apps.** Expo is an MIT-licensed framework on
  React Native.
* **EAS is optional and swappable** for build, submit, and OTA updates. Start on EAS Free
  (15 iOS + 15 Android builds/month, OTA to 1K MAU); move to Starter ($19/month plus usage,
  includes $45 build credit) when several apps ship; run our own Mac builder
  (`eas build --local` / fastlane) only at high volume.
* **exo-mobilize owns the release template:** app config, signing, TestFlight and store
  listings, Exo ID sign-in via a native redirect scheme (e.g. `universe://auth/callback`),
  and the exo-analyze SDK built in.
* **The PWA-required-for-mobile repository contract gate stays.**

## Unit economics [#unit-economics]

| Stage                   | Fixed cost             | Marginal cost                                      |
| ----------------------- | ---------------------- | -------------------------------------------------- |
| Apple Developer Program | $99/year (required)    | —                                                  |
| EAS Free                | $0                     | capped at 15+15 builds/month, OTA to 1K MAU        |
| EAS Starter             | $19/month              | usage beyond the $45 build credit                  |
| Own Mac builder         | hardware or hosted Mac | \~$0 per build; pays off only at high build volume |

At current volume the toolkit costs \~$8/month (Apple fee amortized). Move up a tier only when
build counts or OTA MAU exceed the current tier.

## Alternatives considered [#alternatives-considered]

| Option                         | Why not                                                         |
| ------------------------------ | --------------------------------------------------------------- |
| Bare React Native              | Same runtime without Expo's build, config, and OTA tooling      |
| Flutter                        | Dart rewrite, no official Convex client, OTA only via Shorebird |
| Capacitor / Ionic              | Webview feel                                                    |
| Kotlin / Compose Multiplatform | Kotlin rewrite                                                  |
| Lynx (ByteDance)               | Too young                                                       |
| Native Swift + Kotlin          | Two codebases                                                   |

Expo wins: open source, reuses our TS/React/Convex/Exo ID code, native UI, OTA updates, and
agents are fluent in it.

## Consequences [#consequences]

* New mobile apps start from the exo-mobilize template, not from scratch.
* EAS is a service choice, not a dependency: the template must keep a local-build path.
* Each app registers its native redirect scheme with Exo ID as part of onboarding.


---

# 0009: Exo Key headless sign-in — device authorization grant and personal Exo Keys

https://exo.now/docs/adr/0009-exo-key-headless-sign-in

> Status: proposed · Date: 2026-10-07



# ADR 0009: Exo Key headless sign-in — device authorization grant and personal Exo Keys [#adr-0009-exo-key-headless-sign-in--device-authorization-grant-and-personal-exo-keys]

* Status: proposed
* Date: 2026-10-07
* Extends: [0004-exo-id-central-identity-provider.md](https://exo.now/docs/adr/0004-exo-id-central-identity-provider),
  ADR 0008 (Exo Platform API, Exo Key API tokens; PR #145)

## Context [#context]

Owner decision: Construct and Exo must be usable headlessly from agents (Hermes, Claude Code,
Codex, T3 Code) through MCP servers and CLIs. Sign-in lives only at Exo (Exo Key); apps trust
Exo-issued RS256 JWTs (`aud` = app). ADR 0004 covers browser apps (authorization code + PKCE with
registered redirect URIs). A CLI or agent has no redirect URI it can register, and a script or
server must not hold a person's browser session.

## Decision [#decision]

Exo Key adds two headless paths. Both end in the same access token apps already verify (issuer
`${EXO_CONVEX_SITE}/exo-id`, `aud` = app, RS256, 15 minutes), plus a `scope` claim.

### 1. Device authorization grant (RFC 8628), for interactive CLIs and agents [#1-device-authorization-grant-rfc-8628-for-interactive-clis-and-agents]

* `POST {prefix}/v1/device/code` with `{app, scope?}` (JSON or form) returns `device_code`,
  `user_code` (8 characters `XXXX-XXXX` from the RFC 8628 §6.1 consonant alphabet
  `BCDFGHJKLMNPQRSTVWXZ`), `verification_uri` (`https://<exo>/id/device`),
  `verification_uri_complete` (`…?code=XXXX-XXXX`), `expires_in` 600 and `interval` 5.
* The person opens `/id/device` on the Exo frontend, signs in to Exo if needed (returning to the
  same URL), sees the app and requested scope, and approves or denies. Approval binds the grant
  to the signed-in Exo user.
* The CLI polls `POST {prefix}/v1/token` with
  `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `device_code`, `app`. Errors are
  RFC 8628 §3.5: `authorization_pending`, `slow_down` (with the new `interval`; each one adds 5 s),
  `access_denied`, `expired_token`; unknown/redeemed codes or the wrong app give `invalid_grant`.
  An approved grant returns the same body as the authorization-code grant (access token, rotating
  refresh token, user) and is single-use.
* The granted `scope` is stored on the refresh-token family and carried to every refreshed
  access token.

### 2. Personal Exo Keys + token exchange (RFC 8693), for scripts, servers and unattended agents [#2-personal-exo-keys--token-exchange-rfc-8693-for-scripts-servers-and-unattended-agents]

* A signed-in Exo user creates, lists and revokes their own Exo Keys (`exoKeyTokens.createMine`,
  `listMine`, `revokeMine`; UI at `/id/keys`). They reuse the ADR 0008 phase 1 table and format
  (`exo_tk_<prefix>_<secret>`, SHA-256 at rest, prefix as the public handle, 1–365 day TTL) and
  are owned by the user (`userId`) under the user's personal tenant `user-<userId>`. The secret is
  returned once. Up to 10 live keys per user; creation is rate-limited per user.
* Scopes: `read` (default), `write`, `ai`, `usage:read`. Apps define what `read`/`write` permit;
  `ai`/`usage:read` keep their platform-API meaning.
* A key is bound to one app or to all headless apps.
* `POST {prefix}/v1/token` with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`,
  `subject_token=<key>`, `subject_token_type=urn:exo:token-type:exo-key`, `app`, and optional
  `scope` (must be a subset of the key's scopes) returns a 15-minute access token (`aud` = app,
  `sub` = key owner, `scope` claim), `issued_token_type`
  `urn:ietf:params:oauth:token-type:access_token`, and **no refresh token**: the key is the
  long-lived credential and every exchange re-checks it. Errors: `invalid_grant` (unknown,
  revoked, expired, or an operator tenant token with no user), `invalid_target` (the key is bound
  to another app, or the app has not opted in), `invalid_scope`.
* Operator-minted tenant tokens (ADR 0008, no `userId`) are never exchangeable: they identify a
  tenant, not a person.

### Registry opt-in [#registry-opt-in]

`convex/lib/exoIdApps.ts` gains `label` and `deviceFlow`. Only apps with `deviceFlow: true`
accept device grants and app-bound personal keys. Enabled: `construct`, and `exo` (new; Exo's own
API and CLI, `aud` `exo`, no redirect URIs so the browser flow can never target it).

### Security [#security]

* `device_code` and Exo Keys are stored only as SHA-256. Comparisons happen through hash-index
  lookups; the existing constant-time compare covers PKCE and refresh successors.
* User codes are short, so they are only resolvable by a signed-in Exo user, through a
  rate-limited mutation (10/min per user). That is about 2.6e10 codes against at most a
  handful of pending grants per 10 minutes. A decided or expired code no longer resolves.
* `device/code` is rate-limited per client IP (rightmost `X-Forwarded-For`, as for `/v1/token`),
  and every token request still hits the existing per-IP limit. Exchange is additionally limited
  per key.
* The consent screen tells the person to approve only a code they started.

## Consequences [#consequences]

* Agents get two documented options: device flow for a person at a terminal, an Exo Key for
  unattended use. Neither needs a redirect URI or a browser cookie.
* Apps change nothing to accept these tokens. To act on `scope`, they read the `scope` claim.
* The `/id/device` and `/id/keys` pages ship with the Exo frontend. `verification_uri` is derived
  from `EXO_ID_AUTHORIZE_URL` (override: `EXO_KEY_DEVICE_URL`).
* Follow-ups: an `exo login` CLI and MCP helper on top of the device flow; per-app scope
  catalogs; showing active device sessions on `/id/keys`.


---

# 0010: Exo Key OAuth for third-party MCP clients (CIMD, DCR, resource indicators)

https://exo.now/docs/adr/0010-exo-key-mcp-oauth

> Status: proposed · Date: 2026-10-08



# ADR 0010: Exo Key OAuth for third-party MCP clients (CIMD, DCR, resource indicators) [#adr-0010-exo-key-oauth-for-third-party-mcp-clients-cimd-dcr-resource-indicators]

* Status: proposed
* Date: 2026-10-08
* Extends: [0004-exo-id-central-identity-provider.md](https://exo.now/docs/adr/0004-exo-id-central-identity-provider),
  [0009-exo-key-headless-sign-in.md](https://exo.now/docs/adr/0009-exo-key-headless-sign-in)

## Context [#context]

Construct (`https://construct.page/mcp`, staging `https://staging.construct.page/mcp`) and Exo
(`<exo convex site>/mcp`) are remote MCP servers. Users connect them from their own agents:
Claude (web/Desktop), Claude Code, ChatGPT, Codex, Hermes, OpenCode, Cursor, VS Code, Muse.
Until now an agent could only connect with a pasted Exo Key. Every one of those clients
implements MCP authorization (OAuth 2.1): it reads the server's protected-resource metadata
(RFC 9728), discovers the authorization server (RFC 8414), identifies itself with a Client ID
Metadata Document (CIMD) or Dynamic Client Registration (RFC 7591), and runs an authorization
code + PKCE flow with an RFC 8707 `resource`. Exo Key stays the ONLY authorization server for all
apps (ADR 0004); the issuer string never changes.

Client facts this design follows (fetched 2026-10-08):

| Client                               | Identification                                                                                            | Redirect                                                                      | Token auth                                                               |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Claude (web)                         | CIMD if `client_id_metadata_document_supported` and `none` are advertised, else DCR                       | `https://claude.ai/api/mcp/auth_callback`                                     | none                                                                     |
| Claude Code                          | CIMD `https://claude.ai/oauth/claude-code-client-metadata`, else DCR                                      | `http://localhost/callback`, `http://127.0.0.1/callback`, any port            | none                                                                     |
| ChatGPT                              | CIMD `https://chatgpt.com/oauth/client.json`, else DCR                                                    | `https://chatgpt.com/connector_platform_oauth_redirect` (with RFC 9207 `iss`) | `private_key_jwt` (jwks `https://chatgpt.com/oauth/jwks.json`) or `none` |
| Codex                                | CIMD `https://chatgpt.com/oauth/codex/client.json`, else DCR                                              | `http://127.0.0.1/callback`, `http://localhost/callback`, any port            | none                                                                     |
| Hermes                               | CIMD `https://nousresearch.github.io/hermes-agent/docs/oauth/client-metadata.json`, else DCR; device flow | loopback ports 27890-27894                                                    | none                                                                     |
| OpenCode, Cursor, VS Code, Muse Code | DCR                                                                                                       | loopback / `https://vscode.dev/redirect` / `https://www.cursor.com/...`       | none                                                                     |

## Decision [#decision]

### Discovery [#discovery]

One metadata document is served as RFC 8414 AS metadata and OIDC discovery, byte-identical at:

* `<issuer>/.well-known/oauth-authorization-server` and `<issuer>/.well-known/openid-configuration`
* the RFC 8414 §3.1 path-inserted forms `<origin>/.well-known/oauth-authorization-server/exo-id`
  and `<origin>/.well-known/openid-configuration/exo-id`
* the same four under the `/exo-key` endpoint prefix.

It advertises `issuer` (`<site>/exo-id`), `authorization_endpoint` (`EXO_ID_AUTHORIZE_URL`, the
frontend `/id/authorize`), `token_endpoint`, `registration_endpoint`, `revocation_endpoint`,
`jwks_uri`, `device_authorization_endpoint`, `response_types_supported [code]`,
`grant_types_supported [authorization_code, refresh_token, device_code, token-exchange]`,
`code_challenge_methods_supported [S256]`, `token_endpoint_auth_methods_supported [none, private_key_jwt]`, `client_id_metadata_document_supported: true`,
`authorization_response_iss_parameter_supported: true`, `scopes_supported [read, write, ai,
usage:read, offline_access]`. Client secrets are not offered: every MCP client above is public
or uses `private_key_jwt`.

### Clients [#clients]

First-party registry apps (`convex/lib/exoIdApps.ts`, `app=`) are unchanged. Third-party clients
send `client_id` instead of `app`:

* **DCR** `POST {prefix}/v1/register`: open registration of public clients
  (`token_endpoint_auth_method` absent or `none`; secrets rejected), any `client_name`,
  1-20 redirect URIs (https; loopback `http://127.0.0.1|localhost|[::1]`; private-use schemes
  such as `cursor://`; never `javascript:`/`data:`/`file:`, never fragments). Unsupported grant
  types are dropped (RFC 7591 §3.2.1). Rows live in `exoKeyOAuthClients` (`exo_dcr_<random>`).
  Rate-limited per client IP (rightmost `X-Forwarded-For`); clients idle 30 days are deleted by
  a daily cron.
* **CIMD**: a `client_id` that is an https URL is fetched server-side and must contain
  `client_id` equal to the URL. SSRF guard: https only, default port, real DNS name (IP literals,
  `localhost`, single-label and internal suffixes refused), no query/fragment/userinfo/dot
  segments, redirects not followed, 3 s timeout, 10 KB streamed cap, JSON only, global and
  per-host fetch rate limits. Results (and failures, for 60 s) are cached 1 h in
  `exoKeyOAuthFetchCache`. Client secrets in a document are refused. Allowed token auth methods =
  the intersection of `token_endpoint_auth_method` + `token_endpoint_auth_methods_supported`
  (SEP-3149) with `none`/`private_key_jwt`.
* **private\_key\_jwt** (RFC 7523): the assertion is verified against the document's `jwks_uri`
  (must share the client\_id origin) or inline `jwks`; RS256/ES256; `iss = sub = client_id`;
  `aud` = the token endpoint (either prefix) or the issuer; `exp` and `jti` required, lifetime at
  most 10 minutes, `jti` single-use (`exoKeyAssertionJtis`). Unknown `kid` refetches the key set
  once. A presented assertion must verify; without one the client must allow `none`.

The Convex runtime has no DNS API, so before every CIMD or `jwks_uri` fetch the host is resolved
over DNS-over-HTTPS (Cloudflare, Google as fallback; A and AAAA) and every answer must be a
public unicast address; a failed lookup refuses the fetch (`convex/lib/ssrfGuard.ts`). The
residual DNS-rebinding window (fetch resolves again) is described in
`docs/security/cimd-ssrf.md`.

### Redirect URIs [#redirect-uris]

Exact string match, except loopback `http` redirects, which match on any port (RFC 8252 §7.3);
host, path and query must still match, so `localhost` and `127.0.0.1` are separate entries (every
client lists both).

### Authorization endpoint [#authorization-endpoint]

`/id/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…&scope=…&resource=…`

`exoKeyOAuth.describeAuthorize` (public action) resolves the client and checks the redirect.
An unknown client or unregistered redirect renders an error page and never redirects. Everything
else that is wrong (`response_type`, missing/`plain` PKCE, missing or unknown `resource`) redirects
to the client with `error`, `state` and `iss`. The consent screen shows the client name (and CIMD
host), the redirect host, the scopes and the resource (`Construct · staging.construct.page`). A
person without a session signs in (or signs up) in place. `exoKeyOAuth.decideAuthorize`
(session-gated, rate-limited per user) re-validates, then on approve stores a 60 s single-use
code in `exoIdCodes` bound to client, redirect URI, PKCE challenge, scope, resource and user, and
redirects with `code`, `state`, `iss` (RFC 9207); on deny, `error=access_denied` + `iss`.

Scopes: unknown values (e.g. `openid`) are dropped; no recognised scope grants
`read write offline_access`. Refresh tokens are always issued.

### Resources and audience (RFC 8707) [#resources-and-audience-rfc-8707]

`EXO_PROTECTED_RESOURCES` (JSON `{"<url>": {"app": "construct", "label": "Construct"}}`) lists
the protected resources; defaults are `https://staging.construct.page/mcp` and
`https://construct.page/mcp` (app `construct`). Exo's own `<site>/mcp` (app `exo`) is always
included. Resources compare as exact strings. Unknown resources are `invalid_target` at authorize
and token time.

Access tokens for third-party clients carry:

```json
{ "iss": "<site>/exo-id", "sub": "<exo user id>", "aud": ["<resource>", "<app>"],
  "client_id": "<cimd url | exo_dcr_…>", "scope": "read write offline_access",
  "email": "…", "email_verified": true, "name": "…", "iat": 0, "exp": 0 }
```

RS256, `kid` from the Exo Key JWKS, 15 minutes. The resource gives MCP servers RFC 8707 audience
binding; the app id keeps Convex `customJwt` providers (`applicationID = <app>`) working, since
Convex accepts any `aud` array that contains the application ID (convex-backend
`crates/authentication`). A token for one resource is refused by the other resource server
(Exo's MCP verifies `aud ∈ {exo, <site>/mcp}`; Construct's MCP must require its own resource URL).

### Token endpoint [#token-endpoint]

`POST {prefix}/v1/token` with `client_id` (and no `app`), form-urlencoded or JSON:

* `authorization_code`: `code`, `redirect_uri` (exact match with the authorize request),
  `code_verifier` (PKCE S256), optional `resource` (must equal the code's). Any attempt consumes
  the code. First-party `app=` requests can never redeem a client's code, and vice versa.
* `refresh_token`: rotation with reuse detection exactly as ADR 0004/#165 (families, 30 s grace,
  family revoke on reuse); the session is bound to `clientId` + `resource`, which every successor
  copies. Another client, a first-party `app`, or a different `resource` cannot use it. `scope`
  may narrow, never widen.
* Response: `access_token`, `token_type`, `expires_in`, `refresh_token`, `scope` (no `user`).
* Errors are RFC 6749 JSON (`invalid_client` is 401 with `WWW-Authenticate`).

`POST {prefix}/v1/revoke` accepts RFC 7009 `token` (+ `client_id`) and revokes the whole family.

### Exo MCP [#exo-mcp]

`convex/mcpHttp.ts` accepts access tokens whose `aud` contains `exo` or `<site>/mcp`; its
protected-resource metadata `resource` is exactly `<site>/mcp` and `authorization_servers` is
`[<issuer>]`.

Connected agents (`convex/exoKeyAgents.ts`, docs/agents.md): `/id/keys` lists each client\_id
the user connected, with per-client revoke and Exo MCP calls per agent. Agent access tokens carry
`sid` (their refresh family root), so Exo MCP refuses a revoked agent's token before it expires.

## Consequences [#consequences]

* Every listed agent can show a "sign in" button for Exo and Construct with no pasted key.
* Construct's MCP must serve RFC 9728 metadata pointing at the Exo issuer and require
  `aud ∋ <its resource URL>` on MCP requests (its Convex `customJwt` provider keeps working).
* New tables: `exoKeyOAuthClients`, `exoKeyOAuthFetchCache`, `exoKeyAssertionJtis`; new optional
  fields on `exoIdCodes` and `exoIdSessions` (`clientId`, `resource`, `scope`).
* Follow-ups: a "Connected agents" list with per-client revoke on `/id/keys`; ID tokens /
  `id_token_hint` for ChatGPT. Done: DNS-resolution SSRF check via DNS-over-HTTPS.


---

# 0011: Exo forges Bases that launch Ships

https://exo.now/docs/adr/0011-forge-bases-and-ships

> Status: superseded by ADR 0013 (2026-10-13) · Date: 2026-10-12



# ADR 0011: Exo forges Bases that launch Ships [#adr-0011-exo-forges-bases-that-launch-ships]

* Status: superseded by [ADR 0013](https://exo.now/docs/adr/0013-exo-is-mission-control-apps-pick-components) (2026-10-13)
* Date: 2026-10-12
* Decided by: Samir (owner)

> **Superseded.** ADR 0013 replaces this model: EXO is mission control for launching apps, its
> products are components, and people build apps that pick the components they need. "Forge", "each owner builds a Base" and Ship as
> a product noun are retired. Kept for history.

## Context [#context]

Exo described itself as "mission control for launching apps", and its docs used App as the top
noun. That hid what Exo actually provides: a well-defined set of software parts (identity, data,
runtime, checks, secrets, messaging) that apps are built on, which each owner uses selectively.
Construct also needed a clear place in the hierarchy for websites, PWAs and native apps, and the
mini-apps that sit on them.

## Decision [#decision]

1. **Exo is the forge.** "Forge" describes Exo; it is not a product noun. The name stays Exo, never
   "Exobase" or "Base Forge". In repository contracts the Entire role is now `primary-host`, because
   "forge" now means Exo.
2. **A Base** is the buildout of parts Exo forges for an owner (an Organization: a person or team).
   Parts come from one catalog. Each Base declares the parts it uses in `project.yaml` as
   `base.parts` (schema 1.9):
   * Required core: `source`, `preflight` (EXO Verify Level 1), `runtime`, `secrets`, `evidence`,
     plus `identity` (Exo ID) unless `base.signIn` is false.
   * Optional: `data` (Convex), `comms` (Bird), `payments`, `models` (Exo Rank), `support`
     (Exo Ask, proposed), `analytics` (Exo Analyze), `surfaces` (Construct).
   * Shared services (the Exo ID provider, Exo Rank, EXO Verify, Bird) run once in the forge. Each
     Base gets its own clients, configuration, data, environments and secrets on top of them. A
     Base is not private or resold infrastructure.
3. **A Ship** is an app a Base launches. A Base has a fleet: it can launch several Ships. Ships
   return, get refitted and fly again: each iteration is a **version**. **Launch** means a version
   reaching Production through the owner-approved promotion. Staging is the launch pad, and EXO
   Verify is the preflight. Rollback means relaunching an earlier version. Environment names stay
   Staging, Release Candidate and Production.
4. **Construct** is the construct that produces **Surfaces**, the interaction layers for users: web
   surfaces (websites), mobile surfaces (PWAs), phone surfaces (iOS and Android apps), or any
   combination. Surfaces carry **Cards**, components that give a Surface its functionality.
   Surfaces and Cards are **published**, not launched.
5. **Placement rule.** Something is a Surface in Construct if it is mostly screens over Construct's
   parts. It is its own Ship if it has its own backend and data. It gets its own Base only when it
   needs walled-off data, secrets and audit trails, for example regulated money or a different
   owner.
   * Hadron is a Ship on the Interspace Base.
   * Foundation gets its own Base because it handles donor money; its landing page stays a Surface
     in Construct.
   * If Hadron is sold, each customer gets their own Base (users, data, agent keys) and launches
     their own Hadron Ship, the same pattern as Parlour's Construct.

## Consequences [#consequences]

* Code, schema, Convex tables and URLs keep `app` (for example `staging.exo.now/{app}`). Ship is
  the product word; no data migrates.
* `project.yaml` schema 1.9 adds `base`; EXO Verify Level 1 checks it. 1.8 projects stay valid and
  get a skip until they migrate.
* Rollout is light: the homepage, docs overview, platform index, README, llms.txt and the
  repository contract. Deeper docs still say App and move over as they are next edited.


---

# 0012: Clerk removed; Convex Auth everywhere

https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere

> Status: accepted · Date: 2026-10-12



# ADR 0012: Clerk removed; Convex Auth everywhere [#adr-0012-clerk-removed-convex-auth-everywhere]

* Status: accepted
* Date: 2026-10-12
* Decided by: Samir (owner)

## Context [#context]

ADR 0003 moved Exo identity to Convex Auth v2 and ADR 0004 made Exo ID (now Exo Key) the central
identity provider, with every App a relying party. Clerk stayed in EXO as a transitional layer:
the staging gateway at `staging.exo.now/{app}` used `clerkMiddleware` and forwarded Clerk session
JWTs to Apps, `/sign-in` and `/sign-up` mounted Clerk's `<SignIn>`/`<SignUp>`, the repository
contract declared `provider: clerk` for staging access, and commerce offered a `web-clerk-stripe`
profile with Clerk Billing. Two identity systems meant two sets of keys, two session models, and
per-MAU pricing for the one we were leaving.

## Decision [#decision]

1. **Exo ID / Convex Auth is the only identity in EXO.** No `@clerk/*` package, import, or
   `CLERK_*` variable remains; a guard test (`tests/int/no-clerk.int.spec.ts`) keeps it that way.
2. **EXO signs in through its own Exo ID.** EXO is a relying party registered as `exo-staging`
   (`convex/lib/exoIdApps.ts`) with exact callbacks `https://staging.exo.now/auth/callback` and
   `http://localhost:3000/auth/callback`. `/sign-in` starts the authorization-code + PKCE round trip
   through `/id/authorize` (Google, GitHub, phone over Bird). The access token and rotating
   refresh token live in host-only HttpOnly cookies. The headless `exo` audience still accepts no
   browser redirect.
3. **Staging access = Exo ID session + exact non-production Exo grant.** The gateway verifies the
   Exo ID access token (RS256 against the Exo ID JWKS, exact issuer and `aud: exo-staging`,
   expiry), refreshes it once when expired, then authorizes the requested App path with
   `authorizeExoAccess` against the `EXO_STAGING_GRANTS` tester grants. It forwards only the
   verified access token to the App and strips EXO's own cookies. Every failure stays closed.
4. **Contracts.** `project.yaml` 1.9 requires `access.provider: exo-id` and forbids Clerk in
   commerce; 1.8 declarations keep accepting `clerk` so existing repositories stay valid while
   they migrate. `convex.application.json` 1.2 declares `identity.provider: exo-id` with an
   `audience` and `EXO_ID_ISSUER`; 1.1 keeps the Clerk shape. Commerce profile `web-clerk-stripe`
   becomes the Stripe-only `web-stripe` (Stripe Checkout and Customer Portal own the experience);
   Stripe and RevenueCat stay.
5. **Data is not broken.** The `billingIntegrations.provider` literal `clerk` stays in the Convex
   schema, marked deprecated, so imported legacy rows remain valid. Nothing new writes it.

## Consequences [#consequences]

* ADR 0002 (Clerk identity boundary) is fully superseded. ADR 0003 and ADR 0004 stand; the
  transitional Clerk `/sign-in` that ADR 0004 kept is gone.
* The gateway depends on a Convex deploy of the `exo-staging` registry entry to the EXO
  deployment, and on the `EXO_STAGING_GRANTS` tester grants being set on the EXO web service.
  Until both happen, staging apps stay closed (fail closed), not open.
* Apps behind the gateway that verified the forwarded Clerk bearer must verify the forwarded
  Exo ID access token instead (issuer `${EXO_CONVEX_SITE_URL}/exo-id`, audience `exo-staging`), or
  rely on their own Exo ID sign-in.
* Repositories outside EXO that still use Clerk (their own `project.yaml` or code) are migration
  debt tracked in `config/project-integrations.json` as `legacy identity`; EXO no longer provides
  or documents Clerk. Cancelling the Clerk account and subscription is an owner action.


---

# 0013: EXO is mission control; apps pick components

https://exo.now/docs/adr/0013-exo-is-mission-control-apps-pick-components

> Status: accepted · Date: 2026-10-13



# ADR 0013: EXO is mission control; apps pick components [#adr-0013-exo-is-mission-control-apps-pick-components]

* Status: accepted
* Date: 2026-10-13
* Decided by: Samir (owner)
* Supersedes: [ADR 0011](https://exo.now/docs/adr/0011-forge-bases-and-ships) (Exo forges Bases that launch Ships)

## Context [#context]

ADR 0011 described Exo as a forge that builds a Base for each owner, and each Base launching
Ships. In practice that put two invented layers (forge, Base) and a second word for an app (Ship)
between people and what they actually do: build apps on EXO and use the components each app needs.
Nobody builds Exo, and nobody builds a Base. The model needed to be simpler to say and to read.

## Decision [#decision]

1. **EXO is mission control for launching apps**, the platform. There is one of it. "Forge" is
   dropped entirely, and so is "Base" as a product noun and "each owner builds a Base".
2. **EXO's products are components.** The components today are Exo ID (sign-in for an app's users), Exo Key
   (keys and headless sign-in for agents, the CLI and MCP), Exo Deploy, EXO Verify, Exo Analyze,
   Exo Rank (model routing; code and URLs still say `router`), Exo Inform (docs and READMEs that
   agents and people can read) and the other services in the platform and stack docs, such as the
   Bird communications layer and Construct.
3. **People build apps.** Each app picks the components it needs. It is modular, and no app uses
   everything. An app declares its components in `project.yaml` as a top-level `components` list
   (schema 1.10). Components come in two tiers:
   * **Core components**, the ones most apps need: `source`, `identity` (Exo ID), `preflight`
     (EXO Verify Level 1), `runtime`, `secrets`, `evidence` and `data` (Convex).
   * **Specialty components**, the ones only some apps need: `comms` (Bird), `payments`,
     `models` (Exo Rank), `support` (Exo Ask, proposed), `analytics` (Exo Analyze) and `surfaces`
     (Construct).
     Not every app needs comms and support, or an AI router like Exo Rank. (This tier was briefly
     called "add-ons"; the owner renamed it "specialty components" on 2026-10-13.)
   * The tiers are a docs and design grouping. Source control and the control plane are recommended
     but optional, and an app may opt out of either. The schema-required list is therefore
     `preflight`, `runtime`, `secrets` and `evidence`, plus `identity` unless `signIn` is false;
     ADR 0011 also required `source`. `data` is shown as core but is not schema-required.
   * The catalog and the wiring checks are unchanged (a declared `source` must still be wired to
     Entire); apart from `source` becoming optional, only the name and where the list lives change (`base.parts` becomes `components`, `base.signIn` becomes `signIn`).
4. **Apps, in every piece of prose.** An app is visualized as a rocket ship in artwork, but the
   prose always says "app". "Ship" is no longer a product noun.
5. **Starter kits.** A **starter kit** is a saved set of components that new apps start from, for a
   family of apps that share the same components. It is a convenience for creating apps, not a
   runtime layer; each app still declares its own `components`.
6. **Separate accounts, not separate Bases.** Anything that needs walled-off money or data is a
   separate account on EXO, with its own users, data, secrets, environments and audit trail.
   * Foundation's donor money lives in its own account on EXO; its landing page stays a Surface in
     Construct.
   * Hadron is an app in the Interspace account.
   * If Hadron is sold, each customer gets their own account on EXO and their own Hadron app, the
     same pattern as Parlour's Construct.
7. **Launch is unchanged.** Launch means an app version reaching Production through the
   owner-approved promotion. Staging is the launch pad, EXO Verify is the preflight, and rollback
   means relaunching an earlier version. Environment names stay Staging, Release Candidate and
   Production.
8. **Construct is unchanged.** Construct publishes **Surfaces** (web, PWA, iOS and Android, or any
   combination) and the **Cards** on them. Surfaces and Cards are published, not launched.
9. **Placement rule, restated.** Something is a Surface in Construct if it is mostly screens over
   Construct's components. It is its own app if it has its own backend and data. It gets its own account
   on EXO only when it needs walled-off money or data, for example regulated money or a different
   owner.

## Consequences [#consequences]

* Code, schema field names, Convex tables and URLs keep `app` (for example
  `staging.exo.now/{app}`). No data migrates and no URL changes.
* `project.yaml` schema 1.10 moves the catalog to a top-level `components` list and `signIn`
  (`$defs.component` and `$defs.componentsList` in the schema). A 1.10 declaration must list
  `components` and must not keep a `base` block. 1.9 `base.parts` is still accepted as legacy (EXO
  Verify reports a skip pointing at 1.10), and 1.8 declarations without components are still
  skipped.
* The EXO Verify Level 1 module is renamed from `lib/level1/base.mjs` to
  `lib/level1/components.mjs` (exports `COMPONENTS`, `REQUIRED_COMPONENTS`, `validateComponents`,
  `componentsChecksFor`), and its check names say components (`components declared`,
  `components required core`, `component data wired`).
* The Entire contract role stays `primary-host`. Prose that called Entire "the primary forge" now
  says "primary source host". Contract constants such as `forge-change` and `primaryForge` are
  code identifiers and are unchanged.
* Rollout covers the homepage, docs overview, platform index, README, llms.txt, the repository
  contract, EXO Verify Level 1 docs, AGENTS.md, HANDOFF.md and the changelog. Deeper docs that
  still say App, or Exo Router for Exo Rank, are already correct in meaning and move over as they
  are next edited.
* Starter kits and accounts on EXO are named here but not yet modelled in code or schema; that is
  later work.


---

# 0014: Exo Secure loads secrets from 1Password at boot; no secrets in platform env vars

https://exo.now/docs/adr/0014-exo-secure-secrets-from-1password

> Status: proposed (draft PR; owner decision pending on the open items below) · Date: 2026-10-21



# ADR 0014: Exo Secure loads secrets from 1Password at boot; no secrets in platform env vars [#adr-0014-exo-secure-loads-secrets-from-1password-at-boot-no-secrets-in-platform-env-vars]

* Status: proposed (draft PR; owner decision pending on the open items below)
* Date: 2026-10-21
* Decided by: Samir (owner), standing rule set 2026-10-21
* Relates to: [ADR 0004](https://exo.now/docs/adr/0004-exo-id-central-identity-provider) (Exo ID issuer and JWKS),
  [ADR 0005](https://exo.now/docs/adr/0005-exo-verify-verification-service) (EXO Verify), roadmap item "Exo Secure"

## Context [#context]

On 2026-10-21 Railway disclosed that an unauthorized party accessed systems that store customer
environment variables, obtained the decrypted contents of some of them, and appears to have been
hunting for AI provider keys; Railway recommends rolling every variable and paused all deployments
([Railway disclosure](https://blog.railway.com/p/security-disclosure-wednesday-october-21st)).

Every EXO secret that lives in a platform env store (Railway, Vercel, Convex) is exposed to a breach
of that platform, and an env var also leaks through crash reports, child processes, `env` dumps and
build logs. Separately, staging.exo.now proves the gateway to five upstream Apps (Exo, Moonshot,
Hadron, Observatory, Universe) with one shared `STAGING_GATEWAY_SECRET` in the
`x-exo-gateway-secret` header, so one leaked App env lets anyone impersonate the gateway to all of
them (security review item M1).

Standing rule from the owner: &#x2A;*Exo Secure must never store secrets as platform env vars.** The only
exception is a single bootstrap token per app.

## Decision [#decision]

### 1. One read-only 1Password service account per app environment [#1-one-read-only-1password-service-account-per-app-environment]

* Each app environment gets its own vault and one **read-only** service account scoped to only that
  vault: `Exo Staging` and `Exo Production` for EXO itself; every managed app follows the same
  pattern (`Moonshot Staging`, …).
* The platform holds only `OP_SERVICE_ACCOUNT_TOKEN` (the bootstrap token) plus non-secret config:
  `EXO_SECURE_SOURCE=1password` and `EXO_SECURE_VAULT=<vault name>`.
* Secrets are referenced as `op://<vault>/<item>/<field>`. References are not secret; they live in
  code (`src/lib/exo-secure/manifest.ts`), so the list of what an app needs is reviewed like code.

### 2. Resolve at boot into memory, never back into `process.env` [#2-resolve-at-boot-into-memory-never-back-into-processenv]

* `src/instrumentation.ts` `register()` calls `loadSecrets(EXO_SECRET_MANIFEST)` once, before the
  server takes traffic. It uses the official [@1password/sdk](https://github.com/1Password/onepassword-sdk-js)
  (MIT, v0.5.0) with one batched `secrets.resolveAll` call.
* Values go into an in-memory store on `globalThis` (a registered symbol, so the Next proxy bundle
  and route bundles share it). Server code reads them with `secret(name)` / `requireSecret(name)`.
  Nothing is written to `process.env`, so env dumps and child processes never carry them.
* **Fail closed in production.** With `EXO_SECURE_SOURCE=1password` in production, a missing token,
  an unreachable vault, or any missing required secret throws in `register()`: the new deploy never
  becomes healthy and the previous one stays live. Staging and development log a warning instead.
* After loading, the server warns (by name, never value) about any managed secret that is still
  also set as a platform variable, so leftovers get deleted.
* **Migration mode.** `EXO_SECURE_SOURCE` defaults to `env`, which reads `process.env` exactly as
  today and warns in staging and production. Merging this ADR therefore changes no behaviour until an
  environment is switched to `1password`.
* **Local development** uses either `op run --env-file=.env.op -- pnpm dev` (resolved values exist
  only in that child process; default `env` source) or `EXO_SECURE_SOURCE=file` with a gitignored
  `.env.secrets.local` (the `.env.*` rule already ignores it). `file` is refused in production.

### 3. Bird and other Convex-only secrets: Convex env as the interim home [#3-bird-and-other-convex-only-secrets-convex-env-as-the-interim-home]

Bird SMS already runs only in Convex (`convex/lib/bird.ts`, `convex/communications.ts`
`sendSms` internal mutation and `deliverSms` internal action). No Next code reads `BIRD_*` or calls
the Bird API; `tests/int/exo-secure-boundaries.int.spec.ts` now fails if that ever changes. Both
functions are registered `internal`, so only Convex code (phone sign-in in `convex/phoneAuth.ts`, or
a deploy-key caller) can trigger a send; they need no entry in the public-write allowlist of
`tests/int/convex-auth-guards.int.spec.ts`. When Bird email arrives it follows the same shape.

Convex env is also a platform env store. Options:

| Option                         | What it means                                                                              | Verdict                           |
| ------------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------- |
| A. Convex env (interim)        | Bird, Exo ID signing keys, OAuth client secrets, AI keys stay in the Convex deployment env | **Recommended now**               |
| B. Convex pulls from 1Password | A `"use node"` internal action resolves from an `Exo Convex` vault and caches per isolate  | Later, for the highest-value keys |

Why A now: the breach was Railway's, and Convex is a separate vendor and blast radius; Convex
functions run inside Convex, so these secrets never touch Railway or the Next server; and B still
needs an `OP_SERVICE_ACCOUNT_TOKEN` in Convex env (the bootstrap exception moves, it does not
disappear), adds a 1Password round trip to Node-action cold starts, cannot serve the default
(V8) runtime that most EXO functions use, and spends service-account rate limit (Teams: 1,000 reads
per hour per token; Business: 10,000;
[rate limits](https://developer.1password.com/docs/service-accounts/rate-limits/)). Revisit B for
`EXO_ID_PRIVATE_KEY` and `AUTH_PRIVATE_KEY` when Convex supports a secret-manager integration or
if Convex's posture changes. Run the new platform-env check against `npx convex env list` output
with an explicit allowlist to keep that store reviewed.

### 4. Per-app Exo Key gateway tokens replace `STAGING_GATEWAY_SECRET` (M1) [#4-per-app-exo-key-gateway-tokens-replace-staging_gateway_secret-m1]

* The gateway asks Exo Key for a token per upstream App:
  `POST <exo key site>/exo-key/v1/gateway-token {"app": "<slug>"}` with
  `Authorization: Bearer <EXO_GATEWAY_CLIENT_SECRET>` (in the `Exo Staging` vault). Convex stores
  only `EXO_GATEWAY_CLIENT_SECRET_SHA256`, which is not a secret. The route answers 404 until that
  hash is set, so it ships dark.
* The token is an RS256 JWT signed by the existing Exo ID issuer key (`convex/lib/gatewayToken.ts`,
  same `kid` as access tokens): `iss` = the Exo ID issuer, `aud` = `exo-gateway:<app>`,
  `sub` = `exo-gateway`, `token_use` = `gateway`, random `jti`, 5-minute lifetime. EXO caches one
  per App until a minute before expiry and forwards it in `x-exo-gateway-token`.
* Each App verifies against the public JWKS (`<issuer origin>/exo-key/.well-known/jwks.json`) with
  `verifyGatewayToken` (`src/platform/delivery/gatewayTokens.ts`). A token minted for one App fails at
  every other App, and an ordinary Exo ID access token is never accepted (no `token_use`). Apps hold
  no gateway secret at all.
* Flag `EXO_GATEWAY_TOKENS` on the EXO service: `off` (default; shared header only, today's
  behaviour), `dual` (both headers), `only` (token only; a missing token fails closed with 503).
  Caller-supplied copies of either header are always stripped.

Per-app migration (each step is reversible by flipping the flag back):

1. Generate the gateway client secret (48+ random bytes) into `Exo Staging/staging-gateway/exo-key-client-secret`;
   set its SHA-256 (base64url) as `EXO_GATEWAY_CLIENT_SECRET_SHA256` in the staging Convex deployment.
2. Move the current shared value into `Exo Staging/staging-gateway/legacy-shared-secret`, switch the
   EXO staging service to `EXO_SECURE_SOURCE=1password`, delete `STAGING_GATEWAY_SECRET` from it.
3. Set `EXO_GATEWAY_TOKENS=dual` on EXO staging. Verify with a request through
   staging.exo.now/\{app} that both headers reach the upstream.
4. For each App (Moonshot, Hadron, Observatory, Universe; then Construct, Rampart, Cosmograph,
   Vector, Dropship as they adopt the gateway): ship `verifyGatewayRequest` with
   `acceptLegacy: true`, deploy, confirm requests verify as `token`; then set `acceptLegacy: false`
   and delete `STAGING_GATEWAY_SECRET` from that App's service.
5. When every App is on tokens only, set `EXO_GATEWAY_TOKENS=only`, delete the legacy item from the
   vault, and remove the legacy code path.

### 5. EXO Verify enforces it [#5-exo-verify-enforces-it]

`exo-verify security` gains a built-in `platform-env` check
(`packages/exo-verify/lib/level1/platform-env.mjs`, plain Node, no git, no network). Given a
variables export (`railway variables --json`, a `[{name,value}]` list, or dotenv; file path or
stdin) it fails on values that look like secrets: known prefixes (`sk-`, `sk_live_`, `ghp_`,
`github_pat_`, `xox[bpas]-`, `re_`, `bk_`, `AKIA`, `ops_` other than `OP_SERVICE_ACCOUNT_TOKEN`,
Stripe `whsec_`, Google `AIza`, Convex deploy keys), JWT and PEM shapes, JSON carrying private JWK
members, high-entropy strings, and names ending `_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`. An
allowlist (`OP_SERVICE_ACCOUNT_TOKEN` plus explicit non-secret config) is the only escape; `op://`
references and Railway `${{ }}` references pass. Findings name the variable and the reason, never
the value. No export provided means a skip (advisory), so builds without one stay green. Standalone:
`pnpm verify:platform-env <file|->`.

## Threat model [#threat-model]

| Threat                                             | Before                                   | After                                                                                                                                                         |
| -------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Platform env store breach (Railway, 2026-10-21)    | Every secret on the service leaks        | Only `OP_SERVICE_ACCOUNT_TOKEN` leaks: read-only, one vault, revocable in one click                                                                           |
| Env dump, crash report, child process inherits env | Secrets included                         | Secrets not in `process.env`                                                                                                                                  |
| One App's env leaks the gateway secret             | Impersonate the gateway to all five Apps | No App holds a gateway secret; a token is good for one App for at most 5 minutes                                                                              |
| Stolen gateway token                               | n/a                                      | Valid only for its App, expires in 5 minutes                                                                                                                  |
| Stolen bootstrap token                             | n/a                                      | Attacker can read that one vault until revoked; it cannot write or see other vaults. Detect via 1Password activity log and the service account's usage report |
| 1Password outage at boot                           | n/a                                      | Production deploy fails closed; the running deploy keeps serving from memory                                                                                  |
| Code exfiltrates memory (RCE in the server)        | Secrets readable                         | Still readable: Exo Secure does not defend against code execution in the app                                                                                  |

## Rotation [#rotation]

* Rotate a secret by editing its 1Password item, then restarting (redeploying) the app; no platform
  change is needed. Restart is required because values are read once at boot.
* Rotate the bootstrap token by creating a new service-account token, setting it on the platform,
  redeploying, then revoking the old one.
* Gateway tokens rotate themselves every 5 minutes. Rotating the signing key follows the existing
  Exo ID JWKS procedure (publish the new key first; Apps fetch JWKS by `kid`).
* The 2026-10-21 Railway advice to roll every variable is a separate, owner-approved action; this
  ADR does not rotate or revoke anything.

## Blast radius [#blast-radius]

One service account per app environment, read-only, one vault. A leak of `Exo Staging`'s token
exposes only EXO staging's few secrets, never production, never another app. Today a Railway leak
exposes every variable on every service in the project.

## Cost and unit economics [#cost-and-unit-economics]

* 1Password service accounts come with the 1Password plan; there is no per-service-account fee in
  the plan Exo already uses for people (owner brief, 2026-10-21; the roadmap's 2026-10-11 survey
  lists Business at $8.99 per user per month). Not re-verified today: 1Password's Business pricing
  page did not render for the check.
* Rate limits are the real constraint
  ([1Password rate limits](https://developer.1password.com/docs/service-accounts/rate-limits/)):
  Teams 1,000 reads per hour per token and 5,000 requests per day per account; Business 10,000 reads
  per hour per token and 50,000 per day per account. Exo Secure makes one batched read per boot, so
  even 100 deploys and restarts a day across every app is far inside Teams limits.
* Cost per unit: $0 marginal per app, per deploy and per request. Gateway tokens cost one Convex
  HTTP action per App every \~4 minutes while traffic flows (about 360 calls per App per day),
  well inside the Convex plan's included function calls. The platform-env check adds well under a
  second to the gate.
* Margin note: Exo Secure is not a cost driver, so it is not a pricing metric; include it in every
  plan, unmetered. If Exo later hosts vaults for customers, cost scales per human seat on 1Password,
  so price per seat, never per app or per secret.

## Migration table [#migration-table]

Every secret-shaped variable the exo repo reads today (grep of `process.env` and `app.env` across
`src/`, `convex/`, `packages/`, `scripts/`, plus `.env.example`), with its destination. Owner is
Samir (Interspace) for every row unless noted; the provider column is who issues the value.

| Variable                                                                                                                                                                                                                                                                                                                                                                                                                                                               | Read by (today)                                                                                    | Current location                                                          | Destination                                                                                                                                       | Owner / issuer              |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `TWENTY_API_KEY`                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `src/platform/crm/twenty.ts` (Next)                                                                | Railway env (EXO service)                                                 | 1Password app vault `twenty-crm/credential`; read via `secret()`                                                                                  | Samir / Twenty              |
| `STAGING_GATEWAY_SECRET`                                                                                                                                                                                                                                                                                                                                                                                                                                               | `src/platform/delivery/stagingGateway.ts` (Next proxy); each upstream App                          | Railway env on EXO staging and on Moonshot, Hadron, Observatory, Universe | 1Password `Exo Staging/staging-gateway/legacy-shared-secret`, then retired (section 4)                                                            | Samir                       |
| `EXO_GATEWAY_CLIENT_SECRET` (new)                                                                                                                                                                                                                                                                                                                                                                                                                                      | `src/platform/delivery/gatewayTokens.ts`                                                           | none                                                                      | 1Password `Exo Staging/staging-gateway/exo-key-client-secret`                                                                                     | Samir                       |
| `EXO_GATEWAY_CLIENT_SECRET_SHA256` (new)                                                                                                                                                                                                                                                                                                                                                                                                                               | `convex/lib/gatewayToken.ts`                                                                       | none                                                                      | Convex env (not a secret: a hash)                                                                                                                 | Samir                       |
| `OP_SERVICE_ACCOUNT_TOKEN` (new)                                                                                                                                                                                                                                                                                                                                                                                                                                       | `src/lib/exo-secure`                                                                               | none                                                                      | Railway env: the one bootstrap exception                                                                                                          | Samir / 1Password           |
| `BIRD_API_KEY`                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `convex/lib/bird.ts`                                                                               | Convex env                                                                | Convex env (interim, section 3); never Railway                                                                                                    | Samir / Bird                |
| `EXO_ID_PRIVATE_KEY`                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `convex/lib/exoIdCrypto.ts`                                                                        | Convex env                                                                | Convex env (interim; candidate for option B)                                                                                                      | Samir                       |
| `AUTH_PRIVATE_KEY`                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `convex/convex.config.ts` (Convex Auth)                                                            | Convex env                                                                | Convex env (interim; candidate for option B)                                                                                                      | Samir                       |
| `AUTH_GOOGLE_CLIENT_SECRET`                                                                                                                                                                                                                                                                                                                                                                                                                                            | `convex/convex.config.ts`                                                                          | Convex env                                                                | Convex env (interim)                                                                                                                              | Samir / Google Cloud        |
| `AUTH_GITHUB_CLIENT_SECRET`                                                                                                                                                                                                                                                                                                                                                                                                                                            | `convex/convex.config.ts`                                                                          | Convex env                                                                | Convex env (interim)                                                                                                                              | Samir / GitHub              |
| `ARCH_ROUTER_KEY`                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `convex/ai/assessors.ts`                                                                           | Convex env                                                                | Convex env (interim)                                                                                                                              | Samir                       |
| `ARTIFICIAL_ANALYSIS_API_KEY`                                                                                                                                                                                                                                                                                                                                                                                                                                          | `convex/ai/benchmarks.ts`                                                                          | Convex env                                                                | Convex env (interim)                                                                                                                              | Samir / Artificial Analysis |
| `EXO_AI_BROKER_TOKEN`                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `convex/aiOpenAICompat.ts`, `convex/lib/callerAuth.ts` (legacy path); `packages/exo-verify` review | Convex env; CI env for review                                             | Convex env (interim); retire the legacy shared token in favour of Exo Key tokens                                                                  | Samir                       |
| `EXO_INTAKE_SECRET_UNIVERSE` / `_COSMOGRAPH` / `_OBSERVATORY`                                                                                                                                                                                                                                                                                                                                                                                                          | `convex/lib/intakeProtocol.ts`                                                                     | Convex env; each App's own env                                            | Convex env (interim); App side moves to that App's 1Password vault                                                                                | Samir                       |
| `EXO_VERIFY_TOKEN`                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `convex/verifyHttp.ts`; `packages/exo-verify` report posting; Dockerfile build `ARG`               | Convex env; Railway build variable                                        | Convex env (verifier side). Build side: open item (a build-time token cannot come from a runtime vault); replace with a short-lived Exo Key token | Samir                       |
| `EXO_STATE_MIGRATION_TOKEN`                                                                                                                                                                                                                                                                                                                                                                                                                                            | `scripts/*-exo-state-staging.ts` (operator)                                                        | operator shell                                                            | `op run` from the operator's 1Password; never a platform variable                                                                                 | Samir                       |
| `RESEND_API_KEY`, `CRON_SECRET`, `ENTIRE_API_KEY`, `ENTIRE_WEBHOOK_SECRET`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN`, `RAILWAY_API_TOKEN`, `TYPESAFE_API_KEY`                                                                                                                                                                                                                                                                                 | not read by code today (listed in `.env.example` only)                                             | possibly Railway env (unverified)                                         | Delete from Railway if present; any future use goes in the 1Password app vault via the manifest                                                   | Samir                       |
| `AUTH_GOOGLE_CLIENT_ID`, `AUTH_GITHUB_CLIENT_ID`, `AUTH_JWKS`, `EXO_ID_JWKS`                                                                                                                                                                                                                                                                                                                                                                                           | Convex                                                                                             | Convex env                                                                | not a secret (public); keep `EXO_ID_JWKS` public-only (the check flags private JWK members)                                                       | Samir                       |
| `EXO_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_SERVER_URL`, `EXO_CONVEX_SITE_URL`, `CONVEX_SITE_URL`, `EXO_KEY_SITE_URL`, `EXO_ID_ISSUER`, `EXO_ID_AUTHORIZE_URL`, `EXO_KEY_DEVICE_URL`, `EXO_DOCS_URL`, `ARCH_ROUTER_URL`, `TWENTY_API_URL`, `BIRD_API_HOST`                                                                                                                                                                                                | various                                                                                            | platform env                                                              | not a secret (URLs)                                                                                                                               | n/a                         |
| `*_STAGING_ORIGIN` (9), `EXO_STAGING_HOSTS`, `EXO_CONTROL_PLANE_HOSTS`, `EXO_DEPLOYMENT_ENVIRONMENT`, `EXO_DELIVERY_STAGE`, `EXO_IDENTITY_PLANE`, `EXO_SERVICE_PROVISIONING_ENABLED`, `EXO_WORK_REGISTER_ENVIRONMENT`, `EXO_MCP_ALLOWED_ORIGINS`, `EXO_PROTECTED_RESOURCES`, `EXO_AI_MODEL`, `EXO_AI_ASSESSOR`, `TWENTY_INTERSPACE_OPPORTUNITY_STAGE`, `BIRD_SMS_FROM`, `EMAIL_*`, `DROPSHIP_RAILWAY_*`, `EXO_SECURE_SOURCE`, `EXO_SECURE_VAULT`, `EXO_GATEWAY_TOKENS` | various                                                                                            | platform env                                                              | not a secret (config and flags)                                                                                                                   | n/a                         |
| `EXO_STAGING_GRANTS`                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `src/proxy.ts`                                                                                     | Railway env                                                               | not a credential (user ids and scopes); keep as config, review as access policy                                                                   | Samir                       |

## Consequences [#consequences]

* EXO's Railway service ends with one secret-shaped variable. Every other managed app adopts the same
  module (copy `src/lib/exo-secure`) and the same gate check.
* A boot now depends on 1Password being reachable; a failed deploy stays on the previous version.
* Secrets change only on restart. Hot reload of secrets is out of scope.
* Open items for the owner: create the vaults and service accounts; choose Teams or Business (rate
  limits); approve option A for Convex; decide how the build-time `EXO_VERIFY_TOKEN` is replaced.


---

# 0016: The approved infrastructure stack is required

https://exo.now/docs/adr/0016-approved-stack-required

> Status: proposed (accepted when Samir merges the PR that adds it); enforcement starts in report mode · Date: 2026-10-23



# ADR 0016: The approved infrastructure stack is required [#adr-0016-the-approved-infrastructure-stack-is-required]

* Status: proposed (accepted when Samir merges the PR that adds it); enforcement starts in report mode
* Date: 2026-10-23
* Decided by: Samir (owner)
* Builds on: [ADR 0012](https://exo.now/docs/adr/0012-clerk-removed-convex-auth-everywhere),
  [ADR 0013](https://exo.now/docs/adr/0013-exo-is-mission-control-apps-pick-components)

## Context [#context]

An agent working on Cosmograph proposed moving its database to Neon instead of Convex. Nothing
stopped it: the approved choices lived in docs and memory, not in the contract. Samir's ruling
(2026-10-23): "this needs to be required at Exo level... the whole point of Exo and the only way
we can reliably support it is if we use a strict set of infra."

## Decision [#decision]

1. **One approved choice per layer**, for every app Exo manages:

   * data = **Convex** (core component `data`)
   * identity = **Exo Key**, on Exo Auth / Convex Auth (core component `identity`)
   * secrets = **Exo Secure**: the app's own 1Password vault, loaded at boot; the platform holds
     one read-only `OP_SERVICE_ACCOUNT_TOKEN` and nothing else secret (core component `secrets`)
   * deploy = **Exo delivery**: Exo-registered delivery targets, Preview -> Staging -> Release
     Candidate -> Production (core component `runtime`). The runtime is Render (a `render.yaml`
     Blueprint per repo), and the OP token is the only secret on the host, a Render Secret File or a
     `sync: false` env var: [ADR 0017](https://exo.now/docs/adr/0017-render-runtime) (2026-10-24), policy v4.

   Specialty components (comms, payments, models, support, analytics, surfaces) sit on top of this
   stack and do not change it.
2. **One policy, everywhere.** `packages/exo-verify/lib/level1/stack.mjs` is the policy.
   * The shared `managed-repository.yml` workflow embeds it (step "Enforce EXO approved stack").
   * `exo-verify contract` and `exo-verify stack` run it, and so does Exo's own `pnpm check`
     (`check:stack`).
   * The Exo control plane records each App's latest check (`recordStackCheck`) and gates
     registration and delivery on it (point 5).
3. **No waivers. A violation can only be fixed.** Samir's ruling (2026-10-23): there is no
   exception path, in this repo or any other. Existing violations are fixed before strict
   enforcement turns on.
4. **Apps fix themselves.** "We need a way for apps to fix themselves if they're wrong." Every rule
   carries a machine-readable `fix`: the approved replacement, concrete steps and a docs anchor
   (`docs/approved-stack.md#fix-*`).
   * `exo-verify stack --fix-brief` writes `STACK-FIX.md` (a plain brief the app's coding agent can
     follow) and `stack-fix.json` (each violation with rule, file, line, package or variable, and
     its fix).
   * In CI the step prints the brief, adds it to the job summary, annotates each violation, and
     uploads both files as the `exo-stack-fix` artifact whenever there are violations.
   * `exo-verify stack --fix` applies the fixes that are purely mechanical and safe. Today that is
     one: a secret declared in `project.yaml` without an approved source gets
     `"source": "exo-secure"`. It never rewrites application code, and it leaves provider variables
     (`DATABASE_URL`, `CLERK_*`) alone because those must be removed, not relabelled.
   * When a failing check reaches the control plane, Exo stores each violation with its fix on the
     App (`stackCheck.violations`); the App's owner or agent reads it back with `getStackCheck`.
5. **Rollout: report mode until apps are clean.** One switch, `STACK_ENFORCEMENT` in `stack.mjs`:
   * `'report'` (the default for now): violations print a warning and the fix brief. CI passes, and
     the control plane does not block registration or delivery.
   * `'enforce'`: violations fail CI; the control plane refuses to register a new App without a
     passing check, refuses to enable delivery while the last check fails, and delivers to Release
     Candidate or Production only after a passing check.
   * **Samir flips it to `'enforce'` once the managed apps are clean**, then runs
     `node scripts/sync-stack-policy.mjs`, which re-embeds the policy in the workflow and regenerates
     `convex/lib/stackEnforcement.ts` (the control plane's copy). `--check` fails if either drifts.
6. Secrets declared in `project.yaml` must say where they load from: `"source": "exo-secure"`
   or `"convex"`. `"platform"` is allowed only for `OP_SERVICE_ACCOUNT_TOKEN` (schema 1.10 adds the
   optional `source` field). A value that is not a credential (a URL, a flag, a recipient address)
   is declared `"secret": false` instead.

## Consequences [#consequences]

* Known violations are fixed, not waived, before enforce mode: Cosmograph (Postgres + Express and
  Clerk), Construct (Clerk), and Exo itself (changelog recipient variables declared as secrets;
  Hadron Dropship's Redis client). The org-wide scan lists the rest.
* Exo's stale `DATABASE_URL` declaration (a Payload leftover) is removed from `project.yaml` and
  the repository template; the template now declares `OP_SERVICE_ACCOUNT_TOKEN`.
* Adding or changing a rule, a fix recipe or the enforcement mode is an Exo PR, followed by
  `node scripts/sync-stack-policy.mjs`.
* The CI wrapper can only run when GitHub Actions runs for the app repo. The same check runs from
  the `exo-verify` CLI, so Entire's CI can call it unchanged.


---

# 0017: Render is the runtime; the 1Password token is the only secret on the host

https://exo.now/docs/adr/0017-render-runtime

> Status: proposed (accepted when Samir merges the PR that adds it) · Date: 2026-10-24



# ADR 0017: Render is the runtime; the 1Password token is the only secret on the host [#adr-0017-render-is-the-runtime-the-1password-token-is-the-only-secret-on-the-host]

* Status: proposed (accepted when Samir merges the PR that adds it)
* Date: 2026-10-24
* Decided by: Samir (owner)
* Builds on: [ADR 0016](https://exo.now/docs/adr/0016-approved-stack-required) (the approved stack is required)
* Supersedes: Railway as the managed-app runtime. Rejects Google Cloud Run (the Cloud Run plan,
  [#234](https://github.com/Interspace-Ventures/exo/pull/234), is closed).

## Context [#context]

ADR 0016 fixed one choice per layer but left the Deploy layer as "Exo delivery" without naming the
runtime, and left "the platform holds one read-only `OP_SERVICE_ACCOUNT_TOKEN`" without saying how
the host holds it. Railway lost trust after its breach. Two replacements were drafted: Render
([#227](https://github.com/Interspace-Ventures/exo/pull/227), a Blueprint per repo with the token as
a `sync: false` env var) and Google Cloud Run (#234, the token in Google Secret Manager through
IAM). An earlier revision of this ADR chose Cloud Run.

## Decision (Samir, 2026-10-24 01:36 EDT) [#decision-samir-2026-10-24-0136-edt]

"Render is the way. No reason for Google Cloud and that's not modern dev stack."

1. **Hosting is Render.** Every managed app service is declared in a `render.yaml` Blueprint in its
   repository: `web` services for HTTP, `worker` and `cron` services for background and scheduled
   work, region `virginia`. Render is the `runtime` component under Exo delivery: Exo still registers
   delivery targets and owns the order Preview (Render preview environments) -> Staging -> Release
   Candidate -> Production.
2. **Secrets stay in each app's own 1Password vault**, loaded at boot by the Exo Secure loader.
3. **The only secret on the host is one read-only 1Password service-account token
   (`OP_SERVICE_ACCOUNT_TOKEN`) per app per environment.** Prefer a Render Secret File (set in the
   dashboard, mounted at `/etc/secrets/<name>`) where the app's loader reads a token file; otherwise
   a secret env var on the service, declared in `render.yaml` as `sync: false` so its value is only
   ever entered in the Render dashboard. Never a value in the Blueprint, never in a Blueprint env
   group. Render preview environments do not copy `sync: false` values, so Preview gets its own
   read-only token (never production's) from one dashboard-managed env group per app that holds
   only that token.
4. **No other secret in host settings.** `render.yaml` envVars and Render env groups carry
   non-secret config only.
5. **Second secrets tool: Doppler only, never platform sync.** If a second secrets tool is ever
   needed, it is Doppler, used as a run-time fetch (`doppler run`). Doppler's platform-sync /
   integrations mode, which copies secrets onto the host, is never used.
6. **Google Cloud Run is rejected.** Why: Samir's call; Render is the modern developer stack
   (git-push deploys, Blueprints, preview environments, no cloud-project and IAM scaffolding to run);
   it is the fastest path off Railway; and the Render plan (#227) is already written, with its vault,
   item and DNS inventories. The trade-off accepted: the OP token sits in Render's own secret store
   (Secret File or `sync: false` env var) rather than a separate secret manager behind IAM. It is
   read-only, scoped to one vault per app per environment, and rotated on any exposure.

## Enforcement (policy v4 in `packages/exo-verify/lib/level1/stack.mjs`) [#enforcement-policy-v4-in-packagesexo-verifyliblevel1stackmjs]

* `APPROVED_STACK.deploy` is "Exo delivery on Render". `render.yaml` / `render.yml` is the approved
  deploy config.
* `runtime.other-host` flags `railway.json`/`railway.toml`, `fly.toml`, `vercel.json`, `Procfile`,
  an App Engine `app.yaml`, `cloudbuild.yaml`/`.yml`/`.json`, a Cloud Run (Knative) service YAML, a
  Cloud Run deploy command and the Cloud Run deploy GitHub Action, with a fix recipe that moves the
  service into a `render.yaml` Blueprint.
* `secrets.token-in-env` flags, in `render.yaml`, an `OP_SERVICE_ACCOUNT_TOKEN` entry that is not
  exactly `sync: false` (a `value`, `previewValue`, `generateValue`, a `fromGroup`, or the token in an
  env group), and a literal service-account token anywhere in scripts or config.
* `secrets.platform-env` flags, in `render.yaml`, any other secret-looking envVar set on a service
  (a literal `value`, `previewValue`, `generateValue` or `sync: false`), an env group holding
  secrets, and every `fromGroup` that pulls in such a group.
* `data.sql` / `data.other-store` flag Render Postgres (`databases:`) and Render Key Value services
  in a Blueprint.
* `secrets.doppler-sync` is unchanged: a `doppler.yaml` with sync or integration keys, a
  `doppler secrets download` command, or calls to Doppler's integrations or syncs API.
* Like every rule in ADR 0016, these have no waivers and start in report mode.

## Consequences [#consequences]

* One Render workspace for Interspace, one Blueprint per repo, and one 1Password vault plus one
  read-only service account per app per environment. The per-app plan, cutover order and costs live
  in the Render plan (#227, `docs/runbooks/render-cutover/`).
* Rotating an app secret: change it in 1Password, then redeploy. Rotating the bootstrap token: issue
  a new service-account token, replace the Secret File or `sync: false` value in the dashboard, and
  redeploy.
* State on local disk (Hadron's capture and sync state) runs on a Render persistent disk for now.
  That is a Convex-only violation the scanner cannot see, tracked to move into Convex.
* Railway configs remain in Construct (Rampart), Cosmograph, Dropship, Exo (Hadron), Moonshot,
  Observatory, Rampart and Universe; each carries the Render fix recipe. No repo uses Doppler.


---

# 0022: Leave Railway for Render + 1Password broker

https://exo.now/docs/adr/0022-leave-railway-for-render-and-1password-broker

> Status: proposed (draft PR; nothing deployed, no account created, no DNS changed) · Date: 2026-10-22 (revised 2026-10-22 22:00 EDT with Samir's decisions: Render, Observatory



# ADR 0022: Leave Railway for Render + 1Password broker [#adr-0022-leave-railway-for-render--1password-broker]

> **Numbering and status (2026-10-25).** Renumbered from 0016 to **0022** when #232 merged: ADR
> 0016 is "approved stack required" and the runtime decision is &#x2A;*ADR 0017 "Render runtime"**
> (Samir, 2026-10-24 01:36 EDT: "Render is the way"); 0018-0021 are taken by other open branches.
> This file is the Railway-exit record and the broker pilot. The current plan, cutover
> order (Construct first), costs and the Needs Samir list are in
> [`docs/runbooks/render-cutover/README.md`](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-cutover/README.md), which supersedes
> the D0-D2 order below.

* Status: proposed (draft PR; nothing deployed, no account created, no DNS changed)
* Date: 2026-10-22 (revised 2026-10-22 22:00 EDT with Samir's decisions: Render, Observatory
  staging-only, Interspace last; **recompressed 2026-10-22 22:43 EDT** to 2-3 days: Samir,
  "move faster ... crank this out in the next 2-3 days or less", while the Render Pro account is
  being created)
* Decided by: Samir (owner). Host decided: Render. The pilot settles only the broker question.
* Relates to: [ADR 0014](https://exo.now/docs/adr/0014-exo-secure-secrets-from-1password) (Exo Secure: secrets from
  1Password at boot), ADR 0015 (key rotation, on the stacked rotation PR), Construct's own Render
  plan (construct PR #406), runbook
  [render-pilot-exo-staging.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-pilot-exo-staging.md)

## Context [#context]

Railway disclosed on 2026-10-21 that an attacker read decrypted customer environment variables and
paused deploys ([disclosure](https://blog.railway.com/p/security-disclosure-wednesday-october-21st)).
ADR 0014 already moves EXO's secrets into 1Password, but on Railway the one bootstrap token
(`OP_SERVICE_ACCOUNT_TOKEN`) still sits in the same env store that leaked, and Railway has no
workload identity (OIDC) to replace it. Every Interspace app is hosted there: EXO, arch-router,
Observatory, Cosmograph, Universe, Construct (which also serves Interspace at
`interspace.ventures`), Hadron/Dropship and Moonshot.

The goal Samir asked for: leave Railway, and ideally reach a host where no long-lived secret is
stored at all, using the **1Password Credential Broker** (workload OIDC token in, 1Password
Environment variables out).

What is true today (2026-10-22):

| Fact                                                                                                                                                                                                                                                                                                      | Source                                                                                                                                                   |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Credential Broker is a public preview for **1Password Business only**. Custom OIDC workflows are beta and **JS SDK only**.                                                                                                                                                                                | [1password.dev/brokered-access](https://www.1password.dev/brokered-access), [custom workflow](https://www.1password.dev/brokered-access/custom-workflow) |
| A broker workload still holds `OP_INTEGRATION_KEY` (secret), `OP_WORKLOAD_ID` and an Environment ID. The admin's integration fixes the expected audience; the workload must present a token for it. SDK 0.5.0 asks for it via `oidcFetcher(audience)`.                                                    | same; `@1password/sdk` 0.5.0 types (`ClientConfiguration.oidcFetcher`, `WorkloadDetails`)                                                                |
| Samir is on 1Password **Families*&#x2A;. Business is **$8.99/user/mo**. Families service accounts: 1,000 reads/hour per token, 1,000/day per account (Teams 5,000/day, Business 50,000/day).                                                                                                              | 1Password pricing and service-account limits                                                                                                             |
| Render OIDC needs a **Pro** workspace ($25/mo) plus compute ($7 for 0.5 CPU/512 MB, $25 for 1 CPU/2 GB).                                                                                                                                                                                                  | [render.com/pricing](https://render.com/pricing), [platform features by plan](https://render.com/docs/platform-features-by-plan)                         |
| Render mints tokens only for **provider audiences**: AWS STS (`sts.amazonaws.com`, via `AWS_ROLE_ARN` -> `AWS_WEB_IDENTITY_TOKEN_FILE`), OpenAI (`api.openai.com`), Anthropic (`api.anthropic.com`), GCP. Issuer `https://oidc.render.com/{tea-…}`. &#x2A;*No OIDC at build time for Dockerfile builds.** | [render.com/docs/oidc](https://render.com/docs/oidc)                                                                                                     |
| Fly.io and Cloud Run mint OIDC tokens for **any** audience.                                                                                                                                                                                                                                               | Fly.io `/.fly/api` OIDC tokens; Cloud Run metadata server identity tokens                                                                                |
| 1Password for Agents (launched 2026-10-22) is consumer sign-in approval for personal agents; free on Individual and Families. It does not deliver workload secrets.                                                                                                                                       | [1Password blog](https://1password.com/blog/1password-for-ai-agents)                                                                                     |

So a Render service can probably **not** get a token 1Password will accept. That is unverified and
is pilot test #1. It is cheap to settle: when an admin creates a custom OIDC integration, 1Password
shows the expected audience. If it is not one of Render's four, the broker cannot run on Render.

## Decision [#decision]

1. **Render is the host (decided by Samir, 2026-10-22).** Every app that stays with Interspace moves
   from Railway to Render, all staging environments first (D0), then the productions (D1-D2), then
   Interspace (D2-D3), in **2-3 days** (timeline below). Render because it is the fastest exit: Blueprints in git, PR previews,
   health-checked zero-downtime deploys, Docker and native Node, Pro-plan OIDC for AWS, OpenAI and
   Anthropic, and Construct already has a Render plan (#406). For the record: the broker fits
   Fly.io or Cloud Run better, because they mint OIDC tokens for any audience.
2. **Observatory moves staging only; it has no production cutover.** Observatory production
   (`observatory.bio`, Railway) is retired after Samir hands the code to his dad. See
   "Observatory" below. Nothing is deleted without Samir's explicit yes.
3. **Ship on the fallback, not the broker.** Each app environment gets one **read-only 1Password
   service account token** scoped to that app's vault for that environment
   (`OP_SERVICE_ACCOUNT_TOKEN`, Render `sync: false`). That is ADR 0014 unchanged, moved to a host
   that has not just leaked its env store, with tokens minted fresh (never copied from Railway).
4. **The broker is a pilot item, not a dependency.** The loader learns
   `EXO_SECURE_SOURCE=broker|auto` now (`src/lib/exo-secure/broker.ts`), so trying it later is a
   config change. `auto` tries broker, then service account, then file (development) or env. Nobody
   buys 1Password Business until pilot test #1 says Render can present the right token.
5. **Blueprints are the only way services are defined.** `render.yaml` holds no secret values, only
   `sync: false` declarations. EXO Verify's new `render-blueprint` check fails the gate if one does.
6. **The pilot is EXO staging, not Observatory staging.** EXO staging already has the Exo Secure
   loader (#225) and its Blueprint (this PR), so it can deploy on D0; Observatory staging would first
   need a loader-port PR in another repo, which puts it on the critical path of a three-day staging
   window. The stakes stay low: the pilot runs on `exo-staging.onrender.com` while `staging.exo.now`
   keeps pointing at Railway until go/no-go. Runbook:
   [render-pilot-exo-staging.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-pilot-exo-staging.md).

### Pushback, stated plainly [#pushback-stated-plainly]

* **Render does not deliver "no stored secret".** With the broker unavailable, each service still
  stores one long-lived secret. It is read-only and scoped to one vault, and Render is not the host
  that just leaked, but it is the same shape of risk we had on Railway. Moving hosts is not the
  security fix; rotation is.
* **Rotation matters more than the move.** Every value that was on Railway is compromised. Copying
  them to Render unrotated carries the breach with us. Rotate into 1Password first (ADR 0015 and the
  secrets inventory), then point Render at the vault.
* **Paying for a preview.** Business at $8.99/user/mo is cheap, but it buys a public-preview broker
  whose custom-OIDC path is beta, for a team of one. Do not upgrade on spec.
* **Two to three days is very tight.** Render is not the slow part. Secret rotation (every value must
  be new before its vault is filled), the Universe Postgres move, DNS, and the repo gap for Moonshot,
  Hadron and Dropship (parallel track below) are. The plan holds by: staging all on D0 behind
  `onrender.com` URLs, TTLs at 300 s from D0, no app's 7-day rollback window ever blocking the next
  cutover, "red smoke test stops that app, not the line", and production on a weekend morning (lowest
  traffic, but slower vendor support). The real risk is rotation: an app whose vault is not filled
  with new values simply waits; it never ships with the leaked ones.
* **Render does remove some keys entirely.** Its managed OIDC for OpenAI and Anthropic means an app
  that calls those APIs directly can drop `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` from its vault once
  its client supports workload identity. That is a real win the broker was never going to give us.

### Alternatives considered [#alternatives-considered]

| Option                                      | Verdict                                                                                                            |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Stay on Railway, rotate, harden             | Rejected. No workload identity; the bootstrap token stays in the store that leaked; deploys were paused.           |
| **Render + service-account token (chosen)** | Fastest exit, Blueprints, previews. One read-only token per app env. Broker probably impossible (audience).        |
| Render + Credential Broker                  | Blocked on audience (pilot #1) and Business plan. Kept behind `EXO_SECURE_SOURCE=broker`.                          |
| Fly.io + Credential Broker                  | Not chosen. More ops (`fly.toml`, machines); no Blueprint-style previews.                                          |
| Cloud Run + Credential Broker               | Not chosen. Heaviest migration (GCP project, IAM, Artifact Registry).                                              |
| Vercel                                      | Fine for Next front ends, wrong for arch-router (long-running CPU model) and Express APIs; secrets still env vars. |
| Cloudflare (Workers/Containers)             | Rewrites for Node-specific servers; not a one-week move.                                                           |

## Cost [#cost]

Render Pro workspace **$25/mo** once, shared by every app, plus compute per service. Plans:
`starter&#x60; 0.5 CPU / 512 MB **$7**, `standard&#x60; 1 CPU / 2 GB **$25**. Sizes below are estimates, not
benchmarks; arch-router at 1 CPU / 2 GB especially (CPU only; Render has no GPUs). Pro includes 15
custom domains, enough for every hostname below.

| App                      | Staging               | Production                                            | Compute / mo | + Pro share (1/8) | Per app / mo            |
| ------------------------ | --------------------- | ----------------------------------------------------- | ------------ | ----------------- | ----------------------- |
| EXO                      | standard $25          | standard $25                                          | $50          | $3.13             | **$53.13**              |
| arch-router              | (one shared instance) | standard $25                                          | $25          | $3.13             | **$28.13**              |
| Observatory              | starter $7            | none on Render (retired on Railway after handoff)     | $7           | $3.13             | **$10.13**              |
| Cosmograph               | starter $7            | standard $25                                          | $32          | $3.13             | **$35.13**              |
| Universe                 | starter $7            | standard $25 (RC env dropped; PR previews replace it) | $32          | $3.13             | **$35.13**              |
| Construct (+ Interspace) | starter $7            | standard $25                                          | $32          | $3.13             | **$35.13**              |
| Hadron (incl. Dropship)  | starter $7            | not yet                                               | $7           | $3.13             | **$10.13**              |
| Moonshot                 | starter $7            | not yet                                               | $7           | $3.13             | **$10.13**              |
| **Total**                |                       |                                                       | **$192**     | **$25**           | **$217/mo ($2,604/yr)** |

Plus, outside Render: PR previews at $7 each while open (manual generation, so near zero); Neon for
Universe (Free or Launch; usage-based); 1Password stays Families (no change) unless the broker
pilot passes, then Business at $8.99/user/mo for one seat. Render Key Value for Hadron's Valkey only
if it turns out to be needed (price to check). Observatory production keeps its Railway bill until
it is retired.

Break-even and margin:

* **Against Railway:** Railway's actual bill could not be pulled (the Railway API token was
  invalidated after the incident). Fill in last month's invoice here before approving; if it was
  under \~$215/mo, Render costs more and we are paying for the exit, not saving.
* **Against revenue:** Cosmograph is the only app with prices set ($9.99/yr or $29.99 lifetime). Its
  $35.13/mo ($422/yr) breaks even at about **43 annual members** or **15 lifetime sales a year**,
  before payment fees. Every other app is pre-revenue; their hosting is pure cost.
* **Cheapest honest variant:** all staging on `starter`, prod on `standard` only for EXO and
  arch-router, the rest `starter&#x60; until load says otherwise: about **$145/mo**. Starter (512 MB) is
  tight for a Next build server; watch memory in the pilot before choosing it for production.

## Migration order [#migration-order]

Staging first, all of it, on D0; then production; Interspace last. Record-by-record DNS:
[render-cutover/dns-changes.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-cutover/dns-changes.md). Vaults and items:
[render-cutover/vaults-and-items.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-cutover/vaults-and-items.md). D0 commands:
[render-cutover/commands-d0.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-cutover/commands-d0.md). Samir's batched yes list:
[render-cutover/approval-list.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-cutover/approval-list.md).

**Staging (D0)**

1. **EXO staging (pilot, first)** — root `render.yaml` in this PR; runbook
   `docs/runbooks/render-pilot-exo-staging.md`. Proves Blueprint, 1Password service-account boot,
   health check and previews on `exo-staging.onrender.com`; pilot test #1 is recorded but no longer
   gates the rest. Go for the others after two healthy deploys (about an hour), then `staging.exo.now`
   flips the same day. The staging gateway's `*_STAGING_ORIGIN`s move to Render URLs as each app
   below lands.
2. **arch-router** — Docker web service (Convex calls it from outside Render, so not a private
   service). `llama-server` is a C++ binary, so its key arrives through an `op run` wrapper entrypoint
   (child-process env only, ADR 0014's documented exception); that entrypoint and its Blueprint entry
   are a small follow-up PR, not in this one. One instance shared by staging and production: staging
   Convex's `ARCH_ROUTER_URL` moves on D0, production Convex's with EXO production (D1). The Railway
   instance keeps serving production until then.
3. **Cosmograph staging** — Neon staging branch stays; new role password.
4. **Universe staging** — Railway Postgres staging -> Neon `staging` branch (below).
5. **Construct staging** — per construct PR #406 and tonight's render.yaml PR;
   `staging.construct.page` and `staging.construct.codes` flip on D0.
6. **Observatory staging** — with its loader-port and render.yaml PR (opening tonight). No DNS
   change: it sits behind `staging.exo.now/observatory`. Staging only; see "Observatory".

**Production (D1-D2)**

7. **EXO production (D1)** — `exo.now` apex and `www`. Production Convex `ARCH_ROUTER_URL` -> Render.
8. **Construct production (D1)** — `construct.codes`, `www.construct.codes`, `construct.page`. **It
   must not take over `interspace.ventures` yet**: Railway's Construct production keeps running and
   serving Interspace (and, unless Samir moves them on D2, `structured.glass`,
   `foundation.financial`, `bumblebee.nyc`).
9. **Cosmograph production (D2)** — `cosmograph.space` (+ a new `www`).
10. **Universe production (D2)** — Railway Postgres -> Neon `production`, then `release.universe.audio`
    (new, the canary) and `universe.audio` (today an old Replit build at Google Frontend, not
    Railway).

**Last (D2-D3, \~06:00 ET)**

11. **Interspace** — `interspace.ventures` + `www`, served by Construct production on Render.
    Zero-downtime cutover below.

**Parallel track (not on the critical path):** Moonshot, Hadron and Dropship need git repos first.
See "Parallel track".

## Timeline (2-3 days) [#timeline-2-3-days]

D0 is the day the Render account exists: **tonight (Thu 2026-10-22) or Fri 2026-10-23**. Dates below
assume D0 = Fri 10-23; if it lands later every row slides by the same amount. Times are ET.

| Day          | Date                            | What                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| D0           | Thu night / Fri 10-23           | Samir's approval items 1-10: rotate exposed keys; Render GitHub App; Render API key; 10 vaults + 10 read-only service accounts; merge #225, #227 and the per-repo render.yaml PRs into `staging`. &#x2A;*All staging on Render:** EXO staging pilot first, then arch-router (staging Convex URL), Cosmograph, Universe (Neon `staging`), Construct, Observatory staging. TTLs to 300 s on every production record that moves. Flip `staging.exo.now`, `staging.construct.page`, `staging.construct.codes`. Railway staging paused, not deleted. Ask Render support about early certificate issuance for `interspace.ventures`. |
| D1           | Sat 10-24, morning              | **EXO production** (`exo.now`, `www`; production Convex `ARCH_ROUTER_URL`). **Construct production** (`construct.codes`, `www`, `construct.page`), **not** `interspace.ventures`. Interspace checked on the Render Construct service via its `onrender.com` URL and a `Host` header, DNS untouched.                                                                                                                                                                                                                                                                                                                            |
| D2           | Sun 10-25, morning              | **Cosmograph production** (`cosmograph.space`). **Universe production** (Neon `production`, `release.universe.audio`, then `universe.audio`). Optionally Construct's other domains.                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| D2-D3        | Sun 10-25 or Mon 10-26, \~06:00 | **Interspace** (`interspace.ventures`, `www`), last, zero downtime. Sunday only if Construct production has been green 12 h and every other cutover is green; otherwise Monday.                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| +7 days each | from Fri 10-30 to \~Tue 11-03   | Each Railway service stays paused 7 days after its own cutover, then is deleted only with Samir's yes. Runs in the background; never blocks the next cutover.                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| parallel     | from D0                         | Repos for Moonshot, Hadron (and Dropship if it does not fold into Hadron); their staging moves whenever a repo exists.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

Rules: one hostname group at a time, each with Samir's yes. A red smoke test stops that app and rolls
it back; it does not stop apps that are already green or unrelated. Interspace never goes
while any other production cutover is red. An app whose vault does not yet hold freshly rotated
values waits; it never ships with Railway's values.

## Observatory [#observatory]

* **Staging only on Render.** `observatory-staging` moves on D0 with its Exo
  Secure loader port. It never gets a Render production service, an `Observatory Production`
  vault, or a production service account.
* **No production cutover.** Observatory production (`observatory.bio`, Railway) keeps running on
  Railway until Samir hands the code to his dad, then it is retired. No `observatory.bio` DNS record
  changes as part of this ADR.
* **Nothing is deleted without Samir's explicit yes.** That covers the Railway Observatory production
  project, its database credential, the `observatory.bio` records (including `_railway-verify`), and
  the Railway Observatory staging project after its 7-day window. Retirement is a separate step
  Samir starts, after the handoff.
* **Until retirement, production still runs on the leaked host.** Its Railway env holds exposed
  values. Recommendation: rotate its provider keys in place (OpenAI, OpenRouter, Resend, intake
  secret) so the exposed ones stop working, even though the app is leaving. Samir's call.
* Cost drops to one `starter` instance on Render ($7/mo plus the Pro share).

## What stays in Convex [#what-stays-in-convex]

Nothing about Convex moves. Convex functions, data, file storage, Exo Key (Exo ID) and Convex Auth,
and every Convex-side secret stay in the Convex deployment env exactly as ADR 0014's migration table
says: `BIRD_API_KEY`, `EXO_ID_PRIVATE_KEY`, `AUTH_PRIVATE_KEY`, `AUTH_*_CLIENT_SECRET`,
`ARCH_ROUTER_KEY`, `ARTIFICIAL_ANALYSIS_API_KEY`, `EXO_AI_BROKER_TOKEN`, `EXO_INTAKE_SECRET_*`,
`EXO_VERIFY_TOKEN` (verifier side), `EXO_GATEWAY_CLIENT_SECRET_SHA256`. Convex pushes keep running
from GitHub Actions, never from a Render build. The only Convex edits the move needs are
non-secret URLs: `ARCH_ROUTER_URL` and any `*_STAGING_ORIGIN` or app origin Convex calls.

## Postgres and Neon [#postgres-and-neon]

Neon is the Postgres home: it is host-independent, branches per environment, and Cosmograph already
uses it. No Render Postgres.

| Database                       | Today                                               | Move                                                                                                                                                                                                                                                                                       |
| ------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Cosmograph prod + staging      | Neon                                                | No data move. Reset both role passwords (exposed), store the new `DATABASE_URL`s in `Cosmograph Production` / `Cosmograph Staging`. If Neon IP allow is on, add Render's Virginia outbound IPs.                                                                                            |
| Universe staging + prod (+ RC) | Railway Postgres                                    | New Neon project with `staging` and `production` branches. Freeze writes; `pg_dump --format=custom` from Railway (temporary TCP proxy or `railway connect`), `pg_restore --no-owner` into Neon; compare row counts per table; switch `DATABASE_URL` in the vault; unfreeze. RC is dropped. |
| Observatory                    | `DATABASE_URL` set on Railway (legacy data backend) | Staging: confirm the Convex backend is live and Postgres unread; if so do not migrate. Production stays on Railway until retirement. Delete the credential only with Samir's yes.                                                                                                          |
| Construct                      | Legacy Postgres/Payload vars on Railway             | Not migrated (Convex is the content authority). Delete after confirming production no longer reads them.                                                                                                                                                                                   |
| Old `exobase` Neon project     | Neon, Payload content                               | Delete (deletion already pending Samir) or reset its password.                                                                                                                                                                                                                             |
| Hadron/Dropship Valkey         | Railway                                             | If it is only a cache, drop it; otherwise Render Key Value. Decide at that step.                                                                                                                                                                                                           |

## DNS cutover at Spaceship [#dns-cutover-at-spaceship]

All six domains are hosted at Spaceship: exo.now, observatory.bio, cosmograph.space,
construct.page, interspace.ventures, moonshot.supply. Today: `exo.now` apex A `69.46.46.38`
(Railway), `www` CNAME `vlc5968k.up.railway.app`, `staging` CNAME `jai6l6x5.up.railway.app`;
`_railway-verify` TXT on exo.now, staging.exo.now and observatory.bio.

Hostnames that move: `staging.exo.now`, `exo.now` + `www`, `cosmograph.space`, `construct.page`,
`interspace.ventures` (last). `observatory.bio` does **not** move (Observatory is staging only);
`moonshot.supply` waits for the parallel track.

Per hostname, with Samir's approval at each flip:

1. **T-24 h:** record the current value of every record you will touch (screenshot or export), then
   lower its TTL to 300 s.
2. **Add the domain in Render** (dashboard service -> Settings -> Custom Domains, or
   `POST /v1/services/{serviceId}/custom-domains`). Render shows the target.
3. **Check CAA and AAAA:** if the domain has CAA records, add `issue` and `issuewild` for
   `letsencrypt.org` and `pki.goog`; remove any AAAA records (Render is IPv4)
   ([render.com/docs/custom-domains](https://render.com/docs/custom-domains)). Adding an apex in
   Render also adds `www` as a redirect to it, and the reverse.
4. **Flip at Spaceship:**
   * Subdomain (`staging`, `www`): replace the Railway CNAME with CNAME `<service>.onrender.com`.
   * Apex: use ALIAS/ANAME `<service>.onrender.com` if Spaceship offers it for the zone; otherwise
     replace A `69.46.46.38` with A `216.24.57.1`.
5. **Verify** in Render (`POST /v1/services/{serviceId}/custom-domains/{id}/verify`), wait for the
   certificate, then run the smoke tests against the real hostname.
6. **Leave `_railway-verify` TXT records** in place during the soak (harmless; needed for rollback),
   delete them when the Railway project is deleted.
7. Raise TTLs back to 3600 s after 24 h green.

### Interspace (`interspace.ventures`): zero-downtime cutover, last [#interspace-interspaceventures-zero-downtime-cutover-last]

Interspace (`interspace.ventures`) is served by the root Construct app (not `services/interspace`, which is staging-only) and must stay up. The rule: both origins serve the site
for the whole flip, so whichever answer a resolver holds, it gets a working page.

1. **D0 (Samir's yes):** export every `interspace.ventures` record. Lower the TTL of the apex and
   `www` records to 300 s, at least 24 h before the flip. Touch nothing else: MX, SPF, DKIM, DMARC and
   site-verification TXT records stay exactly as they are.
2. **D1-D2:** Construct production is live on Render and `construct.codes` has been green for at least 12 h.
   Add `interspace.ventures` (Render adds `www`) as a custom domain on the Render Construct
   production service. Add CAA entries for `letsencrypt.org` and `pki.goog` if the zone has CAA.
   Remove AAAA records only at flip time, and only if any exist.
3. **Verify before flipping:** on the `onrender.com` URL, check the Interspace routes
   Construct serves for that host, e.g.
   `curl -fsS -H 'Host: interspace.ventures' https://<construct-prod>.onrender.com/` and the same
   for the key pages and `/api/health`. Compare with the live Railway page (status, title,
   canonical links, assets). Construct's host routing must accept the `onrender.com` host or the
   `Host` override; if it does not, stop and fix that before the flip.
4. **D2 or D3, \~06:00 ET, flip:** apex to ALIAS/ANAME `<construct-prod>.onrender.com` (or A
   `216.24.57.1`), `www` to CNAME `<construct-prod>.onrender.com`. Keep Railway's Construct
   running and serving `interspace.ventures` throughout. Immediately press Verify in Render and
   flush the Google and Cloudflare public DNS caches.
5. **Watch for 30 minutes:** `curl` the real hostname every minute from two networks; check the
   certificate issuer and expiry; check `/api/health`.
6. **Known gap, unverified:** Render documents issuing the TLS certificate only after DNS verifies,
   so a resolver that picks up the new record in the first minutes may see a TLS error or a 502.
   On D0, ask Render support whether they can pre-issue the certificate (as they do for
   wildcard domains via an `_acme-challenge` CNAME). If they cannot, the 300 s TTL and an
   early-morning flip keep the window to minutes; if the certificate is not issued within 10
   minutes, put the records back. (`interspace.ventures` already has an `_acme-challenge` TXT of
   unknown origin; a Render `_acme-challenge` CNAME would need it removed first.)
7. **After 24 h green:** raise TTLs to 3600 s. Railway's Construct stays paused, not deleted, for 7
   days.

## Rollback [#rollback]

* **Every service** gets a **7-day rollback window** after its own cutover, staging and production
  alike: the Railway service is **paused, not deleted**, env untouched (it is all compromised and
  rotated anyway, so it is only useful for the rollback itself). The window runs in the background
  and does **not** block the next app's cutover.
* To roll back a hostname: set the Spaceship record back to its recorded Railway value (A
  `69.46.46.38`, CNAME `vlc5968k.up.railway.app` / `jai6l6x5.up.railway.app`, and so on), resume the
  Railway service, and wait out the 300 s TTL. If the rotated secrets differ, point the Railway
  service at the same 1Password vault (it reads the same items through its own, freshly minted
  read-only token) rather than pasting values.
* `universe.audio` rolls back to its current Replit origin (A `34.111.179.208`, `replit-verify`
  TXT kept), not to Railway, which never served it. Keep that Replit deployment untouched until
  Universe production has been green for 7 days.
* Rolling back one app does not roll back the others. The exception is arch-router: if it rolls
  back, set both Convex deployments' `ARCH_ROUTER_URL` back to the Railway instance.
* Interspace rolls back the same way, and stays rollback-ready longest because Railway's
  Construct keeps serving it until the day after its flip.
* A Render deploy that fails its health check never takes traffic; the previous deploy keeps
  serving. Production also fails closed when 1Password cannot be read.
* **Nothing is deleted without Samir's explicit yes**: no Railway project, database, credential or
  DNS record, after 7 green days or ever. Observatory production is not on this clock at all; it is
  retired only after the handoff (see "Observatory").

## Parallel track: repos for Moonshot, Hadron and Dropship [#parallel-track-repos-for-moonshot-hadron-and-dropship]

Render builds only from a git repo. Three apps have none it can use, so they run alongside the main
week and never block it:

| App      | Today                                     | Needs                                                                                                       |
| -------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Moonshot | a local folder deployed with `railway up` | a new GitHub repo in `Interspace-Ventures`, the folder pushed to it (after a secret scan), then a Blueprint |
| Hadron   | no GitHub repo                            | a new GitHub repo, then a Blueprint                                                                         |
| Dropship | code moving into Hadron                   | no repo of its own if the move into Hadron lands first; otherwise its own repo until it does                |

**Samir must OK creating each repo** (name, visibility). Nobody creates a repo, a Render service or a
DNS record for these on their own. Each moves to Render staging whenever its repo exists, and stays
on Railway until then; `moonshot.supply` does not change in this ADR's week.

## How each app receives its secrets [#how-each-app-receives-its-secrets]

One rule everywhere: the host stores one read-only, vault-scoped bootstrap token per app per
environment, as Render `sync: false`. Nothing else secret lives in Render env, in `render.yaml`, or in
a build. Builds need no secret (and on Docker builds there is no OIDC anyway); never declare a Docker
`ARG` for a secret. PR previews inherit staging's token and vault, never production's.

| App                               | Vaults (read-only service account each)                                | How the process gets values                                                                                                                     |
| --------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| EXO                               | `Exo Staging`, `Exo Production`                                        | `src/lib/exo-secure` in-process at boot, `EXO_SECURE_SOURCE=auto` (resolves to the service account on Render)                                   |
| arch-router                       | `Arch Router`                                                          | entrypoint `op run -- /opt/llama/llama-server …` with `LLAMA_API_KEY=op://Arch Router/llama/credential`; values exist only in the child process |
| Observatory                       | `Observatory Staging` only (production stays on Railway until retired) | copy of the Exo Secure loader (port PR in the observatory repo, D0); OpenAI key is a candidate for Render's managed OpenAI OIDC later           |
| Cosmograph                        | `Cosmograph Staging`, `Cosmograph Production`                          | loader copy in `api-server`, or `op run` wrapper if the server is not easily patched                                                            |
| Universe                          | `Universe Staging`, `Universe Production`                              | loader copy or `op run` wrapper                                                                                                                 |
| Construct (+ Interspace)          | `Construct Staging`, `Construct Production`                            | per #406: loader port, one token per env                                                                                                        |
| Hadron (incl. Dropship), Moonshot | `Hadron Staging`, `Moonshot Staging`                                   | `op run` wrapper until they adopt the loader; needs a repo first (parallel track)                                                               |
| Convex (all apps)                 | n/a                                                                    | Convex deployment env, unchanged                                                                                                                |

If pilot test #1 ever passes, that app's bootstrap changes from
`OP_SERVICE_ACCOUNT_TOKEN` to `OP_INTEGRATION_KEY` + `OP_WORKLOAD_ID` + `EXO_SECURE_ENVIRONMENT_ID`
with `EXO_SECURE_SOURCE=broker` (or `auto`), and its secrets move from vault items to a 1Password
Environment. `OP_INTEGRATION_KEY` is still a stored secret, but it is useless without a fresh token
from the host's issuer that matches 1Password's trust policy.

## 1Password for Agents [#1password-for-agents]

1Password for Agents, launched 2026-10-22, lets a personal AI agent ask to use a login from Samir's
vault; he approves, and 1Password fills the sign-in form without the agent ever seeing the password.
It is free on Individual and Families, so it costs nothing to turn on. It is about people's
sign-ins, not servers: it does not hand secrets to a running workload, so it replaces neither the
service-account token nor the broker. Where it helps here is the migration itself: the agent doing
Render, Spaceship and Neon dashboard steps can sign in with Samir's approval instead of a saved
password, which shrinks what an agent session could leak. Treat it as an operator-side improvement,
separate from this decision.

## Consequences [#consequences]

* Railway leaves the critical path in 2-3 days from D0 (all staging on D0, productions D1-D2,
  Interspace D2-D3, last). Railway projects are deleted, with Samir's yes, no sooner than 7
  days after each service's cutover. Observatory production stays on Railway until it is retired
  after the handoff.
* Hosting costs about $217/mo at the sizes above (about $145/mo lean).
* The stored-secret risk shrinks (one read-only, vault-scoped, freshly minted token per app env)
  but does not reach zero on Render.
* `EXO_SECURE_SOURCE` gains `broker` and `auto`; the default stays `env` until each app's cutover
  sets `auto`.
* EXO Verify fails any `render.yaml` that holds a secret value; `OP_INTEGRATION_KEY`,
  `OP_WORKLOAD_ID`, `EXO_SECURE_ENVIRONMENT_ID` and `EXO_SECURE_OIDC_TOKEN_FILE` join the
  platform-env allow list (the first is a bootstrap secret, the rest are identifiers).

## Open items for the owner [#open-items-for-the-owner]

One batched, numbered list with a link per item:
[render-cutover/approval-list.md](https://github.com/Interspace-Ventures/exo/blob/staging/docs/runbooks/render-cutover/approval-list.md). In short: rotate the
exposed keys before reconnecting anything; Render GitHub App and API key; the 10 vaults and their
read-only service accounts; merging #225, #227 and the per-repo render.yaml PRs; Convex device
approvals; the DNS changes by day (D0 TTLs + staging, D1 EXO and Construct, D2 Cosmograph and
Universe, D2-D3 Interspace); the extra Construct domains; Render's custom-domain allowance;
the Moonshot and Hadron repos; and, later, each deletion (Railway after its 7 days, Observatory
production after the handoff). Still open beyond that list: Railway's last invoice (break-even), and
1Password Business only if pilot test #1 ever passes.
