Starting point

In industrial maintenance and shutdown work, a status change rarely stays inside one work package. Work on a pipe system makes the related rooms unavailable. A calibration that needs that room cannot start. A further task that itself depends on the calibration is blocked as well. Spreadsheets and general project tools record tasks. They do not compute the transitive lock.

The model answers one question: given the authored graph and a single status event, which other items must change? It does not choose the next job, and it is not a safety case for the plant. It computes the cascade.

A typed ACTION / RESOURCE graph

The network is directed and typed. Nodes are either ACTION or RESOURCE. Edges are one of three kinds:

TypeReads asRole in the cascade
BLOCKaction a locks resource b while a is activeDirect lock: b gains a in blocked_by
REQUIREitem a needs item b availableBack-propagation: a gains b in required_from when b is locked or required
LOCATEDitem a sits spatially in bLayout / twin only — no cascade

Actions drive. Resources are passive: they do not fire cascades on their own. When an action enters an active status, its BLOCK edges fire; REQUIRE edges then carry the “unavailable” mark upstream, recursively, along the require chain.

LOCATED stays in the model because plants are spatial. It must not pretend to be a constraint edge. If it cascaded, every room assignment would lock half the site.

Dual status: own versus evaluated

Each item keeps two statuses:

  • Own — what I set (or the default). The cascade never rewrites it.
  • Evaluated — what the UI should show after constraints: a pure projection of the marking.

Evaluation is three rules, in order:

if blocked_by ≠ ∅     → DepLock
else if required_from ≠ ∅ → DepReq
else                  → own

When the lists clear, evaluated falls back to own on its own. That separation is the whole point: intent stays visible under a temporary lock.

Marking lists and reference counting

Three lists sit on the item marking:

  • blocked_by — actions currently locking this item via BLOCK
  • required_from — items whose unavailability makes this item required
  • conflicts_with — predictive overlap: other actions whose BLOCK targets intersect resources this item transitively requires

They behave as reference counts. An item stays DepLock or DepReq while the respective list is non-empty. Multi-blocker is ordinary: two actions locking the same resource both appear in blocked_by; releasing one does not free the resource until the other is gone.

conflicts_with is a warning mark, not an eval status. It does not invent edges and it does not schedule. It flags interference before a second action starts, if you choose to look.

The cascade as a pure, depth-bounded function

Operationally the cascade is:

(snapshot, status_event) → set of marking / eval changes

Pure: same snapshot and event yield the same changes. Side effects (persist, websocket) sit outside. Depth-bounded: REQUIRE back-propagation aborts past a configurable max_depth (with visited sets on cyclic conflict walks), so cycles in the authored graph cannot hang the operator.

What fires is configurable per layer (and optionally per edge):

  • active statuses — entering one of these can start a block cascade
  • immune statuses — items in these skip receiving blocked_by / required_from during propagation (classic case: Done)

Immunity is a design choice, not magic. Re-opening an immune item without rescan can leave a wrong plain Open while the world is still locked — reopen, if you enable it, rehydrates from the live network.

A short walk

  1. Action A1 goes active. BLOCK(A1 → R1) adds A1 to R1.blocked_by → R1 evaluates DepLock.
  2. REQUIRE(A2 → R1) sees R1 locked → A2 gains R1 in required_from → A2 evaluates DepReq. If something requires A2, the require walk continues — until depth or the chain ends.
  3. A1 completes (or returns to a non-active status). Unblock removes A1 from R1.blocked_by. If the list is empty, R1 returns to own; unrequire clears downstream required_from the same way.

Nothing in that walk invents a dependency. The graph was authored. The cascade only reads it.

What this is not

Honesty about the boundary matters as much as the algorithm:

  • Not an automatic scheduler or optimiser. It does not pick the next job or minimise downtime.
  • Not certified safety or a theorem-proved plant model. It is an operator-facing constraint projection.
  • Not a full Petri-net verification runtime. Research may inspire the token lists; shipping behaviour is a typed graph walk with reference counts.
  • Not inventing dependencies, and not letting LOCATED cascade.
  • Not ML or predictive AI. conflicts_with is graph geometry, not a forecast model.
  • Not cross-layer magic by default. Cascade stays layer-isolated; anything that bridges layers is an explicit external pattern, not a silent engine feature.

Why I keep the model small

Large ops tools grow side channels: override flags, admin marks, special-case statuses that short-circuit the rules. Every side channel teaches operators that the graph is optional. The cascade I trust is the one where own stays own, eval is a projection, BLOCK and REQUIRE do the work, LOCATED stays spatial, and depth bounds keep the walk finite.

That is enough to keep a picture of the maintenance or shutdown consistent. Scheduling, the safety case, and optimisation remain separate tasks.

Published · Updated