# Server ID as Host Identifier Design

**Goal:** Decouple the user-facing server label from the host-side identifier. The systemd unit name and on-disk paths become functions of `Server.id`; `Server.name` becomes a free-form display label.

**Approval status:** User-approved 2026-05-08.

## Context

`Server.name` was doing two unrelated jobs. It was the human label rendered in the UI *and* the literal string fed to `l4d2ctl`, which became the systemd unit instance (`left4me-server@<name>.service`) and the directories under `/var/lib/left4me/{instances,runtime}/<name>/`. To stay safe as a unit-template parameter and a path component, the name was forced through `[a-z0-9][a-z0-9_-]{0,63}` and held globally unique. The cost was a UI that demanded machine-friendly slugs, no rename support, and an awkward divergence from overlays — which already separate identity (`id`) from label (`name`).

This change moves servers onto the same model as overlays. Web URLs already key on `id` (`/servers/<int:server_id>`), so the change is mostly local: pick an id-derived host identifier, pass that everywhere `server.name` was passed, and relax the `name` constraints.

## Locked Decisions

1. **Host-side identifier = plain numeric id.** `left4me-server@42.service`, `/var/lib/left4me/instances/42/`, `/var/lib/left4me/runtime/42/`. The host CLI's `validate_instance_name` regex (`[a-z0-9][a-z0-9_-]{0,63}`), the systemctl helper's argument check (`[A-Za-z0-9_.-]`), and the unit template (`%i`) all already accept digit-only strings — no host-side change.
2. **Name = free-form display label, unique per user, required (≤128 chars).** Whitespace is stripped on save. Two users can both have a server named "Practice"; one user cannot.
3. **No data preservation.** Dev-only deploy. Existing servers on the test host are not migrated; their old `left4me-server@<old-name>.service` units and `<old-name>/` directories become orphans and are cleaned up manually.
4. **Single source of truth for the id-to-host-name rule.** A one-line helper (`server_unit_name(server_id) -> str(server_id)`) lives in `l4d2web/services/server_identity.py`. Every callsite that used to pass `server.name` to `l4d2ctl` or `journalctl` calls this. Future format tweaks (e.g. `srv-{id}`) are a one-line edit.

## Schema

`servers` (Alembic 0006):
- Drop the (unnamed) global `UNIQUE (name)` from the original 0001 schema.
- Add `UNIQUE (user_id, name)` as `uq_servers_user_name`.
- Column stays `name VARCHAR(128) NOT NULL`.

The migration uses `batch_alter_table(recreate="always")` with a `naming_convention` so the originally-anonymous unique can be referenced as `uq_servers_name` for `drop_constraint`.

## Code touchpoints

- `l4d2web/services/server_identity.py` (new)
- `l4d2web/models.py` — drop `unique=True` on `Server.name`; add `__table_args__` with the per-user unique.
- `l4d2web/alembic/versions/0006_server_name_per_user.py` (new)
- `l4d2web/services/l4d2_facade.py` — five `l4d2ctl` invocations switched to `server_unit_name(server.id)`. Parameter renamed to `unit_name` on `server_status` / `stream_server_logs`.
- `l4d2web/services/job_worker.py` — status refresh uses `server_unit_name(server.id)`. The `server_name` log-label variable still holds `server.name` (the display label); that's correct now and shows up in job logs as e.g. "starting initialize for My Practice".
- `l4d2web/routes/log_routes.py` — SSE log stream feeds `server_unit_name(server.id)` to `journalctl`.
- `l4d2web/routes/server_routes.py` — replace `validate_instance_name` with `_validate_display_name` (strip + non-empty + length ≤128). Broaden the `IntegrityError` handler to disambiguate `servers.name` (409 "name already in use") from `servers.port` (409 "port already in use") via the underlying SQLite error string.
- `l4d2web/services/security.py` — `validate_instance_name` deleted (no remaining callers).
- `l4d2web/templates/servers.html` — name input gains `maxlength="128"`.

## Failure modes

- **Name with shell metacharacters reaches a host command.** Cannot happen — the host call now receives only `str(server.id)` (digits). The display name is never passed through `l4d2ctl`.
- **Two servers under the same user with the same name.** Blocked at the DB layer (`uq_servers_user_name`); surfaced as a 409 "name already in use" with no row written.
- **Migration on a DB with existing servers.** `batch_alter_table(recreate="always")` rebuilds the table preserving rows; the new per-user constraint is satisfied trivially since the old global constraint already enforced strict uniqueness.

## Verification

1. `python -m pytest l4d2web l4d2host deploy` from the repo root — green.
2. Stepwise migration on a fresh sqlite (upgrade to 0005, insert two users + a server, upgrade to 0006): row preserved, second user can take the same name, same user cannot (UNIQUE constraint failed: servers.user_id, servers.name).
3. Post-deploy on the test host: create a server named `"My Practice"` (with the space), confirm the systemd unit is `left4me-server@<id>.service`, confirm `/var/lib/left4me/runtime/<id>/merged` is mounted on start, confirm log streaming still works.

## Operator note

After deploy, on the test host: stop and remove any pre-existing `left4me-server@<old-name>.service` units and their `/var/lib/left4me/{instances,runtime}/<old-name>/` directories. The web app no longer references them.
