0035 — Configuration is authored by the role that owns it, and selected by the operator¶
- Status: accepted (2026-09-18, by the maintainer, after one revision — the discovered step)
- Date: 2026-09-18
- Deciders: maintainer
Context¶
The station screen asks a stopped machine's operator why, in a free-text
input whose placeholder reads "e.g. jam at infeed, waiting on fitter"
(src/fsmes/web/station.html:43). The answer is stored exactly as typed
(EquipmentState.reason, String(120), src/fsmes/domain/equipment.py:40)
and the downtime pareto groups on that raw string —
key = state.reason or UNLABELLED (src/fsmes/services/analysis.py:583),
with no strip, no casefold and no alias map anywhere in the path. Four
spellings are four bars; sorting is by seconds, so a reason split four ways
sinks below reasons it outweighs. The chart truncates past fourteen
characters (src/fsmes/web/analysis.js:179-191), so two different typos can
render identically. An operator who types the word unlabelled moves
unlabelled_share — a headline honesty figure — with no error anywhere. The
value is published to the broker on the state-change event
(src/fsmes/services/outbox.py:104), so it leaves the MES.
There is no vocabulary table anywhere in this product. Twenty-six free-text
columns hold a reason, code, category or comment; sixteen are typed by a
person; none has an enum, a foreign key, a check constraint or a length
check at the API. docs/ARCHITECTURE.md:74 has listed a downtime_reasons
table since the architecture was written; it has never been built.
docs/design/backlog/scrap-reason-codes.md has been open since 2026-09-01
asking whether such a list is master data per plant or an enum in the
product. docs/ai/JEV.md:403-412 defers the flagship downtime-reason work
behind the same question.
The other half is who may answer it. A Role is a row with a JSON array of
capability names (src/fsmes/domain/masterdata.py:159-183) and capabilities
are twenty-one strings the API, the agent tools and the screens all gate on
(src/fsmes/services/capabilities.py:24-46) — the right shape. But one
capability, masterdata.write, gates everything an engineer of any kind
would author: equipment, materials, BOM, routings
(src/fsmes/api/routers/masterdata.py:64,105,129,175), quality
specifications (src/fsmes/api/routers/quality.py:120) and gauges
(api/routers/quality.py:407). Only admin holds it, and admin holds all
twenty-one (capabilities.py:58-60). There is exactly one configuration role in this
MES today, and it is the plant administrator.
The lifecycle this needs already exists three times: work instructions
(documents.write / documents.approve, revisions that supersede rather
than overwrite, src/fsmes/domain/documents.py:38-55), triggers ("the
draft → approved lifecycle work instructions have, because logic that fires
against a live plant is a controlled thing", src/fsmes/domain/triggers.py:1-9)
and setpoint adjustments. The agent role is deliberately built to hold the
drafting half of each pair and never the approving half
(capabilities.py:88-97).
The design page is configuration assistance.
Options considered¶
| Option | For | Against |
|---|---|---|
| Three tiers: role-authored vocabulary, operator selection, fixed — with the domain expressed as a capability prefix, and the draft→approve lifecycle the product already has | Nothing new to invent: the lifecycle, the audit spine, the capability gate and the protected-term list all exist. An engineer authors in their own domain without being made plant administrator. The operator's screen strictly narrows | Splitting masterdata.write touches roles plants may already have built. Four domains are named before anything uses them |
| A fixed enum of reasons in the product | One list, no drift between plants, comparable across a fleet | Two honest plants do not stop for the same reasons. It is house rule 4 (config, not code, at every plant boundary) answered in the wrong direction |
| Vocabulary in the plant pack only | Already validated offline, fingerprinted, reviewable, no schema change | fsmes pack apply never updates an entry that exists (src/fsmes/pack/masterdata.py:36-41), so a list a plant edits monthly cannot live there alone |
| Leave the text box and clean the data on read — normalise, casefold, fuzzy-merge | No schema change, fixes the existing history too | Guessing that two strings meant one thing is inventing a record nobody made. Principle 4 in reverse |
Decision¶
Every setting in this MES belongs to exactly one of three tiers.
Role-authored vocabulary is a list this plant owns — its reasons, its
machines, its materials, its shifts, its tolerances — authored by the role
that owns the domain and by nobody else. Operator selection is what a
person on the floor chooses at the moment of work: always a choice from a
tier-one list or a measurement against a tier-one tolerance, never a new
word. Fixed is what the product owns — equipment states, order statuses,
capability names, role codes, event kinds, KPI names, the four dispositions,
the four SPC rules, and every field an API response is built on. The test is
what breaks if two plants answer differently; the fixed list is not written
out again but read from protected_terms()
(src/fsmes/pack/check.py:101-141), which builds it by importing the
product's own vocabularies. The rule that follows: every tier-two field
names its tier-one list, and a tier-two field with no tier-one list behind
it is free text, and free text is the bug.
A configuration domain is expressed as a capability prefix, not as a new
concept. Roles stay data. masterdata.write stops being one word for five
jobs: process.define/process.approve, signals.define/signals.approve,
quality.define/quality.approve and erp.define are named now;
users.manage is unchanged; where the database lives and which port a plant
serves on stay outside the product's role model, because they are facts about
the machine.
A change to tier-one configuration follows the lifecycle this product
already uses for instructions and triggers: a person holding
<domain>.define drafts, or an assistant drafts for them with
on_behalf_of recorded; the draft is validated for schema, for
cross-references, and by a dry run against the plant's own data that states
what it would and would not cover, with the total; the validated draft is
discovered — it appears, counted and with its total, on the screen of
every role that holds the matching <domain>.approve capability and on no
screen that cannot act on it, stating how long it has waited; a person
holding <domain>.approve signs it off, never the assistant; the approved
revision
supersedes the previous one and the operator's screen changes at the next
load; and undo is approving the previous revision. Retiring a code changes
what may be chosen next and never what was chosen before — an interval
labelled with a retired code keeps that label, and the analysis keeps showing
it.
Tier one is seeded by the pack and owned by the database, in the shape
shifts already has.
Consequences¶
Easier: an engineer configures their own domain without being made administrator of the whole plant, which is the failure this is aimed at. Easier: an assistant can be given real configuration work, because the boundary it must not cross is a list the product computes rather than a paragraph of prose. Easier: the pareto stops lying.
Harder: masterdata.write has to be split, and a plant that has already
built a role on it must be migrated deliberately rather than silently.
Harder: four domains are named before anything uses them, which is a bet
that the names are right — taken because adding a capability is cheap and
redefining one is not.
Harder: discovery is a surface that has to be built, not a free ride on one
that exists. A lifecycle whose last step is that somebody remembers to look
is the failure this decision is aimed at — and all three of the lifecycles
this one copies have exactly that gap today. A document draft is visible only
to someone who opens the instructions screen and reads the rows, and its
"draft only" filter means never approved, so a pending new revision of a
document already in force is excluded from it
(src/fsmes/web/instructions.js:114-116,
src/fsmes/api/routers/documents.py:71-72). A trigger draft is a tile on the
triggers screen (src/fsmes/web/triggers.js:89-92). A proposed adjustment is
a tile on the adjustments screen counted over the loaded page rather than the
queue (src/fsmes/web/adjustments.js:108-111). Nothing tells an approver
anything: there is no inbox, no aggregate count, no approvals endpoint and no
notification channel of any kind in this product, while the drafting
capabilities sit with supervisor and agent and all three approve
capabilities are held by admin alone
(src/fsmes/services/capabilities.py:55-59,93-94). So the pilot carries the
discovery half rather than deferring it.
Honest gaps, named rather than half-built: approval here is an account code
and a timestamp. There is no qualification record, no training matrix and no
electronic signature in this product, so "a qualified role signs off" means,
today, "a role an administrator granted the approve capability to". And
configuration changes are not audited at all outside the database —
fsmes pack apply writes no audit row; its only record is the .pack.json
fingerprint stamp.
To revisit: when the first real plant says which of the six domains it actually staffs; when a second vocabulary (scrap reasons) is due, which is the test of whether the loop generalised; and if a regulated plant needs signature meaning, which is a decision of its own.
House rules touched¶
3 — agent-native, and an agent acts for a person. The drafting half of each capability pair is what an assistant may hold; the approving half is what it may never hold, and the audit row says who it acted for.
4 — never invent production; unknown is not zero. No number moves
because of anything here. A drafted vocabulary is a draft, so
unlabelled_share reports what it reports today. Existing typed reasons are
kept exactly as typed and never folded into codes automatically, because
guessing what somebody meant is inventing a record. Every list this produces
states its total, including how much of a window came from the vocabulary
and how much did not.
7 — config, not code, at every plant boundary. A reason list is the
plant's, not the product's. It is seeded by the pack and owned by the
plant's database, because pack apply deliberately never updates what is
already there.