Principle: Staging Is a Runtime Duplicate, Not a Parallel Admin Plane
Status: Adopted
Date: 2026-05-10 UTC
Closes: #1581
Refs: PR #1579 (env-gate stopgap), PR #1580 (RBAC V2 schema add), PR #1582 (toggle taxonomy)
Supersedes (partial): project_environments_mental_model.md — staging = playground for code, NOT for prod-affecting admin
The Principle
Staging is a runtime duplicate of production: same code, same config shape, same
infra topology. It exists so code changes can soak before they touch real users.
It is not a second admin plane. Staging operators can observe staging state and
exercise staging infrastructure, but they cannot queue, approve, or trigger actions
that land in production. The boundary between staging and prod is a hard write-gate,
not a UI affordance. Any console feature that renders prod-affecting controls on
console-staging.raxx.app is a design defect, not a missing permission check.
"Staging is usually just a runtime dup of production — we shouldn't have too much reliance there. Wiring these together shouldn't be a coding project." — Operator, 2026-05-10 UTC
What This Means Concretely
console-staging.raxx.app — allowed / not allowed
| Allowed | Not allowed |
|---|---|
| Read staging flags, staging token state, staging audit log | Submit a flag promotion that targets prod |
| Trigger staging rotations (rotate staging Heroku key) | Approve or reject a promotion with target_app = prod |
| View customer records that exist in the staging database | Access, modify, or view real customer records from prod |
| Test new console features end-to-end | Execute any workflow that calls a prod API, prod vault path, or prod Heroku app |
| Reproduce a reported bug against staging data | Initiate a scheduled job that writes to prod infrastructure |
console.raxx.app (production) — allowed
Everything in staging's allowed column, scoped to prod infrastructure, plus all prod-affecting write actions (flag promotions, live rotations, customer admin).
The clear line
Prod-affecting actions are only reachable from console.raxx.app.
If an operator is on console-staging.raxx.app and believes they are taking a
prod action, the system has failed them. The env is hostname-derived and
immutable (retired switcher: project_console_env_switcher.md). Display the
env-pill clearly on every page. Gate every write path on active_env.
The RBAC Primitive Shape
Roles follow <app>-<resource>-<level> (RBAC V2, rbac-design.md).
Staging-scoped groups compose differently from prod-scoped groups — this is the
mechanism that enforces the write-gate without custom code per feature.
staging-admins group
→ console-flag-reader (read-only on flags, any env)
→ console-token-user (read tokens)
→ console-audit-user (read audit log)
# NOT console-flag-admin — staging admins never hold the write role
production-admins group
→ console-flag-admin (read + promote + approve flags)
→ console-token-admin (read + rotate tokens)
→ console-audit-user
→ console-invite-admin
An operator onboarded to staging-admins only gets reader roles. The write
roles (console-flag-admin, console-token-admin) are assigned exclusively
in groups that are gated to active_env = prod. RBAC V2 schema (#1580)
adds the env_scope column to console_admin_group_roles to make this
assignment explicit at the data layer rather than in application logic.
Bootstrap Is Config, Not Code
Adding a new staging admin should take under 60 seconds with no engineering involvement:
heroku config:set ADMIN_EMAIL=<email> --app raxx-console-staging >/dev/null 2>&1
# Console reads the config and auto-enrols the address into staging-admins
# on first passkey registration. No migration, no code ship.
The same is true for a new prod admin via raxx-console-prod. Role assignment
flows from group membership, which is config-driven. The RBAC group seed
(migration 0021, PR #1500 RV-1) provisions staging-admins and
production-admins at deploy time. No bespoke admin-provisioning code path.
Designer / Architect Checklist
When designing any new console feature that touches prod-affecting state:
- [ ] Default: gate writes by
active_env+ role. The check isenv == "prod" AND has_permission("console-<resource>-admin"). Both conditions must hold. Neither alone is sufficient. - [ ] Always render the read-only view on staging. A feature that shows nothing on staging forces operators to use prod for debugging — the wrong habit. Show the data; hide the action buttons.
- [ ] Display action buttons only where the env supports the write. Use the
env-pill context, not a separate flag. If
active_env != "prod", the "Promote" / "Rotate" / "Approve" button must not render at all — not disabled, not grayed, not present. - [ ] Scope
target_appseed rows to env. If your feature seeds a DB table with target app names (e.g.,console_flag_promotions.target_app), scope the seed migration so staging rows reference staging apps and prod rows reference prod apps. Never let a staging migration seedraxx-console-prod. - [ ] No scheduled jobs in staging-console that write to prod. If a cron job exists in the staging console app, its write scope must be verified to be staging-only before it ships.
Anti-Patterns
1. Both consoles showing an identical "pending prod promotion" queue.
The case that triggered this principle. A staging operator sees the same list
of pending promotions as a prod operator, including items awaiting prod
approval. Staging has no business displaying pending prod promotions — it
cannot act on them and the display creates a false sense of authority. Fix:
target_app column on the queue table scoped at query time to the current
env. PR #1579 (env-gate stopgap) addresses this directly.
2. UI elements that imply a prod action is reachable from staging.
A "Promote to prod" button that is visible but disabled on staging is still
wrong. The button implies the action is locally available — the operator
learns the wrong mental model ("I need a different permission" instead of
"I need to be on the prod console"). Remove the button entirely when
active_env != "prod".
3. Scheduled jobs in the staging console that touch prod state.
A cron digest or rotation sweep that runs inside raxx-console-staging must
not write to raxx-console-prod's database, vault paths, or Heroku apps.
These jobs share code — env scope is determined by which app the job runs
in, verified via active_env. If the job does not check active_env
before every write, it is a latent cross-env contamination risk.
Example Applications
Flag promotion queue (lit example — #1579 stopgap → #1580 long-term)
Before this principle was written, console_flag_promotions had no
target_app column. Both consoles rendered all pending promotions. Staging
operators could see — and in some configurations act on — prod-targeted items.
- Stopgap (#1579): env-gate in the application layer; staging queries
filter to
target_app LIKE 'raxx-%-staging'. - Long-term (#1580): RBAC V2
env_scopeon group roles means theconsole-flag-adminrole is never assigned to a session on staging. The application layer check becomes a second defense, not the primary gate.
Customer admin (#1044 / #1045 / #1047)
console-staging.raxx.app can VIEW customers who exist in the staging
database (seeded test accounts, QA fixtures). It must never display, search,
or modify real customer records from production. The data model for customer
admin uses separate DB connections per env — there is no shared customer
table between staging and prod. Any feature that joins across envs
(e.g., "show me if this email exists in prod") is a design violation of this
principle and must be escalated before implementation.
Reference
| Artifact | Role |
|---|---|
project_staging_is_runtime_dup.md (memory) |
Operator framing that drove this principle |
project_console_env_switcher.md (memory) |
Env-switcher retired; env is hostname-derived |
project_rbac_v2_decisions_2026_05_09.md (memory) |
RBAC V2 decisions; env_scope column |
project_rbac_as_feature_dispatch.md (memory) |
Role/permission flip as config, not code |
project_environments_mental_model.md (memory) |
Partial supersede: staging = code playground, not prod-affecting admin |
project_staging_scope.md (memory) |
Staging only exists for console + api |
docs/architecture/rbac-design.md |
RBAC V2 role taxonomy + group composition |
docs/architecture/console-env-isolation.md |
Vault path env-scoping (complements this principle) |
| ADR-0020 | Manual prod deploys via workflow_dispatch — same intentional-friction spirit |
| PR #1579 | Env-gate stopgap on flag promotion queue (in flight) |
| PR #1580 | RBAC V2 schema add — env_scope column on group roles (queued) |
| PR #1582 | Toggle taxonomy — independent but topical |