# v0.23.0 — Cutover guide

> **Audience:** consumer agents (and humans relaying for them)
> upgrading to `mgf-common` 0.23.0.
> **What this is:** the canonical migration document for this
> release. Per [`RELEASING.md`](../standards/RELEASING.md) **REL-09**.

This is the **second polish round** for the v0.21.0
`mgf.common.process` feature. Adds quality improvements +
comprehensive test layer expansion. **No behaviour breaks** for
v0.22.0 consumers — every existing test passes unchanged.

---

## At-a-glance

| What landed | Severity | What it covers |
|---|---|---|
| **Public `redactor` property on formatters** | quality | Stable accessor replacing the fragile private-attribute introspection in `get_active_redactor`. |
| **`Service.__repr__`** | quality | Concise debug representation showing name / command / status / pid. |
| **Best-effort OTel span wrapping** | feature | `run()` and `Service.start()` open `operation_span` when bootstrap is active. Gated on bootstrap state — no `UnconfiguredWarning` when no `AppContext`. |
| **Tuple commands accepted** | UX | `run(("echo","hi"))` works alongside list form. |
| **Smoke / E2E / Integration test layers** | quality | New `tests/smoke/` + `tests/e2e/` + `tests/integration/` directories with `pytest -m smoke`/`-m e2e`/`-m integration` markers. **+29 tests across 4 layers.** |

**Pin bump:** `mgf-common = ">=0.23,<0.24"` per the 0.x window
discipline.

**Public-Python-surface change:** +1 method (`Service.__repr__`)
+ 2 properties (`JsonFormatter.redactor`, `TextFormatter.redactor`).
Top-level `__all__` exports unchanged.

**Behaviour change:** none for existing v0.22.0 callers.

**No deprecation cycles introduced.**

---

## Why this round

The v0.22.0 round was driven by stress-test analysis (race
conditions, timeouts, rollback gaps). v0.23.0 is driven by
**testing pyramid completeness**:

- v0.21.0 shipped with **64 unit tests** for the process module.
- v0.22.0 added **11 polish-specific unit tests** for the polish round (75 total).
- v0.23.0 adds **29 tests across 4 layers** — 7 smoke + 8 e2e + 5 integration + 9 more unit (104 total in the process subtree).

The new layers catch failure classes the unit tests can't:

- **Smoke** (`tests/smoke/`) catches packaging/install failures
  (a missing `__init__.py` import statement, a typo in
  `__all__`, a missing extras-dependency). Fast: < 100ms total,
  no fixtures, no bootstrap.
- **E2E** (`tests/e2e/`) catches integration breaks across the
  bootstrap → run() → redaction → log-record chain. v0.21
  unit tests verified each link individually; the E2E layer
  confirms the chain works in real bootstrap conditions.
- **Integration** (`tests/integration/`) catches cross-module
  interactions — `ExitStack` LIFO teardown, concurrent `run()`
  isolation, multi-service no-leak under partial failure.

---

## Quality items

### Public `redactor` property on formatters

**Why:** `mgf.common.process._streamer.get_active_redactor()`
discovered the bootstrap-wired `Redactor` by reading
`formatter._redactor` (a private attribute). If `JsonFormatter`
or `TextFormatter` ever renamed their internal `_redactor` field,
redaction would silently stop working — bad failure mode.

**Fix:** added `redactor` as a public property on both formatters.
`get_active_redactor()` now prefers the public property; falls
back to `_redactor` for back-compat with older deployed wheels
(consumers might mix versions during a rolling upgrade).

```python
from mgf.common.observability.json_formatter import JsonFormatter
from mgf.common.observability.redaction import Redactor

f = JsonFormatter(redactor=Redactor())
print(f.redactor)  # public, stable accessor (v0.23.0+)
```

### `Service.__repr__`

**Why:** debugging `Service` objects in pdb / log output
required reading internal state. Now `repr(svc)` shows what
matters at a glance.

```python
>>> svc = Service(["uvicorn", "myapp.main:app"], ready=PortReady("127.0.0.1", 8000))
>>> svc
Service(name='uvicorn', command=['uvicorn', 'myapp.main:app'], status=not-started, pid=-)
>>> svc.start()
>>> svc
Service(name='uvicorn', command=['uvicorn', 'myapp.main:app'], status=running, pid=12345)
>>> svc.stop()
>>> svc
Service(name='uvicorn', command=['uvicorn', 'myapp.main:app'], status=exited(-15), pid=12345)
```

### Best-effort OTel span wrapping

**Why:** the v0.21 design noted "each `run()` opens an
`operation_span`" but didn't ship it (deferred). v0.23 closes
the gap.

`run()` and `Service.start()` now wrap the launch in an
`operation_span` when:

1. `mgf.common.bootstrap()` is active (an `AppContext` exists).
2. The OpenTelemetry SDK is installed (the `[observability]`
   extra).

When either condition fails, the span is silently a no-op —
no warning, no overhead.

The gating-on-bootstrap is the load-bearing piece: pre-v0.23
calling `operation_span` without an active context emitted
`UnconfiguredWarning` (because it called `app_name()` to name
the tracer). Test suites that promote that warning to error
would have failed spuriously. v0.23 gates the call — the span
is only attempted when there's a real context to attach to.

```python
# When bootstrap is active + OTel installed, traces show:
#   process.run.alembic       command_id=ab12cd34   duration=12.4s
#   service.start.postgres    command_id=ef01gh23   duration=2.1s
# Failed launches (ProcessTimeout / ServiceNotReady) are recorded
# on the span as exception events.
```

### Tuple commands

UX paper-cut: `run(("echo", "hi"))` failed with TypeError in
v0.21–v0.22. Tests/scripts often build command tuples; the
list-only constraint added friction without value (the security
property — no shell strings — depends on the type being a
sequence-of-strings, not specifically a list).

`_validate_command()` (new shared helper) accepts list OR tuple,
rejects strings (the security gate), normalises to `list[str]`
so downstream Popen always sees a list and `ProcessResult.command`
is always a list.

---

## Test layer expansion

The new pyramid:

```
                    ┌──────────────┐
                    │  smoke (7)   │  ← post-install sanity, < 100 ms
                    ├──────────────┤
                    │   e2e (8)    │  ← bootstrap + run + redaction chains
                    ├──────────────┤
                    │ integration  │  ← multi-service, concurrent run()
                    │     (5)      │
                    ├──────────────┤
                    │   unit (84)  │  ← isolated component tests
                    └──────────────┘
                       Total: 104
```

Run any tier in isolation:

```bash
pytest -m smoke           # post-install sanity
pytest -m e2e             # full-chain
pytest -m integration     # cross-module
pytest                    # everything
```

### Smoke layer

`tests/smoke/test_process_smoke.py` — 7 tests covering:

- Top-level imports work (every name in PUBLIC_API.md is
  reachable).
- Exception MRO contract (every `Process*` error inherits
  `OperationError` for EH-04 unchanged-handler catching).
- `run(["echo", ...])` returns a clean `ProcessResult`.
- `check=False` preserves returncode without raising.
- `Service` constructor is no-launch.
- `Service.__repr__` is sane.
- Tuple commands accepted; shell strings rejected.

These run in < 100 ms total. CI integration: a downstream job
can use them as a "did the wheel install correctly?" pre-flight
check before kicking off the heavier integration suite.

### E2E layer

`tests/e2e/test_process_redaction_e2e.py` (5 tests):

- bootstrap + `run()` printing `Authorization: Bearer <token>` →
  assert `[REDACTED]` in BOTH `result.stdout` AND the streamed
  log record (every link of the redaction chain).
- `redact=False` opt-out preserves the secret in capture.
- `redact=True` without bootstrap raises (clear error vs silent
  leak).
- No-bootstrap default-None silently skips redaction (no warning).

`tests/e2e/test_process_correlation_e2e.py` (3 tests):

- Every captured record carries `extra={"command_id": …}` matching
  the `ProcessResult.command_id`.
- Concurrent `run()`s have distinct `command_id`s.
- `Service` records (lifecycle: starting/ready/stopped) share the
  `command_id` with the streamer's records (cross-logger
  correlation).

### Integration layer

`tests/integration/process/test_multi_service.py` (2 tests):

- Two `Service`s nested in `ExitStack` start in declared order;
  both reach ready.
- A failure in the second service's start tears down the first
  cleanly via the stack — no leaked process.

`tests/integration/process/test_concurrent_run.py` (3 tests):

- 16 parallel `run()` calls in threads → 16 distinct
  `command_id`s, isolated `result.stdout` per call (no crossed
  output), log records don't cross processes.

### Unit layer additions

`tests/unit/process/test_polish_v023.py` (9 tests) covering
restart-after-failure, OTel span gating (nullcontext vs real
span), public redactor property on both formatters, and
`Service.__repr__` across lifecycle states.

---

## Per-consumer migration notes

### vm-vmanager-app (Qt desktop)

**No action required.** The polish + test additions are
transparent to existing v0.22.0 callers. If your CI is
considering adding a "post-install sanity" job, run
`pytest -m smoke` for ~100ms turnaround vs the full process
suite.

Pin: `mgf-common = ">=0.23,<0.24"`.

### inthewords (Django web)

**No action required.** If the `tools/run_stack.py` rewrite is
in motion, the new `Service.__repr__` makes it easier to log
the stack state during development. Best-effort OTel spans
(when your project's `bootstrap()` is active) give you trace
correlation across the multi-service launch sequence.

Pin: `mgf-common = ">=0.23,<0.24"`.

### PlasmaMapper (FastAPI scientific platform)

**No action required.** Same as above. The E2E redaction tests
(`tests/e2e/test_process_redaction_e2e.py`) are a useful
template if your project wants to assert that secrets emitted
from subprocess fixtures (e.g., Postgres startup banners) are
scrubbed correctly.

Pin: `mgf-common = ">=0.23,<0.24"`.

---

## Engineering note

The pattern this round establishes is worth naming: **once a
feature has stabilised through one full minor cycle of
unit-test-driven development, the next polish round invests in
test pyramid completeness**. Smoke, E2E, and integration each
catch a different failure class. Adding them after the unit
tests have proven the surface (rather than alongside) avoids
churn — you're not refactoring tests as the design evolves;
you're adding higher-level checks that pin the real-consumer
contract.

The pytest marker registration (`smoke` / `e2e` / `integration`)
in `pyproject.toml` is also load-bearing: with `--strict-markers`
on, declaring them up front means future contributors can't
silently introduce new markers and the layer structure stays
explicit.
