# p0147a: ITolerantJsonParser consolidation

- [Architecture] Single `ITolerantJsonParser` shared across all LLM-JSON consumers — no base class. Composition over inheritance: forcing every handler to inherit a parsing trait it shouldn't care about is the wrong shape. Service injected via DI.
- [Architecture] `TolerantParseResult { Document, Diagnostics }` returns a recovery list rather than throwing. Callers decide whether partial recovery is acceptable for their context — FilterRoundHandler may treat a truncated array as "what we got is what we use"; GeneratePlanHandler may fail the run. The parser does not make that judgement.
- [Architecture] Empty-string-vs-omitted-field unification is exposed as `GetStringOrNull(JsonElement, params string[])` on the parser, not done per-handler. Removes the copy-paste defence pattern that lived in `GateObservationParser.NullIfEmpty` and would have spread to every handler that consumes LLM-emitted optional fields.
- [Architecture] Recovery shapes named via `TolerantRecoveryKind` enum: `None`, `FencesStripped`, `JsonExtracted`, `ResilientFallback`, `Failed`. Counter labels live off this enum so operators can see which LLM is misbehaving most without invasive logging.
- [Architecture] `ITolerantParseTelemetry` is the seam for emitting per-recovery counters; default registration is a no-op singleton so the parser is usable with no extra wiring. Hosting layers (Cli/Server) replace with a counter-emitting impl after `AddAgentSmithCommands`.
- [Implementation] Parser is registered Transient (stateless); `ITolerantParseTelemetry` is registered Singleton via `TryAddSingleton` so hosting code can override. The five formerly-static LLM-output parsers (`ObservationParser`, `GateObservationParser`, `PlanParser`, `ConsolidationResponseParser`, `ConvergenceResultParser`, `WikiUpdateParser`) became instance + Transient and inject the parser.
- [Implementation] `ResilientJsonObjectExtractor` and the standalone `JsonExtractor` (formerly in `Services/Handlers/`) are deleted; their brace-counting and prose-extraction logic now lives inside `TolerantJsonParser` (with `TolerantJsonObjectScanner` extracted for the 120-line class cap). Existing `ResilientJsonObjectExtractorTests` retired — coverage of the same behaviour moved into `TolerantJsonParserTests.ExtractArrayObjects_*`.
- [TradeOff] DI registration lives in a new `Application/Services/TolerantJsonParserExtensions.cs` next to the parser service itself. Considered hanging it off `LoopRuntimeExtensions` (LoopRuntime owns SkillCallRuntime which feeds the JSON consumers) but a feature-set extensions file colocated with the service it registers is easier to find. Composition root in `Application/Extensions/ServiceCollectionExtensions.cs` calls `AddTolerantJsonParser()` between configuration validation and pipeline handlers — handlers depend on it.
- [TradeOff] `ObservationOutputValidator` was Singleton; flipped to Transient because it now depends on Transient `ObservationParser`. `SkillOutputValidatorFactory` is still Singleton — it captures the Transient validator at construction. Stateless parsers + factory caching = no observable behaviour change, just lifecycle correctness.
- [Implementation] `ObservationParser` exceeded the 120-line cap when made instance, so the SkillObservation construction + truncation + category-drift logic moved into a new `RawObservationMapper` static helper. Both files now sit under the cap. No behaviour change vs the prior single-file static parser.
- [Implementation] `GateOutputHandler` lost its direct `JsonDocument.Parse(JsonExtractor.Extract(...))` calls — both verdict and finding-list responses route through `tolerantParser.ParseObject(...)`. Tests preserve identical surface (invalid-JSON returns Fail with "invalid JSON" message etc.). Grep audit confirms zero direct `JsonSerializer.Deserialize` calls remain in `Services/Handlers/` for LLM-output paths.
