# p0150b: ObservationParser + ProjectAnalyzer + ConvergenceCheckHandler decomposition

- [Architecture] One service per responsibility per parent class. `IObservationNormalizer` owns the per-element rules (confidence migration 1-10 → 0-100, per-field truncation, category-drift suppression). `IProjectMapJsonReader` owns the LLM-JSON → `ProjectMap` mapping. `IConvergenceEvaluator` owns the structured LLM call + result parsing for convergence analysis. Each new service has a single verb in its name — no "and"-named services.
- [Architecture] Composition, not inheritance. `ObservationParser` becomes an orchestrator over `ITolerantJsonParser` + `IObservationNormalizer` + the existing `ObservationRecoveryHelper`. `ProjectAnalyzer` becomes a thin LLM-driver over `IProjectMapJsonReader`. `ConvergenceCheckHandler` keeps only pipeline-state gating + round bookkeeping; everything LLM-shaped lives in `IConvergenceEvaluator`.
- [Architecture] `IObservationNormalizer` is declared in the Application namespace, not Contracts. Reason: the normalisation rules log per-(role, field) deduped warnings via `ILogger`, and the Contracts assembly is a no-`ILogger` zone (only the AI abstractions, sandbox wire DTOs, and domain models live there). Moving the interface to Application keeps Contracts pure without losing the seam. The spec text named Contracts; the constraint is real and outranks the path hint.
- [Architecture] `IProjectMapJsonReader` (LLM-JSON → `ProjectMap`) was the chosen split for `ProjectAnalyzer`, not the spec's named `ILanguageDetector + IFrameworkDetector`. Reason: the actual `ProjectAnalyzer` does not detect anything from the filesystem — it drives an LLM that emits `primary_language` + `frameworks` as JSON fields. Splitting along the imagined detector boundary would require new filesystem-regex code that the [no-language-specific-parsers](../../.claude/agents/feedback_no_language_specific_parsers.md) principle forbids. The honest split is "drive the LLM" vs "decode the LLM's JSON".
- [Architecture] `IConvergenceEvaluator` returns a non-null `ConvergenceResult` on parse failure (synthesised as non-consensus over the input observations), rather than returning null and leaving the handler to recover. Reason: the handler shouldn't know what "parse failed" looks like — that's the evaluator's failure mode to absorb. Encapsulation of LLM-side concerns is the whole point of the extraction.
- [Architecture] `IConvergenceEvaluator` takes an `Action<ChatResponse> costSink` callback rather than a reference to `PipelineCostTracker`. Reason: keeps the contracts assembly free of pipeline-execution types; cost metering is the handler's concern, which it threads in via the callback after `GetOrCreate`. The evaluator just reports "here's a response, charge it somewhere".
- [Implementation] `RawObservationMapper` static helper deleted; its rules moved verbatim into the new `ObservationNormalizer` service. `RawObservation` (the deserialisation DTO) grew a `ToFields()` mapper to produce the `RawObservationFields` record the normalizer accepts. No behaviour change.
- [Implementation] DI registrations live in their feature-set extension files: `IObservationNormalizer` in `TolerantJsonParserExtensions` (sits next to `ObservationParser`); `IProjectMapJsonReader` in `PipelineHandlersExtensions` (sits next to `IProjectAnalyzer`); `IConvergenceEvaluator` in `SkillRunHandlersExtensions` (sits next to `ConvergenceCheckHandler`). All three are Transient — stateless services with injected dependencies, default lifetime per the codebase convention. No composition-root edits needed.
- [TradeOff] `ObservationParser` lands at 105 lines and `ConvergenceCheckHandler` at 117 lines — under the 120-line hard cap but above the 80-line target the spec sets. Further extraction was attempted (`CheckPreconditions` / `DecideAsync` split for the handler) but each extra method-boundary cost more lines than it saved due to signature ceremony with `out` parameters and async wrappers. The Single-Responsibility win is real; the additional 25-line cut would require fragmenting a single coherent algorithm and was rejected. ProjectAnalyzer cleared 70 lines.
- [TradeOff] Phase-history `// p0146c:` etc. comments were stripped from the three touched files plus the new services. None remained on those paths after the rewrite — grep audit returns zero hits across the seven touched/new code files.
