# Playthrough has no LoseLife; it settles Attempts Playthrough and Attempt are separate aggregate roots (ADR-0002), but "one death costs exactly one life" requires ending the Attempt and decrementing lives *together*. Rather than expose bare mutators and rely on the caller to sequence them, Playthrough exposes only what happened — `Commit(attempt)`, `SettleDeath(attempt)`, `SettleReplay(attempt)` — each of which calls the Attempt's own `TryEnd…` internally and does nothing when it returns false. Every operation is therefore atomic and idempotent, and there is no `LoseLife` to call from somewhere else. The alternative shape, `if (attempt.TryEndInDeath()) playthrough.LoseLife();`, puts the idempotency in the domain but leaves the sequencing to the caller. A public `LoseLife` is an invitation: the code being replaced has two paths to exactly that operation — `LivesStateHandler.RemoveLife` and `ConsoleManager` writing `Player.Lives` directly, unclamped — because the operation existed to be called. ## Considered options Collapsing the two into one aggregate with Attempt as an inner entity, making the atomicity structural. Rejected: `Commit(Attempt)` already gave the shape we needed, and passing one root into another's method is a transient reference rather than a stored association, so ADR-0002 stands unchanged. ## Consequences - Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before `DeathScreen` reloads, cost one life rather than two. - `ExitDoorComponent` and `ConsoleManager.GoToNextLevelCommand` can both request completion; only the first banks. - The application layer holds the active Attempt and passes it in. The Attempt carries its own `LevelId`, so `Commit` decides First Clear versus Replay from the Attempt itself rather than trusting a level supplied alongside it — finishing a Replay stops being a way to corrupt campaign position.