Skip to content

ADR 0049: An external quality system gates a change our own ceremony already signed, the loop is polled with the record's state read fail-closed, and any close that is not an approval ends the ChangeRequest as Rejected

Status: Accepted Date: 2026-08-11 Issue: #1424 Related: ADR 0013 (the golden path, and why in-cluster-queue is experimental), ADR 0029 (a poll is the loop and a push is an accelerator), ADR 0034 (a mechanism that cannot be completed is not shipped as if it could).

Context

In a GMP plant the system of record for change control is the customer's electronic quality management system, not ours. TrackWise, Veeva Vault QMS, MasterControl and ETQ all sit in that seat, and a regulated buyer tests that story before almost anything else. Today we cannot reach any of them.

The ChangeRequest reconciler already delegates the applying mutation to a pluggable backend, and three implementations ship. direct-apply writes the object, git-pr opens a pull request, and in-cluster-queue parks the request in Approved and waits for a deployer-supplied controller to move it. All three are internal, so there is no seam by which a decision recorded in an external quality system becomes the decision that releases a change here.

in-cluster-queue looks like that seam and is not. ADR 0013 marked it experimental because it defines only our half of the contract: the transport, the identity carried across it, and the behaviour on rejection are all left to the deployer, and no reference consumer ships (#649). A generic external backend that repeated that shape would be the same non-answer with a longer configuration block.

#1424 filed the work and deliberately left three questions open, because each one has a defensible answer and a comfortable answer and they are not the same answer. Whether the external system's approval replaces our electronic signature ceremony or layers on top of it. Whether the loop is polled or received as a callback, and what would authenticate a callback. What becomes of a ChangeRequest whose external record is closed as rejected, cancelled or superseded.

A fourth question was not asked and turns out to matter more than two of the three. Nothing in git-pr's shape says what actually applies the change once the external system agrees. git-pr gets away without answering because the pull request is the artifact carrying the change, and the deployment's reconciler over the site tree delivers it on merge. A quality record carries no change. It is a decision about a change that is still sitting in the cluster, so a backend that only opens the record leaves the ChangeRequest with nothing to finish it.

Decision

An external quality system is a gate placed after our own approval ceremony, never a substitute for it. The backend polls the record it opened, reads the record's state through a declared mapping that fails closed, hands delivery to a configured delegate backend when the state is an approval, and ends the ChangeRequest as Rejected on any close that is not one.

Concretely:

  1. The external decision layers on top of the electronic signature; it never replaces it. The backend seam sits downstream of ProposedApproved, so by the time any backend is called the required signatures have already been collected and HMAC-verified. Replacement is not a configuration choice we declined to offer, it is a different state machine. It is also the wrong record: our signature is a MAC binding a named identity to the exact change content in a key store we control, while an eQMS decision reaches us as a claim by a machine account about something that happened elsewhere. The first is attribution under §11.50 and the second is a reference to it.

  2. The record we open carries the verified signer identities and not the signature digests. The reviewer in the quality system needs to see who proposed the change and who approved it here, so meaning, signer and signing time travel with the payload. The digest stays behind, because it binds to our key store and proves nothing to a party that does not hold the key.

  3. The loop is polled. The operator has no inbound HTTP surface, and giving it one means an ingress, an authenticator, a replay window and a second audited write path, which is a larger commitment than the feature needs. ADR 0029 already settled the shape of this trade for annunciation: the poll is the loop, and a push is an accelerator that shortens latency without ever being the reason the loop closes. A callback can be added later against the same fields. What would authenticate it is therefore left open on purpose, and this ADR does not pretend to settle it.

  4. The state mapping is declared per deployment and reads fail-closed. The deployment lists which of its own record states mean approved and which mean rejected. A state matching neither list means the record is still open, so an unrecognised value can only ever delay a change and never release one. A guard that cannot interpret what it is reading must not report that everything is fine (#1427).

  5. The backend gates and a delegate delivers. On an approving state the webhook backend calls a configured delegate — direct-apply or git-pr — and returns that backend's result verbatim, so the ChangeRequest reaches Applied and Reconciled through exactly the path the deployment already uses. This is what keeps the new backend from being a second half-contract: the round trip completes inside the product, and choosing an eQMS does not cost a deployment its GitOps delivery.

  6. A close that is not an approval ends the ChangeRequest as Rejected, not as Failed. Failed means the backend could not do its job, and it is re-stageable by annotation, so recording a quality decision there would put a false statement into a Part 11 record and offer a retry against a rejection nobody has revisited. The reconciler's BackendOutcome gains Rejected for this, audited as a rejection at rejection severity. Cancelled, voided and superseded map to Rejected as well: the external system's vocabulary is recorded verbatim in the resolution and the condition message, and we do not mint phases to mirror it.

  7. While the record is open the ChangeRequest stays in Approved. #1424 named git-pr's "report Applied when the external record opens" as the shape to copy, and that is the one place the analogy does not carry. Applied is documented to mean the mutation has been performed, which is true of an open pull request carrying the change and false of a quality record deliberating about one. The waiting state is reported through a condition naming the record and its last observed state.

Alternatives Considered

  • Ship vendor adapters for TrackWise, Veeva, MasterControl and ETQ. Rejected, and this is the decision #1424 was filed to make. Every product in the class is commercial, so we cannot test an adapter, cannot film it, and cannot keep it honest across versions. The per-install API surface differs enough that a shipped adapter would need per-deployment configuration anyway, at which point the generic backend is the same thing with the vendor's name removed.

  • Receive a callback instead of polling. Rejected for now on surface area, not on merit. It is the better latency story, and the poll it would ride on has to exist regardless, because a callback that is lost leaves the ChangeRequest parked forever unless something is also asking.

  • Reuse in-cluster-queue and ship a reference bridge that speaks HTTP. Rejected. It moves the transport, the identity mapping and the rejection behaviour into deployer-owned code, which is exactly the arrangement ADR 0013 marked experimental, and it leaves us unable to state in the documentation what happens when the quality system says no.

  • Map an external rejection to Failed. Rejected. It requires no new outcome and it is wrong in the record, which is the only place the difference is ever read.

  • Have the webhook backend apply the change itself. Rejected. It duplicates direct-apply, and it would force a GitOps deployment to give up git-pr delivery in order to gate on its quality system.

  • Let the external system's approval stand in for a missing signature. Rejected on the §11.50 argument in the decision above. A deployment that genuinely wants the eQMS to be the only substantive review can already configure its required meanings down to the minimum and let the quality process do that work, without the product claiming an attribution it cannot verify.

Consequences

  • API. ChangeRequestBackend gains webhook and the spec.backend enum widens to admit it. ChangeRequestResolution gains externalRecordID, externalRecordURL and externalRecordState so the audit anchor names the record that gated the change and the state it closed in.

  • Reconciler. BackendOutcome gains Rejected, handled from both Approved and Applied and audited with AuditActionReject / AuditResultRejected. A backend's partial resolution now persists on the Pending path as well, which it did not before — the condition write dropped it, and that would have lost the external record identifier on the very first pass that produced one.

  • Idempotence is a requirement on the endpoint, and it is written down. The backend sends the ChangeRequest UID as its idempotency key and polls an already-recorded record instead of opening a second one, but a crash between the create call and the status write can still re-issue the create. The integration contract states that the endpoint must treat a repeated key as a lookup of the record it already created.

  • A response cannot redirect the credential. The poll URL is derived from the configured endpoint and the record identifier, and the response body has no field that can move it. The credential the deployment minted for its quality system is therefore only ever sent to the host the deployment named, which is a structural guarantee rather than a check that could be got wrong.

  • in-cluster-queue stays experimental and stays shipped. It remains the lower-level seam for a deployment that wants to drive the phase itself. webhook becomes the supported answer for reaching an external quality system, and the documentation says so.

  • What is not settled. Callback authentication, per-Site endpoints, and any demonstration footage. #1424 rules footage out of scope on the grounds that every product in the class is commercial, and that holds until a customer brings their own instance.

  • Reversibility. High. The backend is additive, it is off unless a deployment configures an endpoint, and withdrawing it costs an unused enum value and a documentation section.