step.2.diff.txt -- narrative retro for step.2 (U1.1: envelope +
adapter contract + mock provider)

Executed: 2026-07-18, same day as the skeleton. Companion artifact:
step.2.gdiff.txt (automated, scripts/step_gdiff).

## Plan vs actual

Skeleton followed closely; every open question (§5) taken exactly as
LEANed, no reversals. Three real findings during execution, all
caught by the step's OWN gates rather than missed -- the gates did
their job.

```
+---+----------------------------------+------------------------------------------+
| # | Planned (§)                      | Actual                                   |
+---+----------------------------------+------------------------------------------+
| 1 | §2.4/G1: "no provider name       | G1's own grep caught a REAL hit: the     |
|   | appears outside adapters/"       | envelope.py Response docstring used      |
|   |                                  | {"provider": "mock", ...} as an          |
|   |                                  | ILLUSTRATIVE example. Fixed by replacing |
|   |                                  | it with a generic <provider-id>          |
|   |                                  | placeholder rather than rationalizing    |
|   |                                  | the hit away as "just a comment, doesn't |
|   |                                  | count" -- a lexical gate that excuses    |
|   |                                  | its own hits stops meaning anything.     |
| 2 | §2.4: "tests/pal_contract.py"    | Renamed to tests/test_pal_contract.py    |
|   | (the skeleton's own filename)    | at write time -- pytest's default        |
|   |                                  | `python_files` pattern is `test_*.py`;   |
|   |                                  | the skeleton's literal name would have   |
|   |                                  | needed new pytest config to be           |
|   |                                  | discovered at all, directly violating    |
|   |                                  | G7's "zero new configuration" bar. Fixed |
|   |                                  | in the file itself, noted here rather    |
|   |                                  | than silently diverging from the         |
|   |                                  | skeleton's prose.                        |
| 3 | §4.2 (from step.1): "adapters/  | Confirmed rather than assumed: ran the   |
|   | mock/adapter.py replacing       | FULL step.1 smoke.sh unmodified after    |
|   | hello.py's role"                | this step's changes landed -- hello.py   |
|   |                                  | untouched, still imported by smoke.sh,   |
|   |                                  | still SMOKE PASS. "Role" replaced,       |
|   |                                  | file/regression untouched, exactly as    |
|   |                                  | the skeleton's §2.3 note intended.       |
+---+----------------------------------+------------------------------------------+
```

## Gate evidence

```
+------+---------------------------------------------------------------------+
| G1   | `grep -rn -e mock -e claude -e openai app/pal/` -> ONE hit found    |
|      | (finding #1 above), fixed, re-grepped: empty. pytest: 8/8 green.    |
| G2   | Live dry-run: registered a throwaway `("mock2", MockAdapter())`     |
|      | entry -> 8 tests became 16 with ZERO test-body edits (the SAME 8    |
|      | test functions just re-ran against the second instance via the     |
|      | existing parametrize). Reverted before commit -- the registration- |
|      | only reuse mechanism (Q-2-D) is proven, not asserted.               |
| G3   | grep for os.environ/socket/urllib/requests/httpx in the mock       |
|      | adapter + fixtures: one hit, but it was "requests" inside the      |
|      | English phrase "across requests" in a docstring -- confirmed a     |
|      | false positive by reading the actual import list (dataclasses,     |
|      | typing, app.pal.*, adapters.mock.fixtures -- nothing else).         |
| G4   | 8 named tests, each asserting one fixture scenario individually:   |
|      | capabilities shape, text response (+usage+run_id), tool_calls      |
|      | response, a 2-part TEXT stream, an unrecognized-request KeyError,  |
|      | abort-unknown=IDLE, abort-running=ABORTED (+idempotent second      |
|      | call), abort-finished=DONE (no-op).                                 |
| G5   | adapters/mock/adapter.py's import list: dataclasses.replace,       |
|      | typing.Iterator, app.pal.adapter, app.pal.envelope,                 |
|      | adapters.mock.fixtures -- nothing outside app.pal + the same        |
|      | adapters/mock/ subtree + stdlib.                                    |
| G6   | grep "INERT" app/pal/envelope.py -> 4 hits, one per inert field     |
|      | (tool_schema, budget, policy) plus the class-level summary line.    |
| G7   | rm -rf .venv work && make venv && make smoke && make test replayed  |
|      | clean end to end; b3ubot porcelain before this commit; .venv/ +     |
|      | work/ stayed untracked throughout.                                  |
+------+---------------------------------------------------------------------+
```

## Findings for later steps

1. **A lexical grep gate will occasionally false-positive on English
   prose, and that's an acceptable cost.** "requests" (the docstring
   word) collided with "requests" (the library name) in G3's check.
   The fix is READING the hit before dismissing it, not loosening the
   pattern -- a looser pattern (e.g. requiring `import requests` as
   the literal grep target instead of the bare word) would have missed
   less-common import styles (`from requests import ...`,
   `importlib.import_module("requests")`). Keep the cheap, slightly
   noisy version; read every hit.
2. **G1's own catch is the more important lesson**: even a skeleton
   author being careful about a rule can accidentally violate it while
   writing an ILLUSTRATIVE comment, precisely because comments don't
   feel like "real code" to the person writing them. The gate existing
   as an actual command (not just a design-doc sentence) is what
   caught it -- this is the concrete payoff of step.1's own precedent
   (build the structural regression guard AS a grep from day one, not
   as prose to remember).
3. **abort() semantics (Q-2-E) held up under adversarial testing.**
   The "don't let a post-abort exhaustion clobber ABORTED back to
   DONE" guard (adapters/mock/adapter.py's `if self._status.get(run_id)
   == RunStatus.RUNNING` check before setting DONE) was written
   proactively, not discovered by a failing test -- worth flagging for
   P3: when the real orchestrator defines abort() calling conventions,
   this exact race (caller keeps consuming a generator after aborting
   it) is a real shape to test against, not a hypothetical.
4. **The contract suite's routing rule (fixtures.py: first context
   entry's `content` string selects the scenario) is deliberately
   dumb.** U1.2's claude-code adapter will need this same test suite
   to drive REAL prompts through a REAL provider -- the fixture
   strings ("say hello", "call a tool", "long run") were chosen
   specifically to also work as plausible real prompts, so the SAME
   test bodies can plausibly pass against both the mock and a live
   adapter without rewriting the scenario content, only what backs it.

## Numbers

```
+--------------------------------------+----------------+
| pinned deps added this step          | 0 (pytest only,|
|                                       | unchanged)     |
| new files                            | 7              |
| contract tests                       | 8              |
| adapters registered                  | 1 (mock)       |
| provider-name leaks found + fixed    | 1              |
| BUILD commit 4cbcad0                 | 7 files, +350  |
+--------------------------------------+----------------+
```

U1.1 done -> unblocks U1.2 (the first REAL adapter, claude-code) and
U1.3 (the one-round-trip CLI). Ledger updated in this RETRO commit per
the §0 execution contract.

End of retro.
