step.2.txt -- b3ubot U1.1: envelope + adapter contract + mock provider (P1.M1) -- design.md §2.4 as code: the request/response envelope, the capabilities()/run()/abort() adapter interface, and the REAL mock adapter (fixture-scripted, zero network, zero keys) that replaces U0.4's throwaway "hello" stub. Everything U1.2's real claude-code adapter will plug into the SAME contract suite this step builds. Step: 2 -- promote UoW **U1.1** (end_to_end.md §3, P1.M1) to execution depth: the provider abstraction layer's CONTRACT, realized as code and locked in place by a reusable, adapter-agnostic pytest suite -- before any real (network-touching) provider exists to tempt the contract into being shaped around one provider's quirks. Parent: end_to_end.md §3 U1.1 ("Envelope + adapter contract + mock provider"; objective = "design §2.4 as code"; done-when = "pytest exercises the whole contract against the mock; no provider name appears outside adapters/") + design.md §2.4 (the PAL contract, quoted in full below) + §2.11 ("PAL ⟂ workflow: no provider name outside adapters + ProviderRun rows") + §1.2 ("honest interims: partial mechanisms are named at creation... never silently deferred"). UoW: U1.1 -- "Envelope + adapter contract + mock provider". Depends on U0.4 (done, step.1, 4dd2b4b). Sole blocker of U1.2 (the first REAL adapter, claude-code) and U1.3 (the one-round-trip CLI). Date: 2026-07-18 (SKELETON -- not executed. Execute on explicit request.) Origin: User directive 2026-07-18: "populate step file for U1.1". ## (0) Status SKELETON. Nothing built. Larger than step.1 (U0.4 was pure plumbing; this is the engine's first real architectural surface), but still a single, offline-testable slice: no network, no orchestrator, no CLI -- just the contract and one adapter that satisfies it. ## (1) Ground truth probed at drafting EXISTS (the contract to implement, design.md §2.4, quoted in full): request: { role-tagged context, tool schema, budget, policy } response: { text | tool_calls[] | done, usage, provider_meta } adapter interface (every provider implements exactly this): capabilities() -> { tools?, streaming?, context_window, modalities, cost_model } run(request) -> response stream abort(run_id) "Adapters are thin: translate the envelope to the provider's native API and back. No adapter contains workflow logic." "The mock provider is a first-class adapter... the whole engine must run and be tested with zero network and zero keys." "Provider identity appears in exactly one place: the ProviderRun record, for audit and cost accounting. The developer-facing surfaces say 'b3ubot'." EXISTS (U0.4's throwaway stub, step.1): adapters/mock/hello.py -- a hardcoded `hello() -> str`, explicitly commented "U1.1 REPLACES this file's contents wholesale." This step does exactly that; adapters/mock/ as a PATH is not new, its CONTENTS are. EXISTS (§3's ProviderRun entity): "one PAL invocation: provider id, usage, tool trail, egress digest (THE audit row -- B-4/B-5)" -- but §3's five-layer data-model pipeline is Track B (P4), not built. CONSEQUENCE: this step's `provider_meta` / usage fields are plain in-memory data on the response object -- there is no SQLite-backed ProviderRun row to write yet. Naming this explicitly (not silently skipping persistence) is the design's own "honest interims" rule (§1.2) in its first real test. EXISTS (the reuse requirement, U1.2's own end_to_end.md text): "the SAME contract suite passes against the live adapter (auto-skip when no Claude Code client is available)." This is a real architecture constraint ON THIS STEP: the test suite built here must be adapter-agnostic/parametrized from the start, not a one-off hardcoded against the mock, or U1.2 has to refactor it under time pressure instead of just registering a second adapter. EXISTS (d3, LEANING Python; §1.2/§2.11 minimalism): no validation boundary exists yet (no HTTP layer, no untrusted external input -- d2 leans CLI-first and the CLI itself is U1.3) -- nothing in this step's scope justifies a new dependency like pydantic; U0.4's requirements.txt is still pytest-only. MISSING: app/ (design.md §2.12's engine-code home) does not exist yet -- this step creates it, scoped to exactly the PAL (app/pal/), nothing else (no orchestrator, no ledger, no oracle harness -- those are P2/P3/§2.5's jobs). tests/ (§2.12) does not exist yet either. The envelope classes, the adapter interface, the real mock adapter, the fixture set, and the contract suite itself are all net-new. ## (2) What U1.1 delivers 2.1 **The envelope** (app/pal/envelope.py): `Request` and `Response` dataclasses matching §2.4's shape exactly -- `Request(context, tool_schema, budget, policy)`, `Response(kind, text, tool_calls, usage, provider_meta)` where `kind` discriminates text/tool_calls/done (Q-2-B: stdlib dataclasses, no new dependency). `policy` and `tool_schema` are defined at their FULL eventual shape but are INERT this step -- no enforcement exists yet (P3's tool surface, P7's egress hardening) -- and the code says so at the point of definition (C-2-D), not by silent omission. 2.2 **The adapter interface** (app/pal/adapter.py): an ABC (Q-2-A) with exactly `capabilities()`, `run(request)`, `abort(run_id)` as abstract methods -- a subclass that skips one fails at INSTANTIATION, not at first call, catching an incomplete future adapter (U1.2's claude-code, U6.1's openai) at import time. 2.3 **The real mock adapter** (adapters/mock/adapter.py, replacing hello.py -- Q-1-D from step.1 pays off here, zero relocation): implements the ABC against fixture-scripted responses (Q-2-C: plain Python dicts/dataclasses in a fixtures module -- no serialization round-trip, nothing crosses a process boundary yet). Fixture coverage: a plain text response, a tool_calls response, a "done" terminal, `usage`/`provider_meta` populated (`provider_meta` names "mock" -- the ONE place provider identity is allowed to appear outside adapters/ itself, per §2.4), and an `abort()` on a running / not-yet-started / already-finished run (Q-2-E: no-op + a returned status enum, since P3's orchestrator will need idempotent-abort semantics eventually regardless). 2.4 **The contract test suite** (tests/pal_contract.py): a pytest-parametrized module (Q-2-D: a fixture yielding a registered `(name, adapter_instance)` list) that exercises EVERY adapter registered against it -- shape conformance for `capabilities()`, the full `run()` response taxonomy, `abort()` idempotency -- currently registering exactly the mock adapter. U1.2 adds ONE registration line, not a new test file. 2.5 **The "no provider name outside adapters/" gate** (new, matching step.1's G6 cppcc-invocation-count precedent): a grep-based structural check that `app/pal/*` never contains the literal strings "mock"/"claude"/"openai" -- the code-level enforcement of §2.11's rule, cheap to run, catches the leak on the day it would happen rather than in a later review. 2.6 **Closure**: end_to_end.md §11 U1.1 -> done; design.md untouched (this step implements existing design, changes none of it -- the honest-interims notes for `policy`/`tool_schema` live in CODE comments, not a design.md rewrite); memory. Retro pair via scripts/step_gdiff (confirmed portable at step.1). EXPLICITLY OUT OF SCOPE (each belongs to a later UoW): - Any adapter that touches a network or a credential (claude-code, openai) -> U1.2, U6.1 - The one-round-trip CLI / tool surface's minimal core -> U1.3 - The orchestrator state machine (INTENT/SKELETON/.../CLOSED) -> P3 - Real enforcement of `policy` (egress) or `tool_schema` (gated tool execution) -- this step defines their SHAPE only -> P3/P7 - The ledger engine, step-file parser/emitter -> P2 - The ProviderRun SQLite-backed entity / any persistence at all -> P4 - Capability-negotiation LOGIC beyond the mock returning a fixed, honest `capabilities()` result (degrading based on it is the orchestrator's job, P3) -> P3 - T2-T4 oracle tiers (live gates, CCS round-trip, AI review) -- this step's suite is entirely T0/T1 (static + unit) -> later ## (3) Verification gates G1 DONE-WHEN (end_to_end.md verbatim): pytest (tests/pal_contract.py) exercises the whole contract against the mock adapter, green; `grep -rn -e mock -e claude -e openai app/pal/` is empty (no provider name appears outside adapters/). G2 CONTRACT SUITE IS ADAPTER-AGNOSTIC, VERIFIED BY DESIGN: the registration mechanism (Q-2-D) is inspected/dry-run to confirm adding a second adapter needs exactly one new registration entry, zero test-body edits -- checked by a throwaway SECOND mock instance registered under a different name inside the test file during BUILD, then removed before commit (proving the mechanism without prematurely building U1.2). G3 ZERO NETWORK / ZERO KEYS: the entire contract suite run makes no network call and consults no environment variable (grep the mock adapter + fixtures for `os.environ`/`socket`/`http` -- empty; extends step.1's C-1-D discipline from the smoke script into real library code). G4 MOCK FIXTURE COVERAGE NAMED: text response, tool_calls response, done terminal, usage+provider_meta populated, abort() on all three run states (idle/running/finished) -- each individually asserted in the suite, not folded into one vague "it works" test. G5 ADAPTERS STAY THIN (G6's step.1 precedent, generalized): adapters/mock/adapter.py imports nothing from outside `app/pal/` + the stdlib -- no orchestrator, no ledger, no tool-surface reference (none exist yet, but the import graph itself is the regression guard for when they do). G6 HONEST INTERIMS NAMED IN CODE: `policy` and `tool_schema` fields carry an inline comment at their definition site stating they are inert pending P3/P7 -- grep-verified present, not just asserted in this skeleton's prose. G7 Hygiene: b3ubot porcelain clean after each commit; `make test` (from step.1's Makefile) picks up tests/pal_contract.py with zero new configuration; retro via step_gdiff. ## (4) LOCKs C-2-A b3ubot porcelain clean after each commit. C-2-B ADAPTERS STAY THIN: no adapter -- mock now, any real one later -- may contain orchestrator/workflow logic (design.md §2.4's own rule; G5 is its regression guard). C-2-C NO PROVIDER NAME LEAKAGE: provider identity lives ONLY inside adapters/ modules and the `provider_meta` response field's runtime VALUE -- never as a literal string anywhere in `app/pal/` logic (§2.11; G1/G5 enforce it). C-2-D HONEST INTERIMS: `policy` and `tool_schema` are defined at their full eventual shape but are INERT this step -- stated in code comments at the definition site (G6), never silently implied as "already enforced." C-2-E standing locks inherited: B-1 (moot -- this step touches no CCS surface at all), B-3 (no disclosure / no push), B-4 (provider credentials env-only -- foreshadowed by C-2-D's `policy` note, not yet exercised since the mock needs no credential), B-6 (deterministic oracles gate -- this step's entire suite IS a T0/T1 oracle, the ledger's first one). ## (5) Open questions Q-2-A Interface realization: an ABC with `capabilities()/run()/ abort()` as abstract methods (LEAN -- explicit contract, fails LOUDLY at instantiation if a future adapter is incomplete, matches §2.4's own "every provider implements exactly this" wording) vs `typing.Protocol` (structural typing, no forced inheritance, arguably more Pythonic for swappable components). LEAN: ABC -- the fail-loud-at-import property is worth more here than Protocol's flexibility, given there will only ever be a handful of adapters, not a large duck-typed ecosystem. Q-2-B Envelope implementation: stdlib `dataclasses` (LEAN -- zero new dependency, matches step.1's minimal-requirements discipline; no validation boundary exists yet to justify more) vs `pydantic` (real validation + serialization, but for a boundary -- untrusted external JSON -- that doesn't exist until a much later CLI/web surface). LEAN: dataclasses; revisit if/when U1.3's CLI or a later web surface needs to parse untrusted input into this shape. Q-2-C Fixture format: plain Python dicts/dataclasses defined directly in a fixtures module (LEAN -- simplest, type- checked by the same envelope classes at import time, no serialization round-trip since nothing crosses a process boundary yet) vs JSON fixture files (more "data-driven," but adds a parse step with no current consumer for the JSON form). LEAN: Python fixtures module; JSON fixtures are worth revisiting if/when a non-Python tool ever needs to read the same scripted responses (none does today). Q-2-D Contract-suite reuse mechanism (the piece U1.2 depends on directly): a pytest fixture yielding a registered list of `(name, adapter_instance)` pairs, consumed via `pytest.mark.parametrize` (LEAN -- idiomatic pytest, each adapter's own module just appends one registration, zero test-body changes) vs a shared base test class each adapter's suite subclasses (more OOP, less pytest- idiomatic, harder to see all adapters' results in one `-v` run). LEAN: parametrized fixture registry -- G2 verifies this mechanically during BUILD. Q-2-E `abort()` semantics with no orchestrator yet to define calling conventions: no-op + a returned status enum (IDLE/RUNNING/DONE/ABORTED) covering idle/running/finished (LEAN -- P3's orchestrator will need SOME idempotent-abort behavior regardless; inventing a small, honest version now is cheaper than guessing blind later without ANY contract to test against) vs leaving `abort()` unimplemented/raising `NotImplementedError` until P3 defines the real semantics (defers a real decision, but the mock adapter then can't satisfy its OWN interface, which is worse). LEAN: no-op + status enum, named explicitly as provisional (P3 may widen it, never narrow it without a version note). ## (6) Acceptance - [x] app/pal/envelope.py: Request/Response dataclasses per §2.4 exactly, `policy`/`tool_schema` commented as inert (2.1, C-2-D, G6). - [x] app/pal/adapter.py: the ABC per Q-2-A (2.2). - [x] adapters/mock/adapter.py: replaces hello.py's role entirely (hello.py itself untouched, confirmed by full smoke.sh replay), implements the ABC, fixture-driven (2.3, G4). - [x] tests/test_pal_contract.py (renamed from the skeleton's pal_contract.py -- pytest discovery requires the test_ prefix, see step.2.diff.txt finding #2): parametrized contract suite, mock registered, 8/8 green (2.4, G1). G2 dry-run (a throwaway second adapter, 8 -> 16 tests, zero test-body edits) demonstrated then reverted before commit. - [x] G3/G5 verified: zero real network/env import (one grep false-positive on the English word "requests", confirmed harmless by reading the actual import list); adapters/mock/ adapter.py's import graph confined to app/pal/ + stdlib + adapters.mock.fixtures. - [x] G1's grep confirms zero provider-name leakage in app/pal/ -- ONE real hit found (an illustrative "mock" in a docstring), fixed with a generic placeholder, re-verified clean. - [x] `make test` (step.1's Makefile, unmodified) picks up the new suite with zero new configuration (G7); full rm -rf .venv work && make venv && make smoke && make test replay green. - [x] Closure: end_to_end §11 U1.1 -> done + memory; retro pair via step_gdiff (2.6, G7). ## (7) Hash backfill SKELETON commit (this file + ledger row U1.1 -> active): 234e360 BUILD commit (envelope + adapter ABC + mock + contract suite): 4cbcad0 RETRO commit (retro pair + ledger done): (HEAD) End of skeleton.