ADR 0017: Typed operator prompts — acknowledge, choice, and bounded value¶
Status: Accepted Date: 2026-07-09 Issue: #798
Context¶
PROMPT(msg) blocks a phase step until an operator responds, and returns
the response as a STRING. SFC validation forces the author to capture the
response and use it in the outgoing transition condition. That was the
whole contract: the response's shape was never designed — STRING was
the most general primitive, and the UI affordance that fell out of it was
a free-text input for what is semantically a button press.
An audit of every phase in the tree found that all transition
conditions guarding a prompt are ack <> '' — any non-empty text
unblocks, and nothing anywhere parses the content. Meanwhile the free-text
field invites exactly the failure modes incumbent batch systems learned to
exclude decades ago:
- Magic-string comparison in logic. The moment an author writes
resp = 'YES', a typo, case difference, or stray whitespace from the operator blocks a batch with no affordance explaining why. - No statement of what constitutes a valid answer. The operator faces a blank field; the logic's expectations are invisible.
- Unauditable intent in the batch record. "asdf" satisfying a containment check is a record that says nothing about what the operator actually verified (ISA-88 Part 4 Table 7 treats Prompt / Prompt Response as first-class batch-production-record events — the response is evidence, not filler).
Real operator prompts in the field have one of three shapes: an acknowledgement, a selection from an enumerated set, or a measured value with a valid range. Free-form text belongs in operator comments and annotations on the batch record, not in flow control.
Decision¶
Split the prompt builtin into a typed taxonomy; the prompt's semantics live in the model and every surface renders them (same direction as issue #797):
PROMPT(msg)— acknowledge. The UI renders an Acknowledge button and no text field. The response is the fixed sentinel'Acknowledged', so the engine contract (non-empty STRING return) and every deployedack <> ''phase are unchanged.PROMPT_CHOICE(msg, 'opt-a', 'opt-b', …)— enumerated decision, two or more options. The UI renders one button per option; the return value is the selected option string, which can only come from the author's list — never from typing.PROMPT_VALUE(msg, min, max [, unit])— validated numeric entry (measured pH, weight, lot count). Returns REAL. Bounds are required — an unbounded operator number entry re-creates the unauditable-input problem — and are enforced server-side before the phase resumes; the optional unit is displayed with the input.
Validation is fail-closed at the gateway: acknowledge responses are
forced to the sentinel, choice responses must be a member of the option
list, and value responses must parse as a number within [min, max].
The runtime bridge re-validates on delivery, so a response injected past
the gateway (e.g. a hand-edited annotation) cannot resume the phase with
an out-of-contract value. Free-form text is excluded from flow control
entirely.
The typed shape (type, choices, min/max/unit) rides
Phase.status.pendingPrompt to the gateway DTO and the UI. CRD bounds
are decimal strings (Kubernetes API types do not carry floats); only the
engine works in REAL.
Alternatives Considered¶
- Keep free text, document the
ack <> ''idiom. Zero code, but preserves all three failure modes and normalizes magic-string parsing the moment someone needs a decision prompt. Rejected: the affordance is the bug. - One
PROMPTwith an options/config argument (e.g.PROMPT(msg, 'choice', 'a', 'b')). Fewer builtins, but overloads one name with three arities and puts the discriminator in a string literal the validator must special-case. Rejected: three names make intent legible in phase source and let static validation check each signature exactly. - Client-side validation only. Simplest server, but any stale or bypassed client re-opens free text into flow control. Rejected: fail-closed server-side validation is the point of the change.
- STRING return for
PROMPT_VALUE. Avoids the REAL conversion, but pushes parsing into every consuming phase — the magic-string problem in a new costume. Rejected.
Consequences¶
- Moves: ST interpreter builtins + static validation
(
pkg/structuredtext),ExecutionContext.Promptsignature (spec in, string out), runtime bridge validation (pkg/stbridge),PendingPromptCRD shape (api/procedural/v1alpha1), gateway prompt DTO + fail-closed handler (internal/gateway), engineering-UI prompt card, docs and compliance traceability. - Backward compatible: existing phases using
ack := PROMPT('…'); … ack <> ''keep working verbatim — the sentinel is non-empty. No CRD field is removed; new fields are optional withtypedefaulting to acknowledge. - Batch record unchanged in shape:
OperatorActionRecord.Responsestays a string (sentinel, option label, or formatted number) — but its content is now always a legible statement of operator intent. - The HMI (
js/hmi/) renders no prompts at all today; that gap is deliberately out of scope here and tracked as a follow-up issue. The operator-prompt docs clip (#665) re-shoots once the typed card lands. - Iconography: a pending operator prompt is denoted by the empty ballot
box ☐ (
☐) on every surface — the HMI topbar prompt badge, the per-unit waiting-prompt chip (#808), and the phase-detail Prompt Pending banner + Operator Prompt section title. It is deliberately distinct from the alarm / hazard warning sign ⚠ (⚠), which is reserved for alarms, and pairs with the e-signature confirm ☑ (☑) on the response ceremony: an unchecked box is awaiting your mark, the checked box is signed. Do not reuse the warning sign for prompts. - Reversibility: additive CRD fields and new builtins are easy to
stop emitting; removing them later would strand phase sources that use
PROMPT_CHOICE/PROMPT_VALUE, so removal would need a deprecation cycle.