2026년 8월 15일 · 10분 읽기
Nostr NIP-29 릴레이: AI 에이전트 채널과 NIP-98 인증 replay 문제
Nostr NIP-29 릴레이 위에 AI 에이전트 팀을 올리며 정본과 권한을 검증한 기록입니다. 같은 초에 만든 NIP-98 인증 이벤트가 같은 id를 갖고 replay로 거부됩니다.
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여기서 buzz와 buzz-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-briefops-inbox는 운영 진입점이고 community, support, apple-reviews는 고객 접점입니다. bugs, product-insights, approvals, daily-brief는 사건을 분류하고 승인하고 요약하는 내부 흐름에 해당합니다. testflight는 테스트 피드백을 별도 경로로 받습니다.
릴레이 메시지만으로는 같은 증상이 여러 번 들어왔는지 판별하기 어렵습니다. Postgres에 cases, case_sources, case_events, approvals, product_signals 테이블을 두고, 사건의 현재 상태와 원본 입력을 분리했습니다. case_id 유니크 제약과 인덱스도 함께 확인 대상에 넣었습니다.
| 입력 | 저장 결과 | 릴레이 결과 |
|---|---|---|
| Discourse 토픽 | cases에 source=discourse, source_id=<topic id> 1행 |
#community에 케이스 게시 |
| 메일 1통 | cases에 source=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-mcp의 tools/list와 케이스 조회는 허용하되, community-mcp의 tools/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 MISSING과 Unknown 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)로 결정됩니다. 같은 초에 같은 u와 method로 두 번 서명하면 이벤트 내용이 같아져 같은 id가 나옵니다. 릴레이는 두 번째 이벤트를 replay로 거부합니다.
8개 요청 중 4개가 처음 401이었습니다. 인증이 틀렸다고 단정하기 쉬운 숫자지만, 서명 키나 URL보다 이벤트 id의 입력이 문제였습니다. nonce 태그를 인증 이벤트에 추가한 뒤 재시도는 전부 200을 반환했습니다.
| 단계 | 인증 이벤트 | 관찰된 결과 |
|---|---|---|
| 첫 요청 | u와 method로 서명 |
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절의 cases와 case_sources는 care-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은 산출물입니다.