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.
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.
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).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.
SeriesP7Flow, PumpCurvesProfile and the Business adapters did not change.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.
Apply. The default policy stops the loop at the first NotPossible or Failed.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.
PumpValidationResult reads the four facts and PumpResultWriter turns them into volumes and notifications on the entities.A booster pump with three pump units in two zones, one zone on the lowest underground reservoir level, and no booster modules:
| # | Rule | Spec: already done? | Applies? | History |
|---|---|---|---|---|
| 1 | FluidChargeMustBeKnown | charge known: yes | n/a | Satisfied |
| 2 | CalculateDirectPumpReservoirVolumes | no | booster: yes | Satisfied "3 units" + volumes |
| 3 | ReportLargestVolumePerZone | no | yes | Satisfied "2 zones" |
| 4 | ApplyLowestReservoirLevelVolume | no | serves lowest level: yes | Satisfied "LowestLevelVolume=…" |
| 5 | CalculateBoosterModuleMinimumTotalVolumes | no | no modules | Skipped NotApplicable |
| 6 | BoosterModulesMustRespectCurveLimits | no (needs modules) | no modules | Skipped 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.
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.| Status | Meaning in the Engine | Used by the safety rules for |
|---|---|---|
| Satisfied | Fine; the subject didn't need correcting (facts may still be added) | every calculation, and the charge check when the charge is known |
| Remediated | The remedy produced a corrected subject | not used: the snapshot is never corrected |
| Skipped | The rule doesn't apply to this subject | non-booster, no lowest level, no booster modules |
| NotPossible | Can't evaluate; input missing | unknown charge, booster module without release pressure |
| Failed | Requirement not met, or the rule threw | booster charge or total volume outside the curve limits |
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.
Specification
FactIsKnown(LowestLevelVolume)
Remedy
SkipUnless(
IsBooster AND ServesLowestLevel…,
ApplyLowestReservoirLevelVolumeRemedy)
The code the Architect quoted moved, unchanged, into ApplyLowestReservoirLevelVolumeRemedy.
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.
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".
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.
| Alternative | Why it doesn't work |
|---|---|
| Recognise crashes by the reason text | The text is arbitrary. A message and a domain reason can't be told apart reliably. |
| A try/catch in every remedy that adds a prefix | Every remedy has to remember it, and it misses exceptions thrown by specs, which run before the remedy. |
| A safety-side "catching remedy" wrapper | Same gap: a remedy wrapper never sees an exception from the spec. |
Keep ApplicableRule's own catch | That's the base class the Architect flagged and we removed. |
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.
| Addition | Verdict | What it does | Why 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. |
a1b2c3d4: the four additions plus 10 tests.e5f6a7b8: six rules as spec + named remedy; ApplicableRule removed.SkipUnless, FactIsKnown, FactBag.Contains, HistoryEntry.Exception. All additive, so renaming is cheap.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.