diff --git a/README.md b/README.md
index 6b24359..51a708a 100644
--- a/README.md
+++ b/README.md
@@ -206,6 +206,9 @@ file with `--config`.
## How it works
+For a step-by-step trace of a clean sweep, see the
+[execution happy-path sequence diagram](docs/execution-happy-path.md).
+
The source lives under `src/`, split into an entry point plus logic
modules:
@@ -358,6 +361,7 @@ move --help
| `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. |
| `src/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. |
+| `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
| `bun.lock` | Bun's lockfile. Commit this. |
diff --git a/docs/execution-happy-path.md b/docs/execution-happy-path.md
new file mode 100644
index 0000000..57f0f16
--- /dev/null
+++ b/docs/execution-happy-path.md
@@ -0,0 +1,88 @@
+# Execution: the happy path
+
+This traces one full idle-triggered sweep that completes cleanly — the
+"happy path" where the machine is idle long enough to fire, the configured
+pattern runs to exhaustion, and no real user activity interrupts it.
+
+For the module breakdown and the three seams (`device` / `strategies` /
+`executor`), see the "How it works" section of the [README](../README.md).
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Entry as move.ts
+ participant Keeper as runKeeper
+ participant Sim as simulateActivity
+ participant Strat as Strategy
(e.g. line)
+ participant Exec as executePath
+ participant Dev as Device
(nut.js)
+
+ Note over Entry: startup (args → config)
+ Entry->>Entry: parseCliArgs()
+ Entry->>Entry: loadConfigFile()
+ Entry->>Entry: resolveConfig(file, cli)
+ Entry->>Keeper: runKeeper(config)
+
+ Keeper->>Dev: createNutDevice()
+ Note right of Dev: sets mouse.config.autoDelayMs = 0
+ Keeper->>Keeper: log.info(banner)
+ Keeper->>Dev: getPosition()
+ Dev-->>Keeper: lastPos
+ Note over Keeper: lastActivity = now
+
+ loop every checkInterval (until idle long enough)
+ Keeper->>Dev: sleep(checkInterval)
+ Keeper->>Dev: getPosition()
+ Dev-->>Keeper: pos
+ Note over Keeper: pos == lastPos (no user movement)
now - lastActivity ≥ moveInterval → fire
+ end
+
+ Keeper->>Sim: simulateActivity(config, log, dev)
+ Sim->>Dev: getPosition()
+ Dev-->>Sim: start
+ Sim->>Dev: width()
+ Dev-->>Sim: width
+ Sim->>Dev: height()
+ Dev-->>Sim: height
+ Note over Sim: strategy = STRATEGIES[config.pattern]
ctx = { start, width, height, rng }
+ Sim->>Exec: executePath(strategy, ctx, dev, log, config)
+
+ Exec->>Strat: path(ctx)
+ Strat-->>Exec: Iterable
+
+ loop for each target point (clean run)
+ Exec->>Exec: resolveTarget(bounds, target) → point
+ Exec->>Dev: setPosition(point)
+ Exec->>Dev: sleep(stepDelay)
+ Exec->>Dev: getPosition()
+ Dev-->>Exec: current
+ Note over Exec: |current - point| ≤ 2px → not the user, continue
+ end
+
+ Note over Exec: path exhausted, no interruption
+ Exec->>Dev: setPosition(round(start))
+ Note right of Exec: restore cursor to origin
+ Exec-->>Sim: "completed"
+ Sim-->>Keeper: (done)
+
+ Keeper->>Dev: getPosition()
+ Dev-->>Keeper: lastPos (== start; re-sync)
+ Note over Keeper: lastActivity = now
loop continues
+```
+
+## Invariants this path relies on
+
+- **`createNutDevice()` is the only nut.js touchpoint.** It disables nut.js's
+ 100ms auto-delay so `executePath` owns cadence via `stepDelay`.
+- **The strategy is pure.** `path(ctx)` yields ideal points from geometry
+ alone (`start` / `width` / `height` / `rng`); it never touches the device,
+ which is what makes every pattern unit-testable without a screen.
+- **Every step re-reads the cursor** and compares it against the *commanded*
+ point (not the strategy's ideal, possibly fractional target) within a 2px
+ tolerance. On the happy path each check passes, so the loop runs to
+ exhaustion. A mismatch beyond tolerance is real user activity and returns
+ `"interrupted"` without restoring — the branch this diagram omits.
+- **Clean completion restores the cursor to `round(start)`.** That is why the
+ follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as
+ a no-op, and the next idle check sees no net movement (so the synthetic
+ sweep is never mistaken for the user returning).