environment_immutable
Declared 422, raised in the database retryable: false Postgres trigger
The update tried to change a project's environment between test and live, and that column is immutable once the project exists.
Why it happens
A BEFORE UPDATE trigger on projects compares the new environment with the old one and raises environment_immutable with SQLSTATE restrict_violation if they differ. There is no REST route that attempts this, so you will not meet it by calling the public API. It appears in database errors and in logs when something writes the column directly. The reason for the rule is that environment is load-bearing everywhere else: key prefixes resolve by it, inbox addressing depends on it, and the quota path distinguishes charged from free traffic by it. A project that changed environment would leave rows whose environment no longer matches the project they belong to.
Every condition below was read in the implementation at the file and line given. They are the branches that raise this code, not a summary of what the name suggests.
- An UPDATE on projects where NEW.environment is distinct from OLD.environment.
supabase/migrations/20260919120400_rls_triggers.sql:70 - Verified by integration test: updating a project's environment to live rejects with this string.
tests/integration/foundation.test.ts:163
How to fix it
- Create a second project in the target environment and keep both. This is the intended pattern, not a workaround.
- Swap the credential rather than the project. An efa_test_ key resolves only to test projects and an efa_live_ key only to live ones, so the environment follows the key your deployment is configured with.
- Do not look for a promotion endpoint. There is none, and this is the enforcement that makes that statement true rather than a convention.
Why it works this way
This lives in a trigger because environment is the boundary the entire test and live separation rests on. Enforcing it in one service method would leave every other writer, including a future one, able to break the invariant that every inbox, message and quota row inherits from its project.
Messages you will see
The message field is written for a human reading a log and is not a contract
value. Branch on code instead. These are the exact strings this code is sent
with today, listed so that a search for one of them lands here.
Where it appears
This string is raised by a database trigger rather than by a route, so it reaches you through a database error or a log line rather than through the API. The surfaces below are the ones whose writes touch the guarded table. The REST base is https://api.emailforagents.ai/v1, and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp.
POST /projects
Verified against supabase/migrations/20260919120400_rls_triggers.sql:70. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.