블로그

2026년 8월 15일 · 10분 읽기

Nostr NIP-29 릴레이: AI 에이전트 채널과 NIP-98 인증 replay 문제

Nostr NIP-29 릴레이 위에 AI 에이전트 팀을 올리며 정본과 권한을 검증한 기록입니다. 같은 초에 만든 NIP-98 인증 이벤트가 같은 id를 갖고 replay로 거부됩니다.

  • Nostr
  • NIP-29
  • AI 에이전트
  • 운영 자동화

2026년 8월 23일 업데이트

Desktop 화면에 CONFIGURATION MISSING이 떴습니다. 그 아래에 Unknown agents (8)이 있었습니다.

에이전트 여덟 개를 릴레이에 올렸는데 Desktop이 자기 것으로 인식하지 못했습니다. 설정 파일을 고쳐도 그대로였습니다. 그 파일이 정본이 아니었습니다.

NIP-98 인증에서도 여덟 요청 중 넷이 401이었습니다. 키가 틀린 것이 아니었습니다.

한눈에 보기

문제 Desktop 화면에 CONFIGURATION MISSING이 떴습니다.
결정 그래서 첫 확인을 둘로 나눴습니다.
결과 Desktop은 설정 파일이 아니라 릴레이 이벤트를 읽어 builtin 3개를 다시 만들었습니다.
제약 이 구조가 필요한 장면은 동일 증상을 Discourse와 메일에서 각각 받았을 때입니다.

릴레이가 살아 있다는 응답만으로는 그룹 채팅을 증명하지 못합니다

Nostr 릴레이는 이벤트를 받아 전달하는 서버입니다. NIP-29는 그 릴레이 안에서 그룹과 멤버십을 다루는 규격입니다. 릴레이가 응답하는 것과 그룹 채팅이 되는 것은 다른 사실입니다.

그래서 첫 확인을 둘로 나눴습니다. Argo Application 두 개가 Synced이고 Healthy인지, 그리고 릴레이가 NIP-11 응답에서 supported_nips에 29를 포함하는지입니다. NIP-11은 릴레이가 자기가 지원하는 NIP를 알려 주는 메타데이터입니다.

# CLAB-1 c1 원문: part6-material.md:17
kubectl get applications -n argocd buzz buzz-storage
curl -sS -H 'Accept: application/nostr+json' https://buzz.example.net

여기서 buzzbuzz-storage를 따로 확인한 이유는 릴레이와 저장소를 한 덩어리로 착각하지 않기 위해서입니다. 둘 중 하나가 동기화되지 않으면 이벤트를 받는 순간과 저장하는 순간을 함께 보장할 수 없습니다.

확인 지점 수용 기준 확인하려는 운영 위험
배포 상태 두 Application이 Synced/Healthy 배포만 되고 저장소가 준비되지 않은 상태
릴레이 응답 NIP-11 HTTP 200 외부에서 메타데이터를 읽지 못하는 상태
기능 선언 supported_nips에 29 포함 NIP-29 그룹을 기대했지만 서버가 선언하지 않은 상태

릴레이가 응답한다는 사실과 에이전트가 협업한다는 사실을 나누고 나면 다음 확인 항목이 저절로 나옵니다. 채널이 실제로 있는가, 사건이 저장되는가, 승인 주체가 제한되는가입니다.

아홉 채널과 사건 원장이 같은 증상을 하나로 묶는지 확인했습니다

채널 이름은 UI 장식이 아니라 라우팅 계약입니다. owner 키로 조회했을 때 다음 9개 채널이 모두 있어야 했습니다.

# CLAB-1 c2 원문: part6-material.md:18
ops-inbox/community/apple-reviews/testflight/support/bugs/product-insights/approvals/daily-brief

ops-inbox는 운영 진입점이고 community, support, apple-reviews는 고객 접점입니다. bugs, product-insights, approvals, daily-brief는 사건을 분류하고 승인하고 요약하는 내부 흐름에 해당합니다. testflight는 테스트 피드백을 별도 경로로 받습니다.

릴레이 메시지만으로는 같은 증상이 여러 번 들어왔는지 판별하기 어렵습니다. Postgres에 cases, case_sources, case_events, approvals, product_signals 테이블을 두고, 사건의 현재 상태와 원본 입력을 분리했습니다. case_id 유니크 제약과 인덱스도 함께 확인 대상에 넣었습니다.

입력 저장 결과 릴레이 결과
Discourse 토픽 casessource=discourse, source_id=<topic id> 1행 #community에 케이스 게시
메일 1통 casessource=mail 행 생성 #support에 릴레이
ASC 리뷰 fixture 커서 저장과 함께 case 생성 #apple-reviews#approvals에 게시

이 구조가 필요한 장면은 동일 증상을 Discourse와 메일에서 각각 받았을 때입니다. cases 행은 1건만 늘고, case_sources에는 2행이 붙습니다. duplicate_of/merge 기록으로 두 입력이 같은 사건으로 합쳐졌다는 사실을 남깁니다.

확인한 입력 cases 변화 case_sources 변화 남는 기록
Discourse + mail, 같은 증상 1건 2행 duplicate_of/merge
서로 다른 고객 접점 각 사건별 1건 각 원본별 행 source와 source_id

이것은 단순한 중복 제거가 아닙니다. 하나의 사건을 담당 채널로 보내면서도, 나중에 어느 접점에서 들어왔는지 되짚을 수 있게 만드는 원장 설계입니다. c4부터 c7까지는 토픽 webhook, 메일 polling, ASC cursor와 fixture, 그리고 중복 병합을 각각 실제 경로로 확인했습니다.

승인은 프롬프트가 아니라 소유자 키와 상태 전이로 제한했습니다

#approvals에 승인 메시지가 보인다고 실행 권한이 생기는 것은 아닙니다. 수용 기준 c9는 owner 키의 승인과 non-owner 키의 동일 승인 시도를 나란히 실행하도록 했습니다.

주체 승인 결과 approvals 상태 executor
owner 승인 허용 approved로 전이 실행
non-owner 거부 상태 불변 실행하지 않음

non-owner의 시도가 오류 메시지로 끝나는 것으로는 부족합니다. 상태가 그대로여야 합니다. 상태가 approved로 먼저 바뀌고 실행만 실패하면 그것을 되돌리는 코드가 또 필요해집니다.

MCP(Model Context Protocol)는 에이전트가 도구를 발견하고 호출하는 연결 규격입니다. 도구 목록 자체가 역할 경계가 되어야 합니다. cases-mcptools/list와 케이스 조회는 허용하되, community-mcptools/list에는 메일 발송 도구가 없어야 했습니다.

# CLAB-1 c8 원문: part6-material.md:24
cases-mcp와 community-mcp에 MCP tools/list 요청 및 cases-mcp로 케이스 조회
MCP 확인 기대 결과 의미
cases-mcp 케이스 조회 케이스 데이터 반환 읽기 경로가 작동함
community-mcp 도구 목록 메일 발송 툴 없음 커뮤니티 역할의 쓰기 경계
owner 승인 상태 전이와 executor 실행 승인 주체가 유효함
non-owner 승인 상태 변화 없음 권한 오류가 상태를 오염시키지 않음

이 확인을 지나면 에이전트가 채널에 글을 쓰는 것과 외부에 실제로 무언가를 하는 것이 갈립니다. 릴레이의 메시지 권한, MCP의 도구 권한, 승인 원장의 상태 권한이 서로 다른 층에 놓입니다.

에이전트 설정의 정본을 로컬 캐시가 아니라 릴레이 이벤트로 바꿨습니다

Desktop은 managed-agents.json을 정본으로 읽지 않았습니다. 이 파일은 캐시였고, 정본은 relay의 NIP-AP 이벤트였습니다. 정의 이벤트가 없을 때 Desktop이 CONFIGURATION MISSINGUnknown agents (8)로 표시한 이유가 여기에 있습니다.

NIP-AP에서는 kind 30175가 정의 이벤트이고 d=slug를 사용합니다. kind 30177은 인스턴스 이벤트이며 d=agent pubkey를 사용합니다. 정의에 연결되지 않은 인스턴스인 “definition-less instances”는 예외적으로 30177 이벤트에 정의 필드를 직접 실어야 합니다.

# BUZZ-3 원문: part6-material.md:41-42
동기화 구독: {kinds:[30177], authors:[self]}
kind:30177, tags=[["d", agent_pubkey]], content={name, system_prompt(페르소나+팀헌장), model, provider, parallelism, respond_to, respond_to_allowlist}

정의 이벤트를 Desktop identity의 키로 게시해야 이 구독에 걸립니다. 임의의 키로 정의를 게시하면 relay에 이벤트가 있어도 Desktop이 자기 이벤트로 인식하지 못합니다. 실제 해결 과정에서는 keychain의 buzz-desktop/secrets에서 identity 항목을 읽었고, 파생 pubkey가 확인된 뒤 정의를 게시했습니다.

재시작 뒤 Desktop은 relay 정의를 읽어 자체 레코드를 만들었습니다. 모델은 sonnet, opus[1m], gpt-5.6-luna[xhigh], gpt-5.6-sol[medium]으로 표시되었고 last_error는 모두 null이었습니다. 손으로 넣은 clab:* 캐시는 삭제하고, Desktop이 만든 slug=agent pubkey 레코드에 실행 설정을 보강했습니다.

이벤트 목적 키 또는 식별자 예외
kind:30175 에이전트 정의 d=slug 일반 정의
kind:30177 에이전트 인스턴스 d=agent pubkey definition-less면 정의 필드를 직접 포함
Desktop 동기화 자기 정의 수신 authors:[self] identity 키로 게시해야 수신

Desktop은 설정 파일이 아니라 릴레이 이벤트를 읽어 builtin 3개를 다시 만들었습니다. 완전 삭제 대신 is_active=false로 뒀습니다.

같은 초의 NIP-98 재서명이 replay가 되는 지점을 nonce로 끊었습니다

가장 긴 디버깅은 NIP-98 인증에서 발생했습니다. 인증 이벤트의 id가 (pubkey, created_at, kind, tags, content)로 결정됩니다. 같은 초에 같은 umethod로 두 번 서명하면 이벤트 내용이 같아져 같은 id가 나옵니다. 릴레이는 두 번째 이벤트를 replay로 거부합니다.

8개 요청 중 4개가 처음 401이었습니다. 인증이 틀렸다고 단정하기 쉬운 숫자지만, 서명 키나 URL보다 이벤트 id의 입력이 문제였습니다. nonce 태그를 인증 이벤트에 추가한 뒤 재시도는 전부 200을 반환했습니다.

단계 인증 이벤트 관찰된 결과
첫 요청 umethod로 서명 8개 중 4개가 401
같은 초 재시도 같은 필드로 다시 서명 같은 id가 replay로 거부
nonce 추가 인증 이벤트에 nonce 태그 추가 재시도 전부 200

초 단위 created_at이 시각 정보가 아니었습니다. 같은 요청을 다시 보낼 때 멱등성 키처럼 작동했습니다. 재시도할 수 있는 인증을 만들려면 서명할 때마다 이벤트 내용이 달라져야 합니다.

첫 시도에는 파이썬 네임스페이스 충돌도 2건 있었습니다. 곡선 상수 P를 pubkey 맵이 덮었고, secrets 모듈을 keychain dict가 덮었습니다. 이름을 복원해 서명 코드를 살렸습니다. 이 실패는 인증 알고리즘과 별개로, 서명 구현 주변의 이름 관리도 검증 대상이라는 점을 남겼습니다.

열 가지를 지나야 팀이 돈다고 말할 수 있었습니다

확인 항목은 "채팅방이 보인다"가 아니었습니다. 배포, 저장, 라우팅, 권한, 실행을 나눠서 봤습니다.

기준 보는 경계 통과 조건
c1 릴레이 배포와 NIP-11 Application 둘 Synced/Healthy, HTTP 200, NIP-29
c2 그룹 채널 9개 채널 존재
c3 사건 원장 5개 테이블, 유니크 제약과 인덱스
c4~c6 외부 입력 Discourse, mail, ASC가 각 채널로
c7 중복 병합 cases 1건, case_sources 2행, merge 기록
c8 도구 권한 community 목록에 메일 발송 도구 없음
c9 승인 권한 owner만 상태 전이와 실행, non-owner는 불변
c10 팀 운영 6 에이전트 presence, triage 라우팅, daily-brief

c10의 여섯과 Desktop 등록 기록의 여덟은 다른 숫자입니다. 검증 범위가 달라서 하나로 합치지 않았습니다.

그 릴레이는 클러스터에서 내려갔습니다

2026년 8월 14일 커밋 하나가 buzz 릴레이와 그 미디어 저장소를 클러스터에서 뺐습니다. argocd/applications/에 buzz로 시작하는 파일이 없습니다.

대신 justsend-care 네임스페이스가 하루 전에 생겼습니다. 그 안에 릴레이가 다시 있습니다. care-buzz이고 주소는 wss://care.example.net입니다.

오늘 그 릴레이에 NIP-11을 물어봤습니다.

// curl -H 'Accept: application/nostr+json' https://care.example.net (2026-08-15)
{ "name": "Buzz Relay",
  "software": "https://github.com/block/buzz",
  "version": "0.2.1",
  "supported_nips": [1,2,10,11,16,17,23,25,29,33,38,42,50,56,43] }

29가 목록에 있습니다. 1절의 확인 항목이 옮긴 릴레이에서도 성립합니다.

software 값이 남은 의문 하나를 풀어 줍니다. 릴레이는 우리가 쓴 것이 아니라 github.com/block/buzz입니다. NIP-29 구현 파일을 우리 저장소에서 못 찾은 이유가 여기 있습니다. 우리가 만든 것은 그 위에 올라가는 에이전트와 도구입니다.

네임스페이스에 오늘 있는 것을 세어 봤습니다. Deployment 아홉 개와 StatefulSet 두 개입니다.

무엇 개수 이름
에이전트 6 care-agent-haram
릴레이 1 care-buzz
오브젝트 저장소 1 care-buzz-minio
사건 원장 DB 1 care-db
릴레이 DB·캐시 2 (StatefulSet) care-buzz-postgresql, care-buzz-redis

Postgres가 둘입니다. 릴레이가 이벤트를 담는 것과 사건 원장이 케이스를 담는 것이 다른 데이터베이스입니다. 2절의 casescase_sourcescare-db 쪽입니다. 릴레이를 갈아 끼워도 사건 기록은 남습니다.

에이전트도 그 안에 있습니다. Mac에서 상주 프로세스로 돌던 것들이 Deployment 여섯 개가 됐습니다.

Deployment CARE_ROLE MCP 실행 파일
care-agent-haram lead care-mcp-lead
care-agent-ria reviews care-mcp-reviews
care-agent-daon community care-mcp-community
care-agent-woojin mail care-mcp-mail
care-agent-sena insight care-mcp-insight
care-agent-taesan ops care-mcp-ops

이미지는 ghcr.io/<org>/justsend-care-agent:0.3.0 하나입니다. 역할마다 다른 것은 환경 변수입니다. 프롬프트 파일 경로, MCP 실행 파일 이름, CARE_ROLE 값이 다릅니다. 모델은 여섯 다 claude-sonnet-5입니다.

쓰기는 아직 닫혀 있습니다. CARE_WRITE_ENABLED=0, CARE_CLUSTER_WRITE=0입니다.

릴레이를 한 번 걷어내고 제품 이름을 붙여 다시 세운 것이 이 갈래의 현재 상태입니다. NIP-29와 NIP-98에서 배운 것은 그대로 옮겨 왔고, 실행 주체가 Mac에서 클러스터로 옮겨졌습니다.

답할 대상을 환경 변수에서 규칙 파일로 옮겼습니다

처음 옮길 때는 구독 방식이 mentions였습니다. 자기가 멘션된 메시지만 받는 모드입니다. 오늘 라이브를 읽어 보니 config로 바뀌어 있었습니다.

# care-agent-ria Deployment 환경 변수 (2026-08-15 읽기)
BUZZ_ACP_SUBSCRIBE=config
BUZZ_ACP_CONFIG=/etc/care/prompts/ria.rules.toml
BUZZ_ACP_RESPOND_TO=allowlist
BUZZ_ACP_PERMISSION_MODE=default

구독 범위를 환경 변수 한 줄이 아니라 파일이 정합니다. 그 파일을 읽어 봤습니다.

# ria.rules.toml — 생성기 산출물
[[rules]]
name = "direct"
channels = ["79419dba-…"]         # 담당 채널 하나
kinds = [9]                        # 일반 메시지
require_mention = false            # 멘션 없이 받는다
filter = '!str_starts_with(content, "//")'

[[rules]]
name = "mention"
channels = ["79419dba-…"]
kinds = [9, 46010, 40007]          # 메시지·승인 요청·리마인더
require_mention = true             # 부를 때만 받는다

규칙이 둘입니다. 담당 채널의 일반 메시지는 멘션 없이 받고, 리마인더와 승인 요청은 이름을 불렀을 때만 받습니다.

filter 한 줄이 사람의 습관에 맞춘 장치입니다. //로 시작하는 줄을 혼잣말로 보고 넘깁니다. 채널에 메모를 적을 때 에이전트가 매번 답하면 채널이 못 쓰게 됩니다. 그래도 멘션하면 두 번째 규칙이 받습니다.

멘션 없이 받게 하면 한 메시지가 여섯을 동시에 깨울 것 같습니다. 그렇게 되지 않는 이유가 셋입니다.

왜 안 깨우나 근거
채널이 에이전트마다 1:1 care-lobby는 haram, mail-desk는 woojin
규칙에 없는 채널은 구독하지 않는다 config 모드는 규칙이 걸린 채널만 본다
자기 글은 무시한다 buzz-acp 기본 동작

kinds에서 9만 남기면 안 되는 이유도 생성기 주석에 적혀 있습니다. 40007은 리마인더이고 46010은 워크플로 승인 요청입니다. 9만 남기면 승인 요청이 에이전트에 닿지 않습니다.

이 파일들은 손으로 쓰지 않습니다. deploy/render-manifests.mjs가 에이전트 정의에서 만들고, 파일 첫 줄에 "직접 편집하지 않습니다"가 적혀 있습니다. 4절에서 정본이 파일이 아니라 이벤트였던 것과 방향이 같습니다. 여기서는 정본이 생성기의 입력이고 TOML은 산출물입니다.