# 0002 — Claim the instance on first run, don't bootstrap the owner from config

**Status:** accepted · **Date:** 2026-07-31

## Context

The previous attempt created the owner account from environment variables —
`STEID_OWNER_EMAIL`, `STEID_OWNER_PASSWORD`, `STEID_OWNER_USERNAME` — read on every
boot and applied when no users existed. This build started by reproducing that.

Two problems surfaced while writing it.

**The password is a long-lived secret held for a one-time operation.** An environment
variable is readable from the process environment, a `.env` file on disk, shell
history, `docker inspect`, systemd units, and CI logs. It stays there for the life of
the deployment even though it is needed exactly once. Attempt #2's `.env.dev` shows the
gravitational pull of the pattern: `STEID_OWNER_PASSWORD=changeme`.

**Config describes something mutable, so it goes stale by design.** The tell was a test
written without registering what it meant:

```rust
async fn a_second_boot_does_not_overwrite_a_changed_password()
```

Once the owner changes their password in the app, the config value is wrong, still
readable, and rotating it does nothing. Config that must be ignored to stay correct is
config holding the wrong thing.

## Decision

An unclaimed installation serves a first-run claim flow. On boot with no owner, Steid
generates a **one-time setup token**, prints it to stdout, and serves `/setup`.
Presenting the token lets a visitor choose handle, email, and password; that creates the
owner and starts their session.

The token lives in memory only, so restarting an unclaimed instance rotates it. The
owner password is gone from configuration entirely.

## Alternatives considered

- **Keep config bootstrap.** Unattended, container-friendly, already written. Rejected
  for the two problems above. It is not being kept as a parallel automation path
  either — no such deployment exists yet, and a second bootstrap route is a second
  thing that can create an owner. Easy to add back if demand is real.
- **Claim with no token.** Simpler, and what several forges do. Rejected because the
  first visitor to an exposed instance becomes its owner, and the window lasts until
  someone notices.
- **CLI subcommand** (`steid init-owner`, prompting on a TTY). No exposure window at
  all and no web layer needed. Rejected because it needs shell access to the host,
  which is awkward in a container, and it means building a CLI surface that does not
  otherwise exist. Worth revisiting if the claim flow proves awkward to operate.

## Consequences

- **No password in configuration.** The only secret at rest is the Argon2 hash.
- **The exposure window is closed by the token**, and rotated by a restart.
- **Milestone 1 reorders.** Config bootstrap needed no web layer and would have been
  verifiable first; a claim flow needs pages and sessions. The first user-visible
  behaviour becomes `/setup` rather than a row appearing in `steid.db`.
- **Operators must read stdout on first boot.** That is a real usability cost — a
  crash-looping container reprints a new token each time. The log line should say
  plainly what it is and that it rotates.
- **The `any_exist` check before writing is TOCTOU.** Two simultaneous claims could
  both pass it. The `UNIQUE` constraints on email and organisation name are what
  actually serialise it, so the second claim fails at the database rather than
  silently creating a second owner. Worth an integration test once SQLite lands.
- Unattended deployment is unsupported until someone needs it.
