2026-05-26T15:28:49Z applied 2026-05-26-audience-check.sql
2026-05-26T15:30:48Z applied 2026-05-26-tg-session-leases.sql
2026-05-26T15:30:49Z applied 2026-05-26-cluster-authority.sql
2026-05-26T15:30:49Z applied 2026-05-26-eu-releases.sql
2026-05-26T15:30:49Z applied 2026-05-26-control-plane.sql
2026-05-30T10:11:53Z applied 2026-05-30-p1-global-identity-uuidv7.sql
2026-05-30T10:37:26Z applied P2 snapshot bootstrap (api/peer/snapshot.py export+import, /api/peer/v1/snapshot endpoint, snapshot_client.bootstrap_from_peer). round-trip verified: 8 tables count+checksum match, FK remap incl deferred circular, idempotent re-apply, live HMAC+gzip 200.
2026-05-30T11:02:00Z applied 2026-05-30-p3-changelog.sql (per-origin append-only changelog: change_log/change_origin_seq/mirror_tombstone/peer_apply_cursor + _next_origin_seq/_fk_sidecars/_emit_change triggers on 8 mirror tables). transport: api/peer/changelog.py (export_changes/apply_changes/_coerce/local_cursors), api/peer/changelog_client.py (pull_changes), GET /api/peer/v1/changelog (HMAC). live-verified: cursor filtering, paginated pull, idempotent re-pull no-op, upsert convergence + deferred FK resolve, jsonb columns store as objects (pool jsonb codec double-encode bug found+fixed), delete->tombstone + remove-wins skip.
2026-05-30T11:10:00Z P4 table-specific merge rules (code-only, no schema change) — api/peer/changelog.py apply_changes: replaced blind ON CONFLICT DO UPDATE (last-apply-wins) with row_revision guard "WHERE EXCLUDED.row_revision > {table}.row_revision" (origin-authoritative monotonic counter, not clock → immune to clock-skew/duplicate/reorder/partition-rejoin). Added skipped_stale counter. delete=remove-wins tombstone, file_id/hash sets=add-wins via per-row keying (no column merge), queue lease not mirrored. conflict tests pass: duplicate/reorder(old-after-new)/intra-batch-reorder/clock-skew/partition-rejoin-converge-to-max/idempotent-replay.
2026-05-30T11:40:00Z P5 bidirectional peer sync (pull-only, code-only) — workers/peer_sync/worker.py: codec-registered pool (REQUIRED: apply_changes passes native jsonb), select_targets = enabled AND allow_inbound AND NOT is_self minus PEER_SYNC_EXCLUDE (default won-family, P6 bulkhead until receiver-enforced family isolation), run_round per-peer failover isolation, interval loop + --once. deploy/systemd/zero-peer-sync.service created (NOT installed/enabled against real nodes). snaptest+local verified: target selection excludes is_self/won-family/disabled/untrusted; dead-peer error isolated (round completes); loopback success round pulled 98 changes via real HTTP+HMAC (95 skipped_stale via revision guard + 2 tombstone-delete), payload jsonb stays object (no double-encode), re-run received=0 (cursor head, idempotent convergence).
2026-05-30T12:05:00Z applied 2026-05-30-p6-family-audience.sql (쓰리 receiver-enforced family bulkhead) — sources.audience text NOT NULL DEFAULT 'adult' + CHECK in ('adult','general','family'); local_audience() STABLE fail-closed (node_config('audience')='family' OR GUC app.local_audience='family' => 'family', else default 'adult' — wrong GUC cannot flip a family node to adult). code: api/peer/changelog.py apply_changes gates each change through _family_allows(conn,table,ch) when local_audience()='family', BEFORE the change_log insert => rejected adult/infra rows leave NO trace (no row, no change_log, no tombstone; only cursor advances, blocking gossip leak). _family_allows: sources => payload.audience=='family'; items/media/telegram_files => chain to family ancestor via sidecar uuid; infra/credential tables (telegram_sessions, external_clients, ...) default-deny. skipped_filtered counter added (changelog.py + changelog_client.py totals). workers/peer_sync/worker._setup_codec sets app.local_audience GUC from LOCAL_AUDIENCE env (fast-path; node_config is canonical). rollback: 2026-05-30-p6-family-audience-rollback.sql. snaptest verified: family node applied=2 (family src+item) skipped_filtered=3 (adult src+item+infra) with adult absent from sources/items/telegram_sessions AND change_log; adult node (no audience) skipped_filtered=0 applied=4 (bulkhead off, non-쓰리 nodes unaffected). NOTE: real 쓰리 node (192.168.1.217) separate-DB physical isolation + migration apply NOT yet done (deferred — needs user confirmation, high blast radius).
2026-05-30T12:30:00Z applied 2026-05-30-p7-media-replication.sql (비재생성 미디어 content-address 복제) — 9번째 미러테이블 media_replica(row_uuid/origin_node_uuid/row_revision + content_hash bytea sha256 + present + local_path, UNIQUE(content_hash,origin_node_uuid); _bump_row_revision + _emit_change 트리거; snapshot.py EXPORT_ORDER 끝에 등록, FK 사이드카 없음=자기완결) + media_fetch_lease(로컬전용, single-flight+backoff, content_hash PK) + media(file_hash_sha256) 부분인덱스 + peer_instances.node_uuid(node_uuid→base_url 매핑; self 즉시채움). 엔드포인트: GET /api/peer/v1/identity (HMAC, node_uuid 노출), GET /api/peer/v1/blob/{sha256hex} (HMAC, origin=local_node_uuid+present+로컬파일 실존시만 송출, max-fanout 세마포어 503 Retry-After, X-Content-SHA256, anti-herd recent_requesters). 워커: workers/media_repl/worker.py — scan_local(로컬 해시 백필 file_hash_sha256 + 자기 media_replica present 선언/회수) + ensure_replicas(HRW/rendezvous 로 해시별 책임 k 노드 결정 → 자기책임+미보유+present<k 면 fetch lease 획득 후 보유 peer 에서 GET /blob 스트리밍→재해시 검증→CAS 저장(MEDIA_REPL_STORE/{hh[:2]}/{hh})→media_replica present upsert+lease 삭제; 실패시 backoff base*2^attempts cap, last_source/last_error; anti-herd 로 last_source 후순위; surplus drop=책임아님+CAS복제본+present>k 면 파일삭제+present=false, authored 원본은 CAS경로 밖이라 절대 안지움). ⚠️asyncpg 가 release 시 RESET ALL 로 GUC 를 지움 → node_uuid GUC override 는 init 이 아니라 setup(매 acquire)에서 적용해야 정확(실서버는 node_config 폴백이라 무관, 테스트/override 정확성). 롤백: 2026-05-30-p7-media-replication-rollback.sql (⚠️미러테이블이라 change_log 흔적 보존+전노드 동시 롤백 필요). snaptest 루프백 검증(2논리노드=연결별 GUC A/B): identity HMAC 200/무서명401, blob HMAC 200(바이트일치+X-Content-SHA256)/미보유404/무서명401, ensure_replicas k=2 수렴(A가 B에서 fetch→검증→CAS→present, present_cnt=2, lease삭제, 재실행 멱등 fetched=0), 죽은소스 backoff(attempts=1/next_attempt_at 미래/last_source=B/last_error), surplus drop(파일삭제+present=false), family 격벽(_family_allows media_replica default-deny). NOTE: 실노드(원 crawler_db/투/쓰리) 마이그레이션·워커 배포 미완(deferred — 높은 blast radius, 사용자 확인 필요). 실DB엔 미적용, snaptest+local 만 검증.
2026-05-30T13:00:00Z P8 host tolerance (진짜 호스트 내성, code/artifact-only, no schema change) — washtime 단일 호스트 사망(전원/메인보드/네트워크)을 견디게: 몸 1개+미디어 복제본을 washtime 밖으로 이전 후 진입점 failover. (1) 안전 게이트 api/peer/v1/failover.py: failover_readiness(pool, expect_audience) + GET /healthz/failover (인증없음, LB probe). 빈 몸 no_data, peer>0 인데 peer_sync 하트비트 없음 never_synced / max_lag(기본 900s) 초과 sync_stale, ?audience 와 노드 local_audience() 불일치 audience_mismatch => 503; ready 면 200. 단독노드(peer 0)는 stale 면제(권위 origin). 신선도 = workers/peer_sync/worker._heartbeat 가 node_config('peer_sync_last_ok_at')(무오류 완주시각)에 ON CONFLICT upsert(run_round 끝에서 ok=not summary['errors']); peer_sync_last_round_at 은 생존 관찰용. app.py 에 peer_failover.router include. ?audience=adult 게이트는 쓰리 격리의 진입점 측 이중방어(family 노드 항상 503 → 성인풀 진입 불가). (2) 아티팩트(미설치): deploy/failover/Caddyfile (reverse_proxy :5055 → 원 127.0.0.1:5055/투 192.168.1.28:8000/off-host placeholder, health_uri=/healthz/failover?audience=adult, health_interval 5s health_status 200, lb_policy first=active-passive, fail_duration 10s) + deploy/failover/README.md (off-host 이전 체크리스트: bootstrap→peer_sync→media_repl→curl 200 확인→Caddy 업스트림 추가→라우터 포트포워딩 재지정→전원차단 리허설; Caddy 는 반드시 washtime 밖) + deploy/systemd/zero-media-repl.service (P7 워커 유닛). snaptest 검증(test_p8_failover.py, 실노드 무영향): no_data/sync_stale/never_synced/audience_mismatch 거부, solo stale 면제, family 노드 ?audience=adult 하드거부 ?audience=family 만 ready, HTTP 200/503. NOTE: 실제 off-host 머신 프로비저닝·라우터 포트포워딩 변경 미완(deferred — 인프라 변경, 사용자 확인 필요). 코드/아티팩트만, 실인프라 무변경.
2026-05-30T13:30:00Z REAL-NODE APPLY (원/crawler_db prod): P6 (2026-05-30-p6-family-audience.sql) + P7 (2026-05-30-p7-media-replication.sql) applied to production crawler_db (previously only snaptest). Verified: local_audience()='adult' (원=성인 노드, 격벽 off 정상), sources.audience 기존 31행 전부 'adult' default, media_replica/media_fetch_lease 생성(빈), peer_instances self node_uuid=019e785c-9e26-7ad1-acc7-15a71ff81b8e 채움. media 인덱스(9160행) 즉시. 앞서 P1/P3 는 이미 prod 적용돼 있었음(P2 데이터/P4·P5 코드). NOTE: 投/쓰리 실DB 및 워커 systemd·앱 재시작은 후속.
