Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Supersedes or amends: ADR-011
Successors or amendments: ADR-017
Date: 2026-05-25
Status: Accepted
Supersedes: ADR-011 (Trunk-Based with Single Production Environment)
Deciders: Daniel Meza
Context and Problem Statement
ADR-011 (May 2026) collapsed the environment topology to a single production Railway environment driven by every push to main. The decision was rational at the time: zero traffic, a team of one, and no GitHub Actions meant a staging environment would have been an unused expense.
Since then, three things changed:
- Bank affiliate integrations are entering scope. Several Costa Rican issuers (BAC, BCR) need a non-prod URL we can hand them to validate the apply-out lead flow (FEAT-012 / FEAT-015) before it goes live. Re-creating that environment ad-hoc per integration would burn more time than running one permanently.
- The "every merge is a prod deploy" model creates a chilling effect on merging. In practice, PRs sit unmerged while the developer babysits the deploy. The single-env model amplified merge friction rather than reducing it.
- AI-assisted change volume is higher than anticipated. Multiple Claude Code sessions land changes daily. Each merge is small but the cumulative blast radius of "main = prod" makes every merge a small ritual instead of a routine. A staging buffer lets merges resume being routine.
A simple "merge to main, see it in staging, smoke-test, promote when ready" loop gives us the integration-testing environment we need for partners and the merge-velocity we want for ourselves, at the cost of one extra Railway environment.
Decision Drivers
- Partner-facing pre-prod URL. Affiliates need a stable non-prod endpoint they can poke at.
- Merge velocity. Merging should be cheap and not require human babysitting of a deploy.
- Promotion remains a human decision. We're not large enough to automate "if smoke tests pass, promote" — we want a person in the loop.
- No GitHub Actions yet. The promotion mechanism must work with just Railway + git + a shell script.
- Cost remains bounded. Two Railway environments, not three. No PR preview environments yet.
- Backward-compatibility with ADR-011's branching. Feature/bug branches still come off
main. Trunk-based stays. - Reversibility. If staging proves underused again, dropping it is a Railway-side change plus a
productionbranch deletion — not an application change.
Considered Options
- Keep ADR-011 (single env, main → prod). Lowest cost. Doesn't solve partner-URL need or merge-friction problem.
- Two envs,
main→ staging, manual promotion to production via git tag +productionbranch. (chosen)mainauto-deploys to staging. Promotion = update a long-livedproductionbranch via a script that runs smoke tests and tags the release. Railway watchesproductionfor prod deploys. - Two envs,
main→ staging, manual promotion via Railway "redeploy from environment". Same envs, but promotion happens in the Railway UI by copying the staging build to production rather than via git. - Two envs, with GitHub Actions managing promotion on tag push. Introduce Actions purely for the promote step. Adds CI dependency and effort.
- Three envs (develop, staging, production) as ADR-010 originally prescribed. Heavy, and we removed it for good reason.
Decision Outcome
Option 2: two environments (staging, production) with manual promotion via git tag + production branch.
Environment topology
| Environment | Branch tracked | Deploy trigger | Purpose |
|---|---|---|---|
staging |
main |
Every push to main (Railway-native GitHub integration) |
Continuous integration target; affiliate-facing pre-prod URL; smoke-test surface |
production |
production (long-lived branch) |
Every push to production (Railway-native GitHub integration) |
Live customer-facing |
Branch structure
feature/FEAT-XXX-name ┐
bug/short-description ┼─► main ──► staging
│ │
│ │ (manual promotion via scripts/promote.sh)
│ ▼
└─► production ──► production
▲
hotfix/short-description ─┘| Branch | Cut from | Merges to | Auto-deploys to | Skill pair |
|---|---|---|---|---|
feature/* |
main |
main |
— (staging via main) | /feature-creator → /spec-generator → /feature-implementor |
bug/* |
main |
main |
— | /bug-planner → /bug-implementor |
hotfix/* |
production |
production only |
— | /hotfix-planner → /hotfix-implementor |
main |
— | — | staging |
— |
production |
— | — | production |
promoted via scripts/promote.sh |
Promotion mechanism
A repo script scripts/promote.sh:
- Curl-checks staging API and web health (
/health,/) - Runs a Playwright smoke subset against the staging URL:
pnpm --filter @urza/web test:e2e:smoke -- --base-url=$STAGING_WEB - Computes and displays the diff between current
productionHEAD andmainHEAD - Prompts for confirmation with the diff summary in view
- Tags
mainasvYYYY.MM.DD-N - Force-updates
productionto point at the new tag - Pushes both
productionand the tag - Watches the production deploy in Railway until it reports healthy
- Prompts the operator to run any pending manual migrations per the migrations handbook
The script is the single sanctioned promotion path. Rollback is documented in the same handbook: re-point production to the prior tag, force-push, watch the redeploy.
Hotfix path
Hotfixes are urgent prod bugs that cannot wait for the next staging cohort. They:
- Branch from
production, notmain - Add a failing test that reproduces the issue, then fix it
- Merge directly to
production(nomainround-trip) - Are promoted out-of-band (production health check only, smoke test skipped because the fix may be the cause of the smoke-test failure being addressed)
- Auto-open a follow-up cherry-pick PR against
mainso the fix isn't silently dropped on the next promotion frommain. The/hotfix-implementorskill is responsible for opening this PR.
This is a known drift hazard. The auto-cherry-pick PR is non-negotiable; without it the next main promotion regresses the hotfix.
Migration timing during promotion
Per the migrations handbook (Obsidian Handbooks/Infra/db-migrations.md):
- Additive migrations (new columns nullable, new tables, new indexes built CONCURRENTLY) run in Railway's build step automatically. No promotion-time action required.
- Destructive migrations (DROP, NOT NULL backfills, type changes) are gated. The
/feature-implementorand/bug-implementorskills flag these at PR time.scripts/promote.shpauses before tagging and walks the operator through the manualrailway runcommand.
What is explicitly NOT in scope
- No GitHub Actions. Promotion is a shell script + git + Railway.
- No PR preview environments. Visual review still happens against local dev.
- No
developbranch. Trunk-based remains. - No automated promotion-on-green. Promotion requires a human at the keyboard.
Positive Consequences
- Affiliate pre-prod URL is real. Bank integrations have a stable target.
- Merging becomes cheap again. Push to
main, see it in staging, move on. No "I broke prod" anxiety per merge. - Promotion is auditable. Git tags +
productionbranch history = the deploy log. - Rollback is a one-liner.
git push -f origin <prev-tag>:production. Same mechanism as forward promotion. - Smoke tests have a natural home. They run during promotion against the deployed staging URL, not as an in-CI step we have to wire up.
Negative Consequences / Trade-offs
- Roughly 2× Railway hosting cost vs. ADR-011. Acceptable: staging is small (one tier below prod) and the partner-URL value justifies it.
- Promotion is one more thing to do. Cost is ~1 minute per release; benefit is a tested artifact.
- Hotfix drift hazard. Mitigated by auto-cherry-pick PR but real. Documented prominently in the SDLC handbook.
- Schema migration choreography is now per-env. Staging and prod both need schema, occasionally out of order. The hybrid rule (additive → automatic, destructive → manual) handles most cases; a small class of mixed migrations needs care.
Pros and Cons of the Options
| Option | Iteration Speed | Pre-prod safety | Partner URL | Cost | Forward-compat to 3+ engineers |
|---|---|---|---|---|---|
| 1. Keep single env (ADR-011) | High | None | No | Low | Poor |
| 2. main→staging, manual promote (chosen) | High | Moderate | Yes | Medium | Good |
| 3. main→staging, Railway-UI promote | High | Moderate | Yes | Medium | OK (no git audit trail) |
| 4. Promotion via GitHub Actions | Medium | High | Yes | Medium-high | Good |
| 5. Three envs (ADR-010) | Low | High | Yes | High | Good |
Option 4 is the natural next step once we have other reasons to run GitHub Actions (e.g., gated PR checks, dependabot autopilot). For now, the marginal value of a CI-gated promotion isn't worth the workflow file maintenance.
Migration from ADR-011
Concrete changes accompanying this ADR (across feature/infra-docs-and-railway-detach, feature/promotion-tooling, feature/claude-skills-and-obsidian-integration):
docs/architecture/decisions/ADR-011-trunk-based-development.md: add Superseded header pointing to this ADRdocs/architecture/Architecture reference: §3 container note about removed staging is reversed; §10.1 Railway layout shows two environments; §10.3 deploy strategy rewritten to staging→production via promotionCLAUDE.md: branch model section updated to show staging + production +bug/*+hotfix/*docs/archive/legacy-sdlc/handbooks/sdlc-handbook.md: stages 5 (Merge) and 6 (Promote) re-separated; new section onbug/*andhotfix/*workflows- Obsidian
Handbooks/Infra/railway-config.md(new): the canonical Railway runbook - Obsidian
Handbooks/Infra/promotion.md(new): the promotion runbook - Obsidian
Handbooks/Infra/db-migrations.md(new): the migrations handbook - Obsidian
Handbooks/Infra/sdlc.md(new): mirror of the repo SDLC handbook for non-technical readers - Obsidian
Handbooks/Infra/obsidian-kanban-conventions.md(new) scripts/seed-staging.ts(new): test-data seeding script for stagingpackages/db/seeds/staging/*.ts(new): seed data definitionsscripts/promote.sh(new): the promotion runbook in script form.claude/skills/{bug-planner,bug-implementor,hotfix-planner,hotfix-implementor,cowork-prompt,demo-prep}/SKILL.md(new).claude/skills/feature-creator/SKILL.md: extended to write to Obsidian and ask for Epic at creation time
After this ADR lands:
- Create the
productionbranch on the GitHub remote:git checkout main && git branch production && git push -u origin production - In Railway: rename the existing
productionenvironment tostaging(or create a new one and migrate), then create a secondproductionenvironment. Connect each environment's services to the corresponding branch. See ObsidianHandbooks/Infra/railway-config.mdfor the full walkthrough. - The first promotion is a one-time bootstrap — point
productionat the currentmainHEAD before the firstscripts/promote.shinvocation.
References
- ADR-011 — the single-env model this ADR reverses
- ADR-010 — the original 3-branch Gitflow (still superseded; historical context)
- Architecture reference §10 — the infra layout this ADR updates
- Obsidian
Handbooks/Infra/promotion.md— the runbook - Obsidian
Handbooks/Infra/db-migrations.md— the migration timing rules - Martin Fowler, "Trunk-Based Development" — https://martinfowler.com/articles/trunk-based-development.html