# 0.10.0 — Substrate hardening: cross-milestone bleed closed end-to-end, branch model gains read-side teeth

The keystone release for milestone scoping. Prior pilots from mvp-demo through obol-260522 surfaced the same class of bug shape-shifting through fix after fix: agents would drift across milestones, design quality would degrade on later milestones as memory accumulated, and per-call costs would climb mysteriously. 0.10.0 closes the underlying architecture gap from four different surfaces simultaneously — seed resolution (T-ab51), tool-layer reads (T-ab35/T-ab46), framing prose (T-ab34/T-ab45), and (most load-bearingly) the read-side branch isolation that T-ab8's write-side branching never had (T-ab52, T-ab53).

**Headline impact for the operator**: M6 design in obol-260522 went from "Alice silent every attempt, $0.13/call, zero output, 16 reruns" to "stories shipped, features composed, scope adherence cleanly held." The branch mental model now actually works at runtime — implement writes land on `impl:<milestone-slug>`, design reads filter to the active branch's inheritance chain, and the project branch stops compounding noise into every milestone's recall.

Also ships an observability + ergonomics tier the diagnosis work demanded: context-size instrumentation (T-ab24a), thread-history truncation (T-ab24b/c), per-test traceback in verify findings (T-ab30), dashboard ticket-reset affordance (T-ab38), Caterpillar oscillation detection (T-ab29), and 14 more substrate fixes covering ticket lifecycle, review-routing, verify attribution, and meeting-boundary correctness.

Three paper-grade architectural lessons logged during the work:
- **Multi-surface scope leaks**: when a class of bug keeps shape-shifting through fixes, audit ALL surfaces pulling from the upstream data, not just where the symptom most recently appeared (T-ab51 keystone fix).
- **Write/read isolation symmetry**: write-side primitives need symmetric read-side audits. T-ab8 was tested for write behavior but no test checked compose_context actually filtered reads — the abstraction leaked silently for months while later fixes piled on top (T-ab52).
- **Memory-accumulation amplifier**: per-milestone branches isolate milestones from each other but reruns of the same milestone re-stack seeded directives in episodic memory. 16 copies of one directive blew alice's M6 context from 57K to 103K tokens. Episodic write-time dedup is the next lever.

---

## Verify catches pytest setups that aren't pyproject-shaped (T-ab22)

`pytest_collects` and `pytest_passes` used to early-skip whenever `pyproject.toml` was missing. `mvp-demo-rerun-A` shipped with `tests/` + `requirements.txt` (no pyproject), so every verify run silently skipped both Python checks — the real SQLAlchemy `collation="NOCASE"` kwarg bug (invalid in SQLAlchemy 2.x) slipped through, and the operator only found out by trying to run the shipped code by hand.

Two changes wired together:

**Detection (`_detect_python_project`)** classifies the project as:
- `configured` — pyproject.toml/setup.py/setup.cfg present
- `tests_only` — no config but `tests/` with Python files (real project, broken setup)
- `absent` — no Python signal at all (legitimate skip)

**Resolution (`_resolve_pytest_command`)** now has a `tests_only` branch: when `requirements.txt` is present + uv is on PATH, the substrate runs `uv run --with-requirements requirements.txt --with pytest pytest`. Without this, the bare-pytest fallback ran in Wonderland's own venv and spuriously flagged the project's declared deps (fastapi, sqlalchemy, etc.) as missing — the original symptom that led to the bug being routed wrong. With the requirements file pulled into the ad-hoc venv, pytest collection hits the actual code bug (SQLAlchemy collation kwarg) on the very first verify run.

When `tests_only` + no pytest runner available, the substrate surfaces a "Python tests present but pytest not installed" finding instead of silently skipping — the operator sees the gap in the dashboard reviews list rather than discovering it post-pilot.

## Agent deliberation crashes no longer silent (T-ab23)

`meeting.py` caught any exception from an agent's `_deliberate_window` and treated it as a clean PASS — no log, no warning, no event. When mvp-demo-rerun-A had 6 implement runs across multiple days produce `$0.00` cost and look identical to "agents had nothing to say," the actual cause was the deliberation pipeline crashing on verify-synthesized tickets and the substrate eating the exception. Took several hours of forensics to find because every observable signal looked like a legitimate pass.

The substrate now logs a WARN-level message with the exception type, the failed agent's name, the phase name, the meeting thread ID, and the full traceback whenever a deliberation raises. The meeting still continues (treating it as PASS keeps the rotation alive), but the operator now has the signal needed to debug.

Crash-vs-deliberate-pass is now distinguishable in the run log — silent failures stop masquerading as normal flow.

## Context-size instrumentation (T-ab24a, Stage 1)

T-ab23's exception logging immediately surfaced the next bug in the chain: agents on mvp-demo-rerun-A were hitting Anthropic's 200K token cap (`BadRequestError: prompt is too long: 203154 tokens > 200000 maximum`) after enough iterations on the same feature accumulated utterances in episodic memory.

Stage 1 instruments `compose_context()` to log the per-layer token approximation (constitution / relationships / thread_history / triggers / engagement_state) so we can see *which* layer is driving overflow rather than guessing. Logs to the `wonderland.context_size` logger at:
- WARN above 100K tokens (50% of cap — operator should act soon)
- INFO above 30K tokens (visible in debug runs, silent in production)
- Silent below 30K (most prompts are fine)

This is the diagnostic prerequisite for Stage 2 (token-budgeted recall) — once we observe the breakdown on real broken prompts, the budget logic can target the actual inflated layer (probably thread_history, but could be triggers when a ticket body has a multi-KB traceback embedded).

## Thread history truncation (T-ab24b, Stage 2)

Stage 1's instrumentation immediately confirmed the hypothesis on the broken pilot — `thread_history` was 649K chars (99% of the context) while every other layer was negligible (constitution 8K, triggers 71 chars, engagement_state 103 chars). Calibration: real API rejected at 203K tokens for what we measured as 657K char-equivalent → 3.24 chars/token for our utterance content.

Stage 2 truncates `thread_history` at 420K chars (~130K real tokens, leaving ~70K headroom for system+tools+constitution+ticket+overhead). When truncation fires:

- **Seed utterances always preserved** — they carry scope/contract framing that establishes what the meeting is about; dropping them orphans the agents
- **Most-recent non-seeds preserved** — walk newest → oldest, keep what fits in the remaining budget
- **Middle non-seeds dropped** — the accumulated iteration noise from past attempts on the same thread
- **Banner prepended** to the rendered transcript: `[Context-budget notice: N earlier utterance(s) elided to fit context window. Seeds + most-recent preserved.]` so agents know they're seeing a partial transcript and can reason about it explicitly
- **INFO log fires** so operators see truncation events without alarming WARN noise

Edge case: when seeds alone exceed budget, all seeds preserved + all non-seeds dropped. Stage 3 (LLM summarization) will handle the seeds-too-big case more gracefully.

Net effect on mvp-demo-rerun-A: deliberation crashes from `prompt is too long: 203154 > 200000` should stop firing. The substrate stays inside Claude's context window even on heavily-iterated threads. Memory inflation as a structural failure mode is mitigated — agents can now iterate on the same feature indefinitely without crashing themselves out.

## Truncation strategy actually shrinks seed-heavy threads (T-ab24c, fix on Stage 2)

T-ab24b's first cut preserved all seed utterances on the theory that seeds = small framing artifacts (directive, ticket, contracts). The mvp-demo-rerun-A pilot broke that assumption immediately: Runner.convene re-publishes prior-thread history as seeds, so after enough iteration, 2165 of 2169 utterances in the broken implement thread were seeds. T-ab24b's truncation dropped the 4 non-seeds and kept everything else → no measurable shrinkage, crashes continued.

T-ab24c replaces the seed-preservation prejudice with **first-K + newest-K**: keep the first 10 utterances chronologically (the meeting's opening framing — directive, ticket, early contracts — regardless of seed flag) + walk newest backward filling the remaining budget. Drop the middle.

Result: seed-flagged but contextually noisy utterances from prior threads now get dropped along with everything else. The framing is preserved by position (first in the thread), not by seed flag. The mvp-demo-rerun-A shape now truncates correctly: ~50 priming + ~20 recent kept, ~2100 middle utterances dropped.

Test updated + regression added (`test_budget_thread_history_handles_seed_heavy_threads`) covering the 50-seeds-4-non-seeds shape that broke the first cut.

## Phase-aware memory scope (T-ab25a)

Stage 2 truncation was treating the symptom (prompts overflow) rather than the cause (implement phases were carrying memory they don't need). The right architectural shift: each phase declares what slice of memory its agents see.

New field on `PhaseSpec` / `PhaseDefinition`: **`memory_scope`** with two values:

- `all` (default — original behavior): every utterance in the thread, including seeds re-published from prior threads. Right for tea-party (contract negotiation needs to see prior contracts), review (caterpillar examines just-emitted implementation), and other phases where accumulated context informs the work.
- `meeting_only`: non-seed utterances only — what happened in THIS meeting, nothing else. Right for pure-execution phases (implement, validate) where the accumulated chatter from N prior iteration attempts is contamination, not signal. The tweedles have the ticket + contracts (passed as triggers + scope artifacts) + recent rotations from this meeting — that's enough to write code.

Plumbing: `PhaseSpec.memory_scope` → `PhaseSpec.to_phase_definition()` → `PhaseDefinition.memory_scope` → meeting engine reads on each priority window → passes to `agent.compose_context([window_open], memory_scope=...)` → compose_context filters thread history before assembling. Validated at PhaseSpec creation time (unknown scope values fail loud at workflow-load).

**tdd-implement.yaml** updates: M7's `implement` and `validate` phases now declare `memory_scope: meeting_only`. The mvp-demo-rerun-A broken thread (2165 seeds vs 4 non-seeds) would now drop 99.8% of its context at compose_context — the implement phase sees only what's relevant to the current execution turn.

T-ab24a/b/c truncation stays as a safety net for the `all`-scope phases (tea-party, review) where context might still grow large.

Four new tests cover: `all` scope preserves original behavior, `meeting_only` drops seeds, invalid scope values fail at workflow-load, default scope is `all` for backwards compat.

302 existing workflow + turns tests pass; 12 existing compose_context tests pass.

## Per-finding verify ticket attribution (T-ab26)

T-ab25a unblocked stuck implement runs, but exposed the next layer of bug: verify-synthesized tickets were attributed to **whichever feature was most recently active when the build_check fired**, not to the feature whose code actually broke. mvp-demo-rerun-A: README work was in flight when verify ran; the full pytest suite caught `test_tags.py` failures (caused by a conftest StaticPool bug); the synthesizer attributed those tag-test tickets to the README feature; the tickets sat parked on a feature unrelated to the failing code; subsequent implement runs scoped to the real owning features never picked them up.

The existing `_pick_feature_for_build_check_attribution` had a comment acknowledging this: *"V1 — V2 would parse pytest output for file paths and per-feature attribute by source mapping."* This commit ships that V2.

New function `_pick_feature_for_finding(project_root, finding, fallback_slug)` chains:
1. `finding.location` (e.g., `tests/test_tags.py:18`) → extract file path
2. Walk `ImplementationRegistry.list_implementations()` looking for a record whose `**Files:**` section lists that path
3. From the matching implementation, follow `ticket_reference` → `TicketRegistry.find_by_slug/guid` → ticket's `**Sources:**` first entry = parent feature

When no implementation claims the file (or no location field on the finding), falls back to the meeting-level pick (`_pick_feature_for_build_check_attribution`). Backwards-compatible default.

The synthesis loop in `_run_verify_meeting` now groups findings by per-finding feature target and synthesizes one `_route_blocking_review` call per group — so a single build_check failure with findings in multiple features lands tickets on each of the right features, not one giant misattributed pile.

Effect on mvp-demo-rerun-A's stuck state: the conftest StaticPool fix tickets, once re-synthesized by a future verify run, would land on the actual tag/notes feature (whichever implementation touched `tests/test_tags.py` or `tests/conftest.py`), where the next implement run scoping to that feature would pick them up. The previously-stuck tickets stay stuck for now — they're operator-investigatable. Future tickets attribute correctly.

3 new tests cover: fallback when location missing, fallback when no implementation matches, happy path routing finding → implementation → ticket → feature.

## Filter nudges from compose_context (T-ab27)

Dodo's priority-window-open nudges (~280 chars of "your turn to act…" boilerplate) are pure scaffolding — orchestration metadata, no semantic content the agent benefits from re-reading on subsequent turns. They accumulated as noise in every memory query, dominating the thread-history floor across all phases.

`compose_context` now drops utterances with `speech_act=NUDGE` after the memory query, before scope filtering. Storage in episodic SQLite is unchanged — operators can still inspect nudges via direct query for debugging meeting flow — but agents see clean transcripts focused on real deliberation.

Filtered by speech_act (not speaker) so any framing nudges from future characters get the same treatment automatically. The fix is upstream of T-ab25a's `meeting_only` scope filter, so even implement phases (which already drop seeds) get a small additional cleanup, and `all`-scope phases (tea-party, review) get the larger benefit since they were carrying nudges from prior meetings as seeds.

## Verify ticket synthesis generates fresh tickets per cycle (T-ab28)

mvp-demo-rerun-A post-T-ab27 surfaced another silent failure: verify caught the test failures, synthesized a review with 3 findings, and the operator saw zero new tickets. Cause: `TicketRegistry.write`'s slug-match dedup. When verify fires across multiple cycles, each cycle's findings produce the same title → same slug → existing ticket on disk (in done/aborted state from prior cycle) → silent overwrite. From the dashboard, it looks like "verify didn't catch anything." This was the `7b324f04` roadmap item that I'd re-used the T-ab23 number for; it never actually shipped.

This commit ships it properly. Two combined behaviors on the verify path:

**Cross-run cycle bumping**: when synthesizing a verify-spawned ticket and the derived slug already exists on disk (from a previous run's cycle), append `(cycle N)` to the title until the slug is unique. Each verify cycle's recurring failures stack visibly as `... (cycle 2)`, `... (cycle 3)` so the operator sees pathology by ticket count rather than digging through state-log archaeology.

**Intra-run dedup**: the verify meeting fires per feature lane in pipelined mode, so a multi-feature run sees the same finding from each lane. Track synthesized slugs in process-local state (`_THIS_PROCESS_VERIFY_SLUGS`) — same slug within one run drops the second synthesis instead of creating a (cycle 2) duplicate. Process == run because each wonderland run is a fresh process; the primitive could be refactored to a contextvar set at run entry if processes ever become long-lived.

Opt-in via `fresh_per_cycle=True` parameter on `_route_blocking_review` so Caterpillar M8 reviews (which intentionally use slug as the "same correction, refined" identity) keep their existing re-emission semantics; only the verify call site flips the flag.

Two new tests: cross-run cycle bumping creates fresh tickets when prior cycle exists, intra-run dedup collapses duplicate findings from multiple verify lanes. All 38 existing build_check + verify_routing tests pass.

## Caterpillar oscillation detection (T-ab29)

mvp-demo-rerun-A surfaced a new failure mode: tweedles oscillating on a contract ambiguity (whether seeded notes should have tags). Cycle 1: tweedles add tags → caterpillar requests removal → cycle 2: tweedles remove tags → caterpillar requests addition → cycle 3: tweedles add tags again. Each side is internally consistent; neither side can resolve the spec from the spec alone. The substrate kept billing cycles without progress.

Directive update on M8 review (tdd-implement.yaml). When caterpillar's review memory shows opposite-direction requested-changes verdicts on the same artifact across cycles, the directive tells caterpillar to:

1. **STOP** — don't file another approve/request-changes verdict on the oscillating artifact
2. **Emit `question_to_operator`** naming the specific ambiguity (which artifact, which field/behavior, which interpretations seen) as a short-list of options
3. **Wait for the operator's OBSERVATION reply** — the reply IS the ruling
4. **In the next review pass**, demand a contract-note update encoding the resolution. Without that artifact update, future implementations re-discover the ambiguity from scratch.

Caterpillar already has `question_to_operator` as an allowed speech act; no code change. T-ab28's `(cycle N)` ticket counts double as a hint for the agent — seeing cycle 3+ in seed context = high probability of oscillation. Pure directive layer per the "don't lightly change constitutions" principle.

## Verify findings carry per-test traceback (T-ab30)

T-ab28 successfully synthesized fresh `(cycle 2)` test-failure tickets — but the tweedles working those tickets immediately misdiagnosed the cause as "dotenv missing" (it isn't; dotenv installs fine). Reason: the ticket bodies showed `**Concern:** assert ...` with nothing else. The actual StaticPool-threading traceback never reached the ticket. Tweedles glanced at memory, found old chatter about dotenv from earlier in the pilot's life, and confabulated.

`_parse_pytest_failures` was capturing only the one-line summary from pytest's short-summary section (`FAILED tests/foo.py::test_bar - assert 500 == 200`) and dropping the per-test traceback section above it in pytest output. Inverse irony: the no-parseable-summary FALLBACK path was actually shipping MORE context (the whole stdout in `request`) than the structured path, and Haiku resolved cleanly from that fallback (per `project_tweedles_unstructured_traceback` memory). The "structured" path was hurting debugging quality by stripping the diagnostic signal.

New helper `_extract_test_traceback(output, test_id)` walks pytest's FAILURES/ERRORS sections — `_____ ClassName.test_method _____` bracketed bodies — and pulls the body for the matching test. The traceback gets appended to the finding's `request` field inside a fenced code block, so the synthesized ticket carries enough context for the agent to diagnose without a local pytest re-run. Bounded by the existing `_truncate_for_finding` cap (~3KB) so bus payloads stay sane.

Verified end-to-end against mvp-demo-rerun-A: the freshly-synthesized verify ticket now includes the literal `sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread` text. The fix is conftest-shaped (StaticPool + check_same_thread=False); whether tweedles make the leap from `tests/test_tags.py` failure → `tests/conftest.py` fixture is the next quality validation.

One new test (`test_passes_finding_includes_traceback_in_request`) confirms the assertion message + failing line make it into the request body. 63 existing verification + build_check + verify_routing tests pass.

## Discovery axis discrimination directive (T-ab31)

T-ab15 shipped the `axis: foundation | capability | both` field on `RequirementPayload` but the alice.py JSON schema docs explicitly told Alice to "default to 'both' when unsure — substrate is permissive on it." mvp-260522 pilot demonstrated the cost: 17/23 requirements tagged `both`, 0 tagged `foundation`. The milestone planner saw no foundation-only requirements and produced 0 foundation milestones; the axis-aware roster filter went unused.

Replaced the one-liner with explicit per-axis examples + an instruction to pick deliberately. Foundation = persistence layer, schema, scale/consistency contracts, build/deploy plumbing (user-visible behavior unchanged, only the engineering changes). Capability = user-visible behavior, persona outcomes, UI states (drive Alice's solo scoping pass). Both = genuinely cross-cutting only (out_of_scope kinds; items equally load-bearing on both lanes). mvp-260522 is named in the directive as the anti-example so future Alices see what defaulting-to-both costs.

Pure directive change — no schema or substrate code touched. Validated by import + the 13 existing compose_context tests passing.

## Milestone-plan must produce foundation M1 on infra constraints (T-ab32)

T-ab31 shipped the axis directive on Alice; obol-260522 ran with it active and still produced **0 foundation-tagged requirements out of 22**. Same shape as mvp-260522. Both pilots had clear persistence + stack + build constraints in their corpora; Alice honestly tagged them `axis: both` (they apply to both lanes), and the milestone planner read "no foundation-only requirements → no foundation milestone needed."

The directive change at the wrong layer. **Axis tagging is descriptive** (where the requirement applies); **milestone kind is prescriptive** (where the WORK lands). They're orthogonal. A foundation milestone can and SHOULD consume `axis: both` requirements — those constraints are exactly the foundation milestone's input.

Added prescriptive trigger to milestone-plan.yaml directive: **MUST produce kind=foundation M1 when the requirement corpus contains persistence/storage constraints (SQLite, Postgres, etc.), stack/language constraints, build/deploy plumbing, scale/consistency contracts, or dev-environment shape (TUI, CLI, web+frontend split).** These need a coherent landing pass before capability milestones consume them.

Both pilots named explicitly as anti-examples in the directive. Pure YAML directive change — no schema or substrate code touched. Validates by `load_workflow()` returning the milestone-plan workflow correctly.

## Ticket sources first entry must be a feature (T-ab33)

obol-260522 M0 design surfaced this: 6 of 13 tickets cited a `story-*` slug as the sole/first source. Schema accepted them (stories ARE legitimate ticket sources — a ticket can cite the story it realizes). But the dashboard groups tickets per-feature by reading the first source as the parent feature slug. Story-first tickets become invisible — user saw 7 in dashboard despite 13 on disk.

`TicketPayload.sources` validator now rejects `sources[0]` starting with `story-` with a clear error: *"first entry must be the parent feature slug, not a story slug. Stories are valid as secondary sources — list them AFTER the parent feature."* Rabbit's response-parse retry loop surfaces the error so the next attempt re-orders correctly.

This encodes the architectural distinction cleanly: **feature is the structural parent (where the ticket renders in the dashboard, what milestone it consumes), story is the conceptual realization (what user story or scope item the work serves)**. Both are useful citations; order matters for downstream consumers.

Two new tests (`test_payload_rejects_story_as_first_source`, `test_payload_accepts_story_as_secondary_source`) + one pre-existing test (`test_render_ticket_includes_all_required_sections`) updated to include a feature slug before its story source. All 50 ticket tests pass.

## Existing-artifacts framing blocks scoped to active milestone (T-ab34)

obol-260522 M4 design first stalled here: agents kept saying "I'm reading the composition history from M0 through M4" and producing concerns about cross-milestone coherence instead of M4 features. Trace through the framing layer found that the `existing-stories` and `existing-tickets` framing blocks rendered EVERY story/ticket on disk regardless of milestone, so by M4 the scoping meeting saw 42 stories spanning all five milestones in its framing alone — the prior milestones' work was being re-injected as context every cycle.

`workflow.py`'s framing renderers for those blocks now read the active milestone scope (set by the per-milestone iteration loop) and filter on `**Milestone:**` parsed out of each artifact. Stories/tickets without milestone attribution still render (defensive default — better to over-include unattributable artifacts than silently hide them). Other framing blocks (existing-features, contracts, ADRs) are unchanged — they have legitimate cross-milestone roles (feature realization tracking, foundation lookups).

Validated against the M4 rerun: framing block sizes dropped from "everything on disk" to "M4 artifacts only." Stalled less, still stalled — surfaced the deeper tool-layer leak (T-ab35). Three new tests in `test_milestone_framing.py` cover: stories framing scopes to active milestone, tickets framing scopes to active milestone, unattributable artifacts still render. All 92 existing framing/workflow tests pass.

## Tool-level milestone scoping for scoping+composition reads (T-ab35)

The final piece of the cross-milestone leak fix the operator surfaced across four obol-260522 M4 rerun attempts. T-ab8 branched memory per milestone, T-ab9 scoped seed fallback, T-ab18 rejected cross-emissions, T-ab34 scoped the framing blocks. M4 design STILL stalled — agents bypassed all of those by simply calling `read_file` on M0/M2/M3 artifacts directly. The implicit-context layer was fully scoped; the explicit-context layer (what agents pull via tools) wasn't.

Tool-call log inspection found 28 cross-milestone `read_file` calls during a single M4 rerun: M0 stories, M0 milestone artifact, M0 features, M0 tickets, M2 features, M3 features. Agents acting on documented curiosity, exactly as the directive told them not to. Memory branching + framing scoping cover the implicit context layer; nothing covered the explicit one.

**New guard:** `_check_milestone_scope_allowed_read` on `WonderlandTools`, called from `read_file` before the data read. Returns early (allows the read) when:

- No active milestone scope is set (non-scoped contexts unaffected)
- The active meeting isn't a scoping/composition phase — later phases (decomposition, consolidation, architecture, tea-party, implement, validate, review, verify) anchor on per-feature iteration filters (T-ab17/T-ab19/T-ab20) and don't need the guard; curiosity-driven cross-milestone reads land on relevant artifacts naturally there
- The path isn't under `.wonderland/{stories,features,tickets}/` — ADRs, milestone artifacts, requirements, contracts remain freely readable cross-scope (legitimate foundation-context lookups)
- The artifact has no milestone attribution (defensive default — better to over-allow than silently block unattributable reads)
- The artifact's milestone matches the active scope

Otherwise raises `ToolError` with a helpful cross-milestone message naming both the requested milestone and the active one.

Attribution chain on tickets uses the T-ab33 invariant: ticket → `sources[0]` (parent feature) → feature's `**Milestone:**` → milestone match. Stories/features parse `**Milestone:**` directly.

**Plumbing:** New `_current_meeting_id` ContextVar in `telemetry.py` (mirrors the `_current_thread_id` pattern); `workflow._convene_one` sets/resets it alongside the thread_id at meeting boundaries; `tools.py` reads it to determine the active phase via meeting-id suffix matching. Same orthogonal cross-cutting-concern shape as the thread_id attribution — no LLMClient/tool signature changes.

Six new tests in `test_tools.py` cover: no-scope-set allows everything, active-milestone story allowed, cross-milestone story in scoping phase blocked, cross-milestone read in decomposition phase allowed (later-phase exemption), cross-milestone ADR read allowed (foundation-context exemption), unattributable story allowed (defensive default). All pass.

The architectural claim this validates: **memory branching + framing scoping are necessary but not sufficient for scope discipline in multi-agent design workflows.** Agents with file-navigation tools will violate scope discipline through documented curiosity unless the tool layer enforces it. Tool-level scoping is the missing primitive — and it only needs to apply when there's no per-feature anchor yet (scoping + composition), because per-feature iteration filters take over from decomposition onward.

## Build-check fallback time-scoped to current run window (T-ab36)

obol-260522 surfaced this: verify ran during an M0 implement pass and synthesized failing-test tickets, but routed them all to an M2 feature the operator had earlier parked in READY_FOR_REVIEW. The cause was the fallback's preferred-state priority (READY_FOR_REVIEW > IN_PROGRESS > QUEUED) tie-broken by highest feature number — both the just-reviewed M0 feature and the operator-parked M2 feature were in READY_FOR_REVIEW, and the M2 slug sorted higher. T-ab26's per-finding attribution couldn't disambiguate because the failing tests cited files no implementation had yet claimed (implementation hadn't shipped them to disk), so the chain fell through to the fallback.

The architectural fix: scope the fallback's candidates to features whose **latest state transition** occurred at or after the current run started. Operator workarounds applied before the run (parking a feature into READY_FOR_REVIEW with no UI to revert IN_PROGRESS → DESIGNED) become ineligible. The actually-being-implemented feature wins because its READY_FOR_REVIEW transition came from the current run's review meeting, in-window by construction.

**Plumbing:** New `_current_run_started_at` ContextVar in `telemetry.py` mirroring the `_current_thread_id` / `_current_meeting_id` pattern from T93 + T-ab35. `Runner.__init__` stamps it once with `datetime.now(UTC)` (and exposes the value on `self.run_started_at` for direct callers). The contextvar is never reset — its lifetime matches the per-process Runner instance, so no token bookkeeping is needed at the run-end boundary. `_pick_feature_for_build_check_attribution` reads it (or accepts an explicit `run_started_at=` kwarg for tests / non-Runner callers) and filters each preferred-state bucket via `feature_lifecycle.transitions_for(...)[-1].at >= run_started_at` before applying the highest-number tiebreaker.

**Degradation behavior:** when the time filter empties a preferred-state bucket (e.g. all candidates are pre-run), the fallback degrades to the unfiltered bucket rather than skipping the state. Verify still has to route somewhere; the routing bug we're fixing is "wrong feature when a better in-window one exists," not "block all routing on time signal alone." Defensive default also covers the no-Runner case (scripts, unit tests, the contextvar unset) — when `run_started_at` is None, no filter applies and the original pre-T-ab36 behavior holds.

Four new tests in `test_build_check.py`: pre-run transitions excluded when an in-window candidate exists, fallback degrades when all candidates are pre-run, contextvar plumbing works without an explicit kwarg, backwards-compat when run_started_at is unset. All 30 build_check tests pass (existing 26 + new 4).

Pairs with T-ab26 (per-finding attribution) as the other half of "verify tickets land on the right feature" — T-ab26 covers the case where the finding has a file-path signal that resolves through implementation history; T-ab36 covers the fallback when that resolution fails. Together they make the operator's "park this feature" workaround safe against verify mis-attribution.

## Time-scope IN_PROGRESS-counts-as-queued in feature-level gate (T-ab37)

obol-260522 surfaced a second misattribution path that T-ab36 didn't cover: the implement workflow's feature-level admission gate at `workflow.py:2068-2076` included any feature whose tickets were in `QUEUED` **or** `IN_PROGRESS` state. The IN_PROGRESS allowance was originally added for intra-run continuity (an early meeting in the current run flips pending→queued→in_progress; later meetings need to still see the feature in the lane), but it didn't distinguish in-run IN_PROGRESS from stale IN_PROGRESS left over by prior crashed runs.

Pilot symptom: M0 implement ran, the operator queued only the M0 feature, but an M2 ticket had been left IN_PROGRESS by a prior interrupted run (no UI to revert IN_PROGRESS → DESIGNED). The gate saw the IN_PROGRESS M2 ticket → pulled the M2 feature into the M0 implement run → tweedles processed both features in the same pass → ~$4.76 of M2 work charged to an M0 run.

Fix: same contextvar pattern as T-ab36. New helper `_ticket_in_progress_is_in_run_window(project_root, ticket_slug, run_started_at)` walks `ticket_lifecycle.transitions_for(slug)` to find the latest transition timestamp and returns True iff it's at or after run_started_at. The gate at workflow.py:2068-2076 now splits the check:

- `TicketState.QUEUED` always counts (operator intent persists across runs)
- `TicketState.IN_PROGRESS` only counts when the latest transition is in-window

Defensive defaults: no run_started_at known (scripts, unit tests not setting the contextvar) → no filter applied, pre-T-ab37 behavior holds. No transition history (shouldn't happen if ticket reached IN_PROGRESS, but) → allow, don't silently exclude on a data-shape edge case.

Three new tests in `test_workflow.py`: stale IN_PROGRESS ticket does NOT pull feature into new run, in-window IN_PROGRESS ticket DOES pull feature (intra-run continuity preserved), no-contextvar case preserves legacy behavior. All 300+ existing workflow tests still pass.

The ticket-level lane scoping at workflow.py:2118-2155 is intentionally NOT time-scoped — within a single feature's lane, the IN_PROGRESS allowance IS for intra-run continuity (an early phase's auto-transition; later phases need to see the ticket). The bug is specifically at the feature-admission layer where stale cross-run IN_PROGRESS leaks features in.

## Dashboard "Reset to pending" button for stuck IN_PROGRESS tickets (T-ab38)

obol-260522 also surfaced a missing affordance: when an implement run crashes mid-iteration and leaves tickets stuck in IN_PROGRESS, the dashboard has Queue / Un-queue / Mark-done buttons but no path back to PENDING. The operator's workaround was to manually move the parent feature to READY_FOR_REVIEW as a "park this so it doesn't auto-run" gesture — but READY_FOR_REVIEW is a terminal-adjacent state that broke the build_check attribution fallback (T-ab36's surfacing pilot).

Per-feature affordance already exists: the "Re-design" button (visible when feature.state == IN_PROGRESS) bulk-reverts a feature's tickets to PENDING and lets the derive logic flip the feature back to DESIGNED. But per-ticket affordance was missing.

New "Reset to pending" button shown when a single ticket is highlighted and its state is IN_PROGRESS. Click → `ticket_lifecycle.chain_transition` walks IN_PROGRESS → QUEUED → PENDING (multi-step legal path; not a one-shot transition), each step logged to the audit trail with operator attribution and a note explaining the park rationale.

Pairs naturally with T-ab37: T-ab38 gives the operator the affordance to mark a ticket "parked, don't auto-pull"; T-ab37 makes the gate respect that signal by excluding the now-PENDING ticket from feature inclusion (and even if it were still IN_PROGRESS, the transition timestamp would be pre-run on the next run). Together they make per-ticket parking a first-class operator workflow rather than a workaround that breaks other substrate behaviors.

All 29 dashboard smoke tests still pass.

## Review phase memory_scope: meeting_only + cycle-history seeds (T-ab39)

Telemetry from obol-260522-1 M1 implement (the first run after the T-ab37/38 shipped) confirmed an observation the operator surfaced: M8 review is now the single biggest cost line in implement runs. On a representative feature: `pipe.<feature>.review` consumed $0.51 / 44 calls — bigger than the most-expensive implementation thread ($0.34) and ~2.5× the most-expensive tea-party ($0.21). Operator reports review meetings spiking to $1.50-$1.75 on harder features. Scaling that across 7 features × 9 milestones, review becomes a $50-100 line item for the next pilot.

The review phase had never received the T-ab25a `memory_scope: meeting_only` treatment that implement + validate got — so caterpillar at M8 was carrying the full thread_history of M5 contract negotiation + M6 tea-party + M7 implementation chatter into every review call. That's accumulated chatter the review doesn't need: contracts come through as seeds, implementation is on disk, prior reviews come through as seeds. The deliberation noise from earlier phases just compounds context-replay cost on every tool-use round-trip.

Two changes wired together:

**`memory_scope: meeting_only` on the review phase.** Same primitive T-ab25a applied to implement + validate. Drops cross-meeting deliberation history; agents only see what happened in THIS review meeting.

**New seed binding: `kinds: [review]` with `tail: 2`** on the M8 meeting's seeds config. This compensates for the T-ab29 oscillation-detection signal loss that meeting_only would otherwise cause. T-ab29's "spot opposite-direction request-changes verdicts across cycles" check relies on caterpillar seeing prior cycles' verdicts. Under meeting_only, those would be invisible via memory query. The new seed pulls the most-recent 2 prior reviews of the same feature in as structured seed context — same signal, cheap structured path instead of full memory.

The `tail: 2` is the minimum that supports oscillation detection: caterpillar sees the immediately-prior cycle's verdict (the "did I just request the opposite change?" check) plus one further back (the "are we ping-ponging?" check). Older cycles are superseded — by cycle 4 the cycle-1 verdict isn't load-bearing for the oscillation decision.

Validation: tdd-implement.yaml loads cleanly via `load_workflow('tdd-implement')`; review phase resolves `memory_scope=meeting_only`; review meeting seeds count goes 5 → 6 with the new `[review]/tail:2` binding present. All 364 workflow + lifecycle + build_check tests still pass (the 2 pre-existing TicketSourceAttribution failures are unrelated and predate this commit).

Projected impact: if the review thread's cost drops proportionally to how implement+validate dropped under T-ab25a (~30-50% on heavily-iterated features), review goes from $0.50-$1.75 to $0.25-$0.90 per feature. Scaling: ~$25-50 savings across the 9-milestone obol-260522-1 pilot. Watching for confirmation in the next implement run's telemetry.

## Decouple review-verdict routing from meeting outcome (T-ab40)

Long-standing UX bug surfaced by the operator: "on review runs, if caterpillar has no findings and verify is clean, the tickets that were processed in the run don't get moved to done." Tickets stay IN_PROGRESS, feature can't derive to READY_FOR_REVIEW, operator can't tell when an iteration on a feature is actually done.

Root cause: `_apply_post_meeting_transitions` (which contained the review-verdict routing) was called only when `MeetingEndEvent.outcome == "COMPLETE"` (gate at workflow.py:7373 phased path, :7630 convene-one path). When M8 review overruns its $0.60 budget or hits max_rotations, outcome lands `MEETING_BUDGET` or `MAX_ROTATIONS` rather than `COMPLETE`. The verdict utterance landed on disk under `.wonderland/reviews/` — the contract was real — but the ticket transitions silently never fired.

A prior partial fix (removing M8's defend phase, per the comment at tdd-implement.yaml:626) shrunk the meeting so it usually finishes under budget. That patched the *symptom* rather than the *coupling*. Any subsequent change that pushed M8 over budget — a tweedle follow-up, a caterpillar rotation, tool-use overhead — restored the bug. On obol-260522-1 M1 cycle 2 today, review cost $0.66 against a $0.60 budget — exactly the overrun shape. If that had been an accept verdict instead of request-changes, tickets would have stuck.

**Fix:** extract review-verdict routing into a new function `_apply_review_verdict_routing` that runs regardless of meeting outcome. The verdict is the contract; meeting-cleanup state is orthogonal.

- `_apply_review_verdict_routing` (new): handles `_find_blocking_reviews` + `_find_accept_review` dispatch. Preserves the T-ab21 meeting-ID gate (only `id ∈ {"review", "m8"}` triggers ticket transitions; M3.5 consolidation reviews don't drive implementation-stage ticket changes).
- `_apply_post_meeting_transitions`: no longer contains the review-verdict block. Keeps `transition_iteration_to` (feature lifecycle) which DOES legitimately need a COMPLETE outcome — failed iterations shouldn't fire feature transitions.
- Both call sites (convene-one + phased) now call `_apply_review_verdict_routing` unconditionally and `_apply_post_meeting_transitions` only on COMPLETE.

Four existing test cases in `TestResolveSeeds` (`test_request_changes_review_synthesizes_followup_tickets`, `test_accept_review_completes_tickets_and_derives_ready`, `test_accept_review_fast_forwards_legitimate_queued_ticket`, `test_accept_review_skips_ticket_queued_for_future_run`) updated to call the new function. The fifth caller (`m6` tea-party test for `transition_iteration_to`) stays on `_apply_post_meeting_transitions` since that's its actual remaining responsibility. All 364 workflow + lifecycle + build_check tests pass (the 2 pre-existing TicketSourceAttribution failures predate this commit).

Forward-looking implication: M8's `meeting_budget: 0.60` is no longer load-bearing for ticket-state correctness — at most it caps cost. The operator can tighten the budget toward a more aggressive cost ceiling (e.g., $0.40 after T-ab39's `memory_scope: meeting_only` drop lands) without worrying about strand-on-overrun. The verdict ships → routing fires → tickets transition cleanly regardless of when the meeting closes.

## Skip features with all-terminal tickets from implement lanes (T-ab41)

obol-260522-1 run 20260523T005911 burned **$0.58 on an M8 review pass for `schema-bootstrap-and-entity-models`** — a feature whose 13 tickets were all DONE. Nothing to implement, nothing to review, full review meeting fired anyway.

Root cause: the feature-level admission gate at `_collect_per_item_items` (workflow.py:2093) has two parallel paths:

1. **Ticket-driven override:** feature has at least one QUEUED or in-window IN_PROGRESS ticket → admit (this is T-ab37's window-scoped path)
2. **State-driven fallback:** feature's own lifecycle state is in `allowed` (e.g. `queued`, `in_progress`) → admit

A feature whose lifecycle is still IN_PROGRESS (carried from a prior cycle) but whose tickets are all terminal passes via path #2. The lane spawns, the per-feature meetings run, M8 review burns budget reviewing already-DONE work.

Fix: build a `features_with_any_tickets` set and tighten the state-driven path — when a feature passes state filter but has tickets AND none are actionable, drop it. Features with zero tickets at all still pass (preserves T-ab16's opt-in semantics for the pre-decomposition / design-phase iteration case where features legitimately have no tickets yet).

Decision matrix:
- 0 tickets, state allowed → admit (design-phase compatibility)
- ≥1 actionable ticket → admit (the normal case)
- ≥1 ticket, all terminal → **drop** (T-ab41's new case)
- state not in allowed AND no actionable tickets → drop (existing behavior)

Two new tests in `test_workflow.py`: `test_t_ab41_feature_with_all_done_tickets_excluded_from_lane` (the schema-bootstrap reproduction) and `test_t_ab41_feature_with_zero_tickets_still_passes` (regression guard for T-ab16 compat). All 364 workflow + lifecycle + build_check tests pass.

Saves $0.50-$0.80 per phantom-feature lane spawn going forward. Compounds across multi-cycle implement runs where features increasingly hit the all-done state as work completes — late-pilot implement runs were paying this cost on every previously-done feature on every subsequent cycle.

## Dashboard markup escape + verify-attribution prefers lane feature (T-ab42)

Two issues surfaced together on obol-260522-1. Both shipped in the same patch since they're tangled around the verify path.

**Dashboard MarkupError on verify tickets.** Clicking a verify-spawned ticket (`test-failed-tests-test-app-py-test-counter-increments-on-button-press`) in the dashboard tree blanked the detail pane with a `MarkupError: closing tag '[/b]' does not match any open tag`. The ticket body embeds a pytest traceback whose assertion message contains literal `[/b]` text — Rich/Textual's markup parser tried to interpret it as a markup tag and failed.

Fix: escape user content (title / slug / body) before passing to `Static.update()` via `rich.markup.escape`. Header styling tags (`[b]...[/b]`, `[dim]...[/dim]`) stay live since those are substrate-controlled; only the dynamic strings get escaped. Applied to both `_render_feature_detail` and `_render_ticket_detail`.

**Verify-spawned tickets attribute to whichever feature's files happen to match, not the lane feature.** Symptom the operator observed: "verify ticket generation is fucked, it's assigning tickets all over the project." Root cause:

- In pipelined `tdd-implement`, M9 verify fires once per feature lane (`pipe.<feature>.verify-...`)
- The lane's feature slug is naturally available to the dispatcher (it's `outer_slug`)
- But `_run_verify_meeting` didn't take it as a parameter — fell back to `_pick_feature_for_build_check_attribution`, which picks the highest-numbered feature in a preferred state
- That heuristic scatters tickets across the project when multiple features touch the same test files

Fix: thread `lane_outer_slug` through the dispatcher into `_run_verify_meeting`. When set, it becomes the default attribution target for the synthesized build-check review AND the per-finding fallback. The T-ab26 per-finding path-walk still overrides for genuine cross-feature cases (e.g. a conftest.py change broke a sibling feature's tests — that finding legitimately belongs to the file's owning feature, not the lane).

Decision matrix for verify attribution:
- **Per-finding path matches a different feature's implementation files** → that feature gets the ticket (T-ab26)
- **Per-finding path doesn't match any implementation** AND **lane feature is set** → lane feature gets the ticket (T-ab42)
- **No lane feature** (standalone verify pass, not part of an implement lane) → fall back to highest-numbered preferred-state heuristic (existing T-ab36 behavior)

All 366 workflow + lifecycle + build_check tests still pass (2 pre-existing TicketSourceAttribution failures predate this commit). Pre-existing TUI smoke failures (13) also predate and are unrelated to the markup fix.

Forward-looking: most verify tickets in pipelined mode now land on the correct lane feature by default. Cross-feature cases (conftest changes, shared fixtures) still route correctly via T-ab26 path-walk. The "tickets scattered all over the project" symptom should disappear on the next implement run.

## Disk reconciliation for accept-verdict reviews (T-ab43)

obol-260522-1 surfaced a deeper bug than T-ab40 caught: caterpillar wrote **2 accept reviews to disk** but caterpillar's episodic memory had **ZERO accept emissions on the bus**. The disk records were valid (verdict=accept, proper approvals); the bus utterances never landed. Without bus utterances, `_find_accept_review` returned None and `_complete_tickets_on_accept_review` never fired. Result: tickets stuck IN_PROGRESS, operator manually bulk-marked 23 tickets done.

Operator's symptom: "I'm still not seeing tickets move to done if there's no caterpillar finding tickets to generate. They move to done if there are follow ups just fine."

Diagnosis (the smoking gun):
- 41 caterpillar review utterances in episodic memory
- 3 with verdict (all request-changes)
- 38 stubs (seeds re-published from disk via T-ab39)
- **0 accept verdicts on the bus, despite 2 accept reviews on disk**
- 55 tickets marked DONE total: 35 via request-changes routing, **23 manually by operator**, 0 via accept verdict

Likely paths for disk-without-bus:
1. Caterpillar's `_record_reviews` writes via registry, then the `Utterance` build raised before yielding (silent T-ab23 shape)
2. The `Utterance` landed but storage failed silently
3. Caterpillar used the `write_file` tool to drop reviews directly (the tools write-guard at tools.py:332 protects substrate-of-record DELETION but not writes to `.wonderland/reviews/`)

T-ab43 ships the pragmatic fix: **post-meeting reconciliation against disk**. After bus dispatch finds nothing, scan `ReviewRegistry.list_reviews()`, filter to:
- `verdict == ACCEPT`
- mtime ≥ meeting_started_at (records written during this meeting's window)

For each match, fire `_complete_tickets_on_accept_review` with the lane feature slug. The mtime gate prevents re-routing reviews from prior cycles. The lane-feature scoping (T-ab42 style) prevents over-eager routing to features that don't own the review.

`meeting_started_at` derives from `datetime.now(UTC) - timedelta(seconds=event.elapsed_s)` at each call site (phased + convene-one). Both call sites now pass it through.

Two new tests in `TestResolveSeeds`:
- `test_t_ab43_disk_reconciliation_routes_accept_when_bus_silent` — write accept review to disk, call dispatcher with empty bus, assert ticket → DONE
- `test_t_ab43_skips_pre_meeting_disk_reviews` — pre-meeting disk reviews stay out of reconciliation, ticket stays IN_PROGRESS

All 366 workflow + lifecycle + build_check tests pass (2 pre-existing TicketSourceAttribution unrelated).

Forward-looking: the underlying "why does caterpillar's accept emission never reach the bus" is still mysterious and worth tracking down separately (possibly tools write-guard tightening for `.wonderland/reviews/`, possibly T-ab23-style silent crash detection on the emission path). T-ab43 unblocks operator UX in the meantime — the verdict on disk IS the contract, and the substrate now honors it whether or not the bus saw it.

## Set `_current_meeting_id` on the phased meeting path (T-ab44)

T-ab35 (tool-level milestone scoping for read_file) shipped with contextvar plumbing in `_convene_one` — but tdd-design uses **phased meetings** via `run_phased_meeting`, which never had the contextvar set. T-ab35's guard read `get_current_meeting_id()`, got back empty string, and silently returned without enforcing. **T-ab35 has been non-functional for tdd-design's scoping + composition meetings since it shipped.**

Smoking gun from obol-260522-1 M5 design (run `20260523T034038`): caterpillar made 12 `read_file` calls — all into M3 + M4 stories and features, all `err=None` (T-ab35 guard didn't fire). The M5 design pass produced 0 M5 stories because agents focused on M3↔M4 coherence concerns instead of M5 story generation. M5 composition's threads referenced "Story 012" (M4), "Feature 006" (M3), "Feature 007" (M4) by number — they had clear visibility into cross-milestone artifacts they should have been blocked from reading.

The fix is mirror plumbing: at the phased branch of `_run_one_meeting_body` (workflow.py line ~7437), wrap the `async for event in run_phased_meeting(...)` block with `set_current_meeting_id(meeting.id)` before and `reset_current_meeting_id(token)` after the `yield event \n return`. Same lifecycle pattern `_convene_one` already had.

The pattern T-ab35 uses for phase detection — `meeting_id == "composition"` or `meeting_id.endswith(".composition")` — now correctly fires when the meeting's id is set. The non-phased path was always fine; only the phased path needed the plumbing.

No new tests added (existing T-ab35 tests in `test_tools.py` directly call `set_current_meeting_id("scoping")` manually, so they test the guard logic but not the plumbing layer). The fix's correctness is observable via the next M5+ design pass blocking cross-milestone reads. All 462 existing workflow + lifecycle + build_check + tools tests still pass; pre-existing TicketSourceAttribution and verify_imports failures unrelated.

Worth noting in retrospective: T-ab35 shipped at $0 substrate value for several pilot weeks because its plumbing was incomplete. The plumbing-vs-logic split mattered. A regression test that exercises the actual phased meeting path (not just the guard logic in isolation) would have caught this earlier — recurring lesson about end-to-end vs unit-level coverage.

## Scope-lock framing on scoping + composition directives (T-ab45)

T-ab35 + T-ab44 closed the tool-layer scope leak (cross-milestone reads blocked at the substrate). But on obol-260522-1 M5 composition, alice STILL reasoned cross-milestone via list_files + reviews + memory + grep — the directive layer hadn't been tightened to match. Alice's first M5 utterance: *"Before I compose M4 and M5 features I need clarity on three cross-milestone dependencies..."* — she literally treated M4+M5 as one composition surface.

Two directive changes:

**Composition convenor_directive (tdd-design.yaml:199):** new prelude block that opens with `**SCOPE LOCK — read this first.**` and names the specific drift patterns observed in obol-260522-1 (treating M4+M5 as one surface; surfacing M3↔M4 sequencing concerns mid-composition; reading sibling-milestone feature names and composing bridge features). Tells the cast to surface cross-milestone concerns as ONE single-sentence `concern` and move on — don't spend rotations debating them.

**Synthesized milestone directive (workflow.py:3640):** the auto-synthesized directive that runs when operator launches scoping with empty directive now ends with the same SCOPE LOCK block. Names the specific drift patterns explicitly ("M3↔M4 sequencing, M2 navigation, M7 Plaid auth"). Reinforces that cross-milestone coupling is handled by per-feature iteration filters downstream (T-ab17/19/20) — the milestone planner already sequenced the work, scoping/composition's job is to design THIS slice well.

Both directives now name three explicit don'ts:
1. Don't compose features for sibling milestones
2. Don't renegotiate other milestones' boundaries
3. Don't ask "should we hold this until X ships" — answer is always no

The fix is purely directive-level (no substrate changes). Pairs with T-ab35/44 (tool-layer guard) as the directive complement: substrate blocks the explicit reads; directive blocks the implicit reasoning. T-ab35 closes one vector; the directive closes the framing-level drift agents fall into even when they can't read the artifacts directly.

Pure YAML + Python text change; no schema or code-path changes. All 338 workflow tests still pass; pre-existing TicketSourceAttribution failures unrelated.

The validation is observational: next M5+ design pass should produce M5 stories (alice not blocked by "I need clarity on M3" detours) and feature composition should stay scoped to M5 sources only. Worth monitoring in M6+M7 (Plaid milestones) where cross-milestone coupling is highest and the drift pattern would be most tempting.

## Filter `list_files` to active milestone in scoping/composition (T-ab46)

T-ab35 closes `read_file` against cross-milestone artifacts. T-ab45 reinforces directive-level scope discipline. M5 design after both was unblocked enough to produce features but still emitted 0 stories in the story-generation phase. Diagnosis: even with reads blocked, `list_files` shows cross-milestone filenames. The slug encoded in `story-01KS982C-kohl-views-spending-progress-against-each-budget-category-in-the-current-month.md` IS the concept — agents that see the filename in the listing reason "stories already exist for budget tracking" and skip generating fresh material for the current milestone, regardless of whether they can actually open the files.

T-ab46 extends T-ab35's scope detection to `list_files`. New helper `_filter_cross_milestone_paths` on `WonderlandTools` applies the same conditions:

- Active milestone scope is set
- Current meeting id is `scoping` / `composition` (or `.scoping` / `.composition` suffix)
- Listed paths under `.wonderland/{stories,features,tickets}/`

For each guarded path, parse the artifact's `**Milestone:**` field (stories/features) or follow `**Sources:**` first entry → feature's milestone (tickets). Hide cross-milestone matches from the listing. Unattributable artifacts stay visible (defensive default mirrors T-ab35 and seeds_fallback's T-ab9 behavior).

The response shape differs from T-ab35 deliberately: `read_file` raises ToolError to refuse loudly; `list_files` silently hides cross-milestone entries from the listing. The reason: a refused `read_file` is one bad decision to surface to the agent ("don't try that again"); a filtered `list_files` is just "you don't need to know these exist" — the absence is the lesson.

Shared helper `_artifact_milestone_or_none` extracted so both the read-time check and the listing-time filter use the same parsing logic. No code duplication; the two call sites differ only in their response.

Three new tests in `test_tools.py`:
- `test_t_ab46_list_files_hides_cross_milestone_stories` — active-milestone story visible, sibling story hidden, legacy unattributable visible
- `test_t_ab46_list_files_unfiltered_outside_scoping_phase` — meeting is `decomposition-some-feature` → filter no-op, all visible (per-feature iteration filters handle scoping downstream)
- `test_t_ab46_list_files_unfiltered_outside_substrate_dirs` — listing on `src/` returns everything (filter targets `.wonderland/` artifact dirs only)

All 465 workflow + tools + lifecycle + build_check tests pass; pre-existing TicketSourceAttribution and verify_imports failures unrelated.

The leak-vector closure now:
- **read_file** (T-ab35/44): explicit refusal, surfaces decision to agent
- **list_files** (T-ab46): silent filtering, removes the temptation entirely
- **Directive** (T-ab45): tells agents what to do when temptation reaches them anyway
- **Memory** (T-ab8): per-milestone branching prevents recall pollution

grep / git_diff remain unguarded — those typically target source-tree code rather than substrate artifacts, so leakage there is lower-impact. Worth a T-ab47 if observed, but unlikely to be the next bottleneck.

## Validate story milestone matches active scope at write time (T-ab48)

obol-260522-1 M6 design: alice wrote 3 stories attributed to `m5-kohl-debt-paydown-tracking` during a run scoped to `m6-csv-and-ofx-import`. The content was correctly debt-tracking (M5-shaped) but alice was supposed to be designing M6 (CSV/OFX import). Substrate accepted whatever `payload.milestone` alice declared — no validation against active scope. Result: M5's story pool got polluted with stories produced during M6's design (story numbers 17-19, prefix 01KS9NKD, post-dating M5's design), and M6 design produced zero own stories.

The operator nailed the structural diagnosis upfront: *"story generation meeting is ending immediately, with one call made, which seems to me like the meeting is getting stories loaded in which is triggering the phase exit condition."* The story-emission triggers `exit_condition_artifact: story` even when the stories are misattributed.

Fix at `StoryRegistry.write`: after Pydantic validation, when `get_active_milestone_scope()` returns non-None AND `validated.milestone` is set, check the declared slug (handles `<guid>:<slug>` form) matches `scope.slug`. Mismatch raises `ValueError` with a clear message — alice's retry loop surfaces the error and she re-emits with the active milestone (or surfaces a `concern` if the content really belongs elsewhere, per the message text).

Defensive defaults: no scope set → skip validation (tests, scripts, legacy backfill paths). `milestone` field omitted → skip validation (pre-T-ab7 stories without explicit attribution; operator can backfill). Same shape as T-ab9 / T-ab35's defensive-default pattern.

Three new tests in `test_story.py`:
- `test_t_ab48_rejects_story_attributed_to_sibling_milestone` — the obol-260522-1 reproduction
- `test_t_ab48_allows_matching_milestone` — happy path with both plain slug and `<guid>:<slug>` form
- `test_t_ab48_allows_unscoped_writes` — defensive defaults (no scope, no milestone field)

All 68 existing story + alice tests pass.

Complements the broader scope-leak fix stack:
- T-ab8 (memory branching) — prevents recall pollution
- T-ab9 (seed-fallback filter) — prevents disk-seed cross-milestone leak
- T-ab34/35/44/46 (tool layer + framing scoping)
- T-ab45 (directive scope-lock)
- **T-ab48** (write-time milestone validation) — closes the last "agent declares wrong milestone" vector

The fix catches the misattribution at the source instead of downstream (where the substrate would need to walk all artifact-emission paths to filter). Operator was right: validation is the right primitive here.

## Single-agent phase succession requires ≥2 PASSED windows (T-ab49)

obol-260522-1 M6 scoping (Caucus Race) ended after a single rotation despite `max_rotations: 3` + `coverage_check: minimum_stories`. Events log told the story cleanly:

```
05:59:52.500  PhaseStarted   cast=['alice']  max_rotations=3
05:59:52.500  PriorityWindowOpened  alice  rotation=0
05:59:57.278  AgentPassed    alice
05:59:57.278  RotationCompleted
05:59:57.278  PhaseEnded     reason='succession'
```

Root cause: `PhaseState.all_passed_in_succession()` at `turns.py:168` returns True iff the last `len(cast)` windows are all PASSED. For multi-agent casts that means "everyone in the room passed — natural consensus, nothing more to add." But T-ab6's roster filter narrows capability-milestone scoping to alice solo (`cast=[alice]`), and the same logic degenerates to **"alice passed once → phase ends."**

Effect: `max_rotations=3` collapses to functionally 1 for single-agent phases. Coverage-check extensions never get a chance to fire. T-ab48's M5-attribution rejection on alice's first attempt meant she had nothing to publish on rotation 0, she passed, phase ended without giving her a retry.

Fix: bump succession floor to `max(2, len(cast))`. Single-agent phases now need 2 PASSED windows in a row before succession fires — the same "passed twice in a row = nothing more to add" semantic that multi-agent phases get naturally. Multi-agent phases unchanged.

Two-line change in `turns.py`. One new regression test (`test_t_ab49_single_agent_succession_requires_two_passes`); all 33 existing turns tests still pass.

Pairs with T-ab6 (roster filter) and T-ab48 (story write validation): T-ab6 narrows the cast to a single voice for capability scoping, T-ab48 rejects misattributed writes, T-ab49 ensures the single voice gets a real chance to retry rather than collapsing on first pass. Without T-ab49, T-ab48's good rejection becomes a wedge — alice can't recover from her own mistake within the phase's rotation budget.

## Milestone-shape classification honors explicit Kind field (T-ab50)

obol-260522-1 M6 scoping (Caucus Race) deadlocked on a contradiction inside the substrate: two pieces of milestone-shape logic disagreed on the same milestone.

- **T-ab6's roster filter** reads `Milestone.kind` directly from the milestone file. M6's `**Kind:** capability` narrowed the scoping cast to `[alice]`.
- **`_classify_milestone_shape`** ignored the explicit Kind field and ran a consumes-based heuristic instead. M6 consumes infra-shaped requirements (constraint/integration/scope), so the heuristic returned `"foundation"` → `_format_m1_lead_block` emitted "**M1 LEAD: Caterpillar.** This milestone is foundation-shape..." at the top of alice's framing.

Result: alice (the only voice in the room per T-ab6) read "I'm not the lead, Caterpillar leads" and silently passed every window. Zero emissions, zero tool calls, three rerun attempts ending the same way. The substrate was telling alice to defer to a teammate that the substrate had already excluded from the cast.

Fix: `_classify_milestone_shape` now short-circuits on the explicit Kind field. When `scope.kind` is `"capability"` or `"foundation"`, return it directly — both T-ab6 and the framing layer now read the same source of truth. The consumes-based heuristic only fires when kind is unset (back-compat for milestones without an explicit Kind line and for the tests that exercise the heuristic path).

Three-line change in `workflow.py:_classify_milestone_shape`. Five existing tests in `test_milestone_framing.py` that intentionally exercise the heuristic path were updated to construct `_MilestoneScope(kind="")` (the opt-into-heuristic sentinel); one new regression test (`test_t_ab50_explicit_kind_wins_over_heuristic`) locks in the precedence rule.

Closes the M6 scoping wedge. Pairs with T-ab6 (roster filter from explicit Kind) and T-ab48 (write-time milestone attribution) — the explicit Kind field is now the single source of truth for milestone-shape decisions across the cast filter, the framing layer, and the story-attribution validator.

## T-ab49 reverted: single-agent succession back to 1 PASSED window

T-ab49 added a `max(2, len(cast))` floor on `PhaseState.all_passed_in_succession()` to give single-agent capability phases a retry chance after T-ab48 attribution rejections. The obol-260522-1 M6 rerun (post-T-ab49, pre-T-ab50) showed the floor was the wrong fix: alice was passing twice in a row when she had nothing more to add → cost doubled, still zero stories.

T-ab50 identified and fixed the actual blocker: alice was being told via the `_format_m1_lead_block` framing that she wasn't the lead (the consumes-based heuristic disagreed with the explicit Kind field). With T-ab50 in place, the rationale for T-ab49's retry safety net evaporates — alice's first window in M6 will see correct capability-shape framing and emit normally.

Reverted `turns.py:all_passed_in_succession` back to `n = len(self.cast)`. Replaced the T-ab49 regression test with `test_single_agent_succession_fires_after_one_pass` which locks in the natural single-pass semantic and documents *why* the floor was tried + why it was wrong.

Lesson: when two pieces of substrate logic disagree, the fix is to reconcile them (T-ab50: same source of truth) rather than to patch around the symptom (T-ab49: give alice another chance). The retry primitive remains a future option if T-ab48 rejections turn out to be a real recurring failure mode — but the symptom they were patching was actually caused by the contradiction, not by rejection.

## Milestone-scope seed filter extended to story + feature artifacts (T-ab51)

obol-260522-1 M6 design surfaced cross-milestone bleed that had eluded multiple prior fix attempts (T-ab9, T-ab34, T-ab45, T-ab46). Symptom: alice silent every M6 design attempt, $0.11–0.13 per call, no output. Diagnosis traced the cost to 103K `cache_creation_input_tokens` per call (entire prompt) and the silence to the prompt content.

The smoking gun: **T-ab9's milestone-scope seed filter only fired for `requirement`-kind bindings.** M1 scoping seeds also pull `[story], consumed_by: feature` (unbound stories worth grounding new stories against). T-ab9 left those story seeds unfiltered, so alice's M6 scoping context received 8 stories from M2/M4/M5 (≈ 14 KB raw, ~20+ KB rendered) on top of the SCOPE LOCK directive forbidding cross-milestone work. Her conclusion was the only consistent one: "I see 8 sibling-milestone stories I'm told not to touch and zero M6 stories to ground against → silence." Memory accumulation across reruns (16 directive copies stacked in the per-milestone branch) amplified the bloat from 57K → 103K tokens, but the silence behavior was present from attempt 1.

Fix: extend the milestone-scope filter at `workflow.py:1668` to handle any artifact kind that carries a `milestone` payload field. Mirrors T-ab34's existing-artifacts framing logic — read `payload.milestone`, strip optional `<guid>:` prefix, compare to `scope.slug`. Story + feature artifacts (T-ab5/T-ab7 both ship `milestone: str | None`) now filter correctly through seed resolution. Artifacts without a milestone field stay visible (legacy back-compat).

Two-step structure mirrors the existing T-ab9 block: (1) drop utterances whose entire milestone-scoped artifact set is off-scope; (2) rewrite mixed utterances to strip only the off-scope artifacts. Requirements path unchanged. Net effect: M6 scoping now seeds zero sibling stories (correct), context shrinks materially (the 8 unbound stories + their rendering ~20 KB), and alice has nothing in her context telling her to defer.

Why this evaded fix attempts:
- T-ab9 sounded comprehensive ("milestone seed scope filter") but only patched the requirement axis
- T-ab34 patched the existing-artifacts framing blocks (the prepended prose) but not the seed-resolution pipeline (the utterance content); two different surfaces of the same artifact data
- T-ab45/46 patched scope-lock directive prose and tool-layer read access but not seed resolution
- Each fix made the bleed harder to observe (smaller, scoped to specific entry points) but the underlying seed-leak path remained open

Lesson: when a class of bug ("cross-milestone bleed") keeps shape-shifting through fixes, the next fix should audit all surfaces that pull from the same upstream data, not just the surface where the symptom most recently appeared. The seed-resolution pipeline is one such upstream — T-ab9 should have covered it for every kind with a milestone field, not just requirements.

Pairs with T-ab48 (write-time milestone validation on stories) and T-ab50 (`_classify_milestone_shape` honors explicit Kind field): the same "explicit milestone attribution is the single source of truth" principle now flows through write validation, scope classification, AND seed filtering.

## compose_context honors inheritance_chain (T-ab52)

T-ab8 set up per-milestone episodic-memory branches at WRITE time but `agent.py:732` called `self.memory.query_by_thread(thread_id)` without passing `branches=` at READ time. `query_by_thread`'s default is "all branches" (per its docstring: "Agent-time queries should pass an inheritance chain"). So T-ab8's per-milestone isolation was purely cosmetic — recall queries pulled from every branch.

obol-260522-1 measured 72 cross-milestone utterances bleeding into M6 design's recall via shared `thread_id='scoping'` from M1-M5's design branches (13 + 7 + 7 + 33 + 12). Each later milestone got noisier because more siblings had accumulated history on the same thread_id.

Fix: pass `branches=inheritance_chain()` to the recall query. For an active branch `design:m6-...`, inheritance is `[project, design:m6-...]` — the agent sees its own branch + project-level fallback, sibling design branches are filtered out. Required forwarding the `branches` kwarg through the `AgentMemory` facade (`memory/__init__.py`) to the underlying `EpisodicStore`.

`compose_context_honors_inheritance_chain` regression test pins the fix: utterances written under a sibling milestone branch don't appear in compose_context's rendered transcript even when they share the trigger's thread_id.

Lesson: write-side isolation primitives need symmetric read-side audits. T-ab8 was tested for write-time behavior but no test checked that compose_context actually filtered reads — the abstraction leaked silently for months.

## Implicit milestone scoping for tdd-implement (T-ab53)

The TUI's "Run Implement" button doesn't pass `--milestone`, so `_resolve_milestone_scope` returned `None` → `_derive_branch_id` returned `PROJECT_BRANCH` → every implement utterance landed on `project`. Zero `impl:*` branches existed in any pilot. obol-260522-1's project branch grew to 6.8 MB across 41 distinct tea-party threads (709 caterpillar reviews + 328 tweedles contracts + 147 cheshire cat proposals + ...) — all unscoped.

The branch mental model said "implement work lives under `impl:<slug>`" but the implementation didn't deliver. T-ab53 derives the milestone implicitly: scan QUEUED + IN_PROGRESS features at run start, read their `milestone` field (T-ab5), return the common slug iff all features share one. Multi-milestone implement runs fall back to `None` (preserves prior behavior + dodges the parallel-lane race on the module-global `_active_branch`).

Why not per-lane scoping: `_active_branch` is intentionally a module-global string (not contextvar) — see episodic.py:51-73. The earlier ContextVar implementation broke because Runner spawns agent tasks before workflow scope is set, so contextvar snapshots were stale. The global works only when all parallel work shares one milestone scope, which is the common implement-run case. Per-lane scoping would need to either serialize lanes or re-architect `_active_branch` — both are bigger lifts than this fix needs.

Common case covered (operator queues features from one milestone, hits run): implement utterances now land on `impl:<milestone-slug>`. Multi-milestone case unchanged. 5 unit tests pin the helper's behavior (common-milestone, multi-milestone, no-queued, guid-prefix normalization, legacy-no-milestone-field bailout).

Pairs with T-ab52: writes correctly scoped (T-ab53), reads correctly scoped (T-ab52). The branch mental model now holds end-to-end for the operator's typical workflow.
