Notes / N.10 Systems · working notes

Legal constraints as engineering inputs

When a bad release is regulatory or clinical rather than a rollback, validation, traceability and change control stop being documents. They become data structures the build can query, and the build produces the evidence.

Reading time9 min
StackIEC 62304 · ISO 14971 · MDR · requirement ids · a CI that emits evidence
StatusMethod note

§ 01What the regulation asks of the architecture

The frame I work in is medical software: MDR (Regulation (EU) 2017/745) for what has to be shown, IEC 62304 for how software is developed and maintained, ISO 14971 for how risk is managed. None of the three prescribes a code structure. What they prescribe is a set of questions that must be answerable at any moment, with evidence: which requirement produced this software item, which hazard it controls, which test verified it, whether that verification survived the last change, and which third-party code sits underneath. In most software those questions are answered once, retrospectively, for an audit. Here they are answered continuously, because the consequence of a wrong answer is not a rollback.

The distinction I care about is between a document and a property. A traceability matrix in a spreadsheet is a document: it was true when someone filled it in. A traceability graph the build regenerates from the repository on every commit is a property: it is true now, or the build fails. The practical test is a change. When a requirement moves, the system should tell you what is now unverified; if answering that takes a person a week, you had a document. The anchor here is medical software, but nothing in the mechanism is specific to medicine. It applies wherever a release commits you to something you cannot undo by deploying again.

The traceability graph as the build sees it hazard · HAZ-07 requirement · SRS-042 design unit · SWU-11 test · TC-118 evidence rev 3 → 4 stale until re-run
Fig. 01 · A hazard, the requirement that controls it, the unit that implements it, the test that verifies it, the evidence the build emits. When the requirement's revision moves, the test is stale until it passes again on the new revision: and the build says so.

§ 02Requirements with ids, and tests that carry them

Every requirement is a record with a stable id, a revision, a safety class and its parents. Parents are what the requirement exists for: a user need, a general safety and performance requirement from MDR Annex I, or a risk control measure from the ISO 14971 file. A requirement without a parent is a feature nobody asked for, and under a lifecycle process that is a finding, not a bonus. I keep the records as YAML files under version control next to the code, one per requirement, so a change to a requirement is a diff, reviewed like code, with a revision I can hash. The ids and texts below are a made-up example, not the product.

# requirements/SRS-042.yaml: one file per requirement, reviewed like code
id: SRS-042
rev: 4                      # bumped by the hash of the normative fields, not by hand
class: B                    # IEC 62304 safety class of the item that implements it
text: >
  The system shall reject any measurement whose acquisition timestamp
  is older than 5 s at the moment of display, and shall mark the
  channel as stale rather than show the last value.
parents:
  - "UN-12"                 # user need
  - "RC-07.2"               # risk control for HAZ-07 (stale value shown as current)
design: [SWU-11, SWU-14]     # software units that implement it
verify:
  method: test               # test | analysis | inspection
  tests: [TC-118, TC-119]
rationale: "5 s is the display refresh budget agreed in the risk file, not a UI choice."

The other end of the link lives in the tests. A test that verifies a requirement declares it in the test itself, with a marker the runner can read. The matrix is never written by hand: a script walks the requirement files, the design records and the collected markers, and emits the matrix together with everything that is broken: requirements with no verifying test, tests referencing ids that do not exist, and requirements whose current revision is newer than the one recorded at the last passing run. That last check turns “the requirement moved” into “these tests are stale” without anyone remembering to look.

# tests/test_display.py: the test declares what it verifies
@pytest.mark.req("SRS-042")
def test_stale_channel_is_marked_not_shown(clock, display):
    display.push(channel="p1", value=98, t=clock.now())
    clock.advance(seconds=5.1)
    assert display.state("p1").stale is True
    assert display.state("p1").value is None

# tools/trace.py: the matrix is derived, and stale is a computed state
def build_matrix(reqs, markers, last_pass):
    """reqs: {id: Requirement}; markers: {test_id: {req ids}};
    last_pass: {(test_id, req_id): rev verified in the last green run}"""
    rows, problems = [], []
    for rid, r in reqs.items():
        tests = sorted(t for t, ids in markers.items() if rid in ids)
        if not tests and r.verify.method == "test":
            problems.append((rid, "unverified"))
        for t in tests:
            seen = last_pass.get((t, rid))
            state = "ok" if seen == r.rev else "stale"
            rows.append((rid, r.rev, r.design, t, state))
            if state == "stale": problems.append((rid, f"stale:{t}"))
    for t, ids in markers.items():
        for rid in ids - reqs.keys():
            problems.append((t, f"orphan:{rid}"))
    return rows, problems

The excerpt below is the shape of what comes out. Nothing in it is typed by a person; the state column is recomputed on every commit from the requirement revision and the last green run of each test.

Requirement Rev Controls Design Verified by State
SRS-0424HAZ-07SWU-11, SWU-14TC-118stale (passed on rev 3)
SRS-0424HAZ-07SWU-11, SWU-14TC-119ok
SRS-0432SWU-14TC-120ok
SRS-0511HAZ-12SWU-20unverified · blocks release

§ 03Risk analysis as a design input

ISO 14971 asks for hazards, the sequence of events that turns a hazard into a hazardous situation, the harm, its severity and probability, and the control measures that bring the risk down to an acceptable level. IEC 62304 clause 7 then asks the software to state which items can contribute to a hazardous situation, what the causes are, and how each control is verified. Read together they say something architectural: a risk control measure is a requirement, with an id, that flows to design and to tests like any other, and it has to be verified as effective, not merely present. The risk file is not maintained alongside the work; it is upstream of it.

What I ask of every feature before design starts is three questions: what can this fail into, what detects it, and what does the system do when the detection itself fails. The third is where most designs go quiet. A watchdog fed by the same thread it supervises detects nothing; a range check on an input the same code produced is decoration. Features that cannot answer the three questions do not get simplified: they get declined, because a control you cannot verify is a residual risk someone has to accept in writing.

# risk/HAZ-07.yaml: the control is a requirement id; the residual risk is a claim
id: HAZ-07
hazard: "A value no longer current is displayed as current"
sequence: [acquisition stalls, display keeps last value, operator acts on it]
harm: "Delayed intervention"
severity: S3            # scale defined in the risk management plan
p1: occasional          # hazard -> hazardous situation
p2: probable            # hazardous situation -> harm
controls:
  - {id: RC-07.1, kind: design,     req: SRS-040}   # acquisition timestamps every sample
  - {id: RC-07.2, kind: protective, req: SRS-042}   # staleness detected on display
  - {id: RC-07.3, kind: protective, req: SRS-044}   # staleness detector supervised by a second clock
residual: {severity: S3, p1: remote, accepted_by: RMB-2025-03, valid_while: [TC-115, TC-118, TC-119, TC-123]}

Note what the record links. Each control is a requirement id, its verification is a test id, and the residual risk carries a valid_while list: it is a true statement only while those tests pass on the current revisions. When the trace builder finds a stale test it can also list which hazards are, at that moment, controlled on paper only. That list goes into the release decision.

§ 04SOUP: the code you did not write, handled explicitly

IEC 62304 calls third-party software of unknown provenance SOUP: libraries, runtimes, the kernel, a database: anything you ship but did not develop under your own process. Its demands are modest and specific: identify each item by title, manufacturer and version, state the functional and performance requirements you place on it and what it needs in order to run, and evaluate its published anomaly lists for anything that could contribute to a hazardous situation.1 What is not modest is doing that for every item and keeping it current when a lockfile bump pulls in forty transitive updates.

So the SOUP list is generated too. The lockfile and the SBOM (the one described in the note on controlled Linux environments) give the inventory; a curated overlay adds what a machine cannot know: why the item is there, what we rely on it for, which anomaly list was reviewed and when. CI fails when the inventory contains a package without an overlay record, or when a version changed and the review date is older than that version. It is a blunt rule and it is meant to be; the alternative is discovering at audit that a parser was upgraded twice since anyone read its changelog.

# soup/libxml2.yaml: the overlay a machine cannot write; the inventory comes from the SBOM
name: libxml2
manufacturer: GNOME project
version: 2.13.5
purl: pkg:deb/debian/libxml2@2.13.5-1
used_by: [SWU-31]                    # units that link it
purpose: "Parse configuration and export documents"
requirements:                        # what we rely on, and what we test
  - "Rejects malformed input without undefined behaviour"   # TC-301
  - "Parses a 20 MB document in under 2 s on the reference hardware"  # TC-302
needs: {os: "Debian 12", arch: amd64}
anomalies:
  source: "upstream issue tracker + Debian security tracker"
  reviewed: 2025-11-04             # must be >= the date this version entered the lockfile
  relevant: [CVE-2025-XXXXX]       # each one evaluated against the risk file
  disposition: "Not reachable: export path validates size before parse (SRS-088)"
licence: MIT

§ 05A change process that gates release, and a build that emits the evidence

Change control in IEC 62304 wants every modification to start from a request or a problem report, be analysed for its impact on requirements, risk and existing verification, be approved, and be traceable to the release that carries it. The step that decides whether this is bureaucracy or engineering is the impact analysis. Computed from the same graph, it is cheap enough to do on every change; written, it is done for the big changes and guessed for the rest.

The table is a worked instance, not the product. Assume 180 requirements, 640 test cases, a mean of 3.5 tests per requirement and 4 s per test on one runner, so a full suite of about 43 minutes. The impact set is the set of tests reachable from the changed requirements through the design and risk links; the full suite still runs on the release commit. What the computed set buys is not the minutes but a reviewable list of what a change touched: and on a hardware-in-the-loop rig where a test takes minutes, it is the difference between re-verifying on every change and re-verifying when someone remembers.

Change Direct reqs Reached via links Tests to re-run Share of suite Minutes
Wording only, hash of normative fields unchanged1000 %0
A limit in one requirement (5 s → 3 s)13172.7 %1.1
A new export format, four requirements49589.1 %3.9
A SOUP major version used by two units02221032.8 %14.0
Full re-verification (release commit)180640100 %42.7

Then the evidence. Releasing under MDR means a technical file that shows the verification was done on the thing being released, and my rule is that the build produces that evidence or it does not exist. On the release commit CI emits a bundle: the trace matrix, the requirement snapshot with revisions and hashes, test results mapped to requirement ids, coverage per unit, the SOUP list with review dates, the SBOM, the closed change requests, and a signed manifest that binds all of it to the commit hash and to the digest of the image being shipped. Anyone can regenerate the bundle from the commit and get the same hashes. That is what makes it evidence rather than a claim.

# .gitlab-ci.yml: the release job refuses to run without its evidence
evidence:
  stage: release
  needs: [unit, integration, system, sbom]
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
  script:
    # 1 · the graph must be clean: no orphan ids, no unverified reqs, no stale tests
    - python tools/trace.py build --reqs requirements/ --junit reports/ \
        --out evidence/trace.json --fail-on orphan,unverified,stale
    # 2 · every shipped package has a SOUP record newer than its version
    - python tools/soup.py check --sbom sbom.cdx.json --overlay soup/ --fail
    # 3 · bind results, coverage, changes and the image digest to this commit
    - python tools/evidence.py bundle \
        --commit "$CI_COMMIT_SHA" --image "$IMAGE@$IMAGE_DIGEST" \
        --trace evidence/trace.json --junit reports/ --coverage coverage.xml \
        --soup soup/ --sbom sbom.cdx.json --changes changes/closed/ \
        --out evidence/
    - (cd evidence && sha256sum * > MANIFEST)
    - cosign sign-blob --key "$COSIGN_KEY" evidence/MANIFEST \
        --output-signature evidence/MANIFEST.sig
  artifacts:
    paths: [evidence/]
    expire_in: never

§ 06Failure modes

The first failure is the one this note is written against: the matrix as a spreadsheet, filled in for the last audit. Its symptom is behavioural. Nobody dares to touch a requirement, because nobody can say what touching it invalidates, so requirements stay vague and the real specification migrates into the tests, where the auditor cannot read it. The second is its cousin: ids in the tests, but the requirement text edited without the revision moving. I no longer let a person increment a revision: it is derived from a hash of the normative fields, so an edit that changes meaning cannot leave the number where it was, and a typo fix invalidates nothing.

The third is a test that carries a requirement id and verifies nothing: it passes, the matrix goes green, and the requirement is as unverified as if the test did not exist. Two checks catch most of it: a lint that fails any marked test with zero assertions, and mutation testing on the units behind the higher safety classes, where a control that survives the removal of the code implementing it is not a control. The fourth is SOUP drift, already covered. The fifth is evidence produced on a different commit than the image that shipped; the fix is boring and absolute: the image digest goes into the signed manifest and the release job cannot run without it.

The last two are about honesty rather than tooling. A safety class chosen for convenience: the standard allows a lower class for a segregated item, but the segregation must be a verified architectural boundary or the whole system inherits the highest class present. And a detector that shares its failure mode with the thing it detects: the same thread, the same clock, the same power rail. Neither is caught by a script. Both are caught by the third question in § 03, asked before the design exists, by someone allowed to decline the feature.

Working this way changed how I engineer everything else. When every change has to be justified before it ships, its cost becomes legible in a way ordinary product work hides, and I now evaluate a feature the same way outside this context: not only whether it can be built, but what it commits us to, who maintains it, and what it costs when it is wrong.

  1. In IEC 62304:2006+A1:2015 the SOUP obligations sit in 8.1.2 (identification), 5.3.3 and 5.3.4 (functional, performance, hardware and software requirements for each item) and 7.1.3 (evaluation of published anomaly lists). Clause numbering is quoted from that edition; check yours.
Next note

Designing retrieval systems where the source remains authoritative

Read it →
← SANIX Written from work described at a high level · no client data