On this page
state-invariant flag drift — reconciliation pass로 복구하기
boolean lifecycle flag가 클리어 코드 경로에 절대 도달하지 못하는 entry들에 계속 끼는 현상이 있었어요. 증상만 고치는 fix는 계속 재발했고, 진짜 해결은 setter나 clearer 어느 쪽이 어떻게 set했든 상관없이 flag가 함의하는 invariant를 강제하는 세 번째 workflow를 추가하는 거였어요.
boolean lifecycle flag (needs_resync: true)가 그 flag를 클리어하는 코드 경로에 절대 도달할 수 없는 entry들에 계속 끼고 있었어요. flag는 한 workflow(/wrap)가 set하고 다른 workflow(sync-from-3b.ts)가 클리어하는데, clearer 쪽에는 setter가 체크하지 않는 precondition(ready: true)이 걸려 있었거든요. ready: false인 entry들에 flag가 영원히 쌓였어요.
처음 점검했을 때 6개 entry에서 증상이 나타났어요. git checkout으로 frontmatter를 수동으로 되돌리니 카운트가 0이 됐죠. 5일 후에 카운트가 12개가 됐어요 — 같은 setter가 더 넓은 범위의 entry에 다시 작동한 거예요. 수동 청소는 증상 처리일 뿐이고, durable fix는 setter와 clearer가 독립적으로 깨뜨릴 수 없는 곳에 살아야 했어요.
한쪽만 고치는 fix가 안 먹힌 이유
서로 다른 코드 경로에 있는 두 개의 setter가 같은 stuck state를 만들고 있었어요. 한쪽 경로의 setter만 조여도 다른 경로는 그대로라서 drift가 계속됐죠. clearer의 게이트(ready: true)는 sync에 정말 필요한 precondition이라 옳았는데, setter의 contract가 “clearer가 그렇지 않으면 작동을 거부할 거다”라는 사실을 반영하지 않았던 거예요.
이 버그는 구조적이에요. setter와 clearer가 다른 일정으로 진화했고, 둘 사이의 implicit contract(“내가 이 flag를 set하면 clearer가 결국 클리어할 것이다”)가 clearer가 더 엄격한 게이트를 추가했을 때 조용히 깨졌어요. 어느 한쪽만 패치하는 건 multi-source 버그에 single-source 수리를 하는 격이에요.
reconciliation pass
오래 가는 fix는, flag가 어떻게 set됐든 상관없이 flag가 함의하는 state invariant를 강제하는 세 번째 workflow를 두는 거예요. workflow 층이 아니라 data 층에 둔 defense-in-depth예요.
needs_resync: true에 대한 invariant는 이거예요:
“Re-sync”는 이전 sync가 있었을 때만 의미가 있어요. 따라서 flag는
published_at이 non-null임을 함의해요.published_at이 null이면 flag는 논리적으로 불가능하고, setter를 참고하지 않고 클리어할 수 있어요.
두 번째 invariant는 “이미 최신 상태” 케이스를 다뤄요:
로컬 synced post의
source_content_hash가 source의 현재 cleaned-body hash와 일치하면, source는 drift하지 않은 거예요. Re-sync는 no-op이 될 거고요. flag는 클리어할 수 있어요.
두 invariant 모두 --reconcile mode 안에 박아 뒀어요. source tree를 따라 내려가면서 두 조건을 적용하고 다시 파일에 써요. Idempotent라서 몇 번을 다시 돌려도 안전해요.
이 pass는 opt-in이에요. 일반 sync 중에 실행되지 않아요 — operator가 시작하는 maintenance지 hot path가 아니거든요. 일반 sync 중에 실행하면 “이거 클리어해도 안전해?”(구조적 질문)와 “지금 sync해야 해?”(편집적 질문)가 섞여요. 둘은 failure mode가 달라요.
구현 중에 깨진 것들
첫 번째 draft에서 걸린 세 가지:
- YAML round-trip serialization이 unquoted string을 손상시켰어요. 첫 draft는
stringifyYaml(frontmatter)로 write back을 했어요.#를 포함한 unquoted string(예:context: PR #103 Round 1...)이#에서 잘렸어요. YAML이 그걸 comment marker로 취급하거든요. 이 교훈은general/yaml-serializer-unquoted-hash-corruption.md에 따로 정리해뒀어요. - frontmatter substring에 surgical regex가 YAML round-trip을 이겼어요. round-trip을 키 하나만 flip하는 scoped regex로 바꾸니 diff 사이즈가 348줄에서 12줄(파일당 1줄)로 줄었고, body content는 unquoted special character가 있든 없든 byte-identical했어요. 일반 원칙: 필드셋이 고정되고 작으면, point edit이 round-trip의 blast radius를 완전히 피할 수 있어요.
replace_all: true가 두 개의 거의 동일한 write block 중 하나만 매칭됐어요. 두 block이 다른 nesting depth — 3 tabs vs 4 tabs —에 있어서 indent-sensitive matching이 한 곳만 잡고 다른 한 곳을 조용히 놓쳤어요. 방어법은 기계적이에요:replace_all후에 항상 grep으로 expected match count를 검증해요.
이 접근이 도움 되는 상황
이 패턴이 맞는 경우:
- sync system에 두 state 사이를 오가는 metadata 필드가 있고, 그 토글 로직이 여러 workflow에 흩어져 있을 때.
- clearer의 precondition이 setter보다 엄격해서, 정상 흐름으로는 청소되지 못한 채 남는 state가 있을 때.
- 수동으로 치워도 같은 증상이 자꾸 재발할 때. 재발은 버그가 operator 수준이 아니라 시스템 수준이라는 가장 강한 신호예요.
drift가 single-source라서 setter만 조여서 고칠 수 있다면 — reconcile pass를 추가하는 건 한 곳에 버그가 있는 경우엔 overkill이에요. 또 metadata field가 stuck state에 의미적으로 의존하는 외부 consumer가 있을 때도 안 맞아요. invariant가 정말 invariant인지 인코딩하기 전에 검증하세요.
실용적인 takeaway
stuck-state flag가 보인다면 setter와 clearer가 따로 노는 거예요. 가장 빨리 굳히는 fix는 setter나 clearer 한쪽만 패치하는 게 아니라, flag가 함의하는 invariant를 강제하는 세 번째 workflow를 두는 거예요. invariant를 코드에 또렷하게 박아 두면, 나중에 workflow를 바꿔도 조용히 깨질 일이 없어요. cleanup workflow는 opt-in으로 표시해요. data 층의 fix는 setter 쪽의 instruction 층 fix(산문 형태의 precondition 말고 checklist)와 짝을 지어서 새 drift를 막아요. reconcile pass는 안전망이고, setter checklist가 1차 방어선이에요.