Skip to content

ADR 0055: A measurement carries provenance, a decision carries a signature

Status: Accepted Date: 2026-08-12 Issue: #1498 Related: ADR 0017 (typed prompts, whose bounds this reuses), ADR 0024 (action catalog), ADR 0034 (permission tier defaults, departed from here with reason)

Context

A recipe phase frequently has to wait on a number produced outside the control system. The canonical case is a laboratory assay: hold the batch, pull a sample, wait for the LIMS to return a titre or a pH, and branch on the value.

Before this decision the only inbound path to a running phase was the prompt mechanism. A chart step calls PROMPT_VALUE('message', min, max, 'unit'), execution pauses, and the run continues when the response endpoint is called. That endpoint cannot be called by a machine, and correctly so. It calls requireFreshToken, which refuses any identity whose token was issued more than five minutes ago or carries no issue time at all. An API key carries no iat claim, so IssuedAt is zero and the refusal is unconditional.

This is not a defect to route around. A prompt response records an electronic signature, 21 CFR Part 11 §11.200 requires a signature to be made by an identified individual, and a machine account cannot be one.

The consequence was that every external-result workflow degraded to a human reading a number off one screen and typing it into another — reintroducing precisely the transcription step a batch record exists to eliminate, into a value the recipe then branches on.

Decision

We had conflated two different acts under one mechanism.

A decision is a human act and carries a signature. Gating it on a fresh token is right and stays.

A measurement is data and carries provenance. It is attributable to a system, a credential and a sample, and to no person at all.

Five consequences follow.

1. A distinct chart primitive

AWAIT_RESULT(resultKey, min, max), with an optional trailing unit argument, parks a step until a named measurement arrives, and returns it as a REAL. The chart author declares which kind of wait this is rather than reusing PROMPT_VALUE and leaving the record to infer it.

It is held to every rule a prompt is held to. The result must be captured and used as the step's transition condition, enforced in both SFC validators. The step is wedge-capable, so make lint-step-timeouts demands a steps[].timeoutSeconds — an assay runs for hours and may never return, which makes this the longest dwell of any blocking builtin we have. And it is edge-unavailable: a delivery arrives through the gateway, so a chart calling it cannot be armed as an edge-local hold (ADR 0008).

2. A non-signature delivery endpoint

POST /api/v1/mes/sites/{site}/phases/{name}/external-result accepts a value from an API-key identity, with a companion GET /api/v1/mes/sites/{site}/external-results/pending so an external system can discover what is being asked of it.

Two placement facts drove this. It sits under /api/v1/mes/ because that prefix is the integration zone: an MES-only identity is structurally fenced off every other path (IEC 62443 zone separation), so a delivery endpoint anywhere else could not be called by the credential it exists for. And it sits at PermOperate rather than ADR 0034's PermAdmin default, because an integration identity carries read and operate by construction — PermAdmin would leave the route reachable by nobody it was built for.

The value is validated against the bounds the chart author declared, through the same function the prompt path uses. A laboratory is not more trusted than an operator; it is differently attributed.

3. The record keeps them apart structurally, not by a flag

A delivery is recorded under a new audit action, ExternalResult, and lands in a new frozen batchRecord.spec.externalResults list. It is never joined into operatorActions.

This is the sharp end of the decision. OperatorActionRecord has no field that could carry "a machine did this": its acknowledgedBy is a person's display name and its auditRecordRef points at an electronic signature. Recording a LIMS delivery under PromptAcknowledge would have filed it there, and the batch record would have asserted that someone acknowledged a number nobody saw — for the lifetime of the record.

We rejected the cheaper option of one list plus a source discriminator. Every existing reader of operatorActions — the reconciler, the gateway DTO, the UI, the record export — currently assumes a person, and a discriminator is only as good as the least-updated reader. Two lists cannot be misread by a reader that does not know about the second one.

An ExternalResult record carries no ElectronicSignature. That absence is the assertion: a reader asking who vouched for this number gets nobody, which is the truth.

4. A manual fallback that is honest about being manual

When the external system cannot deliver, an operator may satisfy the same wait by hand through the existing prompt-response endpoint — with a fresh token, an electronic signature, and a required reason. That entry is recorded as a PromptAcknowledge operator action, because that is exactly what it is, and carries the distinct signature meaning external-result-manual-entry so an auditor can retrieve every value that took the fallback. That population is the one worth reviewing when a LIMS interface is suspect.

So one step supports two paths, and each is recorded as what it is. The asymmetry is deliberate: the machine path may never wear the operator action, while the human path may never wear the provenance record.

5. A wait is not a stall

A phase parked on a laboratory is making no forward progress by every counter the SFC engine keeps. The stuck-phase watchdog and the planned-outage at-rest check both now count an external-result wait alongside a prompt. Without that, waiting exactly as long as the chart author instructed would have force-aborted the batch, or let a planned outage drain a unit mid-assay.

Alternatives Considered

Let the API key call the prompt endpoint by exempting it from requireFreshToken. This is the smallest change and the worst one. It would mint an electronic signature attributed to a machine account, which is the Part 11 violation the fresh-token gate exists to prevent. Rejected outright.

Have the LIMS write the value to a tag and let the chart guard on it. This needs no new primitive and reuses an audited write path. It fails on three counts. Integration identities are deliberately fenced out of tag writes, so admitting them would breach the same IEC 62443 boundary for a much wider surface than one endpoint. A tag carries an engineering range and a control meaning that a laboratory result does not have. And a tag write carries no correlation to a sample id, so the batch record could say a number changed but not which analysis produced it — losing exactly the provenance that justifies the whole mechanism.

One operatorActions list with a source discriminator. Covered above: cheaper to build and only as trustworthy as the least-updated reader.

Require an operator to countersign every machine delivery. This keeps a person in the loop and would be defensible. It was rejected as the default because the signature would mean "I saw a number on a screen", which is weaker attestation than it appears while still costing the operator step. A chart author who wants a person to vouch keeps PROMPT_VALUE on that step, so the capability is retained as an authoring choice rather than a platform posture.

Consequences

An external system integrates by holding an API key, polling the pending list, and delivering against a wait id and result key. LabWare, LabVantage and STARLIMS all reach it identically; we build no vendor connector, exactly as the MES surface builds none.

The batch record gains a second list that a reviewer must read to see the whole picture. That is the intended cost. The alternative was one list that told a comfortable lie.

One over-grant is knowingly accepted. APIKeyStore.Verify mints every key into the single MES integration group, so a key issued for a laboratory can also create and delete work orders. A deployment that wants a narrower key strips the extra actions with an ADR 0024 deny entry in its roles file. Splitting the integration group properly would mean per-key group storage, a mint-endpoint change and widening the fence from one prefix to a set — a real change to an IEC 62443 boundary, and not one to make as a side effect of this issue.

Amendment (2026-08-14, #1515): the asymmetry needed enforcing in both directions

Decision 4 above ends on the invariant this ADR is built around: the machine path may never wear the operator action, and the human path may never wear the provenance record. The first half was enforced structurally. The second half was not enforced at all.

Two placement facts made the delivery endpoint reachable by a person. It sits at PermOperate, which decision 2 chose so an integration identity could reach it — and every human operating role holds that tier too. And the /api/v1/mes/ fence runs one way: it keeps an MES-only identity off every other route, which is not the same as keeping a person off this one. mesReadGuard's site check applies only to an apikey: subject, so a human caller skipped even that.

An operator could therefore hand-craft the POST and file the number the recipe branches on with no fresh token, no signature and no reason, against a route whose whole compliance rationale is that nobody could have signed. The record would have been honest about who delivered it, which is why this was a hole rather than a forgery. It is still the one place in the product where a person enters a chart-steering value unsigned.

The handler now refuses any caller that is not an MES-only identity, with a 403 naming the prompt-response path. Three things follow. The predicate is auth.IsMESOnly, not the apikey: subject prefix, so a person who also holds the integration group is refused like any other person. The pending list is untouched — reading what a phase is waiting for is an ordinary read, and a person watching a wait is not entering a value. And nothing narrows for the identity the route exists for: an API key minted by APIKeyStore.Verify carries the MES group and no human role, which is exactly the shape the fence admits.