Skip to content

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 deployed ack <> '' 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 PROMPT with 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.Prompt signature (spec in, string out), runtime bridge validation (pkg/stbridge), PendingPrompt CRD 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 with type defaulting to acknowledge.
  • Batch record unchanged in shape: OperatorActionRecord.Response stays 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 ☐ (&#9744;) 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 ⚠ (&#9888;), which is reserved for alarms, and pairs with the e-signature confirm ☑ (&#9745;) 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.