연구기관이 생물안전 레코드를 하나의 서비스로 제공하는 방법을 다룬다. 리소스와 데이터 객체, 버전 관리, 오류, 인증, 페이지 매김, 멱등성, 웹훅, 그리고 레코드 작업을 위한 타입 지정 클라이언트가 그 내용이다.
제2장과 제3장은 생물안전 레코드에 무엇이 담기는지를 정의했다. 등록된 시설과 그 인증 블록, IBC 승인, 교육 이수 기록, 상태 기계를 따라 움직이는 사고 보고서가 그것이다. 그러나 데이터 구조로만 존재하는 레코드는 열 이름이 조금 나은 스프레드시트에 지나지 않는다. 가치는 그 레코드를 건드리는 모든 시스템, 즉 생물안전 부서의 업무 화면, 인사 시스템 가져오기 작업, 출입 통제 시스템, 점검자의 읽기 전용 화면, 협력 기관의 교환 게이트웨이가 같은 규칙을 가진 하나의 인터페이스로 읽고 쓸 때 생긴다. 이 장은 그 인터페이스를 정의한다.
범위는 레코드 관리이다. 서비스는 시설을 등록하고, 점검·감사 결과를 저장하며, 교육 이수 기록과 출입 기록을 유지하고, 사고 보고서를 워크플로에 따라 처리하며, 보관 연속성과 재고 항목을 장부 수준에서 관리한다. 서비스가 답하는 질문은 행정적인 것이다. 이 인증은 유효한가? 이 사람은 출입이 허가된 시설에 필요한 유효한 교육을 이수했는가? 기한을 넘긴 시정 조치는 무엇인가? 누가 언제 이 레코드를 바꾸었는가? 서비스는 어떤 작업에 어느 수준의 밀폐가 필요한지 결정하지 않으며, 어떤 상황이 안전한지 판단하지도 않는다. 그런 결정은 그것을 내릴 권한이 있는 사람과 기구가 내린 결과로서 레코드에 들어온다.
규범 사항과 제안 사항의 구분. WIA-BIO-017 PHASE-2 API 문서가 밝히는 인터페이스 사실은 몇 가지뿐이다. 기본 URL https://api.wia-bio.org/bio-safety/v1, OAuth 2.0과 API 키를 함께 쓰는 인증, 시간당 1,000회 요청이라는 호출 한도가 그것이다. 이 책은 기본 URL을 예시로 취급하고(실제 가용성은 확인되지 않았다) 호출 한도는 구성 예시로 본다. PHASE-2 부속서는 스키마 진화와, 배포의 운영 범위를 OpenAPI 문서에 선언하는 일에 관한 비규범적 메모를 덧붙인다. 리소스 경로, 헤더, 오류 본문, 웹훅 형식을 비롯한 이 장의 나머지 내용은 모두 이 책이 제안하는 프로파일이며, SDK의 타입과 제5장의 교환 규칙에 맞도록 설계했다.
프로파일의 모든 리소스는 다섯 가지 원칙을 따른다. 구현 중에 생기는 설계 논쟁은 대부분 이 원칙으로 돌아가면 정리된다.
status처럼 워크플로 의미를 가진 필드는 전용 하위 리소스(/transitions, /receipt)를 통해서만 바뀐다. 이 하위 리소스가 선행 조건을 확인하고 사유를 기록한다. 제3장의 전이 표는 사용자 화면이 아니라 여기에서 강제된다.
경로는 서비스 기본 URL, 예컨대 기관 자체 호스트의 /v1을 기준으로 한 상대 경로이다. 모든 컬렉션은 목록 조회용 GET(4.5절)을, 모든 레코드는 ID로 조회하는 GET을 지원한다. 표에는 쓰기 동작을 정리했다.
| 리소스 경로 | 레코드(ID 접두어) | 쓰기 동작 | 비고 |
|---|---|---|---|
/facilities | BiosaftyFacility (FAC-) | POST 등록, If-Match를 동반한 PATCH | 위치 세부 정보는 별도 필드 묶음으로, 알 필요가 있는 경우에만 제공 |
/facilities/{id}/certifications | 인증 항목 | POST 추가 | 결정마다 추가되며, 시설의 certification 블록은 현재 항목을 보여 줌 |
/authorizations | IBC 승인(AUT-) | POST, 유효기간·상태에 대한 PATCH | 위원회의 결과만 기록하고 심의 내용은 회의록에 둠 |
/risk-assessment-records | 위해성 평가 메타데이터(RAR-) | POST, PATCH | 평가자, 일자, IBC 참조, 승인 등급, 문서 참조. 문서 자체는 해석하지 않음 |
/inspections | 점검·감사(INS-) | POST, POST .../findings | 내부 감사와 당국 점검. 지적 사항은 CAPA와 연결 |
/training-records | TrainingRecord (TRN-) | POST, 정정용 PATCH | 일괄 가져오기도 멱등 키와 함께 같은 엔드포인트 사용 |
/access-grants | 출입 허가(ACG-) | POST, POST .../revocation | 누가 어느 시설에 어떤 근거로 언제까지 출입할 수 있는가 |
/access-reviews | 출입 기록 검토(ACR-) | POST | 출입 통제 시스템 내보내기와 명단 대조. 원본 이벤트는 출입 통제 시스템에 남음 |
/incidents | IncidentReport (INC-) | POST, 워크플로 외 필드에 대한 PATCH | 상태는 /incidents/{id}/transitions로만 변경. CAPA와 NotificationRecord는 하위 리소스 |
/inventory-entries | 재고 항목(INV-) | POST, POST .../reconciliations | 장부 수준만: 등록부 참조, 시설, 보관 책임자, 상태 |
/custody-transfers | 이전·인수(TRF-) | POST, POST .../receipt | 제5장의 레코드 쌍 |
/compliance-checks | ComplianceResult (CHK-) | POST로 점검 실행 | 저장되며 변경 불가 |
/audit-events | AuditEvent (AUD-) | 없음 | 읽기 전용. 서비스가 스스로 기록 |
/webhook-subscriptions | 구독(WHS-) | POST, DELETE | 다른 시스템으로의 이벤트 전달(4.8절) |
이 장은 제2장의 접두어 목록에 INS-, ACG-, ACR-, INV-, WHS-를 더한다. 규칙은 같다. 의미를 담지 않고, 바뀌지 않으며, 재사용하지 않는다. 인력은 일부러 표에서 뺐다. 레코드 서비스는 인명부를 직접 두지 않고, 제6장에서 설명하는 대로 PER- 식별자를 기관의 신원 관리 시스템에 조회해 해석한다.
서비스가 돌려주는 모든 레코드는 작은 서비스 메타데이터 블록으로 감싸진다. 메타데이터를 레코드 본문 밖에 두면 본문은 SDK 타입 또는 그 프로파일 확장과 정확히 같은 모양을 유지한다. 그래서 클라이언트 코드는 변환 없이 본문을 BiosaftyFacility나 IncidentReport 타입을 받는 함수에 넘길 수 있다.
GET /v1/facilities/FAC-EXU-B07-0314
Authorization: Bearer eyJhbGciOi…
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v7"
{
"meta": {
"id": "FAC-EXU-B07-0314",
"recordType": "BiosaftyFacility",
"schemaVersion": "1.2.0",
"version": 7,
"updatedAt": "2026-02-18T07:41:10Z",
"updatedBy": "PER-0B44",
"redactedGroups": ["location"]
},
"record": {
"facilityId": "FAC-EXU-B07-0314",
"name": "Example University Teaching Laboratory 3",
"bslLevel": "BSL-2",
"certification": {
"certified": true,
"certificationDate": "2025-03-20T00:00:00Z",
"expirationDate": "2027-03-19T00:00:00Z",
"certifyingAuthority": "Example Certifying Body",
"nextInspectionDate": "2026-03-20T00:00:00Z"
},
"authorizedPersonnel": ["PER-7F3A", "PER-2C91", "PER-0B44"]
}
}
식별자, 이름, 날짜는 모두 가상의 값이다. version은 변경이 받아들여질 때마다 1씩 늘어나며 ETag 헤더에도 그대로 실린다(4.3절). schemaVersion은 본문이 따르는 스키마를 밝힌다. redactedGroups는 레코드에 존재하지만 이 호출자에게는 보내지 않은 필드 묶음을 나열한다. 예시의 호출자는 위치 세부 정보를 볼 수 없으므로 location 블록이 빠져 있고, 메타데이터가 그 사실을 알려 준다. 이 목록이 없으면 클라이언트는 "보류됨"과 "입력된 적 없음"을 구분하지 못하고, 레코드가 불완전하다고 잘못 보고할 수 있다.
시설의 certification 블록에는 현재의 결정만 있다. 그 결정에 이르기까지의 점검과 내부 감사 이력, 그리고 각 점검에서 무엇이 지적되었는지는 담기지 않는다. 점검자의 첫 질문은 대개 "지난 점검에서 무엇이 지적되었고, 그에 대해 무엇을 했는가?"이므로, 프로파일은 점검에 별도의 레코드를 부여한다.
| 필드 | 타입 | 의미 |
|---|---|---|
inspectionId | string | INS- 접두어를 가진 고정 ID |
facilityId | string | 점검 대상 등록 시설 |
kind | enum | internal-audit, ibc-review, authority-inspection, certification-renewal |
conductedBy | object | 내부 인력이면 가명 ID, 외부 기관이면 기관명과 접수 번호 |
scheduledAt, conductedAt | ISO 8601 UTC | 예정일과 실시일 |
outcome | enum | no-findings, findings-open, findings-closed, certification-granted, certification-withheld. 점검 기관이 통보한 대로 기록 |
findings[] | array | findingId, 선택적 code, 짧은 행정적 summary, capaRef, dueAt |
documentRef | string | 문서 관리 시스템에 보관된 서명된 보고서 |
당국 점검이 인증 결정으로 끝나면 두 번의 쓰기가 뒤따른다. 결과를 담은 점검 레코드, 그리고 /facilities/{id}/certifications에 추가되는 새 항목이다. 두 레코드는 같은 documentRef를 가진다. 서비스는 점검 결과로부터 인증을 추론하지 않는다. 생물안전관리책임자가 당국이 내린 결정을 그대로 입력하고, 서비스는 두 레코드가 서로 맞는지만 확인한다. 기술적 관찰 내용은 서명된 보고서에 남는다. summary 필드는 추적용("2월분 문 닫힘 확인 기록 누락")이며, 저장 전에 제6장의 비식별·삭제 점검표를 거친다.
교육 이수 기록은 제2장에서 정의한 모양 그대로 제공된다. 가장 많이 쓰이는 조회는 두 가지이다. 한 사람의 현재 유효한 교육 전체, 그리고 일정 기간 안에 만료되는 기록 전체이다. 두 번째 조회가 승인이 모르는 사이에 효력을 잃지 않도록 하는 알림을 움직인다.
출입은 두 레코드로 나눈다. 출입 허가는 어떤 사람이 어떤 시설에 들어갈 수 있다는 진술이며, grantId, facilityId, personnelId, basis(보통은 AUT- 승인이고, 생물안전관리책임자가 승인한 동행 출입일 수도 있다), validFrom, validUntil, grantedBy, status로 이루어진다. 시설의 authorizedPersonnel 목록은 유효한 허가로부터 도출된다. 출입 기록 검토는 누군가 특정 기간의 출입 통제 시스템 기록을 명단과 대조했다는 사실을 남긴다. 기간, 검토자, 내보낸 기록 파일의 참조, 불일치 건수, 그로 인해 열린 사고 레코드에 대한 링크가 담긴다. 원본 출입 이벤트는 레코드 서비스에 들어오지 않는다.
출입 허가를 만드는 순간이 서비스가 가장 쓸모 있는 교차 레코드 확인을 하는 지점이다. POST /access-grants를 받아들이기 전에 서비스는 시설 인증이 유효한지, 근거 승인이 유효하며 이 사람과 이 시설을 포함하는지, 이 사람이 기관 규정이 요구하는 유형의 유효한 교육을 이수했는지를 확인한다. 어느 하나라도 어긋나면 4.6절의 코드와 함께 거부한다. 필요한 교육 유형은 기관의 설정 사항이며, 표준도 이 책도 이를 정하지 않는다.
기관은 어떤 등록 물질이 어느 등록 시설에 보관되어 있고 각각의 책임자가 누구인지를 보여 줄 수 있어야 한다. 재고 항목은 딱 그 질문에만 답한다. 필드는 inventoryEntryId, materialRef(기관 내부 등록부 ID), 등록부에 기록된 riskGroup 라벨, facilityId, custodianId, status(held, in-transfer, transferred-out, disposal-recorded), lastReconciledAt이다. 물질에 관한 서술적·정량적 데이터는 담지 않으며, additionalProperties: false를 선언한 스키마가 그런 데이터를 추가하려는 시도를 거부한다(제2장의 데이터 최소화 규칙).
상태는 다른 레코드가 움직인다. 보관 이전(제5장)이 항목을 in-transfer로 바꾸고, 인수 확인이 이를 transferred-out으로 바꾸면서 받는 쪽에 새 항목을 만든다. 폐기는 그 사실을 증명하는 문서를 참조하는 방식으로 기록한다. 대사(reconciliation)는 보관 책임자가 특정 일자에 항목을 등록부와 맞춰 보았다는 기록이다. 불일치는 항목을 고쳐서 해결하지 않는다. 제5장이 보관 인수에 대해 요구하는 것과 마찬가지로 사고 또는 아차사고 레코드를 연다.
주 버전은 PHASE-2 기본 URL과 같이 경로(/v1)에 나타난다. PHASE-2 부속서는 하나의 주 버전 안에서 추가적 변경만 허용한다. 새 선택 필드, 새 리소스, 새 오류 코드가 그렇다. 필드 이름을 바꾸거나 없애는 것은 오류 본문에서라도 호환성을 깨는 변경이다. 서비스는 전체 버전을 응답 헤더(WIA-API-Version: 1.3.0, 프로파일이 정한 이름)와 공개 OpenAPI 문서에 밝히며, 부속서의 권고대로 그 문서에 배포의 운영 범위도 선언한다.
새 주 버전이 나오면 이전 버전은 병행 기간 동안 계속 제공된다. PHASE 부속서들은 이 기간의 길이에 대해 서로 다르게 말한다(부속서 F는 최소 90일, 부속서 H는 최소 180일). 프로파일은 더 엄격한 180일을 택하며, 이 충돌은 제7장에서 다룬다. 병행 기간 동안 이전 버전의 응답에는 퇴역 예정일을 담은 Sunset 헤더(RFC 8594)가 붙는다. 클라이언트 운영자는 장애를 겪고 나서가 아니라 자기 로그에서 마감일을 보게 된다.
두 사람이 같은 시설 레코드를 동시에 편집하는 일은 일상적이다. 생물안전관리책임자가 명단을 고치는 동안 사무국이 인증 일자를 입력한다. 동시성 통제가 없으면 나중에 저장한 쪽이 먼저 저장한 내용을 조용히 지운다. 프로파일은 HTTP 조건부 요청(RFC 9110)을 쓴다. 모든 응답은 레코드 버전을 ETag로 싣고, 모든 PATCH는 그 값을 If-Match로 되돌려 보내야 한다.
PATCH /v1/facilities/FAC-EXU-B07-0314
If-Match: "v7"
Content-Type: application/merge-patch+json
{ "name": "Example University Teaching Laboratory 3 (Annex)" }
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "https://example.org/wia-bio-017/problems/version-conflict",
"title": "Record changed since it was read",
"status": 412,
"currentVersion": 8
}
클라이언트는 레코드를 다시 읽고 변경을 다시 적용한 뒤 재시도한다. If-Match 없이 보낸 PATCH는 428 Precondition Required(RFC 6585)로 거부되므로, 어떤 클라이언트도 실수로 이 확인을 건너뛸 수 없다. 병합 패치 형식(RFC 7396)은 배열을 통째로 교체한다. 명단을 목록 패치가 아니라 출입 허가를 통해 바꾸는 이유 가운데 하나가 이것이다.
이전 버전도 계속 읽을 수 있다. GET /v1/facilities/{id}?asOf=2026-01-10T00:00:00Z는 그 시점에 효력이 있던 버전을 돌려주고, /versions는 감사 이벤트 참조와 함께 모든 버전을 나열한다. 제5장에서 설명하듯이, 이전 당일에 인증이 유효했는지는 오늘의 레코드가 아니라 그날의 레코드로 확인해야 한다.
OAuth 2.0과 API 키가 일을 어떻게 나누는지는 제6장에서 설명한다. 사람은 다중 인증을 거쳐 기관의 신원 제공자로 로그인하고, 클라이언트는 그 결과로 받은 OAuth 2.0 액세스 토큰(RFC 6749)을 베어러 토큰(RFC 6750)으로 제시한다. 인사 시스템의 교육 이수 가져오기처럼 사람이 개입하지 않는 연동은 수명이 짧은 토큰을 받는 클라이언트 자격 증명 방식을 쓴다. API 키는 등록된 클라이언트 애플리케이션을 식별하는 데 쓸 수 있지만, 그것만으로 제한 정보나 개인정보 필드를 읽을 수는 없다. 서버 간 연결에는 기관이 지원할 수 있다면 인증서에 묶인 토큰(상호 TLS, RFC 8705)이 낫다. 토큰이 유출되어도 클라이언트의 개인 키 없이는 쓸모가 없기 때문이다.
스코프는 토큰이 무엇을 할 수 있는지를, 역할은 그 사람이 누구인지를 나타낸다. 서비스는 둘 다 허용하는 경우에만 접근을 허락한다.
| 스코프 | 허용 범위 | 대표적 보유자(제6장의 역할) |
|---|---|---|
facilities:read | 시설 요약, 인증 이력 | 모든 내부 역할, 점검자(기한 한정) |
facilities:location | 위치 필드 묶음 | 생물안전관리책임자, 범위 내 점검자 |
facilities:write | 시설 등록, 인증 항목 추가 | 생물안전관리책임자 |
training:write | 교육 이수 기록 생성·정정 | 교육 담당자, 인사 시스템 가져오기 클라이언트 |
access:write | 출입 허가와 검토 | 생물안전관리책임자, 출입 통제 시스템 클라이언트(검토만) |
incidents:report | 사고·아차사고 보고서 생성 | 모든 등록 인력, 익명 보고 채널 클라이언트 |
incidents:manage | 상태 전이, CAPA, 통보 | 생물안전관리책임자, 배정된 조사자(담당 건) |
audit:read | 감사 이벤트 | 생물안전관리책임자, 감사인 |
어떤 결정은 데이터에 달려 있다. 연구책임자는 자기가 승인받은 과제에 속한 사람들의 교육 이수 상태만 읽을 수 있고, 조사자는 자기에게 배정된 사고만 전이할 수 있다. 서비스는 이런 관계를 요청 시점에 레코드 자체에서 판단하며, 이 판단은 역할 행렬 위에 덧붙는다.
점검자 접근은 명시적으로 모델링한다. 생물안전관리책임자가 점검 레코드, 대상 시설, 읽기 전용 스코프, 만료 시각을 지정한 허가를 만든다. 점검자의 토큰은 그 허가 ID를 지니고, 그 토큰으로 한 모든 읽기는 허가 ID를 사유 참조로 삼아 감사 추적에 남는다. 허가가 만료되면 토큰 자체의 수명과 관계없이 토큰은 더 이상 작동하지 않는다.
인증은 되었지만 권한이 없는 호출자, 예컨대 facilities:location 스코프가 없는 토큰으로 위치 묶음을 요청한 경우, 서비스는 403과 함께 코드 B003 "Unauthorized access"(명세 §9.4)를 돌려주고 동작이 access-denied인 감사 이벤트를 기록한다. 제6장은 이 이벤트에 경보 규칙을 건다. 토큰이 없거나 만료된 경우는 B 코드 없이 401을 돌려준다. 거부는 누군가 문 앞까지 와서 돌려보내졌다는 뜻이고, 401은 클라이언트가 다시 로그인해야 한다는 뜻일 뿐이다.
교육 이수 기록이나 감사 이벤트 같은 컬렉션은 끝없이 늘어난다. 오프셋 방식의 페이지 매김(?page=40)은 읽는 도중 데이터가 바뀌면 오동작한다. 행이 페이지 사이를 옮겨 다니며 두 번 나오거나 아예 빠진다. 프로파일은 그 대신 불투명한 커서를 쓴다.
GET /v1/training-records?expiresBefore=2026-06-30T00:00:00Z&facilityId=FAC-EXU-B07-0314&limit=50
HTTP/1.1 200 OK
Link: </v1/training-records?cursor=eyJrIjoiVFJOLTIwMjYtMDA0ODEifQ>; rel="next"
{
"items": [
{ "meta": { "id": "TRN-2025-000731", "version": 2 },
"record": { "trainingRecordId": "TRN-2025-000731",
"personnelId": "PER-2C91",
"trainingType": "refresher",
"expirationDate": "2026-05-12T00:00:00Z" } }
],
"nextCursor": "eyJrIjoiVFJOLTIwMjYtMDA0ODEifQ",
"snapshotAt": "2026-03-02T01:00:00Z"
}
커서는 일반 HTTP 도구가 이해하는 Link 헤더(RFC 8288)로도 제공된다. snapshotAt은 목록이 반영하는 시점을 밝힌다. "이번 분기에 교육이 만료되는 모든 사람" 같은 보고서를 만드는 클라이언트는 이후 모든 페이지에 이 값을 asOf로 넘긴다. 그러면 도중에 레코드가 바뀌어도 보고서 전체가 한 시점을 기술한다. 감사 이벤트의 커서는 제5장의 스트림 seq를 따르므로, 페이지를 넘기면서 이벤트를 건너뛰거나 반복하는 일이 없다.
필터는 범용 질의 언어가 아니라 이름이 정해진 질의 매개변수이다. 작고 고정된 집합은 보호하기도, 색인하기도 쉽다. 또 클라이언트가 조건을 조합해 비식별 처리로 가린 내용을 추론하는 일을 막는다.
| 컬렉션 | 필터 | 대표적 질문 |
|---|---|---|
/facilities | bslLevel, certificationExpiresBefore, building(위치 스코프 전용) | 다음 위원회 회의 전에 만료되는 인증은? |
/training-records | personnelId, trainingType, expiresBefore, facilityId | 출입 허가 갱신 전에 보수교육이 필요한 사람은? |
/access-grants | facilityId, personnelId, status, validAt | 특정 날짜에 이 실험실에 들어갈 수 있었던 사람은? |
/incidents | status, severity, eventClass, facilityId, occurredFrom/occurredTo | 아직 조사 중인 중대 사고는? |
/audit-events | recordRef, actor, action, from/to | 누가 언제 이 레코드를 바꾸었는가? |
필터는 필드 수준 권한을 따른다. 위치 스코프가 없는 호출자는 건물로 걸러 볼 수 없고, 인력 ID를 해석할 수 있는 역할만 그 ID로 사고를 걸러 볼 수 있다. 호출 한도(PHASE-2는 시간당 1,000회로 제시하며 이 책은 예시로 본다)에 이르면 서비스는 Retry-After 헤더와 함께 429를 돌려준다. 야간 가져오기 같은 일괄 작업에는 별도의 클라이언트 신원과 할당량을 주어, 작업이 밀려도 생물안전관리책임자가 사고 화면에서 밀려나지 않게 해야 한다.
오류는 문제 상세(problem details, RFC 9457, application/problem+json) 형식으로 돌려준다. type URI, 짧은 title, HTTP status, 사람이 읽는 detail로 이루어진다. 프로파일은 해당하는 경우 WIA-BIO-017 코드를 담는 code를 더하고, 한 요청이 여러 확인에서 동시에 실패하면 findings[]를 더한다. 첫 번째 문제가 나머지를 가리지 않게 하기 위해서이다.
POST /v1/access-grants
Idempotency-Key: grant-PER-5D10-FAC-EXU-B07-0314-2026-03
Content-Type: application/json
{ "facilityId": "FAC-EXU-B07-0314", "personnelId": "PER-5D10",
"basis": "AUT-2026-0019", "validFrom": "2026-03-04T00:00:00Z",
"validUntil": "2026-08-31T23:59:59Z" }
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://example.org/wia-bio-017/problems/grant-preconditions",
"title": "Access grant preconditions not met",
"status": 409,
"detail": "2 of 3 preconditions failed for PER-5D10 at FAC-EXU-B07-0314.",
"findings": [
{ "code": "B009", "subject": "PER-5D10",
"message": "No valid training record of required type 'initial'." },
{ "code": "B008", "subject": "AUT-2026-0019",
"message": "Authorization approvedLevel does not match facility bslLevel." }
]
}
이 사람에게는 유효한 신규 교육 기록이 없고, 출입 허가의 근거가 될 승인에는 시설에 기록된 등급과 다른 승인 등급이 적혀 있다. 둘 다 레코드 간 불일치이지 기술적 판단이 아니다. 응답은 생물안전관리책임자에게 어느 레코드를 고쳐야 하는지 알려 줄 뿐, 그 작업에 무엇이 필요한지는 말하지 않는다. 그것은 위원회가 승인을 내릴 때 이미 정한 사항이다.
명세 §9.4는 B001부터 B006까지 여섯 개 코드를 나열한다. SDK의 BiosaftyErrorCode 열거형은 이 여섯 개에 더해 명세 표에 없는 B007부터 B010까지 네 개를 더 가진다. 클라이언트가 이미 이 열거형을 가져다 쓰고 있으므로 프로파일은 열 개 식별자를 모두 쓰되, 역할을 서로 다르게 부여한다.
| 코드 | BiosaftyErrorCode 멤버 | 출처 | 레코드 서비스에서의 역할 | HTTP |
|---|---|---|---|---|
| B003 | UNAUTHORIZED_ACCESS | 명세 §9.4, SDK | 인증되었으나 권한 없음. 감사 이벤트를 반드시 기록 | 403 |
| B007 | INVALID_PARAMETERS | SDK에만 있음 | 스키마 검증 실패(잘못된 열거값, 틀린 ID 접두어, 닫힌 객체의 알 수 없는 필드) | 422 |
| B008 | BSL_MISMATCH | SDK에만 있음 | 레코드 간에 기록된 등급 라벨이 다름(승인 대 시설, 평가 레코드 대 승인) | 쓰기 시 409, 점검 시 지적 사항 |
| B009 | TRAINING_EXPIRED | SDK에만 있음 | 명단이나 출입 허가에 오른 사람의 필수 교육이 없거나 만료됨 | 쓰기 시 409, 점검 시 지적 사항 |
| B010 | CERTIFICATION_REQUIRED | SDK에만 있음 | 시설 인증이 없거나 만료일이 지남 | 쓰기 시 409, 점검 시 지적 사항 |
| B001, B002, B004, B005, B006 | 나머지 멤버 | 명세 §9.4, SDK | 책임 있는 기구의 보고서에 따라 입력된 라벨로서만 점검 지적 사항이나 사고 레코드에 저장. 서비스 자체 확인으로는 결코 생성하지 않음 | 없음(오류가 아니라 데이터) |
가장 중요한 것은 마지막 행이다. 시설의 공학적 통제 점검표에 있는 참·거짓 값으로부터 이 다섯 가지 상태를 "탐지"하는 확인 로직은 소프트웨어가 내리는 기술적 판정이 된다. 게다가 그 점검표는 인증 기관이 특정 시점에 확인한 내용을 기록한 것일 뿐이므로, 그런 로직은 정작 중요한 순간에 틀린다. 프로파일은 이 코드를 라벨로 남겨 지적 사항과 사고를 범주별로 셀 수 있게 하되, 서비스가 이 코드를 만들어 내는 것은 금지한다.
프로토콜 문제는 B 코드와 따로 둔다. version-conflict(412), precondition-required(428), illegal-transition(409, 제3장 전이 표에 없는 사고 전이), idempotency-key-reuse(409), rate-limited(429)가 그것이다. 그러지 않으면 대시보드가 오래 열어 둔 브라우저 탭 하나를 준수 위반으로 셀 것이다.
SDK는 생성자 (code: BiosaftyErrorCode, message: string, details?: Record<string, unknown>)를 가진 BiosaftyError를 내보낸다. 철자는 SDK 고유의 것이므로 가져오는 경계에서는 그대로 쓰고, 제2장의 권고대로 올바른 철자의 별칭을 다시 내보낸다. 클라이언트는 코드가 붙은 문제 응답을 이 클래스로 바꿀 수 있고, 그러면 서비스에서 온 오류와 로컬 검증 오류를 같은 방식으로 처리할 수 있다.
import { BiosaftyError, BiosaftyErrorCode } from '@wia/bio-017';
interface Problem {
type: string; title: string; status: number; detail?: string;
code?: string; findings?: { code: string; subject: string; message: string }[];
}
const KNOWN = new Set<string>(Object.values(BiosaftyErrorCode));
export function toBiosafetyError(p: Problem): BiosaftyError | undefined {
const first = p.code ?? p.findings?.[0]?.code;
if (!first || !KNOWN.has(first)) return undefined; // 프로토콜 문제이지 지적 사항이 아님
return new BiosaftyError(first as BiosaftyErrorCode, p.detail ?? p.title, {
status: p.status, type: p.type, findings: p.findings ?? [],
});
}
제5장은 기관 간 메시지에 멱등 키를 적용한다. 같은 문제는 한 기관 안에서도, 클라이언트가 응답을 받지 못한 요청을 재시도할 때마다 생긴다. "사고 제출"을 누른 직후 노트북의 네트워크가 끊기거나, 야간 가져오기가 중간에 다시 시작되는 경우이다. 생물안전 레코드의 중복은 무해하지 않다. 같은 사고가 두 건이면 당국에 보내는 통계가 부풀려지고, 같은 교육 이수 기록이 두 건이면 한쪽에만 적용된 정정이 가려질 수 있다.
그래서 프로파일은 레코드를 만들거나 동작을 일으키는 모든 POST에 Idempotency-Key 헤더를 요구한다. 헤더 이름은 IETF HTTP 작업반에서 논의 중인 초안을 따르고, 의미는 제5장과 같다.
import-HR-2026-03-01-row-4410)가 무작위 키보다 감사하기 쉽다.idempotency-key-reuse로 거부한다. 서비스는 어느 본문이 의도된 것인지 추측하지 않는다.
PATCH에는 키가 필요 없다. If-Match가 이미 재시도를 안전하게 만들기 때문이다. 버전이 이미 올라간 뒤의 재요청은 412로 실패하고, 이로써 첫 시도가 성공했음을 알 수 있다.
생물안전 행정의 많은 부분은 날짜와 상태 변화에서 시작되며, 다른 시스템이 이에 반응해야 한다. 출입 허가가 끝나면 출입 통제 시스템이 출입을 막아야 하고, 학습 관리 시스템은 보수교육을 잡아야 하며, 심각도 3의 사고에는 당직 호출이 울려야 한다. 모든 컬렉션을 주기적으로 조회하는 방식은 낭비가 크고 느리므로 프로파일은 웹훅 구독을 제공한다.
| 이벤트 유형 | 발생 시점 | 대표적 구독자 |
|---|---|---|
facility.certification.lapsed | 새 항목 없이 만료일이 지남 | 출입 통제 어댑터, 대시보드 |
training.expiring | 교육 이수 기록이 알림 기간에 들어섬 | 학습 관리 시스템 |
access-grant.ended | 출입 허가 만료 또는 취소 | 출입 통제 어댑터 |
incident.created | 새 사고·아차사고 보고서가 접수됨 | 통보 서비스(제3장 SLA 타이머) |
incident.transitioned | 상태 전이가 받아들여짐 | IBC 사무국 대기열 |
custody-transfer.overdue | 합의된 기간 안에 인수 확인이 없음 | 보내는 쪽과 받는 쪽 생물안전 부서 |
알림 기간의 길이는 기관 설정 사항이다. 프로파일은 이벤트의 이름만 정하고 일수는 정하지 않는다.
페이로드에는 레코드 내용이 아니라 식별자만 담는다. 구독자는 자기 자격 증명으로 API를 통해 레코드를 가져간다. 그러면 접근 통제는 API 한 곳에서만 적용된다. 실수로 로그에 남은 페이로드는 거의 아무것도 드러내지 않으며, 처리가 늦어진 구독자도 낡은 사본이 아니라 현재 상태를 읽게 된다.
POST /hooks/biosafety (구독자의 엔드포인트)
Content-Type: application/json
WIA-Webhook-Id: 0192f7b1-4d20-7a33-8e11-6b2c9d0e7f45
WIA-Webhook-Timestamp: 1772500000
WIA-Webhook-Signature: v1=5e0f…c29a
{
"eventType": "incident.transitioned",
"occurredAt": "2026-03-03T01:06:40Z",
"subscriptionId": "WHS-0007",
"recordRef": { "type": "IncidentReport", "id": "INC-2026-0042", "version": 6 },
"change": { "from": "under-investigation", "to": "resolved" },
"auditEventId": "AUD-2026-0005102"
}
timestamp + "." + body에 대한 HMAC-SHA-256(RFC 2104)을 계산한다. 구독자는 상수 시간 비교로 서명을 검증하고, 스스로 정한 허용 범위(예컨대 몇 분)를 벗어난 타임스탬프는 거부한다. 비밀 값을 교체하는 동안에는 두 서명을 모두 보낸다.WIA-Webhook-Id를 쓰며, 구독자는 이 값으로 중복을 버린다.recordRef의 version으로 구독자는 이미 가진 상태보다 오래된 이벤트를 무시할 수 있다. 엄격한 순서가 필요하면 /audit-events를 seq 순으로 읽는다.
/compliance-checks 리소스는 이 장 곳곳의 교차 레코드 규칙을 한데 모은다. 클라이언트가 범위(시설, 승인, 또는 부서)와 asOf 시각을 보내면, 서비스는 그 시각에 효력이 있던 레코드 버전을 대상으로 규칙을 평가하고 결과를 CHK- ID를 가진 ComplianceResult로 저장한다. 결과는 SDK의 모양(isCompliant, violations, warnings, recommendations, complianceScore, requiredUpgrades)을 그대로 쓰고, 프로파일은 구조화된 findings[]와 참조한 모든 레코드의 ID와 버전을 더한다.
규칙은 몇 개뿐이며 모두 행정적이다. asOf 시점에 인증이 존재하고 만료되지 않았는가? 시설의 유효한 승인은 모두 시설에 기록된 등급과 같은 승인 등급 라벨을 가지는가? 명단의 모든 사람이 유효한 출입 허가와 규정이 요구하는 유형의 교육을 가지고 있는가? 기한을 넘긴 점검 지적 사항, CAPA 항목, 통보가 있는가? 각 규칙은 기록된 값을 다른 기록된 값이나 날짜와 비교할 뿐이다.
두 레코드 묶음을 비교해 보자. 첫째 묶음에서 시설 FAC-EXU-B07-0314는 BSL-2로 기록되어 있고 인증은 2027년까지 유효하며, 유일한 유효 승인 AUT-2026-0011은 BSL-2로 승인되었다. 둘째 묶음에서는 같은 시설에 승인 AUT-2026-0019가 더 연결되어 있고, 그 승인 등급은 BSL-3으로 입력되어 있다. 입력 실수일 수도 있고 다른 시설의 과제가 잘못 연결되었을 수도 있다. 서비스는 어느 쪽인지 알 수 없으며, 알아내려 하지도 않는다.
POST /v1/compliance-checks
Idempotency-Key: chk-FAC-EXU-B07-0314-2026-03-05
Content-Type: application/json
{ "scope": { "facilityId": "FAC-EXU-B07-0314" }, "asOf": "2026-03-05T00:00:00Z" }
HTTP/1.1 201 Created
Location: /v1/compliance-checks/CHK-2026-00188
{
"checkId": "CHK-2026-00188",
"asOf": "2026-03-05T00:00:00Z",
"result": {
"isCompliant": false,
"violations": ["AUT-2026-0019: approved level differs from facility level"],
"warnings": [],
"recommendations": ["Confirm AUT-2026-0019 with the IBC secretariat"],
"complianceScore": 50,
"requiredUpgrades": []
},
"findings": [
{ "code": "B008", "subject": "AUT-2026-0019", "related": ["FAC-EXU-B07-0314"] }
],
"consulted": [
{ "id": "FAC-EXU-B07-0314", "version": 7 },
{ "id": "AUT-2026-0011", "version": 2 },
{ "id": "AUT-2026-0019", "version": 1 }
]
}
첫째 묶음에 대해 같은 호출을 하면 위반도 지적 사항도 없는 isCompliant: true가 돌아온다. 둘째 묶음에서 권고는 결정의 주인인 사람들에게 레코드를 확인하라는 것이다. 서비스는 시설이나 작업을 바꾸라고 권고하지 않으며, requiredUpgrades는 설계상 비어 있다. complianceScore는 SDK 타입과의 호환을 위해 남겨 둔 필드이며, 제7장에서 설명하듯 통과한 확인의 비율이 아니라 경험적 수치이다. 결과마다 참조한 버전이 기록되므로 점검은 재현할 수 있고, 점검자는 시스템이 3월 5일에 정확히 어떤 레코드 버전을 보았는지 확인할 수 있다.
package.json에 따르면 참조 패키지의 이름은 @wia/bio-017, 버전은 1.0.0이다. 공개 npm 레지스트리에 실제로 올라가 있는지는 확인되지 않았으므로, 예제는 표준 저장소의 소스에서 빌드해도 된다. 레코드 작업에 쓰이는 것은 세 가지이다. 레코드와 결과 타입(BiosaftyFacility, TrainingRecord, IncidentReport, ComplianceResult와 제2장의 열거형), BiosaftyErrorCode 열거형과 BiosaftyError 클래스, 그리고 getVersion()으로 라이브러리 버전을 알려 주는 BioSafetySDK 클래스이다. 이 버전은 제7장의 증거 패키지에 기록한다. 셸 도구의 wia-bio-017 version 하위 명령은 CI 로그에서 같은 역할을 한다.
레거시 v1.0.0 SDK에는 위험 점수, 노출, 밀폐, 보호구에 관한 계산 도우미도 들어 있지만, 기술적 안전 판단은 기관생물안전위원회와 공식 당국의 몫이므로 이들은 이 레코드 표준의 범위 밖에 있다.
패키지에는 HTTP 클라이언트가 없으므로, 배포 측이 직접 작성하거나 OpenAPI 문서에서 생성한다. 아래 인터페이스는 이 책의 제안이며 v1.0.0 패키지에 포함된 것이 아니다. 이 장의 규약을 감싸 두어 애플리케이션 코드가 같은 처리를 반복하지 않게 한다.
// records/client.ts - 이 책의 프로파일이며 @wia/bio-017 v1.0.0에 포함되지 않음
import type { TrainingRecord, IncidentReport, ComplianceResult } from '@wia/bio-017';
import type { BiosafetyFacility } from './sdk-aliases'; // 제2장의 별칭 파일
export interface Envelope<T> {
meta: { id: string; version: number; schemaVersion: string; redactedGroups?: string[] };
record: T;
}
export interface Page<T> { items: Envelope<T>[]; nextCursor?: string; snapshotAt: string }
export interface RecordClient {
getFacility(id: string, opts?: { asOf?: string }): Promise<Envelope<BiosafetyFacility>>;
patchFacility(id: string, ifMatch: number, patch: Partial<BiosafetyFacility>)
: Promise<Envelope<BiosafetyFacility>>;
listTraining(filter: { personnelId?: string; facilityId?: string; expiresBefore?: string },
page?: { cursor?: string; limit?: number; asOf?: string })
: Promise<Page<TrainingRecord>>;
createTraining(rec: TrainingRecord, idempotencyKey: string)
: Promise<Envelope<TrainingRecord>>;
reportIncident(rec: Partial<IncidentReport>, idempotencyKey: string)
: Promise<Envelope<IncidentReport>>;
transitionIncident(id: string, to: IncidentReport['status'], reason: string,
idempotencyKey: string): Promise<Envelope<IncidentReport>>;
runComplianceCheck(scope: { facilityId: string }, asOf: string, idempotencyKey: string)
: Promise<{ checkId: string; result: ComplianceResult }>;
}
애플리케이션 코드의 상당 부분은 두 가지 사용 방식으로 해결된다. 고정된 스냅숏에서 목록 전체를 순회하는 것, 그리고 낙관적 동시성 아래에서 레코드를 바꾸고 충돌 시 한 번 재시도하는 것이다.
import type { RecordClient } from './records/client';
// 1. 특정 날짜 전에 만료되는 교육 이수 기록 전체를 하나의 일관된 스냅숏으로.
export async function* expiringTraining(c: RecordClient, before: string) {
let cursor: string | undefined;
let asOf: string | undefined;
do {
const page = await c.listTraining({ expiresBefore: before }, { cursor, limit: 100, asOf });
asOf ??= page.snapshotAt; // 이후 페이지를 첫 스냅숏에 고정
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
// 2. 낙관적 동시성으로 시설 이름 변경.
export async function renameFacility(c: RecordClient, id: string, name: string) {
for (let attempt = 0; attempt < 2; attempt++) {
const cur = await c.getFacility(id);
try {
return await c.patchFacility(id, cur.meta.version, { name });
} catch (e) {
if ((e as { status?: number }).status !== 412) throw e; // 다시 읽고 한 번만 재시도
}
}
throw new Error(`${id} kept changing; try again later`);
}
가져오기는 4.7절을 따른다. 원본 행마다 고정된 키를 하나씩 두면 다시 실행해도 아무것도 두 번 만들어지지 않고, 한 행이 B007로 거부되면 전체 작업을 멈추는 대신 그 행을 교육 담당자의 처리 대기열에 넣는다. 레코드를 엄격한 SDK 인터페이스 타입을 받는 코드에 넘겨야 할 때는 단일 어댑터 모듈에서 대체를 명시적으로 처리한다. 엄격한 SDK 타입은 IncidentReport에 여전히 agent 객체를 요구하기 때문이다(제3장). 타입 단언을 애플리케이션 곳곳에 흩어 두어서는 안 된다.
ETag/If-Match는 동시 편집을, Idempotency-Key는 재시도를, 스냅숏을 동반한 커서는 목록의 일관성을 지킨다.기술적 안전 내용은 공식 생물안전 당국에 문의할 것. 이 장은 행정 레코드를 저장하고 확인하는 인터페이스를 정의한다. 밀폐 등급, 위해성 평가, 보호 조치를 비롯한 모든 기술적 안전 사항은 기관생물안전위원회, 생물안전관리책임자, 자격을 갖춘 인력이 질병관리청과 국가 생물안전 당국, WHO 실험실 생물안전 매뉴얼, BMBL 등 공식 지침에 따라 결정한다. 레코드 서비스는 그 결과를 저장할 뿐이다.
status 필드 패치가 아니라 /incidents/{id}/transitions를 통해 바꾸게 하는가?POST /training-records를 재시도했다. 서비스는 무엇을 돌려주며, 그 이유는 무엇인가?snapshotAt과 asOf는 여러 페이지에 걸친 보고서의 일관성을 어떻게 지키는가?