# Track: Any-Type Componentization (Promote `dict[str, Any]` to `dataclass(frozen=True)`)

**Status:** Active (spec approved 2026-06-21)
**Initialized:** 2026-06-21
**Owner:** Tier 2 Tech Lead
**Priority:** Medium (developer + AI-readability; not a regression blocker)

---

## 1. Overview

The `data_structure_strengthening_20260606` track established the `TypeAlias` convention: 10 aliases + 1 `NamedTuple` in `src/type_aliases.py`, replacing 416 of 528 weak-type sites (79% reduction) across 6 high-traffic files. The aliases are **renames** — they point to the same underlying `dict[str, Any]` / `list[dict[str, Any]]` shapes. The alias names document intent; they do not add type safety.

A follow-on audit (`docs/reports/ANY_TYPE_AUDIT_20260621.md`, committed 2026-06-21) identified **5 fat-struct candidates** that warrant promotion to `dataclass(frozen=True)` definitions, following the `src/vendor_capabilities.py` pattern (`frozen=True` dataclass + module-level registry + factory function). This track is the implementation of the audit's recommendations.

**The 5 candidates (89 of the 300 `Any` usages, ~30%):**

| Rank | Target | Sites | Value |
|---|---|---:|---|
| P1 | `src/mcp_client.py: MCP_TOOL_SPECS` (45 tools) | 8 | HIGH — 180 implicit fields become explicit |
| P1 | `src/openai_compatible.py: NormalizedResponse + OpenAICompatibleRequest` | 17 | HIGH — well-documented OpenAI schema |
| P2 | `src/ai_client.py: 7× ProviderHistory + 7 locks` | 41 | HIGH — 14 module globals → 1 dict |
| P2 | `src/log_registry.py: Session metadata` | 7 | MEDIUM — 2 levels of structural anonymity |
| P3 | `src/api_hooks.py: WebSocketMessage + JsonValue` | 16 | LOW — generic serialization |

**The audit's 5-pattern taxonomy (`ANY_TYPE_AUDIT_20260621.md` §2.2):** only Pattern 1 (JSON-shaped payloads) and Pattern 2 (per-provider message lists) are componentization candidates. Patterns 3 (SDK holders), 4 (`__getattr__`), 5 (generic serialization) stay as `Any` — see §10.

**Scope is deliberately bounded.** The track promotes the 5 fat-struct candidates to `dataclass(frozen=True)`. It does NOT migrate all 300 `Any` usages; it does NOT convert `TypeAlias` definitions to `TypedDict`; it does NOT introduce Pydantic. The audit's recommended boundary is honored.

**Sequencing (revised 2026-06-21 per user direction).** The audit's §5.2 originally proposed gating this track behind `code_path_audit_20260607`. **This gate is removed.** The two tracks are orthogonal:
- `code_path_audit` measures RUNTIME cost per call (CPU/memory)
- `any_type_componentization` measures SEMANTIC clarity (AI-readability)

Neither depends on the other. The code_path_audit's report can retroactively flag which any-type candidates it found in hot paths as a side benefit. Both tracks can run in parallel.

## 2. Goals (Priority Order)

| Priority | Goal | Rationale |
|---|---|---|
| **A (primary)** | Convert the 5 fat-struct candidates (89 sites) to `dataclass(frozen=True)` definitions following `src/vendor_capabilities.py` template | The audit identified these as the high-value subset; aliases alone don't add type safety |
| **A (primary)** | New `scripts/audit_dataclass_coverage.py` with `--strict` mode | The CI gate that prevents regression of dataclass promotion work |
| **B (architectural)** | New `JsonValue` recursive `TypeAlias` (in `src/type_aliases.py`) for the JSON wire format | Phase 5 (api_hooks) needs it; reusable for future JSON-boundary tracks |
| **B (architectural)** | New styleguide §12 "When to Promote `TypeAlias` to `dataclass`" section | Captures the rule that future contributors can apply without re-deriving |
| **C (documentation)** | Update `docs/type_registry/` registry entries for the 3 new modules + modified files | The type-registry generator picks them up automatically; `--check` mode validates |
| **D (forward-looking)** | Note the cross-phase coupling opportunity (Phase 2's `OpenAICompatibleRequest.tools` could consume Phase 1's `ToolSpec`) as a follow-up track — NOT in this track | Cross-phase coupling is a future concern; this track ships each phase independently |

### 2.1 Non-Goals (this track)

- **NOT** converting all 300 `Any` usages. Only the 5 fat-struct candidates.
- **NOT** converting SDK client holders (Pattern 3). They stay as `Any` — heterogeneous SDK types.
- **NOT** changing the `__getattr__` dynamic-dispatch pattern (Pattern 4). It stays as `Any` — intentional.
- **NOT** typing the generic serialization functions (Pattern 5). They stay as `Any` — input-driven.
- **NOT** converting `dict[str, Any]` to `TypedDict` (per `data_structure_strengthening_20260606` §10, deferred to a separate decision).
- **NOT** introducing Pydantic (would be a much larger architectural decision).
- **NOT** changing function signatures at the runtime level (dataclasses are serialization-compatible via `from_dict()`/`to_dict()` helpers).
- **NOT** waiting for `code_path_audit_20260607` (per the §1 sequencing revision).

## 3. Architecture

### 3.1 The Reference Pattern: `src/vendor_capabilities.py`

`src/vendor_capabilities.py` is the **canonical "module-level abstraction layer"** (76 lines):

```python
@dataclass(frozen=True)
class VendorCapabilities:
 vendor: str
 model: str
 vision: bool = False
 tool_calling: bool = True
 caching: bool = False
 # ... 22 named fields total

_REGISTRY: dict[tuple[str, str], VendorCapabilities] = {}

def register(cap: VendorCapabilities) -> None: ...
def get_capabilities(vendor: str, model: str) -> VendorCapabilities: ...
```

**Properties that make this pattern successful:**

| Property | Why it matters |
|---|---|
| `frozen=True` | Immutable; thread-safe; no accidental mutation |
| Named fields | Every field is addressable by name (no `dict['vision']` lookups) |
| Module-level registry | O(1) lookup; no instantiation overhead |
| Wildcard `*` model | Fallback for unregistered models |
| Flat (no nesting) | Single cache-line access for most queries |
| Registration pattern | Extensible without modifying existing code |

All 5 fat-struct candidates follow this template.

### 3.2 The Conversion API: `from_dict` / `to_dict`

For each new dataclass, the convention is:

```python
@classmethod
def from_dict(cls, data: Metadata) -> Result[Self, ErrorInfo]:
 """Parse a dict into the dataclass. Returns Result for graceful failure."""

def to_dict(self) -> Metadata:
 """Serialize the dataclass back to a dict (for logging, JSON wire)."""
```

The `Result[Self, ErrorInfo]` return type follows the data-oriented convention from `data_oriented_error_handling_20260606` (see `conductor/code_styleguides/error_handling.md`). Conversion failures (missing required field, type mismatch, malformed JSON) return `ErrorInfo` instead of raising.

### 3.3 The `JsonValue` Recursive Type

Phase 5 (`api_hooks.py`) needs a type for arbitrary JSON-shaped data. Python 3.12+ has `type` statement; earlier versions need a `TypeAlias`:

```python
# src/type_aliases.py (extension)
JsonPrimitive: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
```

This makes `_serialize_for_api(obj: Any) -> JsonValue` and `broadcast(message: WebSocketMessage)` (with `payload: JsonValue`) explicit.

### 3.4 Module Layout

```
src/
  type_aliases.py                  # MODIFIED: add JsonPrimitive + JsonValue TypeAliases
  vendor_capabilities.py           # UNCHANGED: the reference pattern (no edits)
  mcp_tool_specs.py                # NEW: ToolParameter + ToolSpec dataclasses + registry
  openai_schemas.py                # NEW: ToolCall + ToolCallFunction + ChatMessage + UsageStats
  provider_state.py                # NEW: ProviderHistory dataclass + _PROVIDER_HISTORIES dict
  mcp_client.py                    # MODIFIED: MCP_TOOL_SPECS -> list[ToolSpec]; update dispatch
  openai_compatible.py             # MODIFIED: NormalizedResponse + OpenAICompatibleRequest use ChatMessage/UsageStats/ToolSpec
  ai_client.py                     # MODIFIED: replace 14 globals with _PROVIDER_HISTORIES dict; update _send_grok/_send_minimax/_send_llama
  log_registry.py                  # MODIFIED: add Session + SessionMetadata dataclasses
  session_logger.py                # MODIFIED: use Session dataclass
  log_pruner.py                    # MODIFIED: use Session dataclass
  gui_2.py                         # MODIFIED: Log Management panel uses Session
  api_hooks.py                     # MODIFIED: add WebSocketMessage dataclass; _serialize_for_api -> JsonValue

scripts/
  audit_dataclass_coverage.py     # NEW: counts anonymous dict[str, Any] per module; --strict mode
  audit_dataclass_coverage.baseline.json  # NEW: baseline count post-track
  audit_weak_types.py              # UNCHANGED (still gates the alias convention)
  generate_type_registry.py        # UNCHANGED (registry generator; auto-includes new modules)

conductor/
  code_styleguides/
    type_aliases.md                # MODIFIED: add §12 "When to Promote TypeAlias to dataclass"

tests/
  test_mcp_tool_specs.py           # NEW
  test_openai_schemas.py           # NEW
  test_provider_state.py           # NEW
  test_log_registry_dataclasses.py # NEW (or extend existing)
  test_api_hooks_dataclasses.py    # NEW (or extend existing)
  test_audit_dataclass_coverage.py # NEW
  (existing test files):           # MODIFIED: update call sites; existing tests should pass unchanged

docs/
  type_registry/                   # AUTO-GENERATED: new modules appear automatically
    mcp_tool_specs.md              # NEW (generated)
    openai_schemas.md              # NEW (generated)
    provider_state.md              # NEW (generated)
    api_hooks.md                   # NEW (generated; replaces existing 16-Any-flavored entry)
    log_registry.md                # NEW (generated)
    src_ai_client.md               # MODIFIED (generated; ProviderHistory changes shape)
    src_openai_compatible.md       # MODIFIED (generated; NormalizedResponse changes shape)
    src_mcp_client.md              # MODIFIED (generated; MCP_TOOL_SPECS changes shape)

docs/reports/
  TRACK_COMPLETION_any_type_componentization_20260621.md  # NEW (end-of-track)
```

### 3.5 Coexistence with the Type-Alias Convention

The new dataclasses **complement** the `TypeAlias` convention (not replace it):

- **`TypeAlias`** = rename a shape that's still a dict at runtime (cheap; 0 structural cost)
- **`dataclass(frozen=True)`** = give the shape fields + methods + invariants (expensive; changes runtime type)

The decision tree (now in styleguide §12):

```
Is the shape open-ended (extra keys allowed, no invariants)?  ──► TypeAlias (Metadata)
Is the shape a closed set of named fields with specific types? ──► dataclass(frozen=True)
Is the shape a JSON wire format (recursive)?                   ──► JsonValue (TypeAlias)
```

The 5 fat-struct candidates are closed sets of named fields. The 112 remaining `dict[str, Any]` sites in the audit's 27 lower-impact files are mostly open-ended (provider payloads, config dicts) and stay as `TypeAlias` (or even raw `dict[str, Any]`) until a future track identifies them as closed-shape candidates.

## 4. Per-Phase Plan

### Phase 0: Shared scaffolding (1 task; ~3 commits)

- **WHERE:** `src/type_aliases.py`, `scripts/audit_dataclass_coverage.py`, `conductor/code_styleguides/type_aliases.md`
- **WHAT:** Add `JsonPrimitive` + `JsonValue` TypeAliases; new audit script that counts anonymous `dict[str, Any]` per module with `--strict` mode (CI gate); styleguide §12
- **HOW:** Use the existing `audit_weak_types.py` script as the template for the new audit; follow `audit_weak_types.py:130-160` for the `--strict` mode pattern
- **SAFETY:** No behavior change; type aliases + new audit script are additive
- **TESTS:** `tests/test_audit_dataclass_coverage.py` (6+ tests; mirror `tests/test_audit_weak_types.py`)
- **VERIFICATION:** `uv run python scripts/audit_dataclass_coverage.py --strict` exits 0 (baseline == current)
- **COMMIT:** `feat(scaffold): JsonValue TypeAlias + dataclass-coverage audit + styleguide §12`

### Phase 1: `src/mcp_tool_specs.py` (P1, 8 sites)

**Current state** (`src/mcp_client.py:1944-2747`):
```python
MCP_TOOL_SPECS: list[dict[str, Any]] = [
 { "name": "py_remove_def", "description": "...", "parameters": {...} },
 # ... 44 more dicts of identical shape
]
TOOL_NAMES: set[str] = {t['name'] for t in MCP_TOOL_SPECS}  # line 2747
```

**Refactor target:**
```python
# src/mcp_tool_specs.py (NEW; ~120 lines)
@dataclass(frozen=True)
class ToolParameter:
 name: str
 type: str # "string" | "integer" | "boolean" | "object" | "array"
 description: str
 required: bool = False
 enum: Optional[list[str]] = None

@dataclass(frozen=True)
class ToolSpec:
 name: str
 description: str
 parameters: tuple[ToolParameter, ...]
 category: str = "file"

_REGISTRY: dict[str, ToolSpec] = {}

def register(spec: ToolSpec) -> None: ...
def get_tool_spec(name: str) -> ToolSpec: ...
def get_tool_schemas() -> list[ToolSpec]: ...
def tool_names() -> set[str]: ...
```

**Call sites to update:**
- `src/mcp_client.py:1944` `native_names = {t['name'] for t in MCP_TOOL_SPECS}` → `mcp_tool_specs.tool_names()`
- `src/mcp_client.py:1958` `res = list(MCP_TOOL_SPECS)` → `res = mcp_tool_specs.get_tool_schemas()`
- `src/mcp_client.py:1972` `MCP_TOOL_SPECS: list[dict[str, Any]] = [...]` → moved to `mcp_tool_specs.py:_REGISTRY`
- `src/mcp_client.py:2747` `TOOL_NAMES: set[str] = {t['name'] for t in MCP_TOOL_SPECS}` → `mcp_tool_specs.tool_names()`
- `src/ai_client.py:560,582,1012` `mcp_client.TOOL_NAMES` → `mcp_tool_specs.tool_names()` (3 sites)
- `src/app_controller.py:2103,2962,3263` `models.AGENT_TOOL_NAMES` (cross-check; not directly `TOOL_NAMES`)

**Compatibility shim:** keep `mcp_client.MCP_TOOL_SPECS` and `mcp_client.TOOL_NAMES` as thin re-exports for the duration of this phase, then remove in a follow-up commit if no external test breaks. Alternative: deprecate immediately and fix the 3 callers.

**Tests:** `tests/test_mcp_tool_specs.py` (8+ tests)
- Verify all 45 tools are registered
- Verify `get_tool_spec("py_remove_def")` returns correct spec
- Verify `tool_names()` matches expected set
- Verify `from_dict()` returns `Result` for valid + invalid inputs
- Verify `TOOL_NAMES` is a subset of `models.AGENT_TOOL_NAMES` (cross-module invariant)

### Phase 2: `src/openai_schemas.py` (P1, 17 sites)

**Current state** (`src/openai_compatible.py:10-30`):
```python
@dataclass(frozen=True)
class NormalizedResponse:
 text: str
 tool_calls: list[dict[str, Any]] # FAT: JSON tool call shape
 usage_input_tokens: int
 usage_output_tokens: int
 usage_cache_read_tokens: int
 usage_cache_creation_tokens: int
 raw_response: Any # FAT: SDK-specific response (Pattern 3, stay)

@dataclass
class OpenAICompatibleRequest:
 messages: list[dict[str, Any]] # FAT: message shape
 model: str
 ...
 tools: Optional[list[dict[str, Any]]] = None # FAT: tool schema (cross-phase: Phase 1)
 extra_body: Optional[dict[str, Any]] = None # FAT: arbitrary params
```

**Refactor target:**
```python
# src/openai_schemas.py (NEW; ~150 lines)
@dataclass(frozen=True)
class ToolCall:
 id: str
 type: str = "function"
 function: "ToolCallFunction"

@dataclass(frozen=True)
class ToolCallFunction:
 name: str
 arguments: str # JSON string

@dataclass(frozen=True)
class ChatMessage:
 role: str # "system" | "user" | "assistant" | "tool"
 content: str
 tool_calls: Optional[tuple[ToolCall, ...]] = None
 tool_call_id: Optional[str] = None
 name: Optional[str] = None

@dataclass(frozen=True)
class UsageStats:
 input_tokens: int
 output_tokens: int
 cache_read_tokens: int = 0
 cache_creation_tokens: int = 0

# NormalizedResponse becomes:
@dataclass(frozen=True)
class NormalizedResponse:
 text: str
 tool_calls: tuple[ToolCall, ...]
 usage: UsageStats # was 4 separate fields
 raw_response: Any # Unavoidable: SDK-specific

# OpenAICompatibleRequest becomes:
@dataclass
class OpenAICompatibleRequest:
 messages: list[ChatMessage]
 model: str
 temperature: float = 0.0
 top_p: float = 1.0
 max_tokens: int = 8192
 tools: Optional[list[dict[str, Any]]] = None # Cross-phase: Phase 1's ToolSpec (deferred)
 tool_choice: str = "auto"
 stream: bool = False
 stream_callback: Optional[Callable[[str], None]] = None
 extra_body: Optional[dict[str, Any]] = None
```

**Cross-phase coupling (deferred):** `OpenAICompatibleRequest.tools: Optional[list[ToolSpec]]` would reuse Phase 1's `ToolSpec`. This is a follow-up track concern; Phase 2 ships with `list[dict[str, Any]]` for that field with a `# TODO(future-track): migrate to list[ToolSpec]` note.

**Call sites to update:**
- `src/openai_compatible.py` itself (~5 internal functions consuming `NormalizedResponse`)
- `src/ai_client.py` `_send_grok()`, `_send_minimax()`, `_send_llama()` (~3 functions; they construct `NormalizedResponse` and `OpenAICompatibleRequest`)
- `src/api_hook_client.py` (the API hook payloads may serialize these; cross-check)

**Tests:** `tests/test_openai_schemas.py` (10+ tests)
- Verify `ChatMessage.from_dict()` round-trip for all 4 roles
- Verify `UsageStats` field access
- Verify `ToolCall.function.arguments` JSON parsing
- Verify `Result[Self, ErrorInfo]` error cases (missing required field, malformed JSON)
- Verify `NormalizedResponse.raw_response` is still `Any` (Pattern 3)

### Phase 3: `src/provider_state.py` (P2, 41 sites)

**Current state** (`src/ai_client.py:111-133`):
```python
_anthropic_history: list[Metadata] = []
_anthropic_history_lock: threading.Lock = threading.Lock()
_deepseek_history: list[Metadata] = []
_deepseek_history_lock: threading.Lock = threading.Lock()
# ... 7 providers × 2 vars = 14 module globals
```

Plus the SDK client holders (Pattern 3, stay):
```python
_gemini_chat: Any = None
_deepseek_client: Any = None
# ... 7 SDK clients stay as-is
```

**Refactor target:**
```python
# src/provider_state.py (NEW; ~80 lines)
@dataclass
class ProviderHistory:
 messages: list[Metadata] = field(default_factory=list)
 lock: threading.Lock = field(default_factory=threading.Lock)

 def append(self, message: Metadata) -> None: ...
 def get_all(self) -> list[Metadata]: ...
 def replace_all(self, messages: list[Metadata]) -> None: ...
 def clear(self) -> None: ...

_PROVIDER_HISTORIES: dict[str, ProviderHistory] = {
 "anthropic": ProviderHistory(),
 "deepseek": ProviderHistory(),
 "minimax": ProviderHistory(),
 "qwen": ProviderHistory(),
 "grok": ProviderHistory(),
 "llama": ProviderHistory(),
}

def get_history(provider: str) -> ProviderHistory:
 return _PROVIDER_HISTORIES[provider]
```

**Call sites to update** (`src/ai_client.py`):
- Lines 463-466: `global _anthropic_history` declarations (4 declarations across `cleanup()` and similar) → removed
- Lines 483-499: 7 `with _<provider>_history_lock:` blocks in `cleanup()` → `get_history("<provider>").clear()`
- Lines 1447, 1457-1460, 1469, 1471, 1475, 1489, 1503, 1506, 1582: ~20 `_anthropic_history` references → `get_history("anthropic").messages` and `.append()`
- Lines 2201-2202, 2221-2222, 2353, 2360, 2418-2420: ~10 `_deepseek_history` references → `get_history("deepseek")`
- Lines 2575-2588, 2605: ~10 `_grok_history` references → `get_history("grok")`
- Lines 2659-2685: ~10 `_minimax_history` references → `get_history("minimax")`
- Lines 2812-2823: ~8 `_qwen_history` references → `get_history("qwen")`
- Lines 2901-2925: ~8 `_llama_history` references → `get_history("llama")`
- The `_repair_<provider>_history()` and `_trim_<provider>_history()` helpers (lines 1353, 1381, 2138, 2462, 2482) take `history: list[Metadata]` parameters — they stay as-is; call sites pass `get_history("<provider>").messages`

**Tests:** `tests/test_provider_state.py` (10+ tests)
- Verify `ProviderHistory.append()` is thread-safe (lock semantics)
- Verify `ProviderHistory.clear()` resets the list atomically
- Verify `get_history("anthropic")` returns the same instance across calls (singleton)
- Verify `replace_all()` swaps the list under lock
- Verify `cleanup()` clears all 6 histories
- Verify SDK client holders (`_gemini_chat`, etc.) are NOT touched (Pattern 3 preserved)

**Risk:** This phase has the largest ripple. The 41 sites include 14 module globals (renames are mechanical) + ~27 call-site updates. The audit may undercount if helper functions in `ai_client.py` reference these globals beyond the listed lines. **Mitigation:** Phase 3 has its own audit baseline snapshot before starting; any new finds get added to the phase's task list.

### Phase 4: `src/log_registry.py: Session` (P2, 7 sites)

**Current state** (`src/log_registry.py:58`):
```python
self.data: dict[str, dict[str, Any]] = {} # session_id -> session content
```

The outer key is `session_id: str`. The inner dict has implicit fields: `path`, `start_time`, `whitelisted`, `metadata`.

**Refactor target** (inline in `src/log_registry.py`):
```python
@dataclass(frozen=True)
class SessionMetadata:
 message_count: int = 0
 errors: int = 0
 size_kb: int = 0
 whitelisted: bool = False
 reason: str = ''
 timestamp: Optional[str] = None

@dataclass(frozen=True)
class Session:
 session_id: str
 path: str
 start_time: str # ISO format
 whitelisted: bool = False
 metadata: Optional[SessionMetadata] = None

@dataclass
class LogRegistry:
 registry_path: str
 data: dict[str, Session] = field(default_factory=dict) # typed!
```

**Call sites to update:**
- `src/log_registry.py` `get_old_non_whitelisted_sessions()` and 6 other internal methods
- `src/session_logger.py` `open_session()`, `close_session()`
- `src/log_pruner.py` `prune_old_logs()`
- `src/gui_2.py` Log Management panel (find via `grep "log_registry"` or "session_log")

**Tests:** `tests/test_log_registry_dataclasses.py` (or extend existing)
- Verify `Session.from_dict()` round-trip
- Verify `Session.metadata` is `Optional[SessionMetadata]`
- Verify `LogRegistry.data: dict[str, Session]` (no longer `dict[str, dict[str, Any]]`)
- Verify `prune_old_logs()` works on the new schema

### Phase 5: `src/api_hooks.py: WebSocketMessage + JsonValue` (P3, 16 sites)

**Current state** (`src/api_hooks.py:48-145`):
```python
def _get_app_attr(app: Any, name: str, default: Any = None) -> Any: ...
def _set_app_attr(app: Any, name: str, value: Any) -> None: ...
def _serialize_for_api(obj: Any) -> Any: ...
def broadcast(self, channel: str, payload: dict[str, Any]) -> None: ...
```

The `_get_app_attr` / `_set_app_attr` are Pattern 4 (stay as `Any`).
The `_serialize_for_api` and `broadcast` are the JSON wire format.

**Refactor target** (inline in `src/api_hooks.py`):
```python
from src.type_aliases import JsonValue

@dataclass(frozen=True)
class WebSocketMessage:
 channel: str
 payload: JsonValue

def _serialize_for_api(obj: Any) -> JsonValue: ...

def broadcast(self, message: WebSocketMessage) -> None: ...
```

**Call sites to update:** `broadcast()` callers (~5-10 sites across `src/app_controller.py`, `src/gui_2.py`)

**Tests:** extend `tests/test_api_hooks.py`
- Verify `WebSocketMessage` is `frozen=True` (cannot mutate)
- Verify `JsonValue` round-trip via `_serialize_for_api`
- Verify `_get_app_attr` / `_set_app_attr` signatures are unchanged (Pattern 4 preserved)

### Phase 6: Verification + docs + archive

- Run full audit: `audit_weak_types.py --strict` exits 0; `audit_dataclass_coverage.py --strict` exits 0
- Run full regression suite: 11-tier batched (per `test_sandbox_hardening_20260619` convention)
- Regenerate `docs/type_registry/` via `scripts/generate_type_registry.py`
- Verify `--check` mode passes
- Write end-of-track report at `docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md`
- Move `conductor/tracks/any_type_componentization_20260621/` → `conductor/tracks/archive/`
- Update `conductor/tracks.md`

## 5. The Audit Script as a Permanent CI Gate

The new `scripts/audit_dataclass_coverage.py` mirrors `audit_weak_types.py`'s design:

**Modes:**
- Default: informational (exits 0; prints report)
- `--json`: machine-readable
- `--strict`: CI gate (exits 1 if current anonymous `dict[str, Any]` count > baseline)
- `--baseline`: path to baseline file (default: `scripts/audit_dataclass_coverage.baseline.json`)

**What it counts:** sites where the structural anonymity persists (the 89 this track targets). Aliases that point to `dict[str, Any]` (e.g., `Metadata`, `CommsLogEntry`) are NOT counted; the audit counts actual `dict[str, Any]` / `list[dict[...]]` annotations and the remaining `Any` usages outside the 5 candidates.

**Baseline:** committed at `scripts/audit_dataclass_coverage.baseline.json` post-Phase-6. Expected: 211 `Any` sites remain (300 - 89 = 211). The audit's 5-pattern taxonomy justifies the boundary.

## 6. Configuration

No new dependencies. No new environment variables. No new config files.

The new dataclasses use stdlib `dataclasses.dataclass(frozen=True)` (Python 3.11+).

## 7. Testing Strategy

| Test File | Purpose | Coverage Target |
|---|---|---|
| `tests/test_audit_dataclass_coverage.py` | Verify the audit script's patterns + `--strict` mode + baseline | 90% |
| `tests/test_mcp_tool_specs.py` | Verify 45 tools registered + dispatch + cross-module invariants | 100% |
| `tests/test_openai_schemas.py` | Verify ChatMessage/UsageStats/ToolCall round-trips + Result[T] errors | 100% |
| `tests/test_provider_state.py` | Verify ProviderHistory thread safety + cleanup + singleton semantics | 100% |
| `tests/test_log_registry_dataclasses.py` | Verify Session dataclass + LogRegistry typed | 100% |
| `tests/test_api_hooks.py` (extended) | Verify WebSocketMessage + JsonValue round-trip | 100% |
| `tests/test_ai_client.py` (existing) | No regressions after 41-site Phase 3 refactor | 100% (regression) |
| `tests/test_mcp_client.py` (existing) | No regressions after Phase 1 dispatch refactor | 100% (regression) |
| `tests/test_openai_compatible.py` (existing) | No regressions after Phase 2 refactor | 100% (regression) |
| `tests/test_log_registry.py` (existing) | No regressions after Phase 4 | 100% (regression) |
| `tests/test_api_hooks.py` (existing) | No regressions after Phase 5 | 100% (regression) |

**Mocking strategy:** Per the project's structural testing contract (`docs/guide_testing.md`), Tier 3 workers do NOT use `unittest.mock.patch` for core infrastructure. The new tests use the real dataclasses with synthetic `Metadata` inputs.

**Audit baseline check:** Post-Phase-6, `audit_dataclass_coverage.py` should report ≤ baseline count. The dataclass-coverage baseline is expected to be 211 (300 `Any` minus the 89 candidates promoted in this track).

## 8. Migration / Rollout

| Phase | What | Risk | Commits |
|---|---|---|---|
| **0 — Scaffolding** | Add `JsonValue`, new audit, styleguide §12 | Low (additive only) | ~3 |
| **1 — `mcp_tool_specs`** | P1 (8 sites) | Medium (45 tools × ~4 params) | ~10 |
| **2 — `openai_schemas`** | P1 (17 sites) | Medium (cross-module: ai_client consumers) | ~10 |
| **3 — `provider_state`** | P2 (41 sites) | **Medium-High** (14 globals + ~27 call sites) | ~15 |
| **4 — `log_registry` Session** | P2 (7 sites) | Low (self-contained file) | ~5 |
| **5 — `api_hooks` WebSocketMessage** | P3 (16 sites) | Low (Pattern 5 preserved) | ~5 |
| **6 — Verify + archive** | Audit + tests + docs | Low | ~2 |
| **Total** | | | **~50 atomic commits** |

Each phase has its own checkpoint commit and git note (per `conductor/workflow.md` Task Workflow §9-10).

## 9. Risks & Mitigations

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Phase 3 (`provider_state`) has more call sites than the audit identified. | Medium | Medium | Snapshot an audit baseline before Phase 3; any new finds get added to the phase's task list. Worst case: Phase 3 grows to ~20 commits (still tractable). |
| Phase 1 (`mcp_tool_specs`) dispatch map (`_dispatch_table`) has dead-code that the typed refactor surfaces. | Medium | Low | The dataclass + registry pattern naturally surfaces dead code. Add a "dead code removal" task to Phase 1 if discovered. |
| The `JsonValue` recursive type fails to type-check in Python 3.11. | Low | Low | Use `TypeAlias` with forward-reference (`"JsonValue"`) in `list` and `dict`; tested in Phase 0. |
| A consumer of `mcp_client.TOOL_NAMES` lives outside `src/` (e.g., `tests/`, `conductor/`) and breaks. | Medium | Low | Compatibility shim (re-export) for 1 commit; remove in follow-up. |
| `frozen=True` dataclasses break code that mutates dict fields. | Medium | Medium | Audit each candidate for mutation patterns before phase; convert mutators to `replace()` (returns new instance) per `dataclasses.replace()`. |
| The new audit script's `--strict` mode is too strict (rejects valid uses). | Low | Medium | Set baseline conservatively (post-Phase-6 actual count); tighten only after 1 week of clean CI. |
| Cross-phase coupling (Phase 2's `tools: list[ToolSpec]`) creates merge conflict with Phase 1. | Low | Low | Explicitly deferred; Phase 2 ships with `list[dict[str, Any]]` + TODO comment. |
| The 5 candidates leave 211 `Any` sites untouched; users expect more. | Low | Low | Document in §10 explicitly; the audit's 5-pattern taxonomy justifies the boundary. |

## 10. Out of Scope (Explicit)

- **The remaining 211 `Any` usages** (300 - 89 = 211). The audit's 5-pattern taxonomy identifies these as Patterns 3/4/5 (SDK holders, dynamic dispatch, generic serialization) — they stay as `Any` because they're intentionally flexible. A future track may identify additional fat-struct candidates; this track does not.
- **TypedDict migration** of any alias. Per `data_structure_strengthening_20260606` §10, deferred.
- **Pydantic models.** Not requested; would be a much larger architectural decision.
- **The `JsonValue` recursive type as a runtime validator** (e.g., `jsonschema` validation). The TypeAlias is a type hint, not a runtime guard.
- **Conversion of the `TypeAlias` definitions themselves to `dataclass` (e.g., making `Metadata: TypeAlias = dict[str, Any]` a `class Metadata(dict)`).** The aliases document intent; converting them is a separate decision.
- **Cross-phase coupling** between Phase 1 and Phase 2 (Phase 2's `OpenAICompatibleRequest.tools: list[ToolSpec]`). Deferred to a follow-up track.
- **Wait for `code_path_audit_20260607` to ship.** Per the §1 sequencing revision, the two tracks are orthogonal.
- **Modifying the audit scripts** (`audit_weak_types.py`, `audit_dataclass_coverage.py`) beyond the new `--strict` mode in Phase 0. Future extensions are separate tracks.

## 11. Decisions Made During Spec Authoring

The following design choices were resolved during spec drafting (formerly "Open Questions"):

1. **`ToolSpec.parameters: tuple[ToolParameter, ...]` (RESOLVED)** — Tuple wins. Immutable matches `frozen=True` philosophy; serialization uses explicit `to_dict()` helper. `list[ToolParameter]` would force runtime conversion at every JSON boundary.
2. **`ProviderHistory.clear()` reuses the lock (RESOLVED)** — The lock protects the list, not the lock instance. `default_factory=threading.Lock` in the dataclass field ensures every `ProviderHistory` gets its own lock on construction; `clear()` does NOT reset the lock.
3. **`Session.metadata: Optional[SessionMetadata] = None` (RESOLVED)** — `Optional` with default None wins. Matches existing call patterns in `session_logger.py` where sessions may exist without metadata populated yet.
4. **`JsonValue` lives in `src/type_aliases.py` (RESOLVED)** — Existing file is the canonical location for TypeAliases. New file would split the convention across 2 modules.
5. **No compatibility shim in Phase 1 (RESOLVED)** — Phase 1's 3 call sites in `ai_client.py` are updated immediately. The shim would add a commit of pure re-exports that gets removed in the next commit anyway.

## 12. See Also

### 12.1 Project References

- `docs/reports/ANY_TYPE_AUDIT_20260621.md` — the audit that drove this track (the input artifact)
- `conductor/tracks/data_structure_strengthening_20260606/` — the parent track (the 10 TypeAliases + 1 NamedTuple; this track builds on it)
- `src/vendor_capabilities.py` — the reference pattern (`frozen=True` dataclass + module-level registry + factory)
- `src/type_aliases.py` — the TypeAlias module (extended in Phase 0 with `JsonValue`)
- `scripts/audit_weak_types.py` — the audit script template (`scripts/audit_dataclass_coverage.py` mirrors its design)
- `conductor/code_styleguides/type_aliases.md` — the canonical styleguide (Phase 0 adds §12)
- `conductor/code_styleguides/error_handling.md` — the `Result[T]` convention (used by `from_dict()`)
- `docs/guide_testing.md` — the test infrastructure (live_gui fixture, structural testing contract)
- `docs/reports/TRACK_COMPLETION_data_structure_strengthening_20260606.md` — the parent track's end-of-track report
- `conductor/tracks/code_path_audit_20260607/` — the parallel runtime-cost track (NOT a blocker)

### 12.2 External References

- **Python `dataclasses.dataclass(frozen=True)`** — the canonical pattern for immutable named records (PEP 681 for `dataclass_transform`; Python 3.11+ stdlib).
- **Mike Acton's data-oriented design** — the "data is the API" framing that motivates named fields over dict access.
- **Casey Muratori on module layer boundaries** — the convention that each module owns its data and exposes a clear interface.
- **Ryan Fleury's "errors are just cases"** — the `Result[T]` convention adopted by this track for `from_dict()` return types.

### 12.3 Follow-up Track (planned; NOT in this track)

- **`any_type_componentization_phase2_2026MMDD`** (placeholder): the 211 remaining `Any` sites not in the 5 candidates. Identified by the audit's Pattern 3/4/5 analysis; may yield additional fat-struct candidates as future tracks touch those code areas.
- **`openai_tools_dataclass_bridge_2026MMDD`** (placeholder): the cross-phase coupling opportunity (Phase 2's `OpenAICompatibleRequest.tools: list[ToolSpec]`).
- **`type_registry_ci_20260606`** (planned in `data_structure_strengthening_20260606` §12.1): wires `generate_type_registry.py --check` into CI. This track ships the new modules; the CI gate is a separate concern.

## 13. Verification Criteria (Definition of Done)

- [ ] `src/mcp_tool_specs.py` exists with `ToolParameter` + `ToolSpec` + registry
- [ ] `src/openai_schemas.py` exists with `ToolCall` + `ChatMessage` + `UsageStats`
- [ ] `src/provider_state.py` exists with `ProviderHistory` + `_PROVIDER_HISTORIES` dict
- [ ] `src/log_registry.py` has `Session` + `SessionMetadata` dataclasses
- [ ] `src/api_hooks.py` has `WebSocketMessage` + `JsonValue` TypeAlias usage
- [ ] `src/type_aliases.py` extended with `JsonPrimitive` + `JsonValue`
- [ ] `scripts/audit_dataclass_coverage.py` exists with `--strict` mode
- [ ] `scripts/audit_dataclass_coverage.baseline.json` committed
- [ ] `conductor/code_styleguides/type_aliases.md` has §12 "When to Promote" section
- [ ] 6 new test files exist with 48+ tests (Phase 0 audit: 6, Phase 1: 8, Phase 2: 10, Phase 3: 10, Phase 4: 8, Phase 5: 6)
- [ ] All existing tests pass (no regressions in 11-tier batched run)
- [ ] `audit_weak_types.py --strict` exits 0
- [ ] `audit_dataclass_coverage.py --strict` exits 0
- [ ] `generate_type_registry.py --check` exits 0 (5 new .md files appear)
- [ ] `docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md` written
- [ ] Track archived; `conductor/tracks.md` updated