Skip to content

Configuration is authored by roles, and selected by operators

Design. Written 2026-09-18 against the code at a21f755, before anything is built. It states what exists, proposes a role taxonomy for configuration, a three-tier scheme for every setting, an outline of the skill document an assistant would read, and one pilot that proves the whole loop on one field. It decides nothing: the decision it turns on is 0035, and it was accepted on 2026-09-18 after one revision: the lifecycle had no discovered step, and now has.


1. The problem

At 06:10 a machine stops. The operator opens the station screen, and the box that asks why is a text input whose placeholder reads "e.g. jam at infeed, waiting on fitter" (src/fsmes/web/station.html:43). Whatever is typed is stored exactly as typed, in a 120-character column with nothing behind it (src/fsmes/domain/equipment.py:40). The downtime pareto then groups by that string and by nothing else — key = state.reason or UNLABELLED (src/fsmes/services/analysis.py:583). So jam, Jam, jam at infeed and infed jam are four separate reasons, each too small to act on, and the real top reason on the line is invisible because it was spelled four ways. The operator did nothing wrong. The screen asked an open question and got an open answer.

The engineer's side is the other half of the same problem. The list the operator should have been picking from does not exist, and there is nowhere to put it: a reason vocabulary is not equipment, not a material and not a routing, so masterdata.write — "Define equipment, materials, BOMs and routings" (src/fsmes/services/capabilities.py:34) — does not cover it either. Of the six built-in roles, only admin holds any capability that writes configuration at all, and admin holds all twenty-one (capabilities.py:58-60). A process engineer who wants to name six downtime reasons must be made administrator of the whole plant, or must find one.

Both halves are one failure: the plant's vocabulary has no author. The operator is asked to invent it one stop at a time, and the person who knows it has no place to write it down and no right to.


2. Who owns what — roles and configuration domains

The kernel already has the right shape and the wrong granularity. A Role is a row in roles with a JSON array of capability names and a builtin flag (src/fsmes/domain/masterdata.py:159-183); an administrator can create roles and redefine the shipped ones on the admin API (src/fsmes/api/routers/admin.py:64,83,185). Capabilities are twenty-one strings named area.verb, and the API, the agent tools and the screens all gate on the same list (src/fsmes/services/capabilities.py:24-46). Nothing here needs replacing.

The granularity is the problem. One capability, masterdata.write, gates everything an engineer of any kind would author: equipment, materials, BOM and 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. So there is exactly one configuration role in this MES today, and it is the plant administrator.

Proposal: the domain is the capability prefix; the role is a bundle an administrator composes. Nothing in the identity model changes shape. Roles stay data. What changes is that masterdata.write stops being one word for five jobs.

Domain What lives in it today Gated by today Proposed
Plant administration accounts and roles; [plant], [modules], [words] in plant.toml; the module registry users.manage unchanged
Manufacturing / process engineering equipment, materials, BOM, routings (masterdata/*.json, POST /masterdata/*); work instructions (documents) masterdata.write, documents.write, documents.approve process.define, process.approve
Controls engineering tag_map.json — node ids, state_map, cycle_seconds, publishes_order (src/fsmes/integrations/opc/tag_map.py:81-99); [serve] opc_endpoint; triggers; setpoint adjustments triggers.write/approve, adjustments.propose/approve; the tag map is gated by nothing — it is a file signals.define, signals.approve
Quality engineering quality specifications (min_value, max_value, unit, src/fsmes/domain/quality.py:16-29); gauges; non-conformance severity masterdata.write quality.define, quality.approve
Supply chain / ERP [erp] mode; MES_ERP_* and MES_ERPNEXT_* (src/fsmes/config.py:177-201); inbound mappings nothing — environment variables only erp.define
IT [storage], [serve] api_host/api_port/secret_key_env, TLS, the fleet's data_dir nothing — files and environment stays outside the role model

Start with two, name six. The work starts with administration and process engineering, because those are the two that exist. The other four are named now so the identity model does not paint itself into a corner: a capability is a string and a role is a list of them, so adding signals.define later is cheap — changing what masterdata.write means after plants have built roles on it is not.

IT is deliberately not a role here. Where the database lives, which port the plant serves on and where the secret key comes from are facts about the machine, not the plant — the fleet file says exactly that about data_dir (src/fsmes/pack/fleet.py:16-19), and a pack names where a secret lives and never holds one (decision 0022). Naming the domain and leaving it empty is the honest answer.

And where a domain does not exist yet, say so. No maintenance engineering beyond maintenance.plan, no printing of any kind (ROADMAP.md:79 is the only mention), no unit-of-measure registry — Material.unit and QualitySpec.unit are free text with no list behind them.


2a. Navigation: one Configuration entry per domain, not one per setting

Decided 2026-09-21, after Scott used the pilot and found the first counter-example: PR #87 gave downtime reasons their own top-level chip under Engineering (FS.NAV, src/fsmes/web/common.js:44-52) — Machines, Tags, Triggers, Downtime reasons, Adjustments, Analysis, Master data, one entry per thing. Scott's rule, stated for everything from here on: a domain gets one Configuration entry in its nav group; every configurable section of that domain lives inside it. He expects hundreds of configurable sections to exist eventually — a nav bar with a new top-level entry per one is the clutter this whole effort exists to prevent, and it is worse than the free-text box it replaces if the person configuring the plant cannot find the screen among fifty siblings.

This is a correction to how §2's domains reach the screen, not to the domains themselves. Engineering's nav group today covers what will become two domains (process and controls) before either is split out; until they are, one Configuration entry under Engineering holds every domain's configurable section as a list within it, and a subnav or a picker inside that page — not a new top-level nav item — is how a second section (a second vocabulary, a routing editor, whatever process or controls engineering next needs to author) gets added. The same shape applies when Quality, Supply chain or the rest are built out: one Configuration chip in that group, not a chip per setting.

reason-authoring's placement (#87) is retrofitted to this shape rather than left as the exception — see docs/design/config-assistance.md's own history and the handoff that did it.

As built — config-nav-restructure, 2026-09-21

The shape above, in code. Engineering's Configuration entry opens /dashboard/config/engineering: a list of that workspace's configurable sections, stating its total the way every list in this product does, with one row today — the downtime vocabulary.

  1. A section is a registry entry, not a navigation change. A module that adds something configurable adds a ConfigSection to its own entry in src/fsmes/modules.py, naming the domain it belongs to. The row appears; the nav bar does not grow. The first section in a new domain adds one ConfigDomain there and one Configuration entry to that group in FS.NAV, and tests/test_configuration_sections.py refuses either half without the other — which is what stops this being retrofitted a second time.
  2. Nothing behind the entry moved. /dashboard/reasons is the same page at the same address, so every bookmark still opens it; only the nav entry pointing at it directly is gone. The screen now lights the Configuration entry it sits inside, the way a machine page lights Machines.
  3. The page gates nothing. It is visible to anybody who may see the workspace, as the screens it lists already are; each section keeps its own define/approve capabilities exactly where its author put them. What the page adds is naming those capabilities and saying whether the person reading holds them — so somebody can see whether the door in front of them opens before walking into it.
  4. A section a plant does not serve is withheld and named, never silently dropped: a workspace with one section and a workspace with one section and three switched off are different plants.

Three domains exist today: engineering (#87 and the restructure above), quality (#97) and supply chain (2026-09-25). The rest of §2's six arrive as their first section does; naming a domain with nothing in it would serve a page that says nothing.

A domain does not have to invent a nav group. Supply chain is the case that settled it: what it configures is the ERP link, and this product has no ERP screen - an order from the ERP lands in the order book and the outbox is a panel on Ops. So its one Configuration entry sits in the Orders group, beside the screens its settings are about, rather than in a group created to hold a settings page and nothing else. The rule stays one Configuration entry per domain; where that entry sits is wherever the domain's screens already are.

3. Three tiers

  • Tier one — role-authored vocabulary. A list this plant owns: its reasons, its machines, its materials, its shifts, its tolerances. Two honest plants answer differently. Authored by the role that owns the domain, and by nobody else.
  • Tier two — operator selection. 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.
  • Tier three — fixed. What the product owns: equipment states, order statuses, capability names, role codes, event kinds, KPI names, the four dispositions, the four SPC rules, the field names an API response is built from. A plant may rename the label on a screen and may not rename the thing.

The rule for deciding. Ask what breaks if two plants answer differently. If nothing outside the plant breaks, it is tier one. If a number, a topic or an API field would mean something different at the two plants, it is tier three. Tier two is not a third kind of setting at all — it is the far end of a tier-one list, seen from the floor.

Which gives the one rule worth remembering:

Every tier-two field names its tier-one list. A tier-two field with no tier-one list behind it is free text, and free text is the bug.

Tier three already has a machine-readable definition in this product, and it was not written for this page: protected_terms() builds the list of words a pack may not rename by importing them from the product — every equipment state, order status, capability, built-in role, event kind, module name, the KPI names, and the structural fields (src/fsmes/pack/check.py:101-141). A capability added next month is protected the day it lands. The tier scheme should read that same function rather than keep a second list.

A dozen settings, placed

Setting Where it lives today Tier Note
Downtime reason EquipmentState.reason, free text String(120) (domain/equipment.py:40) two, with no tier one behind it The pilot
Scrap reason nowhere — scrap is a bare quantity (domain/execution.py) two, missing entirely docs/design/backlog/scrap-reason-codes.md, open since 2026-09-01
Non-conformance disposition NcDisposition, four values (domain/quality.py:80-91) three The ERP reads it; four is what a plant does
Non-conformance severity String(20), default "minor" (domain/quality.py:99) two, no tier one Free text by accident, not by design
Quality specification limits QualitySpec.min_value/max_value/unit; pack masterdata/quality_specs.json one Quality engineering
SPC rules 1–4 — which are drawn and recorded _western_electric, hard-coded (services/spc.py) three A rule a plant can switch off is a chart that lies
SPC rules 1–4 — which raise a hold [quality] hold_rules, all four by default one Decision 0036, 2026-09-22. The row above was one row until then, and the distinction is the whole of that decision
OPC state_map — raw PLC value → MES state tag_map.json (integrations/opc/tag_map.py:33-37) one Controls engineering. The MES state names it maps to are tier three
Shift patterns shift_patterns table; pack kind shifts one Decision 0028 fixes what a shift means; the patterns are the plant's
Machine names Equipment.code/name, plus MachineMap.object one Two domains, joined by the tag map
Units of measure Material.unit, QualitySpec.unit — free text (masterdata.py:66, quality.py:25) one, missing No registry, no conversions
ERP endpoint and mode [erp] mode; MES_ERPNEXT_* (config.py:188-201) one Supply chain. Lives in the environment today, not in the pack
OEE coverage floor [oee] coverage_floor (pack/format.py:177-185) one May only withhold a figure, never invent one (decision 0033)
Over-run behaviour not configurable (decision 0029) three Deliberately
Performance cap not configurable, permanently (services/oee.py:12-13) three "a cap is a lie in the direction of flattery"
[words] display terms plant.toml [words] one Already fenced by protected_terms()

How this meets "config, not code, at every plant boundary"

Principle 7 is already satisfied for the tier one that is static: what a plant is when it is built lives in the pack and is seeded by fsmes pack apply. But pack apply never updates — an entry whose code already exists is left exactly as it is, because a pack that rewrote a routing an order has already run against would be rewriting history (src/fsmes/pack/masterdata.py:36-41).

That is right, and it means a vocabulary a plant edits during its life cannot be pack-only. The shape that already works is shifts: a database table, seeded from the pack at build time, edited afterwards through a capability-gated endpoint, audited on change. Tier one is seeded by the pack and owned by the database.


4. Draft → validated → discovered → signed off → live → undone

This lifecycle does not need inventing. The product has built it twice and a half already, and both times for the same stated reason.

  • Work instructions. Document is a revision, not a document: UniqueConstraint("code", "revision"), approving freezes it, changing it makes the next revision and leaves the old one readable (src/fsmes/domain/documents.py:38-55). The local model drafts the prose and the draft arrives as revision 1 with the model that wrote it recorded in drafted_by_model, "because a model that could put words directly in front of an operator is a hazard rather than a feature" (src/fsmes/services/drafting.py:1-13).
  • 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).
  • Setpoint adjustments. adjustments.propose and adjustments.approve — "the human in the loop before a PLC write" (src/fsmes/services/capabilities.py:45).

Configuration vocabulary is the fourth thing of that kind. The proposal is to give it the same lifecycle rather than a new one — plus the one step none of the three has, which is the step where somebody is told a draft is waiting.

Draft. A person holding <domain>.define drafts, or an assistant drafts for them. The product already knows how to say that: the audit row carries actor and on_behalf_of, and only an account whose live role is agent may set X-On-Behalf-Of (src/fsmes/domain/audit.py:27, src/fsmes/api/deps.py:163-181). A draft is a row with status = draft. It changes nothing on the floor.

Validated. Three things, and they are cheap because the product can already do each one somewhere:

  1. Schema. Codes are unique and topic-safe; no code collides with a term the product owns — the same protected_terms() the pack checker reads (src/fsmes/pack/check.py:101-141).
  2. Cross-reference. Nothing still in use disappears silently. Retiring a code that labels live intervals is allowed; doing it without the draft saying how many intervals carry it is not.
  3. A dry run against this plant's own data. Every write here already takes dry_run=true and returns the exact request it would send (docs/agents/write-discipline.md). For a vocabulary the dry run can be better than that: run the proposed list over the last thirty days of what operators actually typed and report how much it would have covered, how much it would not, and which spellings fold where. A person can judge that in ten seconds, and it is computed from the plant's history rather than asserted.

Discovered. The step this page was missing, and the one that decides whether any of the rest ever happens. A validated draft is worth nothing until the person who can sign it off knows it exists.

What this product does today, checked rather than assumed. Three lifecycles already run here, and the design-chat notes are a fourth kind of thing that waits. Not one of them tells anybody.

Waiting item Where it is visible today Anything tells the approver?
A document draft /dashboard/instructions, and only by reading rows. The "draft only" filter means never approved (src/fsmes/web/instructions.js:114-116, the same test in the API at src/fsmes/api/routers/documents.py:71-72), so a new revision of a document already in force is excluded from it. No draft count anywhere on the screen No
A trigger draft /dashboard/triggers, on a tile — "Drafts awaiting approval", counted over the plant and not the page, deliberately (src/fsmes/web/triggers.html:28, src/fsmes/web/triggers.js:89-92). The best surface in the product No
A proposed adjustment /dashboard/adjustments, on a tile — "Awaiting a decision" — counted over the loaded page of fifty (src/fsmes/web/adjustments.js:108-111), so a deep queue reads short No
A design-chat note Nowhere in the product. fsmes design-pending on the host, against ~/.local/share/fsmes/design.db (src/fsmes/cli.py:2496-2510) No

The honest sentence is: you have to know to go and look. There is no inbox, no badge, no aggregated count, no approvals endpoint and no notification of any kind — no mail, no webhook, no push — and not one of the four domain events the outbox emits is about an approval (src/fsmes/services/outbox.py:82,112,145,155). The capability split makes that the normal case rather than a corner: documents.write, triggers.write and adjustments.propose sit with supervisor and with agent, while all three approve capabilities are held by admin alone (src/fsmes/services/capabilities.py:55-59,93-94). The proposer and the approver are usually different people and nothing crosses between them — and an approved trigger can fill the adjustment queue overnight by itself through propose_adjustment (src/fsmes/services/triggers.py:107) with nobody told at all.

So the gap is not this page's. It is the product's, three times over, and a vocabulary draft would have been the fourth.

Start where Scott started: an admin dashboard. The nearest thing that exists is /dashboard/admin, gated on users.manage (src/fsmes/web/common.js:55), and it is people, roles and routings and nothing else (src/fsmes/web/admin.html:34,64,89). The plant dashboard /dashboard is the screen everybody opens, and its tiles are machines, orders, OEE and the ERP queue (src/fsmes/web/index.html:38-46); the payload behind it, /dashboard/summary, counts no waiting item of any kind (src/fsmes/api/routers/dashboard.py:191-356).

A pending-approvals panel is one row per waiting item: the kind, the code, who drafted it and on whose behalf, how long it has waited, the headline of its dry run — for a vocabulary, what it would have covered — and one action that opens it where it can be signed. Counted, and with its total, in the envelope every list endpoint here already returns (src/fsmes/api/paging.py:36-47) and the house rule the style guide states as "every list states its total" (docs/design/STYLE.md:32).

And then the harder half, because an admin dashboard is not where a process engineer looks. The whole point of §2 is that holding process.approve should not mean being administrator of the plant — so the person who signs off a vocabulary may never open an administrator's screen. The rule this page proposes is therefore not a screen but a placement:

A pending item appears on the screen of the role that can act on it, counted, with its total — and on no screen that cannot act on it.

That is one panel, rendered from what the caller may do rather than from who they are, and the mechanism is already in every screen: GET /auth/me returns the caller's capabilities, read live from the database rather than taken from the token (src/fsmes/api/routers/auth.py:57-73); one call per page is cached in FS.whoami(); FS.can() and [data-needs-cap] decide what renders (src/fsmes/web/common.js:204-212). A panel that asks the server for everything waiting that I hold the approve capability for is that mechanism turned around — the server answers from the caller's capabilities, instead of the screen hiding what the answer already contained.

Where it goes: on the plant dashboard, because that is the screen everybody opens, and on the domain screen beside the thing it is about. Same panel, different scope — the plant dashboard shows every kind the caller may approve, the triggers screen shows triggers. Somebody who only ever opens one screen still finds their own queue on it, and nobody is shown a queue they cannot act on.

A draft nobody acts on waits, visibly. It does not expire, it does not go live by itself, and it is never quietly dropped. The only thing that changes with time is one column — how long it has waited — so a forgotten draft reads as "drafted 11 days ago" rather than falling off the end of a list. Unknown is not zero, and neither is ignored.

The design-chat notes belong on the same panel. A note somebody typed on a screen is a draft of a different kind with exactly this failure. Today it surfaces only if a person on the host remembers to run fsmes design-pending — and the module's own docstring already promises something the product does not do: "opening the screen's Design panel shows what happened to what you said" (src/fsmes/services/design_triage.py:26-27), while the panel calls /design/chat and /design/status and nothing else, starting an empty conversation on every load (src/fsmes/web/design.js:160,213). One more kind on one panel, for whoever can act on it.

No notification channel is proposed here. Mail, a webhook or a push is a question for later (§7, question 6) and not a proposal: this product has no such channel at all, and the cheap honest fix is that the screen a person already opens tells them the truth when they open it.

Signed off. A person holding <domain>.approve. Never the assistant: the agent role is built to hold the drafting half and never the approving half, deliberately and in one place (src/fsmes/services/capabilities.py:88-97). Approval records who and when (approved_by, approved_at), exactly as a document's does.

What this honestly is not: approval here is an account code and a timestamp. This product has no qualification record, no training matrix and no electronic signature anywhere in it. "A qualified role signs off" therefore means, today, "a role an administrator granted the approve capability to". If it should mean more, that is a decision and a schema, and it is not in this page.

Live. The approved revision supersedes the one before it, and the operator's dropdown changes at the next screen load — the screens already fetch what they may show from the API on load and gate on it (src/fsmes/web/common.js:204-212). The audit spine gets the row: who, when, before and after (src/fsmes/services/audit.py:8-30).

Undone. Cheaply, and in one move: approve the previous revision, which supersedes the current one. Nothing is deleted and no history is rewritten. And the part that makes it genuinely cheap:

Retiring a code changes what may be chosen next. It never changes what was chosen before. An interval labelled jam-infeed keeps that label when jam-infeed leaves the list, and the analysis screen keeps showing it. A record is not un-made — the same principle decision 0029 applies to an order that over-ran.

So the worst outcome of a bad vocabulary is a month of stops labelled with words somebody regrets — which is exactly the situation today.

What the assistant may never do

Decisions 0031 and 0032 already answer this, and this page does not reopen either.

A model's answer is a proposal, never an input to a graded number (0031). A drafted vocabulary is a draft; EquipmentState.reason stays as it is until a person selects from an approved list, so unlabelled_share reports today what it reports today.

And 0032 decides where the work runs. A plant's reason vocabulary is named in that decision as configuration class. But the free text an assistant would read in order to propose one is production class — "anything a person typed" — refused in shadow mode and off by default everywhere. So the clustering step runs on the local model, the same qwen3:8b over Ollama that already drafts work instructions (src/fsmes/services/drafting.py:27-28), and sends nothing anywhere. Not a workaround: the decision applied.


5. What the MES's own skill document must cover

A skill document is what lets an assistant work a system confidently and safely without being told everything each time. The interesting question is not what to write first — it is where it lives, and decision 0032 already answers that.

Both, and the class decides which half. What is identical at every plant — the tiers, the lifecycle, the capability names, the refusals — is catalogue class: text that ships in the wheel, in the repository at docs/agents/configuring-a-plant.md, beside the write discipline it extends. What is this plant — its machines, materials, shifts, current vocabulary and how much of the last month that vocabulary covers — is configuration class, generated from the running plant, shipped nowhere.

How it stays true: a test, in the shape this repository already uses twice. tests/test_capabilities.py asserts every require("…") literal names a real capability; tests/test_shadow_mode.py scans the source and fails on an unregistered outbound call site. Every capability, tier-three term and refusal the document names is read back from CAPABILITIES, protected_terms() and the register, so a document that has drifted from the product fails the build.

The outline, section by section. This page does not write it.

  1. What this MES is, and the one rule. Three tiers in six lines, and: every tier-two field names its tier-one list.
  2. The domains and who owns them. Generated: the capability list with its one-sentence descriptions, and this plant's roles with what each holds.
  3. This plant's vocabulary. Generated: machines, materials, shift patterns, the approved reason list, and — the part that matters — how much of the last thirty days each list actually covers, with the total.
  4. The tier test. How to decide which tier a setting is, and the protected list, read from the product.
  5. How to propose. Always a draft; always dry_run first; always on_behalf_of a named person. Nothing here is new — it is the write discipline applied to configuration.
  6. How to validate before proposing. The three checks of §4, and how to compute the coverage dry run.
  7. What it must refuse, and how to say so. Anything tier three. Anything that would change a number already booked. Any state class this plant has not turned on. Approving its own draft, always.
  8. How to hand a draft over. What the person is shown before they sign: the diff, the coverage, what is retired and how many records carry it — and that handing over means the draft now stands in that person's own pending queue, not that a message was sent somewhere.
  9. How to undo, and what undo does not do — it changes what may be chosen next, never what was chosen before.
  10. When to say it does not know. Unknown is not zero. A vocabulary the assistant cannot justify from the plant's own history is not proposed.

6. The pilot: reason codes

Today

Twenty-six free-text columns in this MES hold a reason, a code, a category or a comment; sixteen of them are typed by a person. Not one has an enum, a foreign key, a check constraint, a pattern or a length check at the API. The pilot takes the one that matters most and leaves the other twenty-five alone.

Where a downtime reason enters, and from whom. Four writers, not one:

Writer What it writes Where
The station screen whatever the operator types src/fsmes/web/station.html:43 → POST /equipment/{code}/state (api/routers/equipment.py:57-63)
A trigger params["reason"], or a composed string like "Infeed jam (Alarm_Word=1)" src/fsmes/services/triggers.py:52-56
An inbound feed the incumbent system's own reason_code column, verbatim config/inbound_mapping.json:12; integrations/inbound/contract.py:116-133
An agent tool any string a model chooses src/fsmes/mcp_server.py:434-446

And a fifth thing that writes nothing: the OPC agent never supplies a reason at all (src/fsmes/integrations/opc/agent.py:589-591). Every stop a machine reports by itself lands unlabelled. That is the cold start, and it is why the vocabulary has to come from somewhere other than the machines.

What a typo does. The pareto groups on the raw string — key = state.reason or UNLABELLED (src/fsmes/services/analysis.py:583) — with no strip, no casefold, no alias map anywhere in the path. So:

  • Four spellings are four bars. Sorting is by seconds (analysis.py:594), so a reason split four ways sinks below reasons it actually outweighs. The pareto inverts, silently.
  • The chart truncates anything over fourteen characters (src/fsmes/web/analysis.js:179-191), so two different typos can render as the same bar.
  • An operator who types the word unlabelled merges into the machine's unlabelled bucket and moves unlabelled_share — a headline honesty number (analysis.py:619) — with no error anywhere.
  • The value is published to the broker on the state-change event (src/fsmes/services/outbox.py:104), so the typo leaves the MES.

The project has already written this down and deferred it: docs/ai/JEV.md:403-412 says the reason vocabulary "only exists if the plant has already labelled stops… the answer is probably the ISO 22400 / incumbent categories as a starting vocabulary a plant edits, which is a plant pack question." This page is that answer, and it agrees with the guess.

Proposed, end to end

  1. Author. The process engineer holds process.define. The assistant reads the last thirty days of typed reasons with their durations — on the box, on the local model, because that text is production class — and comes back with: "these 40 spellings look like 6 reasons. Here is a draft, with what each one would have covered. You decide."
  2. Validate. Codes unique and topic-safe; none collides with a term the product owns (protected_terms()); and the coverage dry run: how many intervals and how many hours the draft would have covered, how many it would not, and which spellings fold where. Stated as a total, per house rule.
  3. Sign off. A person holding process.approve. Not the assistant, ever — and one draft at a time. docs/ai/JEV.md:409-412 makes the point against itself: "a proposal a supervisor accepts a hundred times a shift becomes a booking by fatigue". A vocabulary is signed off once, which is exactly why it is the right place to start.
  4. Live. The station's reason box becomes a <select> fed by a catalog endpoint, in the exact shape GET /triggers/catalog already has — a server-owned {code: sentence} map the screen renders (src/fsmes/api/routers/triggers.py:29-43). That this is the right shape is not a matter of taste: the only two selects in the whole reason family today are hardcoded <option> blocks duplicating a Python enum in HTML and in JavaScript, and one of them is already wrong — the schedule screen offers shutdown/overtime where the enum is non_working/working (src/fsmes/web/schedule.html:108-112 against src/fsmes/domain/calendar.py:24-26), so both of its options 400.
  5. Undo. Approve the previous revision. Intervals already labelled keep their labels.

The smallest PR that proves it

  • One table, downtime_reasons — code, name, description, revision, status, created_by/at, approved_by/at — shaped exactly like documents, with one Alembic migration. docs/ARCHITECTURE.md:74 already lists this table as a module table; it has never been built.
  • A nullable reason_code beside the existing reason, never in place of it. str_enum in this codebase emits no check constraint (src/fsmes/domain/common.py:11-19), so this would be the first database-level constraint on any reason here — which is a reason to add it additively and leave every existing row untouched.
  • GET /equipment/downtime-reasons, in the /triggers/catalog shape.
  • Two capabilities, process.define and process.approve, added to CAPABILITIES and to the admin bundle. Nothing is re-gated yet.
  • The station screen: a <select> from the catalog when a vocabulary is approved; today's text box when there is none.
  • The pareto groups by code where there is one and by text where there is not, and states both totals — every list states its total, and "how much of this window came from the list" is the number that says whether the pilot worked.
  • One pack masterdata kind, downtime_reasons, so a vocabulary can ship with a plant (src/fsmes/pack/masterdata.py:53-90 refuses any file that is not one of its nine kinds).
  • The discovery half, or the pilot has not tested the loop that fails. Three small things: GET /equipment/downtime-reasons?status=draft returning the paging envelope with its total, the way /adjustments already does (src/fsmes/api/routers/adjustments.py:28-39) and the two older lifecycles do not; one endpoint that answers what is waiting that I may approve from the caller's capabilities, which in this pilot has exactly one kind behind it; and one panel on /dashboard that renders it — kind, code, drafted by and for whom, how long it has waited, the coverage headline, one link.

What it would not touch. Not the OEE math. Not the definition of unlabelled_share. Not scrap — scrap has no reason column at all and that is a second pilot, already written up in docs/design/backlog/scrap-reason-codes.md. Not the other three writers: triggers, inbound feeds and agent tools keep writing text, and the pareto says how much of the window came from the list and how much did not, rather than pretending. Not masterdata.write — splitting it is its own change. Not any screen but the station's and the plant dashboard's. Not the three existing lifecycles: documents, triggers and adjustments keep the surfaces they have, and join the panel when the second vocabulary does.

What that adds, honestly: one endpoint, one panel and their tests — call it a third again on top of the rest of this PR. It is still the right size. A pilot that proves drafting and signing but not finding has tested everything except the thing that went wrong.

How it shows on the two lab plants

Half of it, and the honest half. Both lab plants are driven by the OPC agent, which supplies no reason, so their downtime is entirely unlabelled and there is no typed history on either to cluster. The clustering step needs a plant with a month of people typing: the first real plant in shadow mode, or a lab script that deliberately types a messy set.

What they can show today is the rest of the loop — seed a vocabulary into each pack, apply it, sign it off, watch the dropdown appear, label stops from the list, and see the pareto group by code with both totals stated. Worth doing first, because it is the part the product has to get right whether or not a model is ever involved.

(Stated for the record: this page did not read the running lab plants' data. The question it would have answered — do they hold free-text reasons worth clustering — is answered in the code above: they hold none.)


7. Questions for Scott

Six, each with the option this page would take, and why.

1. Are those six domains the right six for a real plant? Administration, process engineering, controls, quality, supply chain, IT. Proposed: yes, with IT named and left outside the product's role model. No amount of reading the code answers this one, and it is the expensive one to change later: a capability name is cheap to add and painful to redefine once plants have built roles on it.

2. Does the product ship a starting reason vocabulary, or does every plant start empty? Proposed: ship one, ISO 22400-shaped, and let the plant own it from its first edit. Seven terms already exist in this repository with operator-readable descriptions — src/fsmes/sim/labelled.py:62-86, written for the calibration work — and a plant that starts empty gets no dropdown on day one, which is the failure this whole effort is about. The pack seeds it; the plant edits it; the product never edits it again.

3. Once a plant has a vocabulary, is a reason required when a machine stops? Proposed: required, with an explicit "not yet determined" code in the list rather than an allowed blank. Required will annoy people in week one. But a blank reads as an omission where the truth is "nobody has looked yet", and nothing else in this product lets a blank stand for an unknown.

4. What happens to the reasons people have already typed? Proposed: keep every one of them, exactly as typed, and never rewrite one. The new code sits beside the old text; the pareto groups by code where there is one and by text where there is not, and states both totals. Folding old text into new codes automatically would be inventing a record nobody made.

5. Should "a qualified role signs off" mean more than "a role an administrator gave the approve capability to"? Proposed: not yet. This product has no training record, no qualification matrix and no electronic signature — approval is an account code and a timestamp. Those are real features for a regulated plant and each is a decision of its own; building half of one inside this pilot would be worse than naming the gap.

6. Where does the approving role see what is waiting? Proposed: Scott's admin dashboard as the place to start, extended by one rule — a pending item appears on the screen of the role that can act on it, counted, with its total, and on no screen that cannot. This is not a new worry: the product has the gap three times already, and §4 sets out what each of the three does today. Starting on the plant dashboard puts the panel where everybody already looks; rendering it from the caller's capabilities rather than from a role name is what keeps it useful to a process engineer who will never open an administrator's screen. The sub-question this page deliberately does not answer: whether any channel outside the screen — mail, a webhook, a push — is ever worth having. There is none in this product today, and adding the first one is a decision of its own.


8. What this is not

  • Not a generic configuration engine. One table, one field, one screen, one loop. If the loop is right, the second vocabulary is a small change; if it is wrong, one table is what has to be undone.
  • Not free-form chat that edits settings. Every change is a draft, and every draft waits for a person with a capability — visibly, on that person's own screen.
  • Not a notification system. No mail, no webhook, no push is proposed here. A screen somebody already opens says what is waiting for them when they open it; whether anything should ever reach further than that is question 6.
  • Not an assistant with a right to sign anything off. The agent role is built to hold drafting capabilities and no approving one (src/fsmes/services/capabilities.py:88-97); that stays true.
  • Not a loosening of what operators may do. The only change on the floor is that a text box becomes a list. Where the list does not yet exist, nothing changes at all.
  • Not a change to any number. No OEE component, no booked count, no coverage figure, no unlabelled_share moves because of anything in this page.
  • Not built. Nothing here is a schema, an endpoint or a screen until decision 0035 is accepted.

9. Built — what the pilot taught, 2026-09-19

The pilot in §6 is built: the table, the two capabilities, the catalogue, the station's select, the pareto's two totals, the pack kind, and the discovery half. The page above is not amended, and neither is decision 0035. Six things it got wrong or left out, recorded where the next piece will look:

  1. Three statuses were not enough. §6 proposed draft / approved / superseded. The build needed a fourth, retired, because superseded means another revision of this code took over and retired means this code is no longer offered. A pareto that could not tell those apart could not explain why a bar it still shows is on nobody's screen.

  2. ?status=draft on the catalogue is two shapes on one path. §4 spelled the waiting list as a query on the catalogue; it was built as GET /equipment/downtime-reasons/drafts, because the catalogue is a map a screen renders and the queue is a list a person works through, and one route returning either is a route a caller has to guess at.

  3. The panel signs rather than opens. §4 asked for "one action that opens it where it can be signed". A vocabulary has no screen of its own in this pilot, so the row's action is the signature, taken on the panel; the server hands each row the path to post. That is honest for a vocabulary — the row shows the whole draft — and it is what makes one panel able to sign several kinds. A kind whose draft cannot be read in a row will want a link instead, and the shape already carries one.

  4. Two of the seven shipped terms do not describe a stop. §6 pointed at the seven reasons in src/fsmes/sim/labelled.py, but none ("the machine did not stop in this window") and counter_reset ("without the machine stopping") are answers about a window, not labels for a stop. Shipping them would have invited a wrong label, so the starting vocabulary is the other five plus the explicit not_yet_determined that §7's question 3 asked for.

  5. A packed vocabulary arrives in force, not as a draft. The page did not say which, and "a draft never goes live by itself" reads like it should be a draft. 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. Every change after the first apply goes through the lifecycle.

  6. protected_terms() is in the wrong layer. It lives with the pack checker (src/fsmes/pack/check.py), which is an edge, and the vocabulary service reads it — so the layering test carries a written allowance for one call-time import. Moving it down to the services layer is a change of its own, and worth making before a second domain reads it.

And what the pilot did not do, beyond what §6 already excluded: the analysis screen does not yet print the two totals the API now carries, because §6 said to touch no screen but the station's and the dashboard's. That is the smallest next piece, and it is one paragraph of JavaScript.


10. The audit — what else is still hard-coded, 2026-09-21

This page decided how. The next question is what else, and it is answered somewhere that can go stale without taking a decision with it: What is still hard-coded that a plant might want to own, produced by fsmes config-audit, which applies §3's two-plants test to the whole tree mechanically and can be rerun any day.

Three things from it belong here rather than there, because they bind everything built from now on and are not a snapshot of one day's source:

  1. The literal that is in the source today becomes the shipped default, unchanged. Every candidate the audit finds already has a value, so making one configurable must not also move it. A plant that configures nothing behaves exactly as it does now.
  2. A list is honest as a plain list up to twelve; past twelve it needs search and a stated, stable sort. Twelve because this product already answered "how many rows is a screenful" once, at services/analysis.py:440. A scrolled list without search reproduces the failure the free-text box had: the word is in there, the person cannot find it, and they add a duplicate.
  3. Kind-tagging is a rule about the PR, not about the literal. The PR that makes any setting configurable adds a ConfigSection in modules.py in the same PR — every setting, including one that will only ever be a key in plant.toml — and adds a KINDS entry in services/review.py in the same PR if and only if it has a draft-then-approve step. Neither is ever added afterwards. The nav was retrofitted once (§2a); that is what this rule exists to prevent happening again.

One correction to §3's table came out of the audit and is recorded here rather than left in a dated page. §3 placed "SPC rules 1–4" in tier three with the note "A rule a plant can switch off is a chart that lies." That reasoning is about the chart, and it holds. Whether a plant may choose which rules raise a hold is a different question that §3 did not ask.

It was answered on 2026-09-22 by decision 0036: every rule is evaluated, drawn and recorded on every plant, and which of them raise a quality hold is the plant's, defaulting to all four. §3's sentence stays true and is now scoped to the chart, which is what it was always about; §3's table above is split into the two rows the distinction needs. The rule windows — 2-of-3, 4-of-5, 8-in-a-row — are tier three either way: a plant that changed them would publish SpcSignal.rule = 3 while meaning something nobody else means by rule 3.


11. How a plant-scope setting becomes editable — built 2026-09-24

§3 said tier one is seeded by the pack and owned by the database, and named shifts as the shape that already worked. §10's audit then turned thirteen [quality] literals into pack keys (#98) — compiled into environment variables and read from there, which made each of them a row on Quality's Configuration page saying nobody — it changes when the pack is applied and the plant restarts.

Scott clicked one on 2026-09-24 and asked the obvious question: "when I click on stuff, it doesn't seem to take me to where I can actually make those changes. Shouldn't it?" He was right, and the honest answer was that #98 had taken the fallback its handoff allowed. So the second half was built, and built generically, because Process, Controls and Supply chain each have the same gap waiting for them.

What a future section has to do

Two things, and nothing else.

  1. Set edit_here=True on its ConfigSection in fsmes/modules.py.
  2. Name the capability in that section's define, and leave approve as None.

That is the whole opt-in. No table of its own, no endpoint of its own, no migration, no KINDS entry, no JavaScript. The page renders an input per key, PATCH /dashboard/config/{domain}/settings/{key} accepts it gated on that section's own define, and fsmes pack apply seeds it.

A section that sets edit_here and names no define is refused by test_live_plant_settings.py, because anybody who can see this screen may change this number is exactly what pack_keys was invented to avoid saying.

The mechanism, in four sentences

  • plant_settings holds one row per setting a plant owns, keyed by the pack's own [section] key and holding its value as text — the same string the compiled setting would have carried, so the database and the environment hold a value in the same shape. It belongs to the dashboard module, which is kernel: a plant that switches Quality off keeps the numbers it chose.
  • Three layers, read in this order: the row, the setting the pack compiled, the literal the product ships. So a plant that configures nothing behaves exactly as it did, and the migration that added the table moves no data — which is also why it does not try to read a plant's pack file from inside a migration, something a migration cannot honestly do.
  • Read through the caller's session, memoised on session.info the way services/calendar keeps shift patterns out of a hot loop. One query per section per unit of work, no process cache and no refresh interval: a number saved is in force on the next reading, in every process, with no restart.
  • Validated by fsmes pack check's own rules, called on the table with the one value changed — which is what lets cpk_marginal be judged against the cpk_capable this plant is actually running on rather than against the product's default. One wording for one rule, whichever door the value came in by.

Why there is no approval step

Rule three of this design, restated because this is the first thing built squarely on it: a number a plant administrator edits and which takes effect when saved has no pending state. Nothing in this MES records the Cpk bar that was in force when I was judged — a non-conformance stores its severity, and a chart is drawn fresh every time — so there is nothing for a revision to protect and nothing for an approver to sign. Undo is typing the old number back, and the audit row says what it was.

That is the line between these eleven sections and the severity vocabulary sitting beside them on the same page, which keeps its full draft → approve → supersede → retire lifecycle: its words are written onto records that outlive it. A setting is editable here when nothing stores the value it had at the moment it decided something. That is the test to apply to the next one.

And the assistant already has it — do not write a tool per domain

There are three agent tools for every live setting there will ever be (two when this was written; setting_changes joined them on 2026-09-26, and the subsection below says why). plant_settings reads — a workspace, a search across all of them, or one setting in full; write_plant_setting(plant, domain, key, value) puts one value in force through the same PATCH a person's Save uses; setting_changes reads the audit trail for settings. All three read the registry rather than a list of their own, so the two steps at the top of this section are still the whole opt-in: a section that becomes live is reachable through the assistant with no tool written for it.

So the drafting story for a live setting is this tool, not a new one. Nobody should add another file under src/fsmes/mcp/ for the next domain's numbers — the reason write_plant_setting is not in agent.NEEDS is exactly that no single capability gates it: it is the owning section's define, read per call from the registry (agent.PER_CALL_NEEDS carries the reason in the source).

The card a person sees is propose_adjustment's, not draft_nc_severity's, for the reason the section above gives: there is no draft state to sign, so the proposal waits for a click rather than for an approver. Show me walks to the owning workspace's Configuration page with the box filled in (?setting=<key>) and ends on Save; Do it writes it and then walks back to the same box to show the value in force. There is no approve capability anywhere near either tool, and there is nothing for one to do.

What is still the pack's

fsmes pack apply seeds a key once and never updates it, exactly as it treats every masterdata kind. So a pack file is how a new plant starts and not how a running one is steered, and editing plant.toml and re-applying does not move a number the plant has taken ownership of. fsmes pack status reports the difference rather than resolving it, which is the same answer decision 0022 gave about a routing an order has already run against.

And the list has to fit in the answer — 2026-09-26

The two tools above were right and too big for the mouth that has to speak them. Scott asked the assistant for "the default reporting window" and was told no workspace holds that setting. It does: [process] default_report_hours, on the Engineering page, section label The default reporting window — item 17 of 22. plant_settings returned every key with its full about paragraph, about 11,100 characters for that one workspace, and the agent loop shows a model the first RESULT_LIMIT characters of a tool result — 6,000, cut mid-string. The model saw eleven of twenty-two, had no way to know the list went on, and reported half a list as the whole.

Four things changed, and the first of them is the rule for every list a tool will ever return.

A list must fit, and a list that cannot fit must say so in itself. A row is now the ten compact fields in fsmes.mcp.settings.ROW and no paragraph — including the owning section's label, which is what a person calls a setting and which the row did not carry at all. Even compact, Administration's thirty-nine rows are about 10,900 characters, so the tool pages: as many whole rows as fit six tenths of the result limit, total for what the plant has, showing for what is here, and more naming the call that reaches the rest. tests/test_every_settings_list_fits_in_one_tool_result.py measures every workspace of every pack this repository ships and fails if any of them crosses the line. Raising the limit was refused as a fix: a bigger number hides the same failure behind the next longer list.

Found by what a person calls it. plant_settings(plant, find="reporting window") searches every workspace's names, labels and paragraphs at once and answers with the matching rows and the workspace each is in — one call, no workspace named. key= returns one setting in full, paragraph included, so nothing was lost by taking about out of the list. GET /dashboard/config is the index the search walks: until it existed, the only way to learn the workspaces from outside was to name one that does not exist and read the 404.

Truncation tells the truth. services/agent._tool_result no longer cuts JSON mid-string. A payload carrying a list loses whole trailing items from whichever list is carrying the bulk and gains a truncated line — showing 10 of 22 settings; the rest were dropped … do not report these as all there are. A plain string still gets cut, and now says how many characters went missing.

Look before saying what is recorded. setting_changes(plant, key, domain, limit) reads GET /audit?entity_type=plant_setting and answers who set what, from what, to what, when. It exists because the second half of that morning was Scott changing the value himself, saying so, and being told "I don't see any change recorded" by an assistant that had made no call at all and had no tool it could have made. The prompt now carries the rule in plain words: when the person says they changed something, read the plant again before you answer.

And one wording fix beside them. The assistant had written "Updating it now" over a proposal card that had changed nothing. The prompt forbids it, but a prompt is a request — so the card itself now opens with Nothing has changed yet, which is the screen's own sentence and not the model's.

12. The second domain, and the first honest exception - 2026-09-25

§11's claim was that a future section needs two lines. Supply chain is the test of it, and it held: five of its six sections are edit_here=True plus erp.define, and they needed no table, endpoint, migration or JavaScript of their own. What they did need was everything §11 does not cover, which is worth writing down because Process and Controls will meet the same two things.

A setting a connector applies, not a service

Quality's eleven are read by services, which are handed the caller's session, so §11's "read through the caller's session" was the whole answer. Four of supply chain's are read by a transport - which order statuses to ask the ERP for, when a number read back is the number sent, how long to wait - and a transport is handed no unit of work on purpose: integrations/erp/sync.py keeps every database transaction short and never lets one span an HTTP call, so an adapter that opened a session to find out its own timeout would be the first thing to break that rule.

So the worker reads them, not the adapter. sync.cycle builds a services.erp.Policy in one short transaction, closed before anything is sent, and hands it to the connector through an optional configure method - read off the object the way check already is, so a connector published on its own and written against the older port keeps the values it was built with and no cycle fails over a method nobody promised. A setting saved on the page is in force at the start of the next cycle: MES_ERP_POLL_SECONDS, five seconds by default, with nothing restarted.

The shape to reuse: where the reader of a setting is an edge rather than a service, the answer is to carry the value to the edge from a caller that has a session, not to give the edge a database.

A setting that genuinely cannot be live, and says so

[erp] confirmation_seconds_tolerance is read by fsmes erp validate, which reads a folder of files and no database - that is its contract, and a plant's ERP team runs it on a laptop that has never had an MES database on it. There is no session to read a live row through, so a box saying in force the moment you save it would have been false.

It is a pack key and a setting with no edit_here, and it is the first section in this product to take that path. The page already had the words: nobody - it changes when the pack is applied and the plant restarts, which config.js kept on the day the live ones arrived precisely because "a setting that genuinely cannot move while a plant is running is a real thing a future section may be". It was.

The test to apply: §11 asks whether anything stores the value a setting had at the moment it decided something. Ask a second question beside it - is there a session where this value is read? A no is not a reason to leave a judgment in the source; it is a reason for the row to say the other true sentence.

One audit row that was wrong, and what reading it settled

S7 of the 2026-09-21 audit called this constant "arguably a bug rather than a new setting", because incumbent.py already reads a plant-supplied tolerances object it was never routed through. Reading both: they are two different comparisons. incumbent.Mapping.tolerances is the slack between this MES and an incumbent MES's export, and scorecard.py already reads it. validate.py's constant is the slack between two numbers inside one document this MES itself wrote, and fsmes erp validate is handed no mapping at all. Nothing was missed; the audit row conflated them. The curated row says so now rather than the correction living only here.

13. Applied a third time — Engineering, 2026-09-25

§11 said the whole opt-in was two lines and claimed the next domain would need nothing else. Scott asked the obvious follow-up — "can the methodology be expanded to all tabs?" — and process and controls engineering were the first answer to it. Seventeen sections, twenty-two keys, and §11's claim held: the plant_settings table, the PATCH endpoint, the three-layer read, the seeding rule and the page's own input all took them with no change of their own.

What the second application did need, and what each of those tells us about §11:

  • Two new pack tables, [process] and [controls], plus one key added to [oee]. A declaration is not the mechanism; §11 never claimed a domain would arrive with its keys already declared.
  • A new key kind, floats, for a list of measurements. report_windows is [0.25, 1, 8, 24, 168] and ints cannot say that a quarter of an hour is a real window. The kind is the pack format's, not this seam's.
  • A new capability, signals.define. §2 named it in this very document and said adding it later would be cheap because a capability is a string and a role is a list of them. It was. Its pair signals.approve is deliberately still absent: there is nothing yet for it to approve, and a capability a plant could grant that gates nothing is a role saying something untrue about itself.
  • A checker per pack table, found by name from plant_settings.write. §11's one wording for one rule only works if the validation a pack file goes through is the validation an input goes through, so a section that gains a <section>_numbers in fsmes.pack.check gains it behind the input, and a section with none is validated by fmt.parse alone. That is the honest answer for a key whose only wrong values are ones that are not the kind of thing.

Two things §11 did not say, and should have

Some settings the browser draws with cannot ride on a payload. §11's shape is that a number reaches the screen beside the figure it judges — the Cpk bar arrives with the chart it colours — and that is right, because a number beside its own figure cannot drift from it. Three of these seventeen cannot do it: the shared time picker is built before any panel has asked for anything, and the shift form and the schedule board's horizon are controls rather than readings. GET /dashboard/screens serves those, and the rule for what belongs on it is narrow: a setting goes there only when no payload it could ride on exists yet. Anything else belongs with its figure.

"In force at once" has honest exceptions, and they have to be named on the page. opc_history_ratio and its floor are read when the OPC agent next subscribes, because a sampling interval is a number the server holds for the life of a subscription. There is no way to make that immediate, and the choice is between saying so on the row and letting somebody believe otherwise. §11's promise should be read as in force on the next reading, and a section whose next reading is a reconnection says which.

The fourteen that were left

Process and controls engineering had thirty-one candidates on the audit page; seventeen are here. The other fourteen are the ones scoped object, and they are the clearest demonstration that this seam has a boundary: a counter that wraps at 65535 and one zeroed every shift are two tags, not two plants. A plant-wide setting for them would be wrong about one of them, and plant_settings is keyed by [section] key with no room for a third name — deliberately, since a table that could hold per object rows would be a second object model. Those belong on the object's own row, and C14 in particular waits on the bulk-edit design §9 opened.

14. The fourth domain, and what it taught — 2026-09-25

Administration's eighteen plant-scope rows and IT's three went onto this seam the same day as the second and third. The claim above — two things, and nothing else — held: sixteen sections, forty-four keys, one flag and one define each. What needed writing was everything around the seam rather than the seam itself, and three of those are worth keeping.

A section may honestly say no. edit_here=False was written as a placeholder that no section reached. Three reach it now, and all three are real: the list envelope's default and ceiling are published in this plant's own OpenAPI document, so a ceiling that moved under a caller holding that document would make the document a lie; logging is configured before the plant's database is open, because it is the thing that reports a database that will not open; the fleet probe is the console's number about every plant it watches and the console has no plant to read a row from. For these the page says nobody — it changes when the pack is applied and the plant restarts, which is better than a box that appears to work.

The browser is a reader like any other. Half of administration's rows were literals in JavaScript, and the mechanism said nothing about them. It does not need to: GET /dashboard/ui-settings answers with the [screens] table typed, the browser asks once per page load through FS.settings(), and no default is written in the browser at all — because a browser default beside a server default is precisely the drift the audit kept finding. The pack table is the list of what the browser reads; nothing enumerates it a second time.

The pack table is not the workspace. [admin], [screens] and [system] are three tables on one Configuration page. That is not an accident of tidiness: a pack table is a table in a file and a workspace is a place on a screen, and plant_settings.owner was written to keep them apart. It is what lets IT's settings sit on Administration's page without IT getting a ConfigDomain that decision 0035 §2 deliberately denies it.

And one thing this found rather than added: write refused what was wrong with the table rather than what the edit broke. Since a pair rule reports at whichever of its two keys reads best, raising cpk_marginal over cpk_capable was refused while lowering cpk_capable under cpk_marginal — the same crossing-over — was accepted. It now judges the difference between the table before and after, which also stops a plant that is out of range on one key from being unable to save any of the others.

15. The ratchet: every action a person can take, measured — 2026-09-26

The four domains above each shipped with the tools a person could use them through, and nothing checked that claim against the screens. Twice in two days the check was Scott: on 2026-09-25 the assistant could not find default_job_minutes, and on 2026-09-26 it said no workspace held "the default reporting window" and then that no change was recorded when he had just made one. Both were real, both were fixed (#105, #108), and both were found by the maintainer because nothing measured what the assistant can do against what a person can do.

tests/test_route_coverage.py has ratcheted screens against routes since 2026-09-02 — every API route has a screen, or a written reason not to. This is its sibling, and reads like it:

Every write route has an MCP tool, or a written reason not to. tests/test_every_write_route_has_a_tool_or_a_reason.py

A write route is one with method POST, PUT, PATCH or DELETE. A tool sends a route when the tool's own source says so: every @mcp.tool() in mcp_server.py and mcp/*.py is parsed, and each call it makes to the server's write() or call() helper gives the method and the path template it sends — so the answer cannot drift from the code, and a tool whose path ends in an argument it only accepts a closed set of values for (order_action's action) is expanded into them rather than reported as a shape. The list of exceptions may only shrink: a route named there that has grown a tool fails the test.

fsmes assist coverage [--role operator|supervisor|admin|agent] prints the same table, and docs/operate/assistant-coverage.md is that command's output — a test regenerates it and fails if the page differs.

What a reason may say

A reason is a rule, never work somebody has not done — a test refuses a reason containing not yet, later or phase, because that is how a list which may only shrink starts growing. Three rules cover all seventeen:

  • An approval is the person's signature, which is decision 0035 read from the other end. The agent role holds every *.define and no *.approve, and a confirmed "Do it" runs as the AGENT account on behalf of the person — so a tool that approved would be signing their name. There is a test that nothing reaches a route gated on a *.approve capability at all. The honest answer to "approve it for me" is a walk to the control, which is what SURFACES is for.
  • An agent never holds a password. /auth/users/{code}/password is a person at a keyboard, and a password that passed through a model's context is a password to rotate.
  • Plumbing is not an action. /assist/* and /design/* are how the assistant is spoken to; a tool for them would be the assistant calling itself.

One route is a judgement call rather than a rule, and the table says so: DELETE /admin/roles/{code} is kept from the assistant while create_role and update_role are not, because defining what a role grants is reversible from the screen that did it and deleting a role is not — every account holding it loses every capability at once, and what it granted is gone with the row.

What it measured on the day it was written

Four write routes had neither a tool nor a reason, and three of them were the most basic thing an operator does: POST /workorders/{code}/operations/{seq}/start and …/complete (order_action covered the whole order and stopped there), POST /execution/lots, and POST /documents/{code}/revise. They became start_operation, complete_operation, create_lot and revise_document, each reading the plant first so its would sentence carries the from and the to — which step, on which machine, in which state; which revision, copying which one in force.

Role Screen actions it can propose, of 66 Before
operator 16 13
supervisor 32 28
admin 51 47
agent 23 19

Fifteen of the sixty-six are by written reason. Walks were the thinner half on the day this was written: SURFACES covered ten of the forty-seven write tools, so an admin who could propose 51 actions could be shown 14 of them, and none of the four tools added here had a surface at all. The column was put here so that this would be countable rather than assumed, and the next section is that work: with it, an admin is shown 42 of the 51, an operator 14 of 16, and all four of the tools above are walkable.

16. Every write tool has a surface — 2026-09-26

The rule

Every write tool the assistant may propose has a walk onto the real form, and a test says so. "Show me" is not a feature of the tools that happened to get one first; it is what the card is, and a card with only "Do it" on it is half a card. assistant.SURFACES is keyed on agent.NEEDS plus agent.PER_CALL_NEEDS, a test asserts the two sets are equal, and the next write tool added without a surface goes red in CI.

Measured before and after, on the same plant: 10 surfaces of 34 proposable write tools, then 34 of 34 — and 38 of 38 once §15's four tools landed and this branch was rebased onto them.

2026-09-27. That figure was 38 of 38 proposable tools, and it turned out nine write tools were not proposable only because they named no capability at all — so the catalogue offered them to anybody holding plant.read and this ratchet could not see them. Naming what each one's route demands makes it 38 of 47. The nine are recorded in assistant.WITHOUT_A_WALK with the control a walk would land on, the test now asserts SURFACES ∪ WITHOUT_A_WALK equals the proposable set rather than SURFACES alone, and that list may only shrink. One of the nine, produce_batch, has no control on any screen by design: it is what a marker or a palletizer sends.

Seventy-odd data-assist anchors were added to the forms that had only a form-level one, and one was fixed: triggers.html carried data-assist="trigger-submit" on the closing tag, where HTML parsing discards it, so no walk could ever have pointed at Save draft.

A tool with no control to walk to is a hole in the screens

The four tools of §15 were written against the routes the screens call, and writing their surfaces asked the narrower question — which control does a person press to do this? Two of them had no honest answer, and both are the same finding from different ends:

  • Nothing on any screen booked a lot in. Lots arrived here by production booking one or by the ERP link sending one; a pallet on the dock, or a quantity somebody counted, had nowhere to be recorded. The assistant could do something no person could, which is the wrong way round. So the floor page has a fourth form beside Book output, Issue material and Quality check — lot code, material, quantity — gated on production.consume, because booking stock in and issuing it are the same person's job at the same bench.
  • No screen changes what a revision says. The instructions screen's Open next revision posts an empty body: it opens the next revision as a word-for-word copy, and there is no box anywhere for the new wording. That one is left as it is and said out loud on the card, because the next revision's text is what "Do it" writes; inventing an edit form for it is a product decision and not a surface.

test_route_coverage cannot see either. Its screen match is textual and method-blind on purpose — the question it asks is whether anything wired the module up — so a page that reads /execution/lots on every refresh reads as a page that calls it, and a screen that posts an empty body to a route reads as a screen that uses all of it. A ratchet is only as honest as the question it asks, and the question "does some script name this path" is not "can a person do this".

The card is painted in one piece

A walk's card said STEP 5 OF 6 over step 4's words, on GitHub's runner, never once on loopback. standOn() scrolls the control into view and paints the card 260 ms later so the ring lands where the control ends up, and that delayed paint read the step from its own closure and the step number from walk.index as it stood when the timer fired. Press Next inside those 260 ms and the stale paint lands under the new number — which a test waiting on the counter alone does, because the smooth scroll fires scroll events and the scroll handler paints the counter early.

A step's number now travels with the step, so a card showing two different steps at once is not expressible, and a paint that a later step has overtaken says nothing. This is the third timing-shaped bug in the walk in three days (#105's single sample, #112's read-before-render, this one) and they share a shape: the browser is asked for a state that arrives in two pieces, and the code reads whichever piece is ready. The rule that came out of it, and is now house rule 6 in CONTRIBUTING.md, is that a browser test waits for the thing it is about to assert — never for the element that will hold it, and never for a fixed number of milliseconds.

What a step does when the screen has no box for an argument

Some tools take something no form can express — a serial the plant mints, a document's tie to a material and a characteristic, a shift's machine, a start time for a plan. The step says so, in the words a person can act on, rather than dropping that part of the proposal in silence. The same rule covers the two arguments that are lists drawn from a registry (a routing's operations, a role's capabilities): the step rings the grid, and names what to type or tick, read out of the proposal by a context function.

One walk deliberately fills nothing. assign_role's control is a dropdown on a person's row that saves the moment it changes, with no button after it — so filling it in would be making the change rather than showing where it is made, which is the opposite of what "Show me" is for.

Approving is a walk, never a tool

Decision 0035 says the agent never approves; capabilities.py says the same in the agent role's own description. That has always been right and has always been answered badly: "approve the draft severity" came back as "no tool named approve is available to this person", which is a true fact about the catalogue and no use to anybody standing at a machine.

The faithful answer is a walk to the control they sign it on. There are five approvable kinds and now five signing walks — a downtime reason, a non-conformance severity, a work instruction, a trigger, a setpoint adjustment. Two of them (the vocabularies) are signed on the floor screen's own review panel, because only those two have a review built for them (review.py's KINDS); the other three are signed on their own screens. Each walk ends on the control that is actually there.

A signing walk is the one kind of walk offered to somebody who may not follow it. Every other guide is hidden from a person without its capability, because teaching a task that ends in a refusal is worse than saying it is not theirs. Here the name of who may sign is the answer, so the walk is listed with the capability and the product's own plain description of it, and show_guide refuses it with that sentence rather than walking somebody to a button their role is not shown.

The card's own walk, reachable by the model

Seen live on 2026-09-26, after #109 had made the model the only brain: with a write_plant_setting proposal on screen, "could you show me where?" declined the card — any text over a proposal is a no, by design — and the model then called guides(), found fourteen walks about other tasks, and said, correctly, that there was no walkthrough for plant settings. The walk being asked for was on the card, two steps onto the very field.

So the proposal's surface is now a walkthrough the model can hand over, under the id proposal: guides() lists it first while a proposal has been made in the conversation, and show_guide("proposal") puts it up. The alternative considered was to have the panel notice a question about the open card and answer it without asking the model — and that is a regex in front of a model that has the conversation in view, which is exactly the gate #109 removed after it answered Scott three times with the wrong walk. "How many did that book?" contains how. The model decides; what changed is that it now has something to decide with.

Two other things landed with it, both at the point of need rather than in the prompt. The decline a typed message produces used to read "the person moved on without confirming" — which is not what happened — and now says they typed instead of deciding, and where the walk is if that is what they were asking for. And the prompt gained exactly two sentences: one saying every proposal carries a "Show me", one saying approvals are walks.

What the suite says now

assist-show-me-surfaces owned ten cases in tests/assist_suite/. All ten pass and their not_yet marks are off: 77 of 77 required cases before, 87 of 87 after, and 16 not_yet down to 6 — the six that remain belong to assist-action-coverage.

Two of the ten were rewritten rather than made to pass as written. A person who may draft and may not sign — the agent role itself, and a supervisor — was down as owed a walk to the signing control. They are owed the sentence instead: the walk is listed to them, which is what lets the assistant name the capability rather than say "no tool named approve", and asking for it is refused with that capability, its plain description, and the fact that the assistant signs nothing for anybody. Walking somebody to a button their role is not shown is a worse answer than the refusal, not a better one.

One thing the suite made visible that is not about approvals at all: without a local model, the walkthrough fallback matched whole words, so "how do I book production?" found nothing — the guide's own line says booking. Words now agree on their first four characters. On a plant with no model running that is the difference between an assistant and a shrug.

17. A refusal says which capability, and who holds it — 2026-09-26

The rule

When a person's role does not let them do what they asked, the answer names the capability, the product's own plain words for it, and the roles that hold it. Never "no tool named 'close_nonconformance' is available to this person", which is a true fact about the catalogue and no use whatever to an operator standing in front of a non-conformance that needs closing.

One sentence, written once, in capabilities.not_yours:

quality.close_nc is what this needs, and you do not hold it. Supervisor and Administrator can. That capability is: Close non-conformances.

The order of the three facts is load-bearing. A tool result is summarised to 160 characters in the turn record and in the transcript the panel shows, so the capability, the fact that it is not held, and who holds it come first; the plain description, the part a reader can most afford to lose, comes last. Some descriptions are a sentence and a half on their own.

It says who, and who is read from the plant

capabilities.holders reads CAPABILITIES and BUILTIN_ROLES backwards — a role is a bundle of capabilities, so who holds one is that bundle read the other way. A plant that has redefined or renamed its roles is asked instead: auth.role_bundles reads them out of the short session the request already has open and hands them over, the same way the walkthroughs travel, because the assistant holds no database session of its own by design. So a plant whose quality.close_nc sits on a role called Shift Lead is told Shift Lead.

Three things it will not do. It will not say "somebody who does presses the button" — the words the signing walks used until today; somebody who does not know who that is cannot go and find them. It will not name more roles than a sentence read at a machine can carry: three, and then the total, because the ones it does not name are still counted out loud (house rule two). And where no role at this plant holds the capability at all it says exactly that, and that an administrator has to grant it, rather than sending the person to a supervisor who cannot help either.

Two places, because the catalogue is filtered

The tool catalogue is built per capability, so the model is never shown a tool the person cannot use. That is right, and it is also what made the refusal useless: there was nothing in view to name. So the same lookup answers in two places.

  • At call time. agent.withheld turns a tool the person may not use into the sentence, with the capability and the holders as fields beside it. For write_plant_setting — the one tool whose capability is an argument rather than a property of the tool (PER_CALL_NEEDS) — it reads the owning ConfigSection out of the same registry the API reads it from, so nc_code_prefix is answered with quality.define and a Controls key with signals.define, with no second table to keep in step. A call that names no key has no one capability behind it, and then it counts the ones that gate a setting rather than picking one of them. A name that is not a tool at all keeps the sentence it always had: there is nothing else true to say about it.
  • Before the question arrives. Every write action this person is not offered is listed in the system prompt — one line each, with the capability, its description, and who holds it — because a model only calls a tool it thinks of calling, and the moment it needs this is the moment it has decided there is nothing to call. An operator's list is 25 of the 38 actions and about 800 tokens; an administrator's is empty, because nothing is withheld. It costs nothing per turn: the prefix is cached, and it was already per-capability-set, because the catalogue after it is.

A who_can(action) tool was the other way to do the second half, and it was not taken for that reason — a tool that has to be remembered at exactly the moment the model has concluded there is nothing to call is a tool that will be forgotten. The prompt gained one sentence: name the capability and who holds it, from the list below, never that there is no tool.

The signing walks were already half of this

The five signing walks of §16 refused with the capability and its description. That is the same sentence, and it is now literally the same sentence: the note a signing walk carries is its own lead — the assistant never approves anything, for anybody, which is the half that is only true of a signature — followed by capabilities.not_yours(…, gates="signs this"). So there is one of these and not two, and the signing walks gained the role names for free.

What the suite says now

The six cases assist-action-coverage left behind were all this one thing. They pass and their marks are off: 91 of 91 required cases before, 97 of 97 after, and the last 6 not_yet down to 0. The whole request suite is required for the first time.

One case was rewritten rather than made to pass as written. operator-refused-changing-a-plant-setting carried no arguments, because it never got far enough to need any; the capability behind a setting is an argument, so the case now sends the domain, key and value a model would send. Scoring a refusal against a tool call with no key would have been scoring the one shape that has no single capability to name.