Files
EVOLV/.claude/refactor/CONTRACTS.md
znetsixe 13da7388ff refactor docs: lock in topic-prefix glossary, child-getters, opt-in tick
Resolves the 5 open questions answered during Phase 1 setup:

- Topic naming: canonical from Phase 1 (set/cmd/data/child/query/evt),
  with full glossary in CONTRACTS.md §1.
- Parent EVOLV branch lineage: rebased onto origin/main.
- Deprecated paths: tracked as Phase 8.5 in TASKS.md.
- Child storage: registry-as-truth + named getters via
  declareChildGetter.
- Tick: opt-in via static tickInterval; default is event-driven via
  source.emitter 'output-changed'. statusInterval (always-on, 1Hz)
  is separate.

Plus two new pre-existing-issue notes from the sanity gate:
- dashboardAPI uses Mocha-style describe() under node:test (broken).
- reactor tests are mathjs-bound (~13s/file load).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:44:42 +02:00

20 KiB

Contracts

The exact shapes that the refactor delivers. These are the things every node converges on. Treat them as APIs.

Order: top-down — what a Node-RED user sees, what a node author writes, what generalFunctions provides.

1. The Node-RED-visible contract per node

Every node exposes the same three Port shapes:

Port Direction Carries
0 out Process data — formatted via outputUtils.formatMsg(..., 'process')
1 out InfluxDB telemetry — formatted via outputUtils.formatMsg(..., 'influxdb')
2 out Registration / control plumbing
in in Commands routed by msg.topic through the commands/ registry

Every node also publishes a per-repo CONTRACT.md listing:

  • Every msg.topic it accepts on Port 0 input, with the payload schema.
  • Every topic shape it emits on Port 0/1/2.
  • Every event its measurements.emitter fires for parents to subscribe.
  • Every position label it expects from children.

This file is generated from the node's commands/ module + a small hand-written events section.

Topic naming — canonical from Phase 1

msg.topic always uses one of these prefixes. <noun> and <verb> are kebab-case after the dot (set.flow-setpoint, not set.flowSetpoint).

Inputs — topics the node accepts on Port-0 input

Prefix Meaning Idempotent? Examples
set.<noun> Setter. Replaces a state value with the supplied payload. Repeating with the same payload does nothing extra. Yes set.mode, set.scaling, set.demand, set.inflow
cmd.<verb> Imperative action. Triggers a transition or sequence. Repeating triggers it again (or is rejected). No cmd.startup, cmd.shutdown, cmd.estop, cmd.calibrate
data.<noun> Bulk data input. Sensor readings, measurement values, raw streams. The node consumes them. n/a — values flow data.measurement, data.flow, data.pressure
child.<verb> Parent/child plumbing. Registration handshakes routed via Port 2. n/a child.register, child.unregister
query.<noun> Synchronous query. The node responds on the same msg (or a sibling output). Used for read-only debug queries from a dashboard. Yes (read-only) query.curves, query.cog, query.snapshot

Outputs — topics the node EMITS

Prefix Meaning Where it appears
evt.<noun> Event. A fact about something that just happened. Other nodes/dashboards subscribe to react. The node fires-and-forgets — no consumer is required. msg.topic on Port 0 output, also fired internally on this.emitter so sibling modules can listen.

evt.* is one-way: the node says "this happened", consumers can do whatever they like with it. Examples: evt.state-change (state machine moved), evt.alarm (a safety threshold tripped), evt.calibrated (calibration completed). If you find yourself wanting to send a command via evt.*, you actually want set.* or cmd.*.

The default measurement output (the delta-compressed payload from outputUtils.formatMsg) keeps msg.topic = config.general.name per the existing convention. evt.* is for additional event-shaped emissions, not for the per-tick measurement stream.

Aliases for legacy names

Each commands/index.js declares the canonical name as topic and lists pre-refactor names in aliases. The first time an alias fires, the runtime logs a one-time deprecation warning. Aliases are removed in Phase 7 after one release cycle.

Why these prefixes (the reasoning)

Today's topics mix setMode (verb-noun, no separator), q_in (snake-case, abbreviation), Qd (PascalCase abbreviation), changemode (lowercase joined), execSequence (verb-noun, camel). A reader can't tell from the topic name whether it's a setter, an action, or an event. The prefix system says it explicitly:

  • set.x means "I'm replacing the value of x". Safe to retry.
  • cmd.x means "I'm asking you to do x once". Don't retry blindly.
  • data.x means "here's a value I'm pushing into your stream".
  • query.x means "tell me what x is right now".
  • child.x means "plumbing — only the parent/child machinery cares".
  • evt.x (output only) means "this happened, do what you want".

2. BaseNodeAdapter — the shape of every nodeClass

Lives in generalFunctions/src/nodered/BaseNodeAdapter.js. Each node's nodeClass.js extends it.

const { BaseNodeAdapter } = require('generalFunctions');
const Domain = require('./specificClass');
const commands = require('./commands');

class nodeClass extends BaseNodeAdapter {
  // The domain class to instantiate.
  static DomainClass = Domain;

  // The command registry — see section 4.
  static commands = commands;

  // Opt-in periodic tick. Default null = event-driven (domain emits
  // 'output-changed' when output should refresh). Set to ms only when
  // the domain genuinely needs a time-based heartbeat.
  // Example reason (above the line): "needs delta-time for predicted
  // volume integrator".
  static tickInterval = null;

  // Always-on status badge poll. Required for Node-RED's editor
  // refresh. Set to 0 only in headless environments.
  static statusInterval = 1000;

  // Build the domain-specific config slice from the Node-RED uiConfig.
  // Base config (general, asset, functionality, logging) is built by
  // BaseNodeAdapter via configManager.buildConfig.
  buildDomainConfig(uiConfig, nodeId) {
    return {
      basin: { volume: uiConfig.basinVolume, height: uiConfig.basinHeight, ... },
      hydraulics: { ... },
      control: { ... },
      safety: { ... },
    };
  }
}

module.exports = nodeClass;

Lifecycle (provided by base, do not reimplement)

In order, in the constructor:

  1. Build merged config (configManager.buildConfig + buildDomainConfig).
  2. Instantiate DomainClass with that config; store as this.source, also as this.node.source for sibling-node lookup.
  3. Send Port 2 registration message (after a 100 ms delay).
  4. Output strategy — pick one based on static tickInterval:
    • tickInterval = N (ms): start a periodic timer that calls this.source.tick?.(), then formats and sends outputs.
    • tickInterval = null: subscribe to 'output-changed' on this.source.emitter. Whenever the domain fires that event, the adapter formats and sends outputs. In both modes, outputUtils.formatMsg does delta compression — a send only emits changed fields.
  5. Start the status loop at static statusInterval ms:
    • Call this.source.getStatusBadge() (see section 7), apply via node.status(...).
  6. Attach the input handler — dispatches by msg.topic through the commands registry.
  7. Attach the close handler — clears timers, removes child listeners, clears status.

Event-driven is the default

A domain that doesn't need time-driven math fires this.emitter.emit('output-changed') whenever its public state shifts (e.g. after a measurement update, a state transition, a calibration). The base adapter pushes outputs in response. No 1 Hz polling.

A domain that DOES need time-driven math (e.g. pumpingStation integrating predicted volume) opts into a tick. The tick runs the time-based update; if that update changes output state, the domain emits 'output-changed' and the same code path that handles event-driven nodes pushes outputs.

This keeps the output pipeline single-shape regardless of which mode the domain uses.

Override hooks

A subclass may override:

Hook When
buildDomainConfig(uiConfig, nodeId) Always — required.
extraSetup() If a node needs custom wiring beyond the base.
extraInputDispatch(msg, send, done) If commands registry can't express a topic. Avoid; prefer the registry.
extraClose() Custom teardown beyond clearing intervals.

Forbidden in subclasses

  • Re-implementing the tick or status loop. Use getOutput() / getStatusBadge() on the domain.
  • Calling this.source._private. Domain exposes a public surface.
  • Importing from another node's src/.

3. BaseDomain — the shape of every specificClass

Lives in generalFunctions/src/domain/BaseDomain.js. Each node's specificClass.js extends it.

const { BaseDomain, UnitPolicy, ChildRouter } = require('generalFunctions');

class PumpingStation extends BaseDomain {
  // Identifies the config in generalFunctions/src/configs/<name>.json.
  static name = 'pumpingStation';

  // Declarative unit policy — see section 6.
  static unitPolicy = UnitPolicy.declare({
    canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
    output:    { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
  });

  // Run after BaseDomain has built emitter, config, logger, measurements,
  // childRegistrationUtils. Wire concern-modules and any extra state.
  configure() {
    this.basin = new BasinGeometry(this.config, this.logger);
    this.flowAggregator = new FlowAggregator(this.context());
    this.safety = new SafetyController(this.context());
    this.strategies = require('./control');

    this.router = new ChildRouter(this)
      .on('machinegroup', this._onMachineGroup)
      .on('measurement', { type: 'pressure' }, this._onPressure)
      .on('measurement', { type: 'level' },    this._onLevel);
  }

  // Per-tick — orchestration only, all real work is in modules.
  tick() {
    this.flowAggregator.update();
    const safe = this.safety.evaluate();
    if (safe.blocked) return;
    this.strategies[this.mode]?.run(this.context());
  }

  // What goes on Port 0 / Port 1.
  getOutput() {
    return {
      ...this.measurements.getFlattenedOutput(),
      ...this.basin.snapshot(),
      ...this.flowAggregator.snapshot(),
    };
  }

  // What the Node-RED status badge shows — see section 7.
  // Aggregators (no clean state machine) use compose. State-machine
  // nodes (rotatingMachine) use byState. Both return {fill, shape, text}.
  getStatusBadge() {
    const direction = this.flowAggregator.direction;
    const vol = this.measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3');
    const pct = (vol / this.basin.maxVolAtOverflow * 100).toFixed(1);
    const arrow = direction === 'filling' ? '⬆️' : direction === 'draining' ? '⬇️' : '⏸️';
    return statusBadge.compose([
      `${arrow} ${pct}%`,
      `V=${vol.toFixed(2)}/${this.basin.maxVolAtOverflow.toFixed(2)} m³`,
    ]);
  }
}

module.exports = PumpingStation;

What BaseDomain provides (do not reimplement)

The base constructor sets up:

Property Type Notes
this.emitter EventEmitter Internal events. Fire 'output-changed' here when public state shifts in event-driven nodes.
this.configManager, this.configUtils, this.defaultConfig Wired from static name.
this.config object Validated config.
this.logger logger Named after config.general.name.
this.measurements MeasurementContainer Built from static unitPolicy.
this.childRegistrationUtils child registry The child dict is auto-created.

Then it calls this.configure() — your hook. Then it calls this._init?.() if defined.

Named child accessors (registry-as-truth, readable in code)

Children live in this.child[<softwareType>][<category>] (the registry, populated by childRegistrationUtils). For readable code, each domain declares named getters in configure() that surface the relevant slices:

configure() {
  // Reads as: ps.machines, ps.machineGroups, ps.stations.
  this.declareChildGetter('machines',      'machine');
  this.declareChildGetter('machineGroups', 'machinegroup');
  this.declareChildGetter('stations',      'pumpingstation');
}

declareChildGetter(name, softwareType, category?) (provided by BaseDomain) installs a getter that flattens this.child[softwareType] into one object keyed by child id (across all categories) — or filters by category if given.

The registry is the source of truth; the getters keep call sites readable. Object.values(this.machines).forEach(...) works exactly like before; assignments like this.machines[id] = child no longer work — registration goes through this.router (or registerChild).

Two output strategies — domain decides

Strategy When to pick What domain does What adapter does
Event-driven (default) Domain reacts to incoming events (measurements, state changes, commands) and has no genuinely time-driven math. Fire this.emitter.emit('output-changed') whenever the public output state shifts. Subscribes to 'output-changed'; on each fire, calls getOutput() and pushes the delta-compressed message.
Tick-driven (opt-in) Domain has time-driven math that can't be expressed as a reaction to events (integrators, simulators, time-based thresholds). Implement tick(). Fire 'output-changed' from inside it whenever the tick changes output state. Calls tick() every static tickInterval ms (set on the nodeClass subclass). Listens to 'output-changed' the same as event-driven nodes.

Both strategies funnel into the same 'output-changed'getOutput()formatMsgnode.send pipeline. The only difference is what fires the event.

this.context()

Returns a frozen view passed to concern-modules so they don't reach into this. Default shape:

{
  config: this.config,
  logger: this.logger,
  measurements: this.measurements,
  emitter: this.emitter,
  child: this.child,
  unitPolicy: this.unitPolicy,
}

A node may override context() to add domain-specific keys (e.g. pumpingStation adds basin).

getOutput() and getStatusBadge() are the only required methods

Everything else is configuration. If a domain can be expressed without a custom tick() (e.g. a passive aggregator), don't define one.

4. The commands registry

Each node has src/commands/index.js that exports an array of command descriptors:

const handlers = require('./handlers');

module.exports = [
  {
    topic: 'set.mode',
    aliases: ['setMode', 'changemode'],   // legacy names
    payloadSchema: { type: 'string' },
    handler: handlers.setMode,
  },
  {
    topic: 'cmd.startup',
    aliases: ['execSequence:startup'],
    payloadSchema: { type: 'object', properties: { source: { type: 'string' } } },
    handler: handlers.startup,
  },
  ...
];

A handler is a pure function:

// handlers.js
exports.setMode = (source, msg, ctx) => {
  source.setMode(msg.payload);
};

exports.startup = async (source, msg, ctx) => {
  await source.handleInput(msg.payload?.source ?? 'parent', 'execSequence', 'startup');
};

The BaseNodeAdapter builds a Map<topic-or-alias, descriptor> at construction time. Dispatch is one lookup. Aliases log a one-time deprecation warning the first time each fires.

Why declarative?

  • Auto-generates CONTRACT.md per node.
  • Lets us add cross-node static checks (no two nodes use the same set.x for different things).
  • Replaces the per-node 100-line input switch with a 5-line dispatch.

5. ChildRouter — declarative parent registration

Lives in generalFunctions/src/domain/ChildRouter.js. Built on top of the existing childRegistrationUtils.

this.router = new ChildRouter(this)
  // Register a callback when a child of a given software type registers.
  .onRegister('machinegroup', (child) => this._onMachineGroupRegistered(child))

  // Subscribe to a measurement event from any child of a given softwareType.
  // The third arg filters by emit-side position.
  .onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => {
    this._onPressure('upstream', data.value, data);
  })

  // Subscribe to predicted-flow events from any group/machine child.
  .onPrediction('machinegroup', { type: 'flow', position: 'downstream' }, (data, child) => {
    this._onPredictedFlow(child, data);
  });

ChildRouter owns:

  • The handler maps (onRegister, onMeasurement, onPrediction).
  • Listener attachment + teardown (called from BaseDomain on close).
  • Software-type alias resolution (already in childRegistrationUtils).

Per-node registerChild boilerplate disappears. The base childRegistrationUtils.registerChild calls this.mainClass.registerChild which delegates to this.router.dispatchRegister(child, softwareType).

6. UnitPolicy

Lives in generalFunctions/src/domain/UnitPolicy.js. Replaces the duplicated _buildUnitPolicy / _resolveUnitOrFallback / _convertUnitValue in rotatingMachine and machineGroupControl.

static unitPolicy = UnitPolicy.declare({
  canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
  output:    { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
  curve:     { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' }, // optional
  // Types whose values must always carry a unit on write.
  requireUnitForTypes: ['flow', 'pressure', 'power', 'temperature'],
});

Methods on the resulting policy:

Method Purpose
policy.canonical(type) Canonical unit for a measurement type.
policy.output(type) Display / IO unit for a measurement type.
policy.resolve(candidate, expectedMeasure, fallback, label) Validate a user-supplied unit, fall back if invalid (logs warn).
policy.convert(value, fromUnit, toUnit, contextLabel) Strict conversion.
policy.containerOptions() Returns the option bag for a MeasurementContainer.

BaseDomain reads static unitPolicy and passes policy.containerOptions() straight into new MeasurementContainer(...).

7. getStatusBadge() shape

Every domain returns the standard Node-RED status object:

{
  fill: 'green' | 'yellow' | 'red' | 'blue' | 'grey',
  shape: 'dot' | 'ring',
  text: string,   // ≤ 60 chars in the Node-RED editor; aim for ≤ 50.
}

Helpers in generalFunctions/src/nodered/statusBadge.js:

const { statusBadge } = require('generalFunctions');

statusBadge.compose(['🟢 OK', `flow=${flow.toFixed(1)} m³/h`])  // joins with ' | '
statusBadge.error(message)                                       // {fill:'red', shape:'ring', text:`⚠ ${message}`}
statusBadge.idle(label)                                          // {fill:'blue', shape:'dot', text:`⏸️ ${label}`}

The badge is computed in domain, not in nodeClass. nodeClass just calls this.source.getStatusBadge() once per second.

8. LatestWinsGate

Extracted from MGC's _dispatchInFlight + _delayedCall pattern. Used anywhere a parent fires commands faster than children can absorb them.

const { LatestWinsGate } = require('generalFunctions');

this.demandGate = new LatestWinsGate(async (demand) => {
  await this._dispatchDemandToChildren(demand);
});

// Caller side — never blocks. The latest demand always wins.
this.demandGate.fire(demand);

Guarantees:

  • At most one dispatch running at a time per gate.
  • If a new value arrives while one is running, only the latest is enqueued; intermediate ones are dropped.
  • After the in-flight call settles, the latest pending value fires.

9. HealthStatus

A standardised shape for nodes that compute prediction quality / drift (today: rotatingMachine.predictionHealth, future: MGC, pumpingStation volume confidence).

{
  level: 0 | 1 | 2 | 3,         // 0 = fine, 3 = unusable
  flags: string[],              // machine-readable tags, e.g. 'no_pressure_input'
  message: string,              // single-line human summary
  source: string | null,        // free-text origin tag
}

Helpers compose multiple sub-statuses (e.g. flow drift + power drift + pressure init) into one node-level status.

10. Output port payload conventions

Already documented in .claude/rules/telemetry.md — kept here only as a pointer:

  • Port 0: process data, formatter chosen by config.output.process.
  • Port 1: InfluxDB line-protocol, formatter chosen by config.output.dbase.
  • Port 2: registration / control plumbing.
  • outputUtils.formatMsg does delta compression — only changed fields are sent. Consumers must cache + merge.