Skip to content

Plant packs

Reference and how-to. One directory says which plant this is. Check it before it touches anything, apply it, and see whether the plant has drifted from it since.

fsmes pack check   packs/finewire    # refuse it before it reaches a plant
fsmes pack apply   packs/finewire    # make the plant match the pack
fsmes pack status  packs/finewire    # what it runs, and whether it has drifted
fsmes pack migrate plants.toml --plant finewire --out packs/finewire

What a pack is

A plant pack is the complete, versioned, checked answer to "which plant is this?", in one directory, owned by the plant and not by this repository.

packs/<name>/
  plant.toml        identity, clock, profile, modules, words   (required)
  tag_map.json      which machines exist and what their tags mean
  masterdata/       equipment, materials, BOM, routings, specs, lots, orders,
                    maintenance plans, shifts — data, not a script
  mappings/         inbound columns, inbound SQL
  line_layout.json  optional geometry for the line view
  README.md         what this plant is, for the next engineer

Four things a pack may not contain (decision 0022):

  1. No code. No Python, no hook, no expression to evaluate. A pack that can run code is a pack nobody can review before it touches a plant. The escape hatch is a module, and a module is code and gets reviewed.
  2. No secrets. A pack names where a secret lives — secret_key_env, database_password_file, an account's password_env — and never holds one. A pack should be safe to paste into an issue.
  3. Nothing that changes what a number means. [words] renames a label on a screen. It may not rename a state, a KPI, a capability, a role, an event kind or any field a topic or an API response is built from.
  4. No schema changes. A pack cannot add a table or a column. That is a module.

The keys

Every key is enumerated in fsmes.pack.format, and an unknown key is an error. That is the whole difference between a pack and the registry it replaces: the registry was read into a plain dict, so nothing could tell a key from a typo, and it drifted from its own documentation in both directions inside two weeks.

Table Keys What it is
[pack] format, requires Which format this file is, and which product versions can read it
[plant] name, label, timezone, profile, enterprise, site Who this plant is. name is one namespace topic segment; profile is laptop, plant or fleet
[modules] one boolean per module What this plant serves. Anything not named stays on
[words] display term → this plant's word Display only. See below
[storage] database_url, database_password_file Where the data lives
[serve] api_host, api_port, opc_endpoint, simulate, speed, secret_key_env, [[serve.nodes]] What it serves, and where. [[serve.nodes]] names the nodes of one plant that run on this box, each with an id and an api_port of its own; leave it out for the plant that serves one address, which is nearly every plant — see two boxes, one plant
[redundancy] enabled, lease_seconds Whether two copies of this plant may run against one database, and how long a role is held between renewals. Needs PostgreSQL and a secret_key_env
[files] tag_map, replay_dir, line_layout, masterdata The rest of the pack, by relative path inside it
[erp] mode off, file, rest or erpnext
[uns] mode, topic_prefix off, log or mqtt
[inbound] mapping, sql, mqtt_mode What other systems tell this plant
[quality] hold_rules, major_rules, cpk_capable, cpk_marginal, spc_min_points, spc_history, gauge_ratio_adequate, gauge_ratio_floor, gauge_default_interval_days, coa_serials_listed, serial_digits, nc_code_prefix, containment_max_depth This plant's own quality numbers — every one ships the value that was in the product's source, so a plant that writes none of them behaves exactly as it does now
[oee] coverage_floor How much of a window this MES must have watched before it reports a KPI for it, between 0 and 1. Leave it out and nothing is withheld — see how much of the window did the MES see
[floor] inspect_every, issue_every, inspect_all, plan_every A simulated plant's own cadence. plan_every is how often the simulated planner looks at the order book; how deep it keeps it is planning.keep_planned in the floor script, and that is 0 — no planner at all — unless the script says otherwise, so this cadence does nothing on its own
[[accounts]] code, name, role, password_env Accounts pack apply creates, from passwords the environment holds

Everything a person writes lives inside the pack directory, by relative path. The one exception is [files] replay_dir: generated line data, often shared between plants replaying one line and often enormous, so it may point outward and it is not part of the pack's fingerprint.

fsmes pack check

No database, no network, no plant. It reads the directory and refuses, one sentence per problem — every problem, not the first, because a person fixing a pack beside a line should need one round trip:

  • a key or a table this format does not have, with the ones it does;
  • a [plant] name that is not a code, a profile that is not a profile, a time zone this machine does not know, or no time zone at all;
  • a [words] entry that renames a state, an order status, a capability, a role, an event kind, a KPI or a field every reader is built on — the protected list is read out of the product itself, so a capability added next month is protected the day it lands;
  • a module this version does not have, or a kernel module a plant tried to switch off;
  • a [pack] requires this release does not satisfy, or a format from a later release;
  • a file the pack names that is missing, points outside the pack, or that its own reader refuses — the tag map's loader, the inbound mapping's, the SQL poller's, so a pack this accepts cannot be one the OPC agent then rejects;
  • a secret, or a script, refused by name rather than as an unknown key.

What it cannot prove without a plant — that the OPC endpoint answers, that the database is reachable, that FSMES_…_PASSWORD is set on the machine this will run on — it reports as unknown, never as passing.

$ fsmes pack check labs/multiplant/finewire
Checking labs/multiplant/finewire against pack format 1.
  unknown the OPC endpoint opc.tcp://10.20.30.40:4840/FineWire/Hall2 - nothing here connects to it; `fsmes opc-verify` does
  unknown the database - nothing here connects to it; `fsmes db-status --pack <this directory>` does
  unknown the environment variable FSMES_FINEWIRE_SECRET_KEY - whether it is set where this plant runs is a fact about that machine, not about this pack
  unknown the environment variable FSMES_FINEWIRE_OPERATOR_PASSWORD - account HALL2 refuses to be created without it, where this plant runs
Usable, as far as a file can say: 9 file(s) read, nothing refused, 4 thing(s) only a running plant can answer.

fsmes pack apply

Check, then the database, then the data, then the receipt. It is the one command that becomes the plant it acts on: it puts the pack's settings into its own environment, so the schema it upgrades and the rows it writes are that plant's.

Which database, on a line of its own, before anything changes. Three answers, in this order and never a fourth:

  1. the pack's own [storage] database_url, with the password put back from the file database_password_file names — a plant that states where its data lives is the authority on it;
  2. MES_DATABASE_URL, when whoever invoked the command set it — that is the variable a deployment points at real data, and fsmes plant <name> init sets it before it calls this;
  3. otherwise <data dir>/<plant>.db, the file its fleet gives it — the same path the plant is started against.

Never this process's own default. Until 2026-09-14 that is exactly what a pack naming no database got, which is how fsmes fleet create came to migrate and seed a stray ./fsmes.db and then record ownership of a plant that had never been created. A database nobody named is not this plant's.

Pack before database. A schema migration may need a value the pack now carries, so the pack is applied first — and fsmes pack apply runs the migrations itself, in that order.

Master data is seeded from masterdata/, one file per kind: equipment.json, materials.json, bom.json, routings.json, quality_specs.json, gauges.json, lots.json, work_orders.json, maintenance_plans.json, shifts.json, downtime_reasons.json, nc_severities.json, personnel.json, skills.json, personnel_skills.json, roster.json, dispatch_rules.json. An entry whose code already exists is counted as present and left alone, never updated — a pack that rewrote a routing an order has already run against would be rewriting history. There is exactly one exception, and it is a column that has never held a value: a maintenance plan written before 2026-10-09 has no trade on it and no priority, because there was nowhere to put them, and a plant has no other way to answer that about a plan it already has. Applying a pack that states them fills those two in — only when both are empty, never over a value — and the receipt says so on its own line: maintenance_plans: 0 made, 6 already there, 6 given the trade and priority they never had. A pack that carries no master data says so rather than reporting that it seeded nothing; it builds a plant with no machines on it, which is allowed, and which fsmes fleet list and the console now show as answered, but empty.

The words an operator picks from

downtime_reasons.json is the list the station screen offers when a machine goes down. Each entry is a code, a name and an optional description — the sentence the screen shows beside the choice, and the one an agent reads to tell two reasons apart.

[
  {"code": "breakdown",  "name": "Breakdown", "description": "It was willing to run, something failed, and it made nothing until it was fixed."},
  {"code": "starved",    "name": "Starved",   "description": "Nothing arrived for it to work on. The shortage was upstream."},
  {"code": "not_yet_determined", "name": "Not yet determined", "description": "It stopped and nobody knows why yet."}
]

A code is two to forty characters, lowercase, starting with a letter — it is grouped on and published, not read as a sentence — and it may not spell a word the product already owns (a state, a capability, a role, a KPI). fsmes pack check refuses both offline.

A packed vocabulary arrives in force, not as a draft, because applying a pack is a deliberate act by a person and a plant whose station screen offered nothing until somebody approved six seeded rows would have shipped with a text box after all. Everything after that goes through draft then approve, and the plant owns the list from its first edit: applying the pack again counts an edited code as present and leaves it exactly as the plant left it.

How bad a finding is

nc_severities.json is the second vocabulary, on exactly the same terms: a code, a name and an optional description, arriving in force, owned by the plant from its first edit, and changed afterwards through draft then approve.

[
  {"code": "minor", "name": "Minor", "description": "The plant can work through it in the normal course of the shift."},
  {"code": "major", "name": "Major", "description": "Somebody should look at this now rather than at the end of the shift."}
]

A severity code is two to twenty characters — it is stored on every non-conformance, in a column twenty characters wide, and a list that could approve a word too long to store would refuse at the one moment it mattered.

A pack that seeds this list must seed minor and major. The product opens non-conformances itself — a recorded check outside its specification, and the SPC rules — and it writes those two words. A plant whose list lacked one would find out at the moment a machine raised a hold, so fsmes pack check refuses it offline instead. The names and the sentences beside them are the plant's; only the codes are fixed, and only those two.

The instruments that take the measurements

gauges.json is the plant's gauge register: a code and a name, plus the optional kind, location, resolution, interval_days, warn_days and calibrated_days_ago.

[
  {"code": "SCALE-01", "name": "Checkweigher scale 1, filler", "kind": "scale",
   "location": "FILL01", "resolution": 0.1, "interval_days": 90, "warn_days": 14,
   "calibrated_days_ago": 7}
]

resolution is what the gauge can read to, and it is the field worth arguing about. A gauge that resolves a third of the tolerance it is asked to judge cannot judge it, and GET /quality/gauges/{code}/resolution answers that question against any tolerance you give it. A fill weight held to twelve grams, measured on a scale reading to a tenth of a gram, can be defended.

calibrated_days_ago is relative, for the same reason due_in_hours is: a pack is seeded whenever somebody builds the plant, and an absolute date in one ages until every gauge in the register reads overdue. Left out, the gauge has never been calibrated — which the register reports as overdue, because a gauge nobody has calibrated is not fine until proven otherwise.

No calibration event is invented behind the date. The register records what the plant says it knows; nobody here performed a calibration, and a last-done date with the certificate wherever the paperwork actually is, is exactly what a gauge register migrated into a new MES looks like.

The order book

work_orders.json is the plant's schedule, not one order to give the counters somewhere to book. Each entry is a code, a material and a quantity; priority, release and due_in_hours are optional.

[
  {"code": "WO-ACME-4711", "material": "FG-BOTTLE", "quantity": 4000,  "release": true,  "due_in_hours": 2},
  {"code": "WO-ACME-4712", "material": "FG-BOTTLE", "quantity": 18000, "release": false, "due_in_hours": 5}
]

due_in_hours is relative — hours after the pack is applied — and that is not a convenience. A pack is seeded whenever somebody builds the plant, so an absolute date written into one is in the past the week after it was written, and every order in the book is late before the line has run a minute. fsmes pack check refuses anything that is not a positive number.

Release one, plan the rest. A line runs one order at a time, and releasing the whole book puts several released orders on one routing — which leaves the MES choosing between them by priority rather than reading which one the line is running (decision 0029 names that as still inferred).

Size it against the line. The slowest station in the pack's own tag_map.json sets the rate: 3,600 ÷ its cycle_seconds is units an hour. The three lab packs hold more than twenty-four hours of their own line's rated output, and each says its arithmetic in its masterdata/README.md. A book of one order is how a lab plant came to report an order 70× over on 2026-09-18 — correctly, and uselessly.

Nothing in the MES releases the next order. On a plant that simulates, fsmes run-operations does it as the shift supervisor would; on a real plant it is a person or the ERP.

And nothing in the MES plans one either. A pack's book is finite, so a simulated plant left up for a week finishes it: bottling's lab plant finished the last of its ten orders at 04:21 UTC on 2026-10-07, forty-three hours after it was built, and measured into no order at all from then on — no queue on the station page, an empty characteristic dropdown, every quality check with no order against it. A pack that wants to go on having work says so with planning.keep_planned in its floor script, and a simulated production planner (FLOOR-PLAN, which holds orders.create and plant.read and nothing else) keeps the book that deep by copying what the plant already makes. The default is 0, which is no planner: a pack that says nothing behaves exactly as it did before this existed, which is what the scripted over-run experiment depends on. The planner never releases what it plans — that stays the supervisor's — so the sequence is a real plant's: planner, then supervisor, then floor.

The crew, and who gets the work

Five files, and together they are the answer to "the filler's chiller just came due at two in the morning — who goes?". A plant that seeds none of them still dispatches: it falls back to one house rule, any due order to a free person with the skill on this shift, least loaded first. A plant that seeds them gets its own sentences instead.

personnel.json is the register: a code and a name, optionally a role and a home_equipment. Nobody here gets a password. The register is who the plant may name as having done something; [[accounts]] is who can sign in, and a millwright who never opens the MES needs the first and not the second. home_equipment is where somebody normally works — it grants nothing and restricts nothing, and is read only to work out who is nearest a machine.

skills.json is the trades this plant employs — a code, a name, an optional description. The product ships none: a plant that keeps pipefitters apart from mechanics writes four rows, and one that calls them all maintenance writes one.

[
  {"code": "ELEC", "name": "Electrician"},
  {"code": "MECH", "name": "Mechanic", "description": "Mechanical fitting, pipework and welding."},
  {"code": "GEN",  "name": "General maintenance"}
]

personnel_skills.json is who holds what, and at what level: person, skill, and level 1, 2 or 3 — trainee, competent, expert. The line between 1 and 2 is the only one the dispatcher reads: a trainee is never sent to a job alone. Somebody with two trades has two rows.

roster.json is who is on which shift. A row with no day is standing — this person is on this shift whenever it runs — and that is what a pack almost always means, because a pack is seeded on a date nobody can predict and a dated roster would be a week of history. A row with a day (YYYY-MM-DD) is that day only and wins over the standing row, which is how an absence or a cover shift is written down without rewriting the roster. available: false with a reason is somebody on shift and not available, which the roster page says out loud rather than leaving them off.

dispatch_rules.json is the supervisor's own sentences, and one row is one sentence:

[
  {"code": "SAFETY-NOW", "name": "Safety work goes out first, to whoever has least on",
   "priority_at_least": 1, "strategy": "least_loaded", "sequence": 10},
  {"code": "FILL-ELEC", "name": "Electrical work on the filler goes to the nearest electrician",
   "equipment": "FILL01", "skill": "ELEC", "strategy": "nearest", "sequence": 20}
]

Rules are tried in sequence order, and the first one that matches the order and finds somebody free wins. A rule matches on up to three things, any of which it may leave out: equipment (that node or anything under it), skill, and priority_at_least (1 safety, 2 production-critical, 3 routine). strategy is least_loaded, nearest or round_robin.

A rule's skill narrows the work, not the people. Eligibility always comes from the trade the order needs, so a rule with no skill on it — like SAFETY-NOW above — still never sends a mechanic to an electrical fault.

A plant that has written even one rule gets no house default. It has said what it wants, and an order none of its rules covers is reported no_rule rather than quietly swept up by a sentence the supervisor never wrote. That is the difference between a dispatcher a supervisor can reason about and one that surprises them.

Finally, maintenance_plans.json gains two optional fields of its own, skill and priority, which each order copies when the plan raises it. Leave them out and the plan means what it says: work anybody on shift can take, treated as routine.

What the simulated floor does

[files] floor names a floor script: which gauge takes a reading, which of them is drifting between calibrations, which of the plant's approved words a stop is named with, and what happens to the material behind a finding. It is read only by fsmes run-operations, and a real plant leaves it out and has people instead. The bottling lab pack is the worked example — see what the simulated floor does.

It refuses to seed into a database that is not at head. The migration above normally leaves nothing to say, and this is the assertion that it did. Rows written through the ORM into a half-migrated file leave a database with some of this product's tables and no Alembic stamp — which the migrator then disowns outright, because a schema it cannot identify is one it will not guess at. That happened once, on 2026-09-14, and left a plant no command could take forward.

Rated cycle times are not repeated in the master data: leave ideal_cycle_seconds out and it is read from the pack's own tag map. OEE performance is ideal cycle × count ÷ runtime, so a rate that drifted from the line it describes produces a number that means nothing.

Applying is idempotent. Run it again and it makes nothing twice.

fsmes pack status

$ fsmes pack status packs/finewire
  plant     finewire
  pack      packs/finewire
  applied   2026-09-13T18:22:04+00:00 by 0.1.2, format 1, 9 files
  drift     yes - the files in the pack have changed since it was applied. `fsmes pack apply` again to bring the plant to them.
  database  postgresql+psycopg://fsmes:***@10.20.30.41:5432/finewire (from the pack at packs/finewire)
  schema    c8b1e40d7a92 (head)

Drift is measured against the fingerprint apply recorded: a hash of every file a person wrote in the pack, names included, so a renamed file is a change. Generated line data is excluded.

The database line says which database the schema line is about, and it is this pack's — [storage] database_url, with the password read from the file database_password_file names. Until 2026-09-14 the schema line was about whatever database the process was configured for, which is how this command once reported "never migrated" about a plant fsmes pack apply had migrated a minute earlier. The same answer is what the running plant returns on GET /pack and what fsmes fleet status prints, from one function, so the three cannot disagree.

A pack that names no [storage] database_url leaves the choice of file to its fleet, so both lines say unknown and name --plant — this command will not answer about a default nobody asked for. Deliberately narrower than fsmes pack apply, whose third answer is the file the fleet gives the plant: finding that file means resolving a data directory, and with no --data-dir that walks up from the working directory and creates one. A command that reads must not make a directory in order to have something to report. A database that did not answer is reported as one that did not answer, never as one that is empty.

A plant that has never been applied says never rather than answering "no drift" — those are different facts, and the second one is a lie about the first. Exits non-zero on drift, on a database behind head, and on a database it could not reach or could not identify, so a deployment script can act on the answer.

fsmes pack migrate

One step exists, and it is the one every pack in this repository was written by: a plant registry entry, format 0, to format 1. Format 0 is not a file shape — it is what a plant was before packs, seventeen keys in a shared TOML file with no schema.

fsmes pack migrate ~/.config/fsmes/prod/plants.toml --plant bottling --out packs/bottling

It says what it moved, what it dropped and why — init and post_boot name code, secret_key is a secret, opc_port became a whole endpoint, agent was documented and read by nothing — and what it could not know. A registry never held a time zone, so the pack is written without one and the receipt says to set it. Guessing it from the machine's clock would tell a plant in Chicago it works in London.

[words]: what a plant calls things

[words]
lot      = "coil"
material = "alloy"

Display only, and the boundary is load-bearing. The terms a plant may rename are enumerated (fsmes.pack.check.RENAMEABLE); everything a number is keyed on is refused, against a list read from the product itself. equipment and operator are deliberately not renameable — they are also the name of an event field and of a built-in role, and a word that is both a label and an identifier is the ambiguity the rule exists to stop. A plant that wants another word for a machine renames machine.

A checked pack compiles [words] into MES_WORDS, and the words ride on /health, /shadow and fsmes info beside the plant's name and clock — so anything about to show two plants' numbers side by side can tell which word is a rename and which is a different thing.

The fleet file

The file that used to describe plants now lists packs:

packs = ["bottling", "machining", "finewire"]

[environment]
data_dir = "labs/multiplant/.data"

Paths are relative to the fleet file. [environment] data_dir stays here and not in a pack: where several plants keep their databases on one machine is a fact about the machine. FSMES_PLANT_REGISTRY still points at it — the variable kept its name; what it points at changed.

See plants from a fleet file for running them.

See also