feat(shared): Validation Engine Redesign with articulated assumptions#2805
Conversation
rocketstack-matt
left a comment
There was a problem hiding this comment.
Solid refactor — the phased ValidationRule/ValidationEngine design is clean, and the original self-reference heap-OOM (flagged on #2778) is genuinely fixed with a regression test I confirmed passes. But I found and reproduced a real correctness bug in the caching/cycle-tracking, not one of the open questions this PR calls out — see inline comments.
| async resolve(reference: string): Promise<unknown> { | ||
| if (this.cache.has(reference)) { | ||
| return this.cache.get(reference); | ||
| } | ||
| this.seen.add(reference); | ||
| const document = await this.delegate.resolve(reference); | ||
| this.cache.set(reference, document); | ||
| return document; | ||
| } |
There was a problem hiding this comment.
resolve() only gates re-fetching via cache.has(), not seen. Since a failed delegate.resolve() never populates cache, a later resolve() call for the same reference retries the delegate — contradicting this class's own docstring ("a failed load still counts as seen ... not retried") and the PR description's claim of the same. The test titled "marks a reference as seen even when the load fails (no retry for siblings)" doesn't actually call resolve() a second time, so it never verifies that claim.
For a real (e.g. HTTP-backed) resolver, a broken reference reachable from many nodes gets fetched repeatedly instead of once. Suggest tracking failures explicitly (e.g. cache the rejection, or check seen before retrying) so a second resolve() on a known-bad reference short-circuits instead of re-attempting the delegate.
There was a problem hiding this comment.
Reproduced. Added a failing test caching-tracking-resolver.spec.ts -> "does not retry the delegate for a reference whose first load failed": a second resolve() on a failed reference attempts the delegate again (asserted once, got twice). Left red for now; see my top-level note on the intended semantics before I fix it.
|
|
||
| if (!detailedArchitecture || !archUrl) return emptyNodeResult(); | ||
|
|
||
| if (references.has(archUrl)) { |
There was a problem hiding this comment.
This conflates "already resolved" with "cycle." I reproduced this: two sibling nodes referencing the same non-existent detailed-architecture URL produce a node-details-validation error for the first node only — the second node's identical (failed) reference is silently skipped as emptyNodeResult() (logged only at debug as "Cycle detected"), even though it isn't a cycle at all, just an unrelated node sharing a broken reference. A real, reportable validation error goes completely missing for every occurrence after the first.
This has the same root cause as the caching-tracking-resolver.ts comment — references.has() doesn't distinguish "previously succeeded" (safe to dedupe silently) from "previously failed" (should still surface an error at this node's path).
There was a problem hiding this comment.
Reproduced. Added a failing test validate-node-details.spec.ts -> "reports a load error for every node sharing the same broken detailed-architecture reference": two sibling nodes pointing at the same broken URL yield a single error (asserted 2, got 1), the second being skipped as "Cycle detected". Left red for now; see my top-level note on the intended semantics before I fix it.
…d node-details bugs
|
Thanks @rocketstack-matt. Both issues are real and I have reproduced each with a failing test on the branch (left red for now, before we agree the fix). Replies in-thread on both comments above. As you noted, both share one root cause: Before I implement, I would like to agree the intended semantics so the fix matches what we want rather than just what makes the tests green:
Happy to go with your steer; the tests are in place to lock in whichever behaviour we choose. |
|
@LeighFinegold my take on these questions:
This is just my 2c from reading the comment. If any of these answers will result in a significant amount of work before we can even consider this mergeable, we should do them as a follow up. The main thing is that this PR doesn't introduce potential infinite loops/process hangs, etc. |
|
Thanks @willosborne, that matches where I landed. Summary of the response: Point 1 (report at every node): fixed in this PR. A Points 2 and 3: raised as separate issues so this PR stays mergeable:
On the size of the change: yes, it is a large refactor, but it deliberately keeps the existing Both previously-failing tests are now green as tweaked assertions |
Addressing validation of detailed architectures and control configurations as a phased extension of the existing validate() flow, superseding PR finos#2778 with a cycle-safe, more consistent design. - Add path-scoped ModelWalker for cycle-safe model traversal and reuse a threaded visitedUrls set through both architecture-with-pattern and architecture-only dispatch branches to prevent infinite recursion. - Add validateNodeDetails: recursively validates each details.detailed-architecture, resolving its pattern via required-pattern or $schema and forking a cache-seeded SchemaDirectory for AJV isolation. - Add validateAllControls using an iterateControls generator plus direct schema config loading, resolving URL and #-pointer requirement schemas. - Add SchemaDirectory.loadDocument and cache-seeded fork(). - Extract shared helpers: tryParseCalmCore parse guard and ValidationOutput.error/.warning static factories to remove positional boilerplate. - Move AnyResolvable type into calm-models and reduce dereference-visitor to a thin ModelWalker hook with a lazy logger. - Port PR finos#2778 behavioural test cases (25 controls + 19 node-details) plus e2e coverage; adapt error-stringify assertions to shared getErrorMessage.
…d node-details bugs
…de and cache resolver failures
|
Heads up: after landing the point 1 fix and rebasing onto latest Two distinct causes, both in the new control-requirement validation (
Proposal. Before changing anything, I would like to add a dedicated control + detailed-architecture e2e test to pin the intended behaviour, then decide per cause: for (1) whether we keep the stricter validation and correct the example data / smoke fixture, or make the |
…ure control-id mismatch
82b775a to
b01b389
Compare
|
Follow-up to the above: that dedicated control + detailed-architecture test is now in. As discussed with @willosborne, we're making control validation stricter now and will see what breaks internally. The smoke coverage: a compliant control validates clean (it carries a hub hosted detailed architecture, so the node-details descent is exercised), and a control whose One caveat: the bad case only works because CalmHub stores control docs as is today. If we add a sidecar that validates on upload, the |
Matt's suggestions have all been either applied or raised as issues for future implementation. He's not available today so I'm dismissing this so we can get this merged; can always fix as follow-up.
Description
Follow-up to @willosborne's PR #2778 (#2778). I believe this starts to
address the concerns raised on that PR and the assumptions we have been making about the validation
framework, by reworking recursive detailed-architecture and control validation into a cycle-safe,
resolver-driven design. Still some open questions but speaking to @willosborne he thinks likely can be tackled in subsequent PRs and this does handle additional requirements that PR #2778 was adding.
Summary of the design (see
shared/src/commands/validate/ValidationTechnicalSpecification.mdfor detail):
ValidationRules run by aValidationEngine, keepingthe existing
ValidationOutcomecontract (output buckets, ordering, error/warning flags).ModelWalkerowns model traversal, dereferencing and cycledetection. This fixes the pattern-less dispatch branch dropping the
visitedset (the self-referenceheap OOM flagged on feat(shared): validate node details and controls #2778).
Resolvable/CalmReferenceResolverseam, witha
CachingTrackingResolvergiving fetch-at-most-once and validate-once/cycle-safety in one place.shared
getErrorMessage.ValidationTechnicalSpecification.mdalso records the open questions the framework does not yetsettle: default recursive traversal vs a caller-selected resolution policy (same always-on concern
raised on feat(shared): validate node details and controls #2778), custom/organisation-specific rules per document, a typed CALM document input
instead of four positional params, and how validation should differ between the CLI and CalmHub.
This is intended as a discussion PR: it restates the validation design and surfaces the remaining
unknowns rather than claiming to close them.
Design detail (from the Validation Technical Specification)
Reference resolution, caching and cycle safety
The CALM model is lazily dereferenced: a
ResolvableAndAdaptable<CalmCoreSchema, CalmCore>onlycalls
resolve(ref)when a caller chooses to dereference it, and the resolve function returns theraw
CalmCoreSchemabefore it is adapted toCalmCore. Node-details validation needs that rawdocument (for Spectral + JSON-Schema), so the resolve seam is the natural place to centralise:
detailed-architecture once") and cycle safety without each caller keeping its own
Set.CachingTrackingResolveris a decorator thatimplements CalmReferenceResolver, wrapping anyinner resolver. This is the single resolver interface used by both the model dereference path and
validation:
classDiagram class CalmReferenceResolver { <<interface>> +canResolve(ref) boolean +resolve(ref) Promise } class CachingTrackingResolver { -delegate: CalmReferenceResolver -cache: Map~string, unknown~ -seen: Set~string~ +canResolve(ref) boolean +resolve(ref) Promise +has(ref) boolean +get(ref) unknown +markSeen(ref) void +resolvedReferences: ReadonlySet~string~ } class SchemaDirectoryReferenceResolver { -schemaDirectory: SchemaDirectory +canResolve(ref) boolean +resolve(ref) Promise } CalmReferenceResolver <|.. CachingTrackingResolver CalmReferenceResolver <|.. SchemaDirectoryReferenceResolver CachingTrackingResolver o-- CalmReferenceResolver : decoratesA reference is marked seen before the underlying load is awaited, so a failed load still counts as
seen (a sibling referencing the same failing URL is not retried), matching the
visitedUrls.add-before-load behaviour originally implemented in PR #2778
(#2778). Only successful loads populate the value cache. In validation the
decorator wraps a
SchemaDirectoryReferenceResolver(which loads architecture documents viaschemaDirectory.loadDocument(ref, 'architecture')); in docify the caller (TemplateProcessor)composes the decorator around its resolver (
Mapped->Composite) so repeated references arefetched once. Both
DereferencingVisitorandModelWalkerare agnostic: they dereference throughwhatever
CalmReferenceResolverthey are given; caching/tracking is purely the caller's compositionchoice.
Resolution policy (design intent)
Whether a given reference is resolvable is governed by a
ReferenceResolutionPolicy, consulted incanResolve. The policy is a constructor input to the resolver adapter; theCalmReferenceResolverinterface is unchanged. Two variants:
canResolvereturnsfalsefordetailed-architecturedocumentreferences, so a shallow run never fetches or recurses into sub-architectures.
canResolveallows those references, so the node-details rule resolves andvalidates each referenced sub-architecture.
Because only the node-details rule uses
ValidationContext.references(schemas load viaSchemaDirectory.getSchemadirectly), the policy affects recursion alone. Child validation contextsreuse the same resolver, so the policy is inherited at every level of recursion. This is design
intent; the policy type, its resolver wiring and the entry-point option are pending implementation.
Open questions / Out of Scope
These are recorded in the specification as design intent, not settled behaviour:
detailed-architecture. This rewrite still suffers from the same always-on concern raised onfeat(shared): validate node details and controls #2778; making it a caller-selected resolution policy is worth considering.
specific documents or document types, which the generic traversal does not model. It is an open
question how a caller such as CalmHub tells the calm-server which rules to run, and how differing
rule sets across a large organisation are catered for (see
Validate uploaded documents in CalmHub via CALM server sidecar #2716 and Questions on CALM Validations #2566).
validate()currently takes four loosely-typed positional inputs andinfers a mode; a single typed CALM document from
calm-models(Standardize CALM document type list in calm-models #2770)would be cleaner.
CalmHub validates on upload and would decide when a deep run is performed.