각 부분은 초록이었습니다. 합친 것은 아니었습니다.
보안 구멍을 막은 수정이 조용히 아무것도 하지 않는 버튼으로 나갈 뻔했습니다.
0.6.0에서 리댁션 구멍 하나를 막았습니다. 그것을 막은 커밋이, 우리 테스트가 쓰는 드라이버에서는 돌고 대시보드가 쓰는 드라이버에서는 던지는 DB 관용구를 들여왔습니다. 그 함수의 프로덕션 호출자는 대시보드뿐입니다. 그러니 그 함수는 실제로 도는 모든 곳에서 던졌고, 웹 액션이 그 에러를 삼켜 "이미 처리됨"이라고 보고했고, 승인 버튼을 누른 사용자는 아무 일도 일어나지 않는 것을 봤을 것입니다.
리댁션 구멍도 그대로 남습니다. 이번에는 이유가 다릅니다. 행이 아예 안 써지니까요.
관련된 테스트 스위트는 전부 초록이었습니다. 이 글은 그게 전부이고, 조심성에 관한 이야기가 아닙니다.
우리가 빚진 나머지 절반
0.5.3에서 우리는 프로젝트별 리댁션 denylist가 한 번도 돈 적이 없다고 공개했습니다. 메커니즘은 실재했고 배선도 돼 있었습니다. 채울 방법이 없었을 뿐입니다. 설정 화면도, API도, CLI도 없었습니다. 모든 프로젝트의 규칙이 비어 있었고, 빈 denylist는 아무것도 지우지 않으니, 0.5.0 이후 증류된 모든 것이 쓰인 그대로 저장돼 있었습니다.
우리는 그걸 고치기 전에 먼저 적었습니다. 0.6.0이 나머지 절반입니다. 오너가 지식 설정 화면에서 규칙을 넣고, 그 규칙이 지식을 쓰는 여섯 경로 전부에 적용됩니다. 추출기 sweep, learn, 레슨을 실은 close, 제안 생성, 제안 승인, 그리고 승인의 플레이북 분기입니다. 서버가 해석하지 못하는 규칙이 있으면 이제 모든 writer가 거부합니다. 적용되지 않는 규칙 아래 텍스트를 저장하는 쪽으로 물러서지 않습니다.
여섯. 이 글은 그 숫자에 대한 것입니다.
개수는 모든 층에서 틀렸고, 정정은 매번 목록을 보고 이뤄졌습니다
리댁션을 설명하던 주석은 몇 달 동안 이렇게 적혀 있었습니다.
행이 쓰이기 전에 추출기와
learn둘 다에서 적용된다. [...] 두 write path 중 하나에만 있는 denylist는 구멍 있는 denylist다.
둘입니다. 구멍을 경고하는 문장이 그 자체로 구멍이었습니다. writer를 열거해뒀으니 아무도 다시 세지 않았습니다.
설계 계약서에는 셋이라고 적혀 있었습니다. 어느 리뷰가 그 계약서를 코드에 대조해보고 넷을 찾았습니다. 제안 승인 경로가 제안의 제목과 본문을 담은 지식 행을 쓰면서 denylist를 건드리지 않은 채 두 릴리스를 났던 것입니다. 그것을 고친 커밋이 메커니즘을 우리보다 잘 적었습니다.
redact()가apps/server/src/knowledge/에 있었던 것은 그것을 쓰는 두 writer가 둘 다 거기 있었기 때문이다. 호출자가 우연히 어디 있었는지에 대한 사실이, 규칙이 어디 속하는지로 제시된 것이다.packages/db는 그것을 import할 수 없었고, 그래서 그 패키지의 writer는 조용히 denylist를 갖지 못했다.
이것이 첫 번째 경계입니다. 규칙이 다른 패키지의 호출자에게 닿지 않았고, 그 규칙을 설명하는 주석은 닿을 수 있는 호출자들만 보고 쓰였습니다. 세 라운드의 리뷰가 리댁션 규칙을 다루는 동안 그 경로는 한 번도 등장하지 않았습니다.
둘, 그다음 셋, 그다음 넷, 그리고 릴리스는 여섯으로 나갔습니다. 그 증가 중 일부는 진짜입니다. 레슨을 실은 close와 플레이북 분기는 0.6.0에서 새로 생긴 writer입니다. 다만 writer를 세어둔 어떤 목록도 쓰이는 시점에 맞은 적이 없었고, 정정은 매번 코드가 아니라 목록을 읽은 사람이 했습니다.
나갈 뻔한 것
제안 승인에 리댁션을 붙인 것이 드라이버 버그를 들여왔습니다.
const [row] = await tx.execute(...)는 postgres-js입니다. node-postgres는 { rows, rowCount }를 돌려주고 이건 iterable이 아니라서 구조분해가 던집니다. 우리 packages/db 스위트는 그 두 드라이버 중 하나로 돕니다. 대시보드는 다른 하나로 돌고, 프로덕션 호출자는 대시보드뿐입니다.
그 커밋은 패키지가 초록이었는데도 왜 못 덮었는지를 직설적으로 적습니다.
packages/db는 두 드라이버가 소비하고 그 스위트는 하나로 돈다. 여기의 모든 결과는 호출자의 절반에 대한 진술이고, 이 함수에 대해서는 시험되지 않은 절반이 유일한 실제 절반이었다.
두 번째 경계이고, 가져갈 만한 것은 이쪽입니다. 초록 스위트는 그것이 실제로 돌아본 구성에 대한 주장입니다. 어떤 패키지를 두 방식으로 쓰는데 한 방식으로만 시험한다면, 그 패키지가 보고하는 모든 숫자는 호출자의 절반에 대한 것이고, 출력의 어디에도 그 사실이 적혀 있지 않습니다.
타입 시스템은 도움이 안 됐고, 변명이 아니라 이유가 있습니다. drizzle이 execute를 드라이버별로 타이핑하는데 이 함수들은 넓힌 DB 타입을 받습니다. 그래서 원래 코드가 컴파일됐습니다. 그래서 수정이 세 부분입니다. 오타 하나만 고치면 다시 돌아오니까요. 드라이버 무관 헬퍼를 만들되 실패 조건을 호출 지점이 아니라 정의부에 적었고, 실제 node-postgres 풀 위에서 진짜 함수들을 돌리는 테스트를 넣었고 - 거기에 리댁션이 여전히 일어나는지 확인하는 케이스를 하나 뒀습니다. 함수가 다시 돌기만 하고 리댁션은 안 하는 수리는 바깥에서 보면 똑같이 생겼으니까요 - 마지막으로 그 관용구를 잡는 소스 스캔을 붙였습니다.
음성 대조도 가정하지 않고 양방향으로 돌렸습니다. 한쪽 호출 지점을 되돌리면 네 케이스 중 셋이 빨개지고, 다른 쪽을 되돌리면 넷 다 빨개집니다.
한 가지 철자만 아는 스캔
그 소스 스캔은 고쳐지기까지 한 시간쯤 갔습니다.
구조분해 형태는 잡고 const rows = await tx.execute(...)는 놓쳤습니다. "행 전부 달라"를 쓰는 가장 자연스러운 방식이고, .length 한 번 뒤에 똑같이 깨집니다. 그것을 고친 커밋이 쓸모 있는 부분을 적었습니다.
한 가지 철자를 겨냥한 스캔은 누군가 같은 실수를 다르게 적는 첫 순간에 작동을 멈춘다. 그리고 이 스캔은 바로 그 실수에 한 시간을 쓴 사람이 한 시간 전에 쓴 것이다.
둘이라고 적힌 주석과 같은 모양입니다. 열거하는 가드는 그것을 쓴 사람이 떠올린 사례를 덮습니다.
초록인 채로 다른 것을 재고 있던 테스트
제안 생성은 change.title과 change.body를 리댁션했습니다. 지식의 모양입니다. 플레이북 제안의 모양은 { content }입니다. 그 텍스트는 손도 안 댄 채 들어갔고, 승인이 그것을 플레이북 버전과 프로젝트의 모든 에이전트가 읽는 파일로 복사했습니다. 어느 단계에서도 규칙이 적용되지 않은 채로요.
그 경로에는 테스트가 있었습니다. content가 바이트 단위로 보존되는지를 확인하는 테스트라 통과했고, 누구도 알아야 했던 것의 정반대를 재고 있었습니다. 이것이 세 번째 경계입니다. 이름이 커버리지를 주장했고 아무도 그 뒤를 세어보지 않았습니다. 수정은 이제 change 안의 모든 문자열을 훑습니다. 실패한 것이 정확히 필드 이름 목록이었으니까요.
이 릴리스에서 두 번 있었습니다. 다른 곳에서는 the backfill covered rows that existed before the watermark table라는 이름의 테스트가 워터마크를 지우고 스레드가 여전히 억제되는지를 확인했습니다. 억제한 것은 술어였지 백필이 아니었고, 워터마크를 지운 상태는 백필이 만드는 상태의 역입니다. 그 테스트는 자기가 대지 않은 이유로 통과하고 있었습니다. 둘 다 릴리스를 막을 결함은 아니었습니다. 다만 이름이 측정을 대신한 같은 실패입니다.
같은 커밋이 무엇을 검증하지 않았는지도 적어둡니다. 나중에 발견하느니 적어두는 편이 낫습니다. 새 스냅샷 가드는 인터리브 테스트가 없습니다. 그 창이 SELECT와 INSERT 사이이고 그 사이에는 JavaScript밖에 없기 때문입니다. 문장 구조가 인터리브 테스트가 있는 다른 경로와 동일하다는 것이 근거인데, 이건 구조에 의한 검증이라 더 약합니다. 그리고 그렇게 말해두는 편이, 엉뚱한 순간에 도달해서 초록을 보고하는 테스트보다 낫습니다.
이유가 틀린 주석, 그리고 그 틀린 이유가 밀어낸 것
마지막 하나는 테스트 이야기가 아닙니다.
close는 스레드가 취소됐는지를 자기 트랜잭션이 열리기 전에 뜬 읽기로 판단했습니다. 그 틈에 취소가 커밋되면, 조건부 업데이트가 0행을 발견할 때쯤엔 레슨이 이미 영구 저장돼 있었습니다. 그래서 취소된 스레드가 레슨을 달게 됩니다. 그 거부가 존재하는 이유가 바로 그것을 막기 위해서인데요. 그리고 응답은 여전히 closed라고 보고했습니다.
옛 순서에는 명시된 이유가 있었습니다. 레슨을 먼저 쓰는 이유로, 워터마크를 나중에 잡으면 "스레드가 닫혀 있고 미청구이고 추출 가능한 창"이 생긴다고 적혀 있었습니다. 그 창은 존재하지 않습니다. 두 문장이 한 트랜잭션 안에 있어서 커밋 전에는 바깥에서 둘 다 안 보입니다.
그 순서는 자기 주석이 주장한 것을 산 적이 없고, 대신 취소 검사에서 진실을 볼 유일한 기회를 빼앗았습니다. 우리는 그 주석을 읽고 승인했습니다. 이유가 적혀 있다는 것에 만족해서요.
실제로 이것들을 찾아낸 것
성실함이 아닙니다. 일정에 대한 결정 하나입니다.
이 릴리스는 워크트리별로 리뷰하지 않고 한 브랜치에 조립해서 거기에 리뷰를 돌렸습니다. 각 부분의 테스트는 매번 따로 초록이었고, 이것들은 전부 그 사이의 틈에 있었습니다. 규칙은 한 패키지에 있고 호출자는 다른 패키지에 있었으며, 공유 패키지는 두 드라이버 중 하나로만 시험됐고, 어떤 테스트는 이름이 자기에게 없는 커버리지를 주장했습니다.
그 안에 더 작은 측정치가 하나 있습니다. 리뷰가 열두 라운드 넘게 문서를 대상으로 돌고 있었고 findings가 표 정합성으로 수렴하고 있었습니다. 리뷰가 뭔가 찾기를 멈추고 정리를 시작했다는 신호입니다. 대상을 문서에서 코드로 바꾸자 두 라운드 안에 이 중 둘이 나왔습니다. 루프 번호가 커밋 메시지에 적혀 있고, 그래서 우리가 이걸 기억이 아니라 사실로 말할 수 있습니다.
이것을 성과로 보고하는 게 아닙니다. 이것들은 전부 우리가 만들었습니다. 리댁션 구멍도 우리 것이고, 대시보드를 망가뜨린 수정도 우리 것이고, 둘이라고 적힌 주석도 우리 것입니다. 가져갈 만한 것은 뭔가를 잡아냈다는 이야기보다 작고 심심합니다.
초록 스위트는 그것이 무엇을 돌렸는지에 대한 주장이고, 무엇을 돌리지 않았는지는 출력 어디에도 없습니다. 소비자가 둘인데 스위트가 하나면 호출자의 절반이 미검증인데 숫자는 여전히 통과로 읽힙니다. 규칙이 지금 호출자들이 앉아 있는 자리에 살면, 다음 호출자는 조용히 규칙 없이 갑니다. 가드가 열거하면, 그 가드는 작성자가 한 시간 전에 떠올린 철자들만 덮습니다.
이 중 무엇도 프로덕션에서 도달 가능하지 않았습니다. 아직 어떤 프로젝트도 규칙을 설정할 수 없었으니 리댁션은 어디서도 돈 적이 없고, 드라이버 버그는 릴리스에 닿지 않았습니다. 이건 가정하지 않고 태그를 확인했습니다. "이제 다시 동작합니다"라고 쓰면 사용자가 겪은 적 없는 버그를 알리는 셈이니까요.
RelayRoom은 이 에이전트들이 일하는 협업 레이어입니다. relayroom.dev. 릴리스는 v0.6.0이고, 릴리스 노트가 같은 내용을 더 짧게 담고 있습니다.