ADR-006: Unified Validation Framework¶
Status¶
Accepted — in progress (Wave 5)
Context¶
The codebase carries three parallel validation subsystems, each with its own engine, its own base/contract, and its own result shape. They grew independently and do not know about each other.
Subsystem A — accelerator compatibility¶
src/lib/validation-engine.js(ValidationEngine) auto-registers four accelerator validators (cuda, neuron, cpu, rocm) viaregisterAcceleratorValidator, and exposesvalidateAcceleratorCompatibility(frameworkConfig, instanceConfig)plusvalidateEnvironmentVariables(...).src/lib/accelerator-validator.js(AcceleratorValidator) is the abstract base:validate(frameworkConfig, instanceConfig)andgetVersionMismatchMessage(required, provided).{cuda,neuron,cpu,rocm}-validator.jsare the strategies.- Result shape:
{ compatible: boolean, error?, warning?, info? }(strings).validateEnvironmentVariablesreturns{ errors[], warnings[], strategiesUsed[] }. - Instantiated only in
src/lib/registry-config-manager.js; env-var validation is triggered fromsrc/lib/template-variable-resolver.jsvia_validateEnvironmentVariables(answers, registryConfigManager). - The
neuronandrocmstrategies'parseVersion/isCompatiblelogic is byte-identical (major must match, minor must be>=);cudainlines the same logic;cpualways returns{ compatible: true }.
Subsystem B — schema/finding pipeline¶
src/lib/schema-validation-engine.js(SchemaValidationEngine) runs a static → cross-cutting → smart pipeline over aValidationContext, withregisterValidatorandcheckStaleness. Auto-registersEnumValidator,TypeValidator,RequiredFieldValidator(notCatalogValidator).src/lib/validators/base-validator.js(BaseValidator) is the contract:name,mode('static' | 'smart' | 'both'), andasync validate(context, { priorFindings, serviceModels })returning anArray<Finding>.- Result shape: an
Array<Finding>, where a Finding is{ service, operation, fieldPath, invalidValue, constraint, severity, confidence, source, remediationHint }, routed bysrc/lib/validation-report.js(ValidationReport) intoschemaErrors / crossCuttingErrors / advisoryFindings / warnings. - Driven by
do/validate(validate-runner.js),dry-run-validator.js({ passed, report, skipped }), andgeneration-validator.js({ skipped, report }).
Subsystem C — CLI config + ad-hoc checks¶
src/lib/config-validator.js(ConfigValidator) —validateConfiguration()andvalidateRequiredParameters(), each returning anArray<string>of human-readable messages.parameter-schema-validator.js→{ valid, error? }.tune-catalog-validator.js→validateModel/Technique/TrainingType→{ valid, error? }.e2e-catalog-validator.js→validateCatalog→{ valid, errors: [{ path, message }] }.e2e-quota-validator.js→validateQuotas→Array<{ instanceType, required, available, sufficient }>.
Why this is a problem¶
For an AI maintainer, "three engines, two base classes, five result shapes" is the worst case: to reason about whether a config is valid, you must know which of three unrelated pipelines answers that question, and each answers it in a different vocabulary. There is no single "run validation" entrypoint, no single "what is a finding" definition, and the near-identical neuron/rocm strategies invite drift.
Decision¶
Establish one validator contract, one finding shape, and one engine, and
express every existing check in those terms. SchemaValidationEngine (Subsystem
B) is the most general of the three — it already has a plugin registry, run
modes, prior-finding chaining, and a structured report — so it becomes the
base engine, renamed in role to the unified validation engine.
1. One Finding shape¶
The Subsystem B Finding is already a superset. It subsumes the other two:
Finding {
service, // optional; AWS service or subsystem ("accelerator", "config")
operation, // optional; grouping key for the report
fieldPath, // what was validated (env var name, parameter, instance type)
invalidValue, // the offending value (optional)
constraint, // the rule that failed (optional)
severity, // 'error' | 'warning' | 'info'
confidence, // 'definitive' | 'medium' | 'low'
source, // attribution: validator name / 'cross-cutting' / 'accelerator' / 'config'
remediationHint // the human-readable message (this is where legacy strings live)
}
- Subsystem A's
{ compatible, error }maps to a Finding withseverity: 'error',confidence: 'definitive',source: 'accelerator', and the error string inremediationHint.warning/infomap toseverity: 'warning' | 'info'.{ compatible: true }with no message produces no Finding. - Subsystem C's message strings become a Finding's
remediationHintwithseverity: 'error',confidence: 'definitive',source: 'config'.
2. One validator contract¶
BaseValidator (Subsystem B) is the contract: name, mode, and
async validate(context, options) -> Array<Finding>. Accelerator strategies and
ad-hoc validators are re-expressed as BaseValidator subclasses (or thin
functions the engine wraps), so a validator registered once runs through the one
engine and contributes Findings to the one report.
3. Adapters preserve the three legacy result shapes¶
Callers are migrated over several waves, not at once. Until every caller reads
Findings directly, adapters translate a ValidationReport (or a subset of
Findings) back into each legacy shape, so no caller breaks:
toAcceleratorResult(findings)→{ compatible, error?, warning?, info? }toEnvVarResult(findings)→{ errors[], warnings[], strategiesUsed[] }toMessageArray(findings)→Array<string>(Subsystem C'svalidateConfiguration/validateRequiredParameterscontract, asserted byconfig-manager-unit.test.js)toValidField(findings)→{ valid, error? }(parameter/tune-catalog shape)
The adapters are the only place the legacy shapes are constructed. This lets us delete duplicate engines and merge neuron/rocm without touching call sites in the same change.
4. Merge neuron + rocm; keep cuda + cpu distinct¶
The byte-identical neuron/rocm semver logic collapses into one
message-parameterized semantic-version validator (major-match, minor->=),
constructed with the accelerator label and the specific mismatch message so
that:
- neuron findings still say
ml.inf2(Inferentia guidance), and - rocm findings still say
AMD GPU.
cuda keeps its own strategy (its message and matching nuances differ), and
cpu stays the trivial always-compatible strategy. Merging is limited to the two
that are provably identical; distinct behavior is preserved distinctly.
Follow-up (post-Wave 5): the major-match/minor->= comparison rule that
cuda had inlined was extracted to src/lib/accelerator-version.js
(parseAcceleratorVersion / isMajorMinorCompatible / compatibleVersions) so
cuda and SemverAcceleratorValidator share the one copy of that logic. cuda
remains a distinct strategy — it keeps its own g5/g6 guidance message and its
major.minor (2-segment) semantics — it just no longer re-implements the compare.
Mapping table — every existing validator has a unified home¶
| Existing element | File | Legacy result shape | Unified home | Legacy shape preserved by |
|---|---|---|---|---|
ValidationEngine.validateAcceleratorCompatibility |
validation-engine.js |
{ compatible, error?, warning?, info? } |
engine call over accelerator validators → Findings | toAcceleratorResult |
ValidationEngine.validateEnvironmentVariables |
validation-engine.js |
{ errors[], warnings[], strategiesUsed[] } |
EnvVarValidator (known-flags + community-reports strategies) → Findings |
toEnvVarResult |
CudaValidator |
cuda-validator.js |
{ compatible, ... } |
CudaAcceleratorValidator (distinct strategy) |
toAcceleratorResult |
NeuronValidator |
neuron-validator.js |
{ compatible, ... } |
SemverAcceleratorValidator({ label:'neuron', mismatchMessage:…ml.inf2… }) |
toAcceleratorResult |
RocmValidator |
rocm-validator.js |
{ compatible, ... } |
SemverAcceleratorValidator({ label:'rocm', mismatchMessage:…AMD GPU… }) |
toAcceleratorResult |
CpuValidator |
cpu-validator.js |
{ compatible: true } |
CpuAcceleratorValidator (trivial strategy) |
toAcceleratorResult |
EnumValidator |
validators/enum-validator.js |
Array<Finding> |
already unified (registered validator) | native |
TypeValidator |
validators/type-validator.js |
Array<Finding> |
already unified | native |
RequiredFieldValidator |
validators/required-field-validator.js |
Array<Finding> |
already unified | native |
CatalogValidator |
validators/catalog-validator.js |
Array<Finding> |
already unified (opt-in registration) | native |
CrossCuttingChecker |
cross-cutting-checker.js |
Array (source cross-cutting) |
engine's cross-cutting phase → Findings | native |
ConfigValidator.validateConfiguration |
config-validator.js |
Array<string> |
config validators → Findings | toMessageArray |
ConfigValidator.validateRequiredParameters |
config-validator.js |
Array<string> |
required-field over CLI config → Findings | toMessageArray |
parameter-schema-validator.js |
same | { valid, error? } |
ParameterSchemaValidator → Findings |
toValidField |
tune-catalog-validator.js |
same | { valid, error? } |
TuneCatalogValidator → Findings |
toValidField |
e2e-catalog-validator.js |
same | { valid, errors:[{path,message}] } |
E2ECatalogValidator → Findings |
toValidField (+ path-list variant) |
e2e-quota-validator.js |
same | Array<{instanceType,required,available,sufficient}> |
QuotaValidator → Findings (quota fields in constraint/invalidValue) |
dedicated quota adapter |
SchemaValidationEngine |
schema-validation-engine.js |
ValidationReport |
becomes the unified engine | native |
ValidationEngine (orchestrator) |
validation-engine.js |
mixed | retained as the accelerator/instance-matching host; its results are normalised to Findings and rebuilt via adapters (see Implementation note) | toAcceleratorResult / toEnvVarResult |
AcceleratorValidator (base) |
accelerator-validator.js |
— | retained as the accelerator-strategy contract (parent of the semver/cuda/cpu strategies) | — |
ValidationReport |
validation-report.js |
report buckets | unchanged (the one report type) | native |
No capability is dropped: every row lands either as a native registered validator or behind an adapter that reproduces the exact legacy shape its callers and tests assert.
Consequences¶
- Positive: one engine, one
BaseValidatorcontract, oneFinding/ValidationReportvocabulary. An AI maintainer asking "is this config valid?" has a single entrypoint and a single result type. The neuron/rocm duplication is gone. Every ad-hoc validator is discoverable as "a validator registered on the engine." - Cost: the accelerator and ad-hoc validators are rewritten as
BaseValidatorsubclasses; adapters are added to keep call sites and their tests green while migration proceeds across Wave 5 tasks. - Risk: medium. Behavior must be preserved exactly through the adapters — covered by keeping the existing accelerator, config-manager, parameter-schema, catalog, e2e, generation, and validate-runner tests green, plus a new test proving neuron and rocm keep their distinct messages after the merge.
- Sequencing: T2 builds the framework + adapters (existing tests pass via adapters); T3 migrates accelerator validators and merges neuron/rocm; T4 makes the unified vocabulary real at the seams (see Implementation note).
Implementation note (Wave 5, option A)¶
The original T4 plan called for physically absorbing ValidationEngine into
SchemaValidationEngine and rehosting every ad-hoc validator on the unified
engine. On implementation this was scoped down deliberately, because two of those
moves would have added translation layers with no benefit (the anti-pattern
ADR-005 warns against):
ValidationEngineis a different concern, not a redundant engine. It does accelerator/instance matching over{ frameworkConfig, instanceConfig }, not schema/payload validation over aValidationContext. Forcing it throughSchemaValidationEngine.validate(context)would require synthesising a fake context/payload. Instead it is retained as the accelerator-matching host, andRegistryConfigManagernormalises its results into Findings and rebuilds the caller shapes throughvalidation-adapters.js— so the subsystem speaks the unified vocabulary at its boundary without a lossy merge.ConfigValidatoris deeply coupled toConfigManager(parameterMatrix,deploymentConfigResolver,schemaValidator, auto-prompt state). It already conforms to the unified contract throughtoMessageArray; rehosting it on the schema engine would gut a working, well-tested subsystem for churn.AcceleratorValidatoris retained as the accelerator-strategy contract (the parent ofSemverAcceleratorValidator,CudaValidator,CpuValidator); retiring it would only mean re-parenting for its own sake.
What Wave 5 did deliver is the substance of the unification: one Finding
shape, one ValidationReport, one BaseValidator contract, one adapter boundary
(validation-adapters.js) as the sole place legacy shapes are constructed, and
the neuron/rocm duplication collapsed. Physically collapsing the accelerator
engine and the CLI-config validator into the schema engine is filed as a backlog
item rather than forced here.
References¶
docs/architecture/validation.md— how the unified subsystem works todaysrc/lib/schema-validation-engine.js— the base (unified) enginesrc/lib/validators/base-validator.js— the one validator contractsrc/lib/validation-report.js— the one report / finding vocabulary- ADR-002 — the consolidation program