The end-to-end suite used whatever database connection string the environment happened to carry. The README suggested running it under the Railway CLI, which injects the production connection string, and under that command the suite really did run against production. Its global setup deleted rows from a rate-limit store, the server applied the checked-out branch's unmerged migrations on start, and the tests wrote their public rows into the live database. Nothing in the code prevented any of this. The safety rested on nobody running the command from the wrong shell.

## Naming the database before touching it

The runner now ignores the application's connection string. It reads a dedicated override that defaults to a local test database, and it refuses any name that does not end in `test` before it opens a connection. Every worktree on the machine shares one lock, so two agents starting a run at the same moment queue behind each other instead of dropping and recreating the same database underneath each other. The Playwright config applies the same rule, so running the test runner directly gets the same protection as the script:

```text
refusing to use "<name>": its name must end in "test"
```

The server's environment is set in one module, which checks the database name first and then strips the platform's deployment markers, so a local server cannot mistake itself for a deployed one.

The development server got the same treatment. Migrations used to run on every start against whatever database the connection string named. Now a small decision function looks at where the database lives and whether the platform has marked the process as deployed. A local database on a developer machine still migrates. A remote database, or any run under the platform CLI, skips migration and prints a warning that says so. Anyone who wants that anyway sets an explicit override. The failure path changed too: a migration error used to print the full connection string, password included, into the logs. It now names only the host, port and database.

## What CI saw that the laptop could not

The first push of the migration guard failed in CI, not locally. Local runs always reach the test database over `localhost`, but in CI the database sits behind a service name, so the guard treated it as remote and skipped migrating it. The suite then ran against an empty schema. No amount of local testing would have shown it, because the local setup never exercised that host. The fix made the e2e server an explicit opt-in, set only after the name check passed, so migration became a deliberate step rather than a side effect of where the host happens to be.

A second CI failure was a test that clicked an invalid-word input before the page had hydrated. The slower runner exposed it, and the local machine never did. Rather than wrapping the click in another retry, the root layout now marks the document as hydrated in its mount hook, and a single helper waits for that mark before any test interacts with the page. The same pass found steering files naming paths that no longer existed. A CI check now fails when a repository path named in those files is missing, so the documents that tell agents where things live cannot drift silently.

## A retrospective that became four rules

The last merge in the range is a retrospective on that work, carried out as an umbrella of four fixes. Each one answers a mistake an agent made during the e2e work, and each one became a rule the repository enforces rather than a reminder left in a document:

- The Claude Code steering file now imports the shared agent guide, so the rules load in every session instead of living in two files that drift apart.
- A CI check rejects a page interaction inside a retry callback, since a click repeated under a retry tests nothing about the page.
- Reviews must quote the search or the code behind any claim about callers, usage or staleness, so a confident "nothing else calls this" can be checked before anyone acts on it.
- The gotcha about which CI jobs skip for documentation-only changes moved out of the steering file and into the workflow's own header, next to the thing it describes.