WPX-1042 · Class-F2 pump safety · Rule Engine

Pump safety rules, back on the Engine's own contract

The Architect reviewed the Series P7 pump safety rules and found they bypassed the Rule Engine's Rule = Specification + Remedy design. This page shows how the P7 check runs end to end, how the Engine works, what changed in each rule, and why each of the four Engine additions was needed.

84/84Chassis Rule Engine tests (10 new)
28/28PumpSafetyRules tests
110/110Business PumpSafety, same as before
0accepted baselines changed
1 · The system

Where the safety check lives

When an engineer presses Calculate, WaterWorks runs a list of post-configuration validators. One of them works out the minimum reservoir volume each pump needs for a Class-F2 fluid (ISO 24111). For Series P7 booster pumps, that validator now runs on the Rule Engine instead of the legacy calculator.

Engineer WaterWorks pump configuration & safety validation Product catalogue presses Calculate reads
C4 · L1 Context. One person, one system, one data source. The safety check is internal to WaterWorks; nothing outside it changed.
WaterWorks process Business net481 PumpSafetyRules rules rewritten netstandard2.0 Rule Engine four additions netstandard2.0 curve-lookup service injected by Business
C4 · L2 Containers and libraries. Highlighted borders mark what this change touched: the PumpSafetyRules library (rules rewritten) and the Engine (four additions). PumpSafetyRules targets netstandard2.0 and can't see the net481 cache, so Business hands it a curve-lookup service (the dashed arrow).
2 · Inside the P7 path

Every class on the path, by library

Read left to right. Business decides whether a system goes to the Engine at all, translates the entity graph into an immutable snapshot, and writes results back. PumpSafetyRules holds the P7 domain: one flow, one profile and six rules. The Engine runs those rules.

Business PumpSafetyRules Rule Engine PumpComplianceValidator SystemSnapshot builder PumpResultWriter SeriesP7Flow PumpCurvesProfile six rules Spec + named Remedy rewritten in this change (section 4) PumpValidationResult Evaluator Rule / BusinessRule FactBag
C4 · L3 Components. The accent path is the P7 call. Highlighted boxes are what changed: the six rules on the PumpSafetyRules side, and three Engine components that gained an addition. SeriesP7Flow, PumpCurvesProfile and the Business adapters did not change.

When does a system take the Engine path?

PumpComplianceValidator sends a system to the legacy calculator when the factory or additional charge is unknown, when the system has a manifold box, or when the series isn't a Series P7, Class-F2, booster-only series. Everything else goes through SeriesP7Flow. If the series is missing fluid-class data, the validator raises a critical notification before any rule runs.

3 · One Calculate, step by step

What happens for one pump system

Validator SeriesP7Flow Evaluator Rule Validate(snapshot) Evaluate(rules, facts) LOOP · EACH RULE — THE ONLY PART THIS CHANGE TOUCHED Apply(subject, facts) status + history writes facts; never changes the subject default policy: stop at first NotPossible or Failed results volumes + notifications
C4 · L4 Runtime sequence. Solid arrows are calls, dashed arrows are return values. The loop is the only part this change touched: what a rule does inside Apply. The default policy stops the loop at the first NotPossible or Failed.
4 · The six rules

Six rules, one shared fact bag

Rules never call each other. They talk through facts: an earlier rule writes a fact, a later rule reads it. The order in PumpCurvesProfile is the legacy calculator's order, so the largest-per-zone report is taken before the lowest-level override, exactly as legacy does.

rule order (from PumpCurvesProfile) → 1 ChargeKnown 2 DirectVolumes 3 LargestPerZone 4 LowestLevel 5 BoosterVolumes 6 BoosterLimits FactBag fresh & empty per system — nothing leaks writes reads
Order and facts. A fresh Evaluator gets a fresh, empty FactBag, so nothing leaks between systems. After the run, PumpValidationResult reads the four facts and PumpResultWriter turns them into volumes and notifications on the entities.

Example run

A booster pump with three pump units in two zones, one zone on the lowest underground reservoir level, and no booster modules:

#RuleSpec: already done?Applies?History
1FluidChargeMustBeKnowncharge known: yesn/aSatisfied
2CalculateDirectPumpReservoirVolumesnobooster: yesSatisfied "3 units" + volumes
3ReportLargestVolumePerZonenoyesSatisfied "2 zones"
4ApplyLowestReservoirLevelVolumenoserves lowest level: yesSatisfied "LowestLevelVolume=…"
5CalculateBoosterModuleMinimumTotalVolumesnono modulesSkipped NotApplicable
6BoosterModulesMustRespectCurveLimitsno (needs modules)no modulesSkipped NotApplicable

The calculations report Satisfied, not Remediated. In the Engine's model, Remediated means the subject itself was corrected. These rules only add facts; the SystemSnapshot never changes.

5 · How the Engine runs a rule

The Engine in two pictures

Evaluator.Evaluate — for each rule tryrule.Apply(…) record status+ history entry catch (Exception ex) status = Failed keeps ex itself (highlighted) + history entry
The Evaluator loop. It already catches every exception a rule throws. Before this change it kept only the message; it now also keeps the exception itself (highlighted).
BusinessRule.Apply(subject, facts) Specificationsatisfied? yes Satisfied no Remedyrun it Remediated / Skipped / NotPossible / Failed SkipUnless wraps a remedy, like All(…)
One rule. BusinessRule.Apply is unchanged: spec first, remedy only if the spec isn't satisfied. SkipUnless is a remedy that wraps another remedy, the same way All(…) wraps several.
StatusMeaning in the EngineUsed by the safety rules for
SatisfiedFine; the subject didn't need correcting (facts may still be added)every calculation, and the charge check when the charge is known
RemediatedThe remedy produced a corrected subjectnot used: the snapshot is never corrected
SkippedThe rule doesn't apply to this subjectnon-booster, no lowest level, no booster modules
NotPossibleCan't evaluate; input missingunknown charge, booster module without release pressure
FailedRequirement not met, or the rule threwbooster charge or total volume outside the curve limits
6 · What changed in a rule

From our own base class to the Engine's contract

The Architect's comment was that the rule engine's design had been overridden: the calculation sat inside the rule instead of in a remedy. That was exactly right. ApplicableRule was a base class written next to the Engine that skipped Specification and Remedy entirely.

Before — ApplicableRule override point: IsApplicable(subject) the calculation ran inside the rule caught its own exceptions, prefixed the message Spec + Remedy skipped entirely After — BusinessRule Specification: FactIsKnown(volume) Remedy: SkipUnless(applies?, calc remedy) Evaluator catches exceptions (one place) the calculation moved, unchanged, into the remedy
Before and after. The outcomes are the same (Skipped / Satisfied / Failed / NotPossible) and the Evaluator catches exceptions — but the rule now obeys the Engine's Spec + Remedy contract instead of its own base class.

A calculation rule now

Specification
  FactIsKnown(LowestLevelVolume)
Remedy
  SkipUnless(
    IsBooster AND ServesLowestLevel…,
    ApplyLowestReservoirLevelVolumeRemedy)

The code the Architect quoted moved, unchanged, into ApplyLowestReservoirLevelVolumeRemedy.

A check rule now

Specification
  HasBoosterModules AND ModulesWithinCurveLimits
Remedy
  SkipUnless(HasBoosterModules,
    BoosterCurveLimitsRemedy) → Failed / NotPossible

The spec includes HasBoosterModules on purpose; see the note below.

The spec runs before the remedy. For a check, the spec means "everything within limits". For a system with no booster modules, "within limits" would be trivially true, so the rule would report Satisfied before SkipUnless ever got the chance to say Skipped. That's why a check's spec repeats its applicability.

7 · Why exceptions need a field

The reason already holds the message. Why isn't that enough?

Because a crash and a real violation end up looking the same in the history: status Failed plus some free text. Code can't reliably tell them apart from text. For safety the difference is the whole point: a violation means "we calculated it and it's not safe", a crash means "we don't know if it's safe".

Real violation Status: Failed Info: "volume outside limits" Exception: none → we calculated it, it's unsafe Crash Status: Failed Info: "object reference…" Exception: present → we don't know if it's safe
Two ways to fail. Status and Info look alike; only Exception separates them. PumpValidationResult.Valid is "no rule crashed", so getting this wrong turns a broken calculation into a normal-looking result.

Before this change, ApplicableRule caught exceptions itself and put EvaluationException: in front of the message, and PumpStepRecord looked for that prefix. Removing the base class without a replacement would have made every crash count as a violation, with Valid still true. The existing test Validate_CurveLookupThrows_IsInvalidRatherThanAViolation guards exactly this, and still passes.

AlternativeWhy it doesn't work
Recognise crashes by the reason textThe text is arbitrary. A message and a domain reason can't be told apart reliably.
A try/catch in every remedy that adds a prefixEvery remedy has to remember it, and it misses exceptions thrown by specs, which run before the remedy.
A safety-side "catching remedy" wrapperSame gap: a remedy wrapper never sees an exception from the spec.
Keep ApplicableRule's own catchThat's the base class the Architect flagged and we removed.
8 · Engine additions

Four additions, and whether each was needed

All four are additive. BusinessRule.Apply, the statuses, how the overall status is computed, and the stop/continue policy are untouched, so other users of the Engine notice nothing. Two were needed; two are placement choices that could have lived in PumpSafetyRules.

AdditionVerdictWhat it doesWhy it's in the Engine
HistoryEntry.Exception Required The Evaluator's existing catch keeps the exception instead of only its message. Only the Evaluator sees exceptions from specs, remedies and services alike. Nothing outside it can mark a crash reliably (section 7).
FactBag.Contains Required for correctness Answers "was this fact written?", including a fact written as null. TryGet uses stored is T, and null is double? is false, so a stored null looked "missing". The store is private, so this can only be fixed inside the bag. Today's lowest-level volume is never null, so it works without this, but a spec called "fact is known" shouldn't lie for nullable facts.
FactIsKnownSpecification
Rule.FactIsKnown(…)
Placement choice The "already calculated?" spec used by the five calculation rules. It's generic (any subject, any fact), it builds on Contains, and the this.FactIsKnown(…) helper can only sit next to the this.And(…) / this.Not(…) inside Rule. In a fresh run it's always "not yet", so the remedy always runs; its value is that the spec says something true.
SkipUnlessRemedy
BusinessRule.SkipUnless(…)
Placement choice Runs a remedy only when a spec holds; otherwise returns Skipped. It's a remedy combinator like All(…) and knows nothing about safety. AllRemedy already handles Skipped, so it fits the model. The this.SkipUnless(…) helper can only sit next to this.All(…) inside BusinessRule.
9 · Status and open points

Where this stands

Done and verified

  • Engine commit a1b2c3d4: the four additions plus 10 tests.
  • PumpSafetyRules commit e5f6a7b8: six rules as spec + named remedy; ApplicableRule removed.
  • Chassis 84/84, PumpSafetyRules 28/28, Business PumpSafety 110/110. The last commit before the change gives the same 110 and 28.
  • No accepted baseline changed; no legacy calculator or routing changed.

For the Architect

  • Names: SkipUnless, FactIsKnown, FactBag.Contains, HistoryEntry.Exception. All additive, so renaming is cheap.
  • Agree that calculations stay Satisfied, since the subject isn't corrected.
  • The observable stream is existing Engine work and isn't part of this change.

Trace detail that changed

When a spec is satisfied, BusinessRule records Satisfied without a reason. So two messages disappear from the history: Charge=… (rule 1) and WithinLimits (rule 6). No test or baseline checks them.