Skip to content

Commit 0c858fa

Browse files
authored
Phase 4 Wave 3: DI-resolved validator path + ISettingsCollection exposure (VAL-01 DI path, API-02) (#35)
* docs: refresh session handoff (Phase 4 Waves 1-2 + comment #3 merged; Wave 3 next) * feat: expose ISettingsCollection from AddSimpleSettings (API-02/D-15) Register the built ISettingsCollection as a DI singleton and add an AddSimpleSettings(out ISettingsCollection, Action?) overload that surfaces the same instance while preserving the IServiceCollection fluent chain. * feat: DI-resolved settings validator path (VAL-01 DI path) Add a deferred, opt-in ISettingsValidationRunner resolved via IServiceProvider.ValidateSimpleSettings(). It resolves DI-registered ISettingValidation<T> from a fresh scope (so scoped-dependency validators work), dispatches through the ISettingsValidator cast (the default-interface bridge, no reflection), and aggregates via the shared SettingsValidationException.ThrowIfAny so the thrown contract is identical to the core populate path. A throwing validator surfaces value-free as SettingsValidatorInvocationException (type-only, no bound value, no inner). * refactor: address Wave 3 review findings - ValidateSimpleSettings surfaces a caller-facing error when AddSimpleSettings was not called (internal runner type no longer leaks into the message). - Trim runner comments to one line each; drop internal planning-ID reference. - Harden tests: deferral test is now an invocation-counter timing probe (0 runs at build, 1 on the explicit call); scope test asserts the scoped validator actually executed; redaction test pins that the secret is bound; add no-op (no DI validators) and misuse coverage. * docs(planning): Phase 4 Wave 3 (04-04) summary, review, plan patch, state Record the VAL-01 DI path + API-02 execution: 04-04-SUMMARY.md, the gsd-code-reviewer 04-04-REVIEW.md, STATE.md (all 5 Phase 4 plans landed; verify+secure pending), and patch the plan's superseded reflective-dispatch text to the shipped default-interface-bridge dispatch.
1 parent 1388c5c commit 0c858fa

10 files changed

Lines changed: 713 additions & 64 deletions

File tree

.planning/STATE.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ milestone_name: milestone
55
current_phase: 04
66
current_phase_name: collection-validation-binding
77
status: executing
8-
stopped_at: Completed 04-05-PLAN.md (VAL-02) — Phase 4 all 5 plans done
9-
last_updated: "2026-07-15T13:58:04.988Z"
8+
stopped_at: Completed 04-04-PLAN.md (VAL-01 DI path + API-02, Wave 3) — all 5 Phase 4 plans landed; phase verify + secure + mark-complete pending
9+
last_updated: "2026-07-15T18:00:00.000Z"
1010
last_activity: 2026-07-15
11-
last_activity_desc: Phase 04 execution started
11+
last_activity_desc: Phase 04 Plan 04 (Wave 3) executed + reviewed
1212
progress:
1313
total_phases: 6
1414
completed_phases: 2
1515
total_plans: 9
16-
completed_plans: 8
16+
completed_plans: 9
1717
percent: 33
1818
---
1919

@@ -28,10 +28,10 @@ See: .planning/PROJECT.md (updated 2026-07-13)
2828

2929
## Current Position
3030

31-
Phase: 04 (collection-validation-binding) — EXECUTING
32-
Plan: 5 of 5
33-
Status: Ready to execute
34-
Last activity: 2026-07-15 — Phase 04 execution started
31+
Phase: 04 (collection-validation-binding) — EXECUTING (all 5 plans landed; verify + secure + mark-complete pending)
32+
Plan: 5 of 5 — complete
33+
Status: Wave 3 (04-04) executed + reviewed; ready for phase verify
34+
Last activity: 2026-07-15 — Phase 04 Plan 04 (Wave 3) executed + reviewed
3535

3636
Progress: [███░░░░░░░] 33%
3737

@@ -64,6 +64,7 @@ Progress: [███░░░░░░░] 33%
6464
| Phase 04 P02 | 12min | 2 tasks | 4 files |
6565
| Phase 04 P03 | 5min | 2 tasks | 10 files |
6666
| Phase 04 P05 | 3min | 1 tasks | 2 files |
67+
| Phase 04 P04 | ~35min | 2 tasks | 5 files |
6768

6869
## Accumulated Context
6970

@@ -89,6 +90,7 @@ Recent decisions affecting current work:
8990
- [Phase ?]: HasValidators short-circuit on the cached plan gates the validation hook before any allocation (protects the B-2 benchmark allocation gate)
9091
- [Phase 04]: 04-05 VAL-02: reuse value-free SettingsPropertyNullException for empty/whitespace rejection — already excluded from the ValuesPopulator:122 redaction filter, so no filter change
9192
- [Phase 04]: 04-05 VAL-02: reject guard placed ahead of 04-01's Func<object> list null-result factory dispatch and gated on _throwOnNull; accept path and factory dispatch untouched
93+
- [Phase 04]: 04-04 VAL-01 DI path + API-02: ISettingsCollection exposed via a DI singleton + an AddSimpleSettings(out ISettingsCollection, Action?) overload; deferred opt-in IServiceProvider.ValidateSimpleSettings() runs DI-registered ISettingValidation<T> from a fresh scope (IServiceScopeFactory), dispatches via the DIM bridge (no reflection), and throws the same value-free SettingsValidationException as the core path via the shared ThrowIfAny. Runner is internal; DI path is additive (reads no attribute).
9294

9395
### Pending Todos
9496

.planning/phases/04-collection-validation-binding/04-04-PLAN.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ must_haves:
2828
- "IntegrateSimpleSettings returns the built ISettingsCollection so both the DI-singleton registration and the out-overload surface the same instance."
2929
- "The DI runner reuses ONLY public types (ISettingValidation<T>, ValidationContext<T>, ValidationResult, ValidationError, SettingsValidationException) — no Core InternalsVisibleTo needed."
3030
- "The DI runner resolves ISettingValidation<T> from `using var scope = provider.CreateScope()` (scope.ServiceProvider), NOT the root provider (review S-1)."
31-
- "Reflective validator dispatch selects the overload via GetMethod(\"Validate\", new[]{ closedContextType }) and builds ValidationContext<T> via MakeGenericType (avoids AmbiguousMatchException — review S-2)."
31+
- "SUPERSEDED (architect+security review): validator dispatch uses the ISettingValidation<T> default-interface bridge — cast to ISettingsValidator and call Validate(new ValidationContext(instance)); NO reflection over Validate and no generic ValidationContext<T> construction. Mirrors the shipped core ValuesPopulator.InvokeValidator path. GetServices still uses MakeGenericType(ISettingValidation<>) to resolve the closed validator type."
3232
- "The deferred runner aggregates errors and throws through the SAME Core helper SettingsValidationException.ThrowIfAny(errors) as Plan 03's core path, so the exception is contract-identical (D-10/D-12 / review S-3)."
3333
---
3434

@@ -177,12 +177,14 @@ New symbols introduced by THIS plan (exclude from drift/orphan checks):
177177
and resolve all registered validators for that type via
178178
`scope.ServiceProvider.GetServices(typeof(ISettingValidation<>).MakeGenericType(type))`.
179179

180-
REFLECTIVE DISPATCH (review S-2, architect A3): because the settings type is a runtime `Type`, both the
181-
context construction and the `Validate` call are reflective. `ISettingValidation<T> : ISettingsValidator`
182-
declares TWO `Validate` overloads, so select the method explicitly with
183-
`closedValidationType.GetMethod("Validate", new[] { closedContextType })` — NEVER the parameterless
184-
`GetMethod("Validate")`, which throws AmbiguousMatchException. Build the context with
185-
`Activator.CreateInstance(typeof(ValidationContext<>).MakeGenericType(type), instance)`.
180+
DISPATCH — DIM BRIDGE, NOT REFLECTION (architect + security review; SUPERSEDES the earlier S-2 text
181+
below): resolve the validators for the runtime `Type` via
182+
`scope.ServiceProvider.GetServices(typeof(ISettingValidation<>).MakeGenericType(type))`, then dispatch each
183+
through the default-interface bridge — `((ISettingsValidator)validator).Validate(new ValidationContext(instance))`.
184+
`ISettingValidation<T>` default-implements the base `Validate`, so there is NO reflection over the `Validate`
185+
methods (`GetMethod`/`MethodInfo.Invoke`) and NO generic `ValidationContext<T>` construction. This mirrors the
186+
shipped core `ValuesPopulator.InvokeValidator` path exactly and structurally avoids the
187+
`TargetInvocationException` inner-chaining leak vector.
186188

187189
Collect every `ValidationError` across all resolved validators and finish by calling the SHARED Core helper
188190
`SettingsValidationException.ThrowIfAny(allErrors)` (review S-3 / D-10) — the SAME helper Plan 03's core path
@@ -202,7 +204,7 @@ New symbols introduced by THIS plan (exclude from drift/orphan checks):
202204
- `ISettingsValidationRunner` + `SettingsValidationRunner` exist in GenericHost; `ValidateSimpleSettings(this IServiceProvider)` is public.
203205
- A failing DI-registered ISettingValidation<T> throws SettingsValidationException only when ValidateSimpleSettings() is called (not during AddSimpleSettings).
204206
- S-1: the runner resolves validators from `provider.CreateScope().ServiceProvider` (a fresh scope), and a validator with a SCOPED injected dependency runs successfully under `BuildServiceProvider(validateScopes: true)`.
205-
- S-2: reflective dispatch selects the overload via `GetMethod("Validate", new[]{ closedContextType })` (no AmbiguousMatchException) and builds `ValidationContext<T>` via `MakeGenericType`.
207+
- S-2 (SUPERSEDED): dispatch is the `((ISettingsValidator)validator).Validate(new ValidationContext(instance))` default-interface bridge — no reflection over `Validate`, no `MakeGenericType` on the context.
206208
- S-3: the runner aggregates and throws via `SettingsValidationException.ThrowIfAny(errors)` (the shared Core helper from Plan 03), so the DI-path exception type + Errors shape match the core-path contract; no bound value is present in ToString().
207209
- The runner uses only public validation types (no new InternalsVisibleTo to Core).
208210
- AddSimpleSettingsIntegrationTests green on net10; dotnet-architect + security-auditor sign-off recorded.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
phase: 04-collection-validation-binding (Wave 3)
3+
reviewed: 2026-07-15T00:00:00Z
4+
depth: deep
5+
diff_range: 4bae833..HEAD
6+
files_reviewed: 4
7+
files_reviewed_list:
8+
- src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/ISettingsValidationRunner.cs
9+
- src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/ServiceProviderValidationExtensions.cs
10+
- src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/ServicesSettingsBuilderExtensions.cs
11+
- src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/SettingsValidationRunner.cs
12+
findings:
13+
blocker: 0
14+
high: 0
15+
medium: 0
16+
low: 3
17+
total: 3
18+
status: issues_found
19+
---
20+
21+
# Phase 04 Wave 3: Code Review Report
22+
23+
**Depth:** deep (cross-file: verified DI runner against core `ValuesPopulator.InvokeValidator`, `SettingsValidationException.ThrowIfAny`, `ISettingValidation<T>` default-interface bridge, `SettingsValidatorInvocationException`)
24+
**Status:** issues_found (LOW only — nothing material)
25+
26+
## Summary
27+
28+
The DI-resolved validator path is a faithful mirror of the core populate path. All security-sensitive
29+
invariants hold:
30+
31+
- **Redaction (LOCKED):** on a validator throw the runner rethrows value-free as
32+
`new SettingsValidatorInvocationException(validator.GetType(), e.GetType())` — no chained inner, no
33+
settings value. The bound `pair.Value` never reaches any library exception's message/ToString. Verified
34+
against the dedicated redaction test (`ThrowingDiValidator` embeds a sentinel secret; the surfaced
35+
exception is asserted clean).
36+
- **No reflection:** dispatch is the `((ISettingsValidator)validator).Validate(new ValidationContext(pair.Value))`
37+
cast; the `ISettingValidation<T>` default-interface bridge forwards to the author overload. Cast is
38+
provably safe (services resolved for `ISettingValidation<pair.Key>` are `ISettingsValidator`).
39+
- **Non-null aggregate:** `errors` is eagerly `new List<ValidationError>()`, so `ThrowIfAny(errors)` is
40+
never handed null. Empty list → no-op, matching core.
41+
- **Fresh scope:** `IServiceScopeFactory.CreateScope()` with `using`; disposed even when `ThrowIfAny`
42+
throws. Singleton runner injecting singleton `ISettingsCollection` + `IServiceScopeFactory` — no captive
43+
dependency, no async/lifetime issue.
44+
- **out-overload + registration:** `AddSimpleSettings(out ISettingsCollection, Action?)` returns the built
45+
collection and `services`; no overload ambiguity with the existing 2-arg `Action` overload (differs by
46+
the `out` parameter). `ISettingsCollection` + `ISettingsValidationRunner` registered as singletons.
47+
48+
No BLOCKER / HIGH / MEDIUM findings. Three LOW items below.
49+
50+
## Low
51+
52+
### LOW-1: Internal planning-ID reference ("See S1") in a comment — reintroduces a prior review finding
53+
54+
**File:** `src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/SettingsValidationRunner.cs:41`
55+
**Issue:** The catch-block comment ends with `See S1.` — an internal planning-ID reference. This is
56+
exactly the class of comment a prior review flagged (LOW-1) and the phase convention explicitly prohibits.
57+
(The core files carry the same references, but this diff adds a new occurrence.)
58+
**Fix:** Drop the planning-ID tail:
59+
```csharp
60+
// A validator threw: surface value-free (the inner may embed a secret it read) — only the
61+
// validator and failure types, never the instance and never a chained inner.
62+
```
63+
64+
### LOW-2: Multi-line rationale comments exceed the org "one line max" guideline
65+
66+
**File:** `src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/SettingsValidationRunner.cs:19-20, 34-35, 40-42, 53-54`
67+
**Issue:** Four two-line comments. Borderline: this matches the established heavily-commented style of the
68+
core files (`ValuesPopulator`, the exception family), so it is arguably an accepted project convention that
69+
overrides the org default. Noted for consistency only; not actionable if the project style is intentional.
70+
**Fix:** Optionally collapse each to a single line, or leave as-is to match core style.
71+
72+
### LOW-3: Repeated `AddSimpleSettings` calls silently validate only the last collection
73+
74+
**File:** `src/Core/ExistForAll.SimpleSettings.Extensions.GenericHost/ServicesSettingsBuilderExtensions.cs:51-53`
75+
**Issue:** Each call adds another `ISettingsCollection` and `ISettingsValidationRunner` singleton. DI
76+
last-wins, so a second `AddSimpleSettings` (different assembly set) means the runner's injected
77+
`ISettingsCollection` is the last one — the first collection's settings are never checked by
78+
`ValidateSimpleSettings()`. Pre-existing last-wins pattern (same as `ISettingsProvider`), and multi-call is
79+
likely unsupported, so this is an edge-case gap, not a regression.
80+
**Fix:** If multi-call is out of scope, no change. If it should be supported, have the runner enumerate all
81+
registered `ISettingsCollection` instances (inject `IEnumerable<ISettingsCollection>`).
82+
83+
## Non-findings verified (parity preserved, intentionally not flagged)
84+
85+
- A validator returning `null` `ValidationResult` would NRE at `result.Errors` (outside the try). This is
86+
**identical** to core `InvokeValidator` (`ValuesPopulator.cs:117`); fixing only the DI side would break
87+
the "identical contract" guarantee. Left as-is intentionally.
88+
- `provider.GetServices(...)` activation failures propagate raw (outside the try). Not a library exception
89+
and cannot carry a bound settings value — no redaction concern.
90+
- Author-supplied `ValidationError.ErrorMessage` may reach `SettingsValidationException`'s message — by
91+
design (author text, not a bound value); matches core.
92+
- `SettingsValidatorInvocationException` uses `validator.GetType()` (runtime) vs core's `validatorType`
93+
(declared) — per the approved constraint; both surface the concrete validator type. Not a divergence.
94+
95+
---
96+
97+
_Reviewer: Claude (adversarial code review)_
98+
_Depth: deep_

0 commit comments

Comments
 (0)