shipper-core 0.3.0-rc.2

Core library behind the `shipper` CLI: engine, planning, state, registry, and remediation primitives for `cargo publish` workspaces.
Documentation
# Module: `crate::ops::lock`

**Layer:** ops (layer 1, bottom)
**Single responsibility:** Advisory file-based lock that prevents concurrent `shipper` runs from operating on the same workspace/state directory.
**Was:** standalone crate `shipper-lock` (absorbed during decrating Phase 2).

The lock is a JSON file in the state directory (default `.shipper/lock`) that
records the PID, hostname, `acquired_at` timestamp, and optional `plan_id` of
the holder. Acquisition is via atomic `File::create` + `fs::rename` after a
check-then-create. Release happens on `Drop` (best effort) or via
`LockFile::release()`. A stale-lock timeout path (`acquire_with_timeout`) lets
callers reclaim locks whose holders died without releasing.

## Public-to-crate API

Re-exported at `shipper::lock` (preserved backward compatibility with the old
`shipper-lock` public surface; callers in `shipper-cli` and the integration
tests use the `shipper::lock::*` path).

- `LOCK_FILE` — default lock filename constant.
- `LockInfo` — serde struct written to the lock file.
- `LockFile` — RAII handle; `acquire`, `acquire_with_timeout`, `release`,
  `set_plan_id`, `is_locked`, `read_lock_info`.
- `lock_path(state_dir, workspace_root)` — resolves the concrete lock path,
  with an optional `DefaultHasher`-derived suffix when `workspace_root` is
  `Some` (so multiple workspaces sharing a state dir don't collide).

## Invariants & gotchas

- **Not a true atomic mutex.** `acquire` does a check-then-create which races
  under concurrent contention. The engine relies on workspace-level cooperation,
  not OS-level file locking. See `concurrent_acquire_only_one_succeeds` — at
  least one thread is guaranteed to win, but more than one *may* succeed in a
  tight race. Callers that need strict mutual exclusion should layer their own
  guard on top (or switch to OS advisory locks — a deliberate future option).
- **Write-then-rename** is used to publish the lock atomically inside a single
  filesystem. `fsync` of the parent dir is attempted but ignored on failure.
- **`Drop` is best-effort.** If the file is externally removed, `release`
  silently succeeds; `set_plan_id` on a released lock returns an error.
- **Stale-lock detection is wall-clock based.** `acquire_with_timeout`
  compares `Utc::now() - acquired_at` to the configured timeout. A lock whose
  age is *exactly* the timeout is NOT considered stale (strictly `>`). Corrupt
  lock files are treated as stale by `acquire_with_timeout` but as errors by
  plain `acquire`.
- **`lock_path` hash is `DefaultHasher`.** Stable for a single Rust build but
  NOT guaranteed across versions; collisions are extremely rare but possible.
  This is fine for the workspace-disambiguation use case it serves.

## Architectural notes

- Layer-1 pure I/O. Must not import from `engine`, `plan`, `state`, or
  `runtime` (enforced by `.github/workflows/architecture-guard.yml`).
- No async. Synchronous filesystem calls only.
- Dependencies: `anyhow`, `serde`, `serde_json`, `chrono`, `gethostname`.