Skip to Content

Blueprint

한 줄 소개: 사람 운영자와 AI 에이전트가 같은 공간에서 일하는 통합 워크스페이스. 프로젝트·이슈·세션·지식이 한 곳에 모이고, 에이전트는 MCP 46개 도구로 그 공간과 사용자의 일정·메일·Teams 에 직접 접근한다.

주소

용도주소
워크스페이스 (로그인 필요)https://axe.axelabs.ai
MCP 엔드포인트https://axe.axelabs.ai/blueprint/mcp

MCP 커넥터 등록

Claude 웹 · 데스크톱 · Code 에 Custom Connector 로 등록한다.

  1. 클라이언트의 커넥터 추가 화면에서 위 MCP 엔드포인트 주소를 넣는다.
  2. Microsoft 계정 로그인 창이 뜬다 (표준 OAuth + PKCE). 조직 계정으로 승인한다.
  3. 승인이 끝나면 아래 46개 도구가 클라이언트에 나타난다.

인증되지 않은 요청에는 401 과 함께 인가 서버 위치를 알려주는 표준 메타데이터가 돌아가므로, 클라이언트가 흐름을 자동으로 이어간다. 토큰을 직접 복사해 붙일 필요가 없다.

권한 경계: 도구는 호출자 본인의 신원으로 동작한다. 일정·메일은 호출자 본인의 캘린더·사서함에 적용되고, 워크스페이스·이슈·지식은 호출자가 멤버인 범위만 보인다. 다른 사람 명의로 보내거나 조직 전체에 나가는 동작은 관리자만 쓸 수 있다.

MCP 도구 46개

워크스페이스·이슈 (조회)

도구분류설명
whoamiread호출자 본인 정보
list_workspacesread본인이 멤버인 워크스페이스
get_workspaceread워크스페이스 상세
list_projectsreadPARA Project 워크스페이스
list_areasreadPARA Area 워크스페이스
list_resourcesreadPARA Resource 워크스페이스
search_archivereadPARA Archive 검색 (이름 부분일치 + 개수 제한)
list_sessionsread본인 세션 목록. entity(법인 슬러그)·mode·service(서비스 슬러그, D-bp-collab-3) 로 좁힐 수 있고, 응답에 법인·서비스 슬러그와 Teams 방(collabChatId·collabChatUrl)이 함께 나온다. 모르는 법인 슬러그는 0행으로 거르지 않고 not_found 로 거절한다 — 보드에서 0행은 “아무도 시작 안 했다” 로 읽혀서 오타와 빈 보드가 같은 화면이 된다. 빈 값(entity="")도 무필터로 넓히지 않고 bad_request 다 — service·status 축과 같은 규율
list_issuesread이슈 목록 (필터 가능)
get_issueread이슈 상세
list_agentsread등록된 에이전트
list_skillsread등록된 스킬
search_peopleread멤버 검색 (이름·이메일)

캘린더

도구분류설명
create_eventwrite본인 캘린더에 일정 생성 — 제목·시작·종료·장소·본문·반복·참석자 (외부 도메인 가능)
update_eventwrite기존 일정 부분 수정
delete_eventwrite일정 삭제 + 참석자에게 취소 통지 자동 발송
get_eventread단일 일정 조회
list_eventsread시간 구간 내 일정 목록
add_attendeeswrite기존 참석자를 보존하며 추가 (대소문자 무시 중복 제거), 초대는 자동 발송
find_free_timeread여러 참석자의 가용 시간 조회

일정 도구 6종은 관리자에 한해 as_user_email 로 대상 사용자의 캘린더에 적용할 수 있다. 이때 주최자는 대상 사용자로 표시된다. 관리자가 아닌 호출자가 쓰면 거부된다.

메일

도구분류설명
send_mailwrite본인 사서함에서 발송 — 수신자·제목·본문(HTML 또는 텍스트)·참조·숨은참조·첨부·보낸편지함 저장 여부·시험발송. 관리자는 as_user_email 로 대리 발송

시험발송(dry run)은 메시지를 조립·검증해 미리보기만 돌려주고 실제로 보내지 않는다. 발송·시험발송·실패 모두 감사 로그에 1행씩 남는다 (호출자 · 대리발송 여부 · 수신자 수 · 제목 · 상태).

Teams

도구분류설명
get_teams_messagereadTeams 메시지 딥링크 → 본문·발신자·시각 + 첨부·인용·인라인 이미지 ID
get_teams_hosted_contentread인라인 이미지를 이미지 블록으로 반환 (모델이 그대로 본다)
get_teams_attachment_fileread공유 파일 첨부를 텍스트 또는 base64 로 가져오기 (기본 50 MB, 상한 100 MB)
list_teams_chatsread최근 채팅 목록 — 채팅별 최근 발신자·미리보기. 옵션으로 제목·유형·멤버까지 보강
read_teams_chatread한 채팅의 히스토리 읽기 — 최근 N개(기본 30, 상한 200), 특정 시점 이후 슬라이스, 오래된 것부터
send_teams_messagewrite봇 신원으로 채팅에 발송. 봇이 멤버인 채팅만. 관리자 전용
create_teams_chatwrite새 그룹 채팅 개설 — 제목·멤버 목록. 봇이 항상 멤버로 포함되어 개설 직후 바로 보고할 수 있다. 관리자 전용. ⛔ 협업 세션용으로 쓰지 않는다 — 세션은 이미 자기 방을 갖는다(D-bp-collab-4). 제목이 협업 세션 이름과 같으면 duplicate_session_topic(409)으로 거절되고 봉투가 sessionId·existingChatId 를 준다. dry_run 도 같은 판정 (B-bp-collab-chat-dedupe)
add_teams_chat_memberwrite기존 채팅에 멤버 추가 (기본 owner 역할). 관리자 전용

봇 신원 발신은 조직 전체에 나가는 성격이라 조회 도구와 달리 관리자만 쓴다. 시도마다 감사 로그가 남고, 시험발송도 마찬가지로 남는다.

이연 발송

도구분류설명
defer_taskwrite미래 시점 발송 예약 — 시각(ISO, 타임존 없으면 KST)·본문(마크다운)·대상 채팅. 대상을 생략하면 본인 1:1 DM 이라 누구나 쓸 수 있고, 특정 채팅을 지정하면 관리자 전용
defer_task_listread예약 조회. 기본은 본인의 미발송 건. 관리자는 전체 조회 가능
defer_task_cancelwrite예약 취소. 등록자 본인 또는 관리자. 이미 발송·취소된 건은 무동작

예약 자체가 곧 스케줄이다 — 사용자가 따로 크론이나 반복 일정을 만들 필요가 없다.

지식 substrate

도구분류설명
query_knowledgeread타입이 있는 지식(회의록·사업계획 등 서비스 간 종합 지식) 조회 — entity·스키마·워크스페이스·범위·검색어·개수
get_artifactread지식 단건 조회. 볼 수 없는 건은 존재 자체를 노출하지 않고 “없음” 으로 응답
list_artifactsread요약 행 목록 (무거운 본문·인용은 제외, 전문은 get_artifact). keyset 페이지네이션 — 응답 커서를 그대로 되넘겨 이어받는다
propose_knowledgewrite타입이 있는 사실을 제안. 본인이 멤버인 워크스페이스에만
resolve_citationsread인용을 원본까지 따라가 현재 값을 확인. 반드시 호출자 본인 자격으로만 조회하며, 권한이 없으면 값도 존재 여부도 알리지 않고 가림 처리한다

가시성은 항상 호출자 기준이다 — 조직 공유 지식은 해당 entity 멤버에게, 개인 지식은 본인 워크스페이스 멤버에게만 보인다.

객체 레지스트리 (D-ops-99)

플랫폼 온톨로지의 신원·링크 평면. 객체 신원은 레지스트리가 발급하는 테넌트 스코프 불변 id 하나이고, deal_code 류 natural key 는 (조직, 타입) 안에서만 유일한 slug(주소)다. 데이터 본체는 각 서비스(backing)가 계속 보유하며, 레지스트리는 hard-delete 하지 않는다 (teardown = 상태 전이 + 감사).

도구분류설명
registry_register_objectwrite객체 등재 — 테넌트 스코프 불변 id 발급. 주소 동치 = DB 정본형(lower(normalize(slug, NFKC)), PG16 이 단일 권위 — 앱 정규화는 advisory) 안에서 (조직, type) idempotent, 기존 행이면 그대로 반환. 은퇴(archived/torn_down) 행과 철자-동일 재등재는 expand 기간 처리된 거부(은퇴 주인 id·state 포함 봉투, contract 파도 후 개방 — B-bp-registry-slug-exact-index-contract)
registry_linkwrite객체 간 1급 링크(houses·invests·involved_in·party_of). idempotent, 계약 밖 link_type 은 거부
registry_resolvereadtype+slug 또는 id 로 객체+링크 해석 — active 한정(은퇴 객체는 주소가 아니라 역사 기록이라 해석 대상 아님). slug 매칭도 DB 정본형 술어. 미존재는 명시적 not_found (generic 에러로 삼키지 않음)
registry_provision_workspacewrite딜 slug 로 워크스페이스 확보 — 탐색·락·프리플라이트·생성이 전부 같은 DB 정본 주소를 쓴다. 없으면 생성(PROJECT) + houses 링크, idempotent. 반환 bound_by = existing_link(기존 링크 재확인) | adopted(단일 정확일치 입양) | created(신규 생성). 동명 후보 복수면 ambiguous 로 후보 목록 반환(자동 생성 안 함)

생명주기는 DB 물리 계층이 강제한다 — 활성 링크 보유 workspace 의 ARCHIVE/DELETE/신원(id·organizationId) 변경과 RegistryObject.type 변경은 trigger 가 거부하고, slug 유일성은 active 한정 CI partial unique(lower(slug)) 가 담보한다(expand 파도 — exact 유니크는 구 writer 호환 위해 유지, drop 은 contract 파도).

워크스페이스 이름도 같은 형태로 제약이다 — (organizationId, entityId, lower(btrim(name))) 의 활성 한정 partial 유니크(마이그레이션 20260903000000_workspace_name_active_unique, 인덱스명 Workspace_organizationId_entityId_lower_name_active_key). 축이 조직이 아니라 entity 인 이유는 Workspace.entityId 가 NOT NULL 이고 ops-area 워크스페이스(“회사 운영”)가 entity 마다 하나씩 같은 이름으로 서기 때문이다(구분자는 이름이 아니라 마커 ops-area-<slug>) — 조직 축으로 걸면 두 번째 entity 의 ops-area 가 경합이 아니라 매번 실패한다. 그래서 서로 다른 entity 의 동명은 중복이 아니고, 조직 축 동명은 계속 앱의 이름 선점 검사(_assertNameFree — 조직 전체·바이트 일치·전 상태)와 레지스트리 slug 유니크 (organizationId, type, lower(slug)), advisory lock 이 나눠 맡는다. ARCHIVE 를 술어에서 빼는 이유는 이 스키마에 hard-delete 가 없어 전 상태 유니크면 치워 둔 워크스페이스가 이름을 영구 점유하기 때문이고, NFKC 동치는 인덱스 식이 불변이어야 해서(정규화표는 Postgres 판올림마다 움직인다) 계속 advisory lock 이 진다. 위반은 날것의 DB 오류가 아니라 DUPLICATE_NAME + 승자 workspaceId(같은 entity 안에서 그 접힌 이름을 쥔 워크스페이스)로 되던져지므로 registry_provision_workspace 는 실패하는 대신 이미 있는 그 워크스페이스를 채택한다.

협업 세션 (D-bp-collab-1 · D-bp-collab-2)

도구분류설명
create_sessionwrite협업 세션 생성 — 이름 · 법인 슬러그 · 공유 프롬프트 · 설명 · 서비스 슬러그. 법인 또는 서비스 중 하나는 필수 (D-bp-collab-3) — 서비스 레일이 귀속처가 되면 법인 없는 세션도 목록을 잃지 않는다
update_sessionwrite이름 · 프롬프트 · 설명 · 상태(active | done | archived) · 서비스 · 방 초대(invite_emails) 수정. 협업 세션에만 적용되고, 세 값 밖의 상태는 조용히 버리지 않고 거부한다. 상태 전이는 생성자·admin 전용 (D-bp-collab-2). 서비스 재분류는 이름처럼 entity-open — 미분류(legacy) 세션을 각 서비스 레일로 보내는 통로다. invite_emails 는 이 세션의 Teams 방에 사람을 부르는 1급 경로다 (B-bp-collab-chat-dedupe): append-only 합집합이라 재호출이 멱등이고, 그 조직에 계정이 있는 주소만 받으며(없는 주소는 저장 전에 거절 — 저장하면 이후 모든 세션 사건에서 멤버 추가가 실패한다), 새로 는 사람이 있을 때만 방에 한 줄이 선다
get_sessionread세션 메타 + 프롬프트 + 게재된 기록(오래된 것부터) + 이 세션의 Teams 방(collabChatId·collabChatUrl; 방 개설 전에는 둘 다 null). 게재물마다 mine — 이것을 쓴 사람이 지금 읽는 사람인가(SessionPost.authorUserId 대 호출자 id; 원 id 는 내보내지 않는다). 읽는 쪽이 “내가 아직 답하지 않은 남의 게재물” 을 세는 축이다 — 이 축이 없던 동안 agent CLI 의 종료 판정은 기준선을 추측했고, 요청자가 먼저 게재한 보통의 세션에서 그 추측은 에이전트 자신의 작업 보고를 요청으로 셌다(일을 끝내고 보고할수록 열린 요청 이 늘어 0 에 도달 불가). 라벨이 아니라 id 로 가른다 — 표시 이름은 동명이인·개명으로 갈리고, 갈리는 순간 판정이 조용히 틀린다. 그리고 collabChatLastHumanAt — 이 세션의 Teams 방에서 사람이 마지막으로 말한 시각(방이 없거나 종료 스윕이 아직 그 방을 안 본 세션은 null). 게재물 축과 나란히 있어야 하는 이유는 실사고다(2026-09-03): 요청자가 보드가 아니라 방에 “이거 상태가 뭐임?” 을 올렸는데 agent CLI 의 종료 판정은 열린 요청 0건 을 보고했다 — 그 정수는 SessionPost 축만 세기 때문이다. 부트 프롬프트가 그 신호로 “실행할 요청이 있는가” 를 가르므로, 방에서 답을 기다리는 사람이 있는데 세션은 “요청 없음” 으로 읽고 멈춘다. 값은 종료 스윕(5분)이 이미 읽고 있는 그 20건에서 봇 자신의 발화를 뺀 최신 건이라 Graph 왕복이 늘지 않고, 같은 이유로 최대 5분 늦다 — 지연이 문제가 되면 웹훅으로 올린다. 열린 요청 정수에 합산하지 않는다: 그 축은 게재물의 정확한 값이고, 채널을 섞으면 오늘 복구한 그 의미가 다시 흐려진다. 그 옆에 collabChatSweptAt(스윕이 이 방을 마지막으로 읽은 시각)과 openAsks/openAsksOldestAt(아직 답을 못 받은 질문 수와 그 최고령)이 함께 온다 — 앞의 것이 없으면 “방이 조용하다” 와 “방을 아직 안 봤다” 가 같은 null 로 접히고(그 접힘 때문에 판정 절의 부재가 “확인함” 으로 읽혔다), 뒤의 것이 update_session(status='done') 을 막는 축이다(D-bp-collab-10)
post_session_logwrite이번 대화 기록을 세션에 게재 — 본문(마크다운) · origin(어느 로컬에서 돌았는지) · 제목 · asks(이 게재가 상대에게 질문을 건다 — 답, 즉 다른 사람의 게재가 오기 전까지 update_sessiondone·archived 전이가 거절된다(force=True 로만 넘음). 질문을 던진 쪽이 스스로 닫아 상대의 답이 갈 곳을 없앤 것이 실사고였다, D-bp-collab-10)
delete_session_postwrite게재물 내리기(정정) — 잘못된 세션에 올라갔거나 내용이 틀린 게재물. 올린 사람 본인 또는 admin 만 내린다(읽기·쓰기는 조직 전원인데 삭제만 좁힌다 — 자기 게재를 내리는 것은 정정이고 남의 게재를 내리는 것은 검열이다). 판정은 라벨이 아니라 authorUserId 로 가르고, 작성자 id 가 없는 옛 행은 admin 만 내린다. 소프트 삭제라 행은 원장에 남고 읽는 표면이 거른다 — get_session 의 목록과 list_sessions·get_session 의 게재 수, 웹 피드에서 사라진다. Teams 방에는 아무것도 밀지 않는다(방에는 이미 그 게재 한 줄이 서 있고, “내렸다” 를 한 줄 더 붙이면 지운 사실이 방에서 더 크게 남는다). 남의 세션 게재 id 와 아예 없는 id 는 같은 404 봉투다 — 403 은 “그 게재물은 있다” 를 알려 주는 존재 오라클이라 id 대입으로 캐게 두지 않는다. 이미 내려진 것을 다시 내리면 성공이다(멱등, alreadyDeleted: true) — 재시도한 호출자가 “내 삭제가 안 됐나” 를 되묻지 않게. 인자 둘(session_id·post_id)이 다 필수인 이유도 같다: 세션 없이 게재 id 만으로 지우게 두면 남의 세션 게재물을 id 대입으로 건드릴 수 있고, 그 조합의 유일한 안전한 답(404)을 낼 근거가 사라진다 (blueprint 다음 ship, 2026-09)
dismiss_sessionwrite다 본 세션을 내 목록에서만 치운다(되돌리기 포함). done 인 세션에만 허용되고, 관여자 전원이 치우면 그 세션은 자동으로 archived 가 된다

다섯 개의 쓰기 도구는 호출자 본인 자격으로 동작하고 관리자 권한을 요구하지 않는다. 대신 협업 세션이 아닌 세션은 거부한다 — 봇이 쓰는 세션과 사람이 쓰는 보드는 같은 표에 살지만 서로 건드리지 않는다.

내리기는 웹에도 같은 문으로 있다 (blueprint 다음 ship, 2026-09) — DELETE /api/sessions/{id}/posts/{postId} 가 MCP 도구와 같은 규칙(작성자·admin · 소프트 삭제 · 교차 세션 404 · 멱등)으로 돈다. 판정은 라우트 밖 순수 함수 하나에 모여 있어 실행으로 잰다 — 두 경로가 갈리면 증상은 에러가 아니라 “웹으로는 되는데 CLI 로는 403”(또는 그 반대)이다. 도구 이름과 인자 이름·순서는 세션 CLI 파리티 계약이 axe blueprint session 명령들과 함께 동결하고 그 계약이 pnpm build 체인 안에서 돌므로, 인자 하나의 개명은 배포 시점이 아니라 첫 호출에서 터지는 대신 빌드에서 잡힌다.

그 계약의 이름은 scripts/verify-session-cli-parity.mjs 다 (blueprint 다음 ship, 2026-09). 규칙은 한 줄이다: 세션 MCP 모듈의 @mcp.tool() 하나하나가, 체크인된 scripts/session-cli-parity.manifest.json 이 선언하는 axe blueprint session <sub> → 도구 호출 순서로 덮여야 한다. 게이트는 소스를 읽어 추론하지 않고 실행해서 본다 — 배포 번들(cli-dist/axe-enduser)을 모듈로 import 해 build_parser() 가 실제로 만든 파서 트리를 걷고(진입 파서 → blueprintsession → 각 서브커맨드), 그 서브커맨드가 무엇을 부르는지는 번들을 돌려서 기록한다. 도는 동안 tools/call 헬퍼는 도구 이름을 받아 적는 것으로 바꿔 끼우고, 소켓·HTTP·서브프로세스·exec 는 import 이전부터 막으며, 환경은 허용 목록만 남긴 임시 HOME 으로 세운다. 무엇을 어기면 빌드가 서는가:

  • 핸들러가 아니라 CLI 의 진짜 문이 돈다. 게이트가 args.func 를 꺼내 자기가 부르는 것이 아니라, 모듈 최상위 if __name__ == "__main__": 가드가 부르는 그 함수에 합성 argv 를 실어 보낸다(가드가 없거나 여럿을 부르면 통과가 아니라 거절이다). 그래서 main() 이 다른 파서로 갈아타든, 파싱 뒤 핸들러를 바꿔치기하든, args.func 를 아예 안 부르든 — 사용자가 그 명령을 쳐서 닿지 못하는 핸들러는 통과할 수 없다. 자동업데이트 같은 전문은 건너뛸 함수 목록으로 빼지 않고 CLI 자신이 문서화한 스위치로 끈다.
  • 매니페스트는 도구 하나가 아니라 호출 순서를 못박는다. 값은 ["get_session", "update_session"] 같은 정확한 순서 배열이고 비교는 동일성이다 — 이름·개수·순서 전부. 선행 호출도 감추지 않고 적는다(done 은 보내기 전에 잔여 요청을 묻고, post 는 게재 뒤 번호를 다시 읽는다). 여분 한 건·중복 한 건·뒤바뀜·모자람은 각각 무엇이 어긋났는지를 이름으로 대고 빌드를 세운다. 기대한 도구에 닿기만 하면 그 뒤에 아무도 검토하지 않은 변경성 호출을 하나 더 보내도 통과이던 자리가 여기다.
  • 별칭은 하나씩 따로 잰다. argparse 는 사용자가 실제로 친 토큰을 핸들러에 넘기고 핸들러는 그것으로 갈라도 되므로, 등록된 이름마다 프로브가 따로 돈다 — 별칭이 아무것도 안 부르거나 다른 도구를 부르면 형제 이름의 증거를 타고 들어오지 못한다. 매니페스트가 주장하지 않는 이름이 등록돼 있어도 실패다: 별칭도 자기 항목을 가져야 한다.
  • 부팅 스크립트의 명령은 자리에 서는 것으로 안 되고 닿아야 한다. 같은 빌드의 형제 검사가 부팅 스크립트를 셸이 읽는 대로 읽어(따옴표·주석·heredoc·리다이렉션) 필수 부팅 호출이 명령 자리에 서 있는지, 그리고 실행되는 경로 위인지를 본다. 아무도 부르지 않는 함수 안, 최상위 exit 뒤, if false 안은 전부 진짜 명령 자리이고 bash -n 도 통과하지만 부팅은 그것을 지나간다 — 그 자리로 옮겨진 부팅 단계는 실패다.

도구에 매핑이 없거나 매핑이 가리키는 도구가 없으면 양방향 모두 빌드 실패고, 래퍼가 없는 도구는 예외 목록에 글로 쓴 사유와 함께 올라야 한다(사유가 빈 항목, 이미 사라진 도구를 가리키는 항목도 그 자체로 실패다). 그래서 새로 붙는 세션 도구의 기본값은 빌드 실패다 — fail closed. 도구를 0개로 파싱하거나 세션 서브커맨드가 0개인 번들도 조용히 통과하는 대신 거절한다. 자리는 pnpm build 체인 안, Teams 파리티 게이트 바로 옆이고, 덤으로 cli-dist 번들의 sync 지연(원본 CLI 에는 래퍼가 있는데 커밋된 번들에는 아직 없는 상태)도 같은 검사가 잡는다. 운영자 CLI 판은 세션을 MCP 가 아니라 내부 REST 로 읽으므로 전면 요구 대상이 아니다 — 실제 MCP 래퍼가 생기는 도구만 명시적으로 그 판까지 요구하도록 올린다.

반대로 내려간 행을 일부러 계속 보는 조회가 셋 있다 — 게재의 멱등키 재생 조회 · Teams 방 종료 선언의 note 중복 가드 · 치움의 관여자 집합. 셋 다 거르면 각각 조용히 나빠진다: 재생된 멱등키가 “그런 게재물 없다” 를 보고 내려간 게재물을 되살리고, 종료 선언 note 가 매 스윕마다 다시 서며, 게재했다가 내린 사람이 관여자에서 빠져 자동 보관이 그 사람 없이 앞당겨진다.

협업 세션 — 웹과 로컬을 잇는 한 바퀴

각자의 Claude Code · Codex 에서 따로 일한 기록이 각자 터미널에 남는 문제를 닫는다. 세션 하나가 그 일의 공유 기록 보드이고, 한 바퀴는 이렇게 돈다.

  1. 웹 우측 패널에서 세션을 만든다/axe/** 어느 화면에서든 우측 레일 “세션” 섹션에 현재 법인의 협업 세션이 컴팩트 한 줄 행(상태 도트: 진행 = 채운 초록, 완료 = 빈 원)으로 보인다. 머리행의 [+] 가 새 세션, 프리셋 아이콘이 세션 목록 페이지(/axe/sessions)다 (@axe/ui 0.38.0 레일 v2, D-bp-collab-3). 이름은 그 자리에서 고칠 수 있고, 행에 마우스를 올리면 법인·마지막 활동·게재 수가 떠오른다.
  2. 프롬프트를 복사해 로컬 에이전트에 붙여넣고 엔터 — Claude Code 든 Codex 든 상관없다. 복사되는 전문은 짧은 인사 핸드셰이크다(D-bp-collab-4): 첫 줄이 사람에게 “붙여넣고 작업 지시 없이 바로 엔터” 를 말하고, 에이전트는 ~/axe-cli/axe blueprint session show <id> 로 기존 기록을 읽어 진행 상황을 요약한 뒤 “뭘 도와드릴까요?” 를 묻는다. 사람은 그 물음에 대답하면 된다. 에이전트는 로컬 대화 세션의 제목도 세션 이름으로 맞춘다(사이드바에서 어느 보드 작업인지 보이게).
  3. 작업 중 전환점마다 짧게, 끝나면 전문을 게재한다 — 명령과 origin 규약은 session show 출력 하단이 가르친다(agent CLI 0.1.60+): 중간은 --kind note --title "<요지>", 종료는 --title "<결과>", origin 은 "claude-code@$(hostname -s)"(Codex 는 codex@…). 세션을 클릭하면 가운데 화면에 게재물이 시간순 채팅형 피드로 쌓인다 — 누가 · 어느 로컬에서 · 언제 한 작업인지가 게재물마다 붙는다.
  4. 같은 흐름이 Teams 방에도 흐른다 (D-bp-collab-4) — 세션마다 봇이 연 group chat(제목 = 세션 이름)에 생성·게재·상태 전이·이름 변경이 한 줄씩 push 된다(“누가@어느 로컬에서 뭘 게재했다” + 링크). 멤버는 만든 사람 · 게재자 · 운영자이고, 게재자가 새로 나타나면 자동으로 들어온다. 방은 알림 채널이지 원장이 아니다 — 기록은 여전히 보드에 게재한다.
  5. 일이 끝나면 done 을 선언하고, 각자 치운다 — 생성자(또는 admin)가 상태를 done 으로 올리면(~/axe-cli/axe blueprint session done <id>) 세션은 배지를 달고 목록에 남는다. 결과를 확인한 사람이 각자 치우면(session dismiss <id>, 되돌리기는 --undo) 그 사람의 목록에서만 내려가고, 관여자가 전원 치운 순간 세션이 archived 로 내려가 모두의 목록에서 사라진다.

상태는 activedonearchived 세 단계이고, done아카이빙 승인 게이트다 — 아직 도는 세션은 치울 수 없다(정리가 아니라 숨기는 것이라서). 상태 전이(세 방향 전부)는 생성자·admin 전용이고, 치움은 조직 구성원 누구나 자기 뷰에서 한다. 전역 archived 를 한 사람이 한 번에 누르지 않는 이유도 같다: 그 한 번은 결과를 아직 못 본 참여자의 목록에서도 세션을 지운다. 관여자 = 만든 사람 · 참여자 · 게재자이고, 계정 없이 도는 에이전트 멤버는 치울 수단이 없어 이 집합에서 빠진다(D-bp-collab-2).

세션은 법인별로 갈린다. 같은 조직의 인증 사용자는 목록·조회·게재·이름변경까지 할 수 있고, 삭제만 만든 사람 몫이다. 치움은 목록 렌더링만 바꾼다 — 조직 안의 가시성 규칙 자체는 그대로다.

세션은 서비스별로도 갈린다 (D-bp-collab-3). Session.service(자유 슬러그, ^[a-z][a-z0-9-]{0,31}$)가 그 세션의 귀속 서비스이고, 각 서비스의 우측 레일은 자기 서비스 세션만 싣는다 — index 에서 만든 세션은 index 레일에, gate 에서 만든 세션은 gate 레일에 선다. blueprint 레일만 service=blueprint미분류(NULL) 를 더해 보여준다: 축 도입 전 세션은 백필로 지어내지 않으므로(마이그레이션 README ② 원칙) 원장 홈이 그들의 거처다. 전 서비스 전 세션은 /axe/sessions 보드가 서비스 구역으로 나눠 보여주고, 재분류는 웹 PATCH 또는 MCP update_session 으로 한다.

세션은 Teams 방을 하나씩 갖는다 (D-bp-collab-4). 저장은 Session.collabChatId 컬럼 하나이고(신규 표 없음 — RLS 표면 불변; 2026-09-03 에 방의 마지막 사람 발화 시각 collabChatLastHumanAt 이 형제 컬럼으로 붙었다 — 아래 get_session), 이벤트는 기존 DeferredTasksession:<id> 센티널로 큐잉돼 기존 1분 sweep 이 배송한다(dm:<email> 과 같은 궤도, 새 폴링 0). 방은 첫 이벤트가 발송될 때 sweep 이 lazy 로 연다(advisory lock + CAS — 경합해도 세션당 방은 하나, 고아 방은 TeamsSendLog 에 남는다) 그래서 세션 생성 직후 몇 초 안의 게재도 유실되지 않는다. 큐잉은 배포 조직의 세션만(봇이 단일 테넌트 신원이라 남의 조직 세션명을 이 테넌트 방에 싣지 않는다), 운영자 초대는 그 조직에 운영자 계정이 있을 때만. done 상태 세션에 게재가 붙으면 방에 ”⚠ 완결 세션에 후속 게재” 한 줄이 얹히고, 재개는 사람이 누른다. 같은 이유로 방 발화 시각도 active 세션에서만 전진한다 — 종료 스윕의 스캔 축이 status='active' 라, 닫힌 세션의 그 값은 닫히기 직전에 멈춘 값이다(읽는 쪽이 그것을 현재 상태로 읽지 않도록 agent CLI 는 active 세션에서만 그 절을 찍는다). done 뒤 방으로 오는 새 요청은 아직 이 축이 못 본다 (B-bp-collab-room-axis-done-sessions). D-ops-103 의 게재-시 운영자 1:1 DM 은 그대로다 — 방은 추가 채널이다.

짝을 거는 CLI 경로~/axe-cli/axe blueprint session pair <id> --with <상대 org> [--key <uuid>] 다 (0.1.70). --key 를 생략하면 이쪽이 uuid4 를 발급해 출력하고, 그 값을 받은 상대 org 가 같은 명령으로 자기 세션을 잇는다 — 한쪽만 걸린 짝은 에러가 아니라 조용한 무복제다. 해제는 --clear(두 컬럼을 함께 비운다; 이미 간 사본은 남는다 — 사본은 기록이지 살아 있는 미러가 아니다). 권한은 서버가 지고(생성자·admin, status 와 같은 급 — 이 세션의 기록이 조직 밖으로 나간다는 결정이라 보드의 공용 어휘가 아니다), 그 서브커맨드가 없던 동안 짝의 두 번째 절반을 걸 길이 사실상 없었다.

그 방은 찾을 수 있어야 하고 초대할 수 있어야 한다 (B-bp-collab-chat-dedupe, 2026-09-03). 방 id 가 어떤 읽기 표면에도 없고 사람을 그 방에 부르는 1급 경로도 없던 동안, 초대를 요청받은 에이전트가 고를 수 있는 수단은 create_teams_chat 뿐이었고 그것이 고른 제목은 당연히 세션 이름 — 즉 미러 방의 제목과 정확히 같은 값이었다. 결과는 같은 이름의 방 두 개(사람은 새 방에, 세션 사건은 옛 방에)로, 채널이 갈렸다. 세 갈래로 닫는다: ① 방 id·딥링크를 읽기 표면에 싣는다 (MCP get_session·list_sessions, 내부 라우트 GET /api/internal/collab-sessions[/<id>], agent CLI session show) ② 초대에 1급 경로를 준다 (update_session(invite_emails=[…]) · axe blueprint session invite — 명단은 Session.collabInviteeEmails 컬럼 하나이고 방 멤버 산출의 넷째 출처가 된다) ③ 그래도 세션 이름으로 온 create_teams_chat409 duplicate_session_topic 으로 거절하고 기존 방 id 를 돌려준다. 셋 중 ③만 있으면 에이전트는 막힌 채 대안을 모르고, ①②만 있으면 모르는 에이전트가 여전히 방을 하나 더 연다.

일반화하면: 공유 자원을 만드는 도구는 ⓐ 읽기 표면에서 기존 자원을 발견할 수 있고 ⓑ 같은 키로 재호출 시 멱등(기존 id 반환 또는 거절)이어야 한다. 둘 중 하나가 없으면 에이전트는 매번 새로 만든다.

보드 위생 (D-ops-104)

작업 세션의 의무는 두 줄이다 — 끝내기 전에 게재하고, 끝났으면 done 을 선언한다.

~/axe-cli/axe blueprint session post <id> --origin "<도구>@$(hostname -s)" --file <기록.md> ~/axe-cli/axe blueprint session done <id>

--origin 은 실제 클라이언트 신원이다 — 예: claude-code@… · codex@… (생략 시 cli@<호스트>).

잊어도 관례에만 기대지는 않지만 보증은 아니다 — 주기적으로 도는 잡이 없고(무언가를 감시하려고 타이머를 새로 심지 않는 것이 이 플랫폼의 원칙), 두 장치 모두 사람이 명령을 치는 순간에만 발화하므로 아무도 명령을 치지 않는 동안의 방치는 잡히지 않는다. ① ~/axe-cli/axe blueprint session list자기 호스트에서 만든 방치 세션을 stderr 한 줄로 경고한다(6시간 스로틀, AXE_CLI_NO_BOARD_NUDGE=1 로 끔) — 이것이 정본이다 — 항상 설치돼 있지만 발화는 그 명령을 실행하는 순간뿐이다. ② 운영자가 직접 돌리는 호스트 점검기가 24시간 넘게 게재가 없는 active 세션을 모아 Teams DM 한 통으로 알린다. 임계 시간은 AXE_BOARD_HYGIENE_HOURS(기본 24)로 조정한다. Claude Code 세션에는 같은 장부를 읽는 호스트-로컬 SessionEnd 리마인더 훅이 하나 더 있으나, 에이전트 종속이라 정본이 아닌 참고 장치다.

어느 쪽도 상태를 대신 바꾸지 않는다 — 통지까지가 전부고 보드를 닫는 것은 사람이다. 설계와 그 뒤의 정정(상시 잡 제거)의 근거는 내부 결정 기록 D-ops-104.

내부 API — /api/internal/collab-sessions

같은 목록을 다른 서비스의 협업 세션 표면이 읽고 쓰는 통로다. 내부 Bearer 키로만 열리고, 신규 비밀은 만들지 않고 기존 internal API key 를 그대로 쓴다. 소비자는 Index · Cortex · Gate — 셋 모두 레일과 인서비스 세션 페이지가 여기서만 데이터를 얻는다 (D-bp-collab-3).

  • GETorg(조직 슬러그) · entity(법인 슬러그, 생략 가능) · service(서비스 슬러그, 정확 일치 — NULL 미포함) · status(active 기본 = 구 소비자와 바이트 호환 | open = active+done, 레일 도트가 완료 행을 보여야 해서 소비자가 명시적으로 넓힌다 | all) · limit 로 좁혀 최근 갱신 순 세션 요약(이름 · 법인 · 서비스 · 상태 · 게재 수 · 마지막 게재 시각·출처)을 돌려준다. entityorg부재와 공백을 가른다. 부재는 종전 계약 그대로 — entity 없음 = 전 법인 무필터, org 없음 = 배포 테넌트 폴백(구 소비자가 오는 갈래) — 이고, 파라미터가 왔는데 값이 비어 있으면(?entity= · ?org=%20 같은 공백 포함) DB 를 만지기 전에 400 이다. 그 URL 이 나오는 자리는 오타가 아니라 변수 하나가 빈 문자열로 렌더된 서버렌더이고, 조용히 넓히면 소비자가 200 + 조직 전체 목록(org 축에서는 남의 테넌트 목록)을 받아 자기 레일에 자기 것인 양 세운다. 값이 온 entity 는 실재를 먼저 확인한다 — 모르는 슬러그는 필터에 실어 0행을 주는 대신 404 unknown entity "<slug>" 로 거절한다(같은 파일 POST 와 같은 조회·같은 문구). 명시적으로 빈 entity 를 거절하는 것은 같은 파라미터를 받는 MCP list_sessions 와 같은 처분이다 — 두 문이 같은 질문에 다르게 답하지 않는다.
  • POST — 소비자 서비스가 자기 레일에서 세션을 만드는 경로. { org, name, createdBy, entity?, service?, prompt?, id? } — 법인 또는 서비스 중 하나는 필수이고, createdBy 는 소비자가 인증한 사람의 이메일로 그 조직 멤버에 귀속된다(멤버가 아니면 400 — 서비스 키가 사람 행세를 하는 통로가 아니다). id멱등키(UUID v4, 소비자 폼이 렌더 시 생성)다 — 같은 키 + 같은 페이로드의 재제출은 기존 세션을 200 으로 재생하고(이중 제출·타임아웃 재시도가 쌍둥이를 안 만든다), 같은 키 + 다른 페이로드는 409 다. 키는 Session.id아니라 org-스코프 전용 컬럼 쌍(idempotencyKey + @@unique(organizationId, idempotencyKey), 교차-테넌트 존재 검침 차단)에 저장되고, 재생 판정은 라이브 행이 아니라 생성 시점 페이로드 해시(idempotencyPayloadHash = sha256 of canonical {createdById, entityId, name, prompt, service})와 비교한다 — 생성 후 이름·서비스가 편집돼도 정당한 재시도는 깨지지 않고, 다른 요청이 재생으로 위장하지도 못한다. 세션 id 는 언제나 응답의 session.id 로 읽는다.
  • GET /[id] — 인서비스 세션 상세 페이지가 읽는 신규 경로. org 필수(신규 경로에 legacy org 폴백을 만들지 않는다) · postsLimit(기본 50, 1..100). 세션 메타 + 프롬프트 + 게재물(마지막 N 건, 오래된 것부터)을 돌려준다. 목록 GET 이 본문을 싣지 않는 이유(다른 서비스 캐시에 남의 대화 전문이 눕는다)와 달리 상세는 클릭 시 1회 서버렌더 소비다 — 소비자는 이 응답을 캐시하지 않는다.
  • PATCH /[id] — 이 세션의 을 세우거나 푼다 (D-bp-collab-9 후속, blueprint 다음 ship). org 필수(?org=, body 로도 받되 어긋나면 400) · { pairKey, pairOrgSlug } — 둘 다 문자열이면 설정, 둘 다 null 이면 해제. 서비스 키가 남의 org 세션에도 짝을 걸 수 있는 것이 이 문의 요점이다: 짝은 양쪽 세션이 같은 키를 들어야 성립하는데, 상대 org 쪽 절반을 걸 수단이 없었다(MCP update_session 은 그 조직의 생성자·admin 사람 토큰을 요구하고, 크로스-org 협업은 정확히 “그 조직에 계정이 없다” 는 사실 위에 선다) — 실측으로 첫 실전 짝의 realchoice 쪽이 키 없이 서서 복제가 한 번도 안 흘렀다. 권한의 크기로 보면 이 문은 형제 POST 보다 약하다: 그쪽은 서비스 키로 남의 org 에 세션을 통째로 만들 수 있고(실제로 그렇게 만들어졌다), 이쪽은 이미 있는 두 행에 같은 UUID 를 적을 뿐 새 행도 새 사람도 새 FK 도 만들지 않는다. “각 org 가 자기 쪽에서” 원칙이 지키려던 크로스-org FK 금지는 그대로다(createdById 는 손대지 않는다). 반쪽 짝은 400(키만·슬러그만 — 한쪽만 선 행은 어떤 조회에도 안 잡히면서 컬럼에는 값이 있어 “짝인데 왜 안 흐르지” 로 보인다), 자기 자신과의 짝도 400, 그 조직에서 이미 쓰인 키는 409(부분 유니크 인덱스에 맡기면 트랜잭션이 aborted 라 원인 없는 500 이 된다). 없는 세션·남의 조직 세션·봇 세션은 전부 같은 404 다(GET 과 같은 침묵 — 존재 오라클 금지). 짝을 걸어도 복제가 흐르려면 양쪽 org 의 동의 등재(collab.pairedOrgs)가 여전히 각자 자기 스코프에서 확인된다.
  • POST /[id]/posts — 인서비스 세션 페이지의 게재 폼이 쓰는 신규 경로. org 필수(?org=, body 로도 받되 어긋나면 400) · { author, content, origin, title?, id? }. author 는 소비자가 인증한 사람의 이메일로 그 조직 User 에 귀속되고(멤버가 아니면 400 — 서비스 키가 사람 행세를 하는 통로가 아니다), origin<서비스>-web@<호스트> 형식이 강제된다(예 [email protected]) — 서비스 키로 만든 기록이 웹(web)·CLI(claude-code@…) 채널의 모양을 흉내내지 못하게 하는 장치라, 기본값으로 메우지 않고 400 이다. kindtranscript 고정(보내면 400), content 상한 초과는 자르지 않고 400, 보관(archived)된 세션에는 게재할 수 없다(400 — 되살리는 문은 상태 축이다). id 는 멱등키(UUID v4)로 같은 키+같은 페이로드는 200 재생, 다른 페이로드는 409 이며 SessionPost 의 전용 컬럼 쌍(idempotencyKey + @@unique(sessionId, idempotencyKey) + idempotencyPayloadHash)에 저장된다 — 축이 sessionId 하나인 파생표라 세션 스코프 유니크가 곧 테넌트 경계다. 게재는 세션 updatedAt 을 올리고 D-ops-103 운영자 DM 을 같은 트랜잭션에 넣는다(웹 게재 라우트와 같은 처분).

내부 API — Teams 방 이름 바꾸기

POST /api/internal/teams/rename-chat (blueprint 다음 ship, 2026-09) — 형제 create-chat 이 지은 방 제목을 나중에 고치는 유일한 1급 경로다. 위와 같은 내부 Bearer 로 열리고 신규 비밀은 만들지 않는다. 헬퍼는 전부터 있었지만 닿는 표면이 봇 대화 턴 안의 에이전트 도구 하나뿐이라, 운영자가 고아 방 하나를 고치려면 Teams UI 를 열어야 했다. 본문은 { chatId, topic, callerEmail, dryRun? }callerEmail 이 필수인 이유는 감사 행이 이 라우트의 존재 이유이기 때문이고(익명 개명은 버그다), dryRun 은 감사만 남기고 실제로 바꾸지 않는다.

1:1 대화는 열어 주지 않는다. Teams 는 chatType=oneOnOne 에 topic 을 허용하지 않으므로 헬퍼가 Graph 를 부르기 전에 그 사실을 확인하고, 라우트는 그것을 502(우리 밖의 고장)가 아니라 400 one_on_one_chat 으로 되돌린다 — 재시도해도 절대 낫지 않는 요청이라 4xx 가 맞다. 시도마다 append-only TeamsSendLog 한 행(contentType = chat-rename, sent/dry_run/failed)이 남고, 원장에 적는 이름은 보낸 문자열이 아니라 Graph 가 실제로 저장한 값이다(헬퍼가 topic 을 한 줄로 접으므로 둘이 갈릴 수 있고, 나중에 이 행을 읽는 사람에게 필요한 것은 방에 실제로 붙은 이름이다). 축을 못 구한 호출자의 감사 행은 배포 축으로 메우지 않고 쓰지 않는다 — 형제 라우트와 같은 판단으로, 남의 테넌트에 섞인 한 줄이 그 원장을 통째로 못 믿게 만들기 때문이다.

MCP 에는 일부러 노출하지 않는다 — 멤버 제거와 같은 이유로, 노출하면 Teams 파리티 계약이 두 CLI 판에 래퍼를 요구해 표면이 셋으로 번진다. 필요해지면 운영자 경로가 이 내부 라우트를 직접 부르면 된다. 그리고 이 문은 폐기와 다른 문이다: 방을 없애는 쪽은 전원 퇴장이고(Graph 에 group chat 삭제 API 가 없다), 이 문의 쓰임은 하나뿐이다 — 지우긴 아까운데 죽었다고 표시하고 싶은 방.

우측 패널의 프롬프트 카드

우측 컨텍스트 레일 — 위 “세션” 섹션과 같은 패널 — 의 슬롯 ② 에 로컬 AI 에이전트에 붙여넣을 복사 블록이 뜬다 (ContextPanel@1, @axe/ui 0.38.6). 블록에 기록 본문은 들어가지 않는다 — 대상 식별자 하나(대상: blueprint/issue BP-42)와 그것을 다시 읽는 명령 한 줄(현재 상태: axe ref …)뿐이고, 실제 조회는 붙여넣는 사람의 기기·로그인으로 일어난다. 첫 줄은 axe blueprint guide 실행하고, 출력된 지침대로 처리해줘. 이고, 붙여넣은 에이전트는 그 guide 와 axe ref 가 허용한 조회 명령만 쓴다.

카드가 그려지는 조건은 화면마다 다르다. 이슈 상세는 재조회가 되는 완전한 카드, 대상이 없는 화면은 서비스 진입점만 내주는 카드, 재조회 표에 없는 종류(프로젝트·에이전트 상세)는 카드 자체가 없다 — 빈 카드나 placeholder 를 대신 그리지 않는다. 조직은 이 표면을 끌 수 있고, Blueprint 는 카드가 내주는 것이 이미 화면에 떠 있는 식별자 하나뿐이라 기본 켜짐이다.

카드는 이제 ContextPanel@1 0.40.0 계약을 따른다 (D-ops-114, blueprint 다음 ship, 2026-09). 복사 텍스트에 실리는 화면 주소는 서버가 자기 라우트 표에서 만든다 — issue/axe/issues, project/axe/projects, agent/axe/team, 대상이 없는 화면 → /. 상세 화면이 아니라 구역 화면을 가리키는 이유는 blueprint 의 대상 식별자가 상세 라우트의 파라미터가 아니어서다 — 이슈의 BP-42 는 표시용 식별자이고 상세 경로가 받는 것은 행 id 라, 둘을 섞으면 카드가 404 로 가는 주소를 인쇄한다(레코드의 정확한 신원은 대상: 행과 axe ref 가 이미 정확히 나른다). 표의 값 하나하나가 실재하는 라우트 파일인지는 테스트가 대조한다. 릴리스 요청 본문의 page(≤32자)는 원장 라벨로 남고 절대 URL 이 되지 않는다(X5) — 렌더 함수들의 시그니처에 경로·URL 자리가 아예 없어 그 승격은 타입 수정 없이는 불가능하다. 슬러그 축은 호출자의 테넌트에서 오고(신원 해석이 이미 들고 있는 값이라 새 조회를 만들지 않는다), 문법을 벗어난 값은 거부가 아니라 그 절째로 빠진다(X6) — 카드를 죽이면 화면이 프롬프트를 통째로 잃지만, 한 자리를 빼면 나머지 행은 그대로 선다.

스킬 번들 배포 API (D-ops-96)

플랫폼 스킬 배포 채널의 서버측. GitHub 없이 플랫폼 토큰 하나로 스킬을 받는다 — 소비/발행 절차는 내부 ops runbook(운영자 macOS /ic 설치) 참조.

Endpoint인증/인가역할
POST /api/skills/publish플랫폼 Bearer + email ∈ BLUEPRINT_SKILL_PUBLISHERS (미설정=전원 거부, 서비스 키도 403)SoT tar.gz 업로드 — SkillBundle upsert, version = 콘텐츠 sha256
GET /api/skills/catalog유효 플랫폼 Bearer번들 목록 — audience=axe-tenanttenant_id=axe 토큰에만 보임
GET /api/skills/bundle/[name]동일 + tenant 게이트 (미통과=404)tar.gz bytes, ETag=version, 304 지원
GET /api/skills/local-statusNextAuth 세션접속자의 CLI 설치 신호(skillsCliLastSeenAt) — 워크스페이스 미설치 배너의 근거 (사용자 단위, 머신 단위 아님). 스탬프는 catalog/bundle 의 per-user 토큰 GET 이 User.organizationId 축을 실어 찍는다 — Bearer 라우트라 세션 축이 없다 (2026-08-18~09-02 미스탬프 회귀 해소, 내부 known-gaps 참조)

데이터 모델: SkillBundle (name pk, audience product\|axe-tenant, version, manifest, data, publishedBy). 클라이언트 = agent CLI axe skills list/apply/check/publish (0.1.41+). 기존 /api/skills NextAuth 세션 Skill CRUD 와는 URL 접두만 공유하는 별개 표면.

플랫폼 사용법 FAQ (B-bp-bot-ops-knowledge)

봇이 “이 플랫폼을 어떻게 쓰나” 에 답할 때 읽는 운영 지식층. 그 전까지 봇의 턴에는 이 질문에 답할 지식원이 하나도 없었다 — 시스템 프롬프트는 어떻게 답하는가 의 규칙이고, 워크스페이스 CLAUDE.mdsettingSources: ["user"] 라 자동 로드되지 않으며, 미바인딩 1:1 의 작업 디렉터리는 빈 스크래치다. 그래서 사용법 질문에 봇이 추측으로 답했다.

배달은 두 갈래다. 인덱스(제목+슬러그)는 매 턴 시스템 프롬프트에 붙고, 본문은 모델이 platform_faq 도구로 필요할 때 읽는다. 본문을 매 턴 싣지 않는 이유는 대부분의 턴이 FAQ 와 무관하기 때문이고, 인덱스에 “추측하지 말고 도구로 읽어라” 규칙을 함께 싣는 이유는 도구를 쥐여주는 것과 쓰게 만드는 것이 다르기 때문이다.

내부 절차는 방을 가린다. tenant/axe-tenant audience 는 방에 신원이 확인되지 않은 참여자(게스트·익명·페더레이션)가 있거나 로스터를 읽지 못하면 인덱스에도 도구 응답에도 실리지 않고, 그때는 product 만 보인다 — 전 테넌트 공통으로 publish 된 공개 절차라 누가 방에 있든 무해하고, 그것마저 막으면 이 층을 만든 문제로 되돌아간다. 판정은 roomAllowsInternalFaq 하나가 한다: 요청자를 포함한 참여자 전원이 이 조직의 Blueprint User 행에 매핑되어야 하고(그 위에 authz 알고리즘이 방 단계에서 보는 것과 같은 재료 — 로스터 해석 가능 여부·raw≠resolved), 한 명이라도 미매핑이면 차단이다. “Teams 신원이 해석됐다” 는 신뢰 앵커가 아니다 — 연합 게스트도 해석되고, customers.yaml entity scope 만 가진 사람은 계정 없이도 resolved 가 된다(그때 userId 는 null 로 남는다). 둘 다 “이름을 알아냈다” 이지 “우리 조직 사람이다” 가 아니다, 봇 턴이 한 번 계산해 인덱스와 도구에 같은 값을 내려보낸다 — 두 표면이 다른 시점의 로스터로 판단하면 “목록에는 있는데 열면 없다” 가 된다.

이 축소를 authz 훅의 도구 분류로 올리지 않은 것은 그 계층의 entity 축이 이 자리에 맞지 않기 때문이다: PLATFORM 은 방 전원 tenant-admin 을 요구해 사실상 아무도 FAQ 를 못 읽고, 채팅 엔티티는 미바인딩 1:1 에서 해석되지 않아 운영자 본인의 1:1 까지 막는다. 실제 위험은 역할 등급이 아니라 신원 미확인 참여자이므로 그 축만 본다.

시스템 프롬프트에 들어가는 것은 고정 지시문 하나다 — DB 에서 온 바이트가 0 이다. 처음에는 항목 목록(제목+슬러그)을 실었고, 제목이 주입 벡터라 슬러그만 남겼다가, 그것도 폐지했다: 슬러그 어휘는 하이픈으로 단어를 잇는데 ignore-all-previous-instructions유효한 슬러그다. 형식으로 문장을 막으려는 시도가 애초에 틀린 방향이었다 — 어떤 문자 집합이든 그 안에서 지시를 근사할 수 있고, 정화·검증은 그 경주를 이길 수 없다.

그래서 봇은 “사용법 질문이면 platform_faq 를 인자 없이 불러 목록을 먼저 봐라” 만 알고, 실제 목록은 도구 응답으로 온다. 도구 응답은 데이터 채널이라 경계가 다르다(모델이 관측 결과로 읽지 지시문 옆에 앉은 문장으로 읽지 않는다). 부수적으로 주입-시점 재검증도, 인덱스 절단 고지도, 제목 정화도 이 경로에서 사라졌다 — 막을 것이 없으면 막는 코드도 없다.

도구 조회는 본문을 마지막에 한 번만 읽는다: 1단이 매칭 전부를 본문 없이 정렬해 가져오고, 검색이면 2단이 상위 3건의 슬러그로만 본문을 되가져온다(가시성 절을 다시 걸어 — 두 조회 사이에 항목의 구획이 바뀔 수 있다). 1단에서 본문을 함께 끌면 매칭 60건 × 최대 32KB 가 통째로 넘어오는데 실제로 펴는 것은 3건이다. 무인자 목록은 본문을 아예 읽지 않는다.

도구 목록은 60건까지 싣고 총수를 따로 센다(count 병행). 상한만큼만 조회하면 가져온 건수가 언제나 상한과 같아 “정확히 상한” 과 “넘침” 을 구별할 수 없고, 그 구별을 잃으면 절단 고지가 영영 발화하지 않는다 — 봇이 잘린 목록을 전부로 믿고 “그런 항목 없다” 를 단언하게 된다. 넘치면 남은 건수와 검색 경로를 함께 밝힌다.

Endpoint / 도구인증/인가역할
POST /api/faq/publish플랫폼 Bearer + email ∈ BLUEPRINT_SKILL_PUBLISHERS (스킬 채널과 같은 목록 — 미설정=전원 거부, 서비스 키도 403)항목 배치 upsert. replace: true 면 그 배치에 실린 구획(audience, tenant 는 +orgSlug)으로 한정해 배치 밖 행을 정리
platform_faq (봇 in-process MCP, blueprint-graph)봇 턴 컨텍스트읽기 전용. 인자 없음=목록 · slug=본문 1건 · query=부분일치 검색

slug 는 전역 @id 다 — 봇 도구가 슬러그 하나로 항목을 해석하므로 구획별 중복을 허용하면 platform_faq(slug:"onboarding") 이 무엇을 가리키는지가 호출자의 테넌트에 따라 갈리고, 그건 도구 계약이 답할 수 없는 질문이다. 전역 유일을 유지하는 대신 조용한 인수를 막는다: 이미 다른 구획이 쓰는 슬러그로 publish 하면 409 로 거절하고(현 소유 구획 + 인수 방법을 문장으로) 돌려준다. 맨 upsert 였다면 AXE 의 product publish 가 어느 고객의 tenant 항목을 소리 없이 삼키거나 그 반대가 됐을 것이고, 어느 쪽도 응답에 나타나지 않는다.

동시성은 advisory lock 이 진다 (pg_advisory_xact_lock(hashtextextended(key,0)), 레지스트리와 같은 관례). 트랜잭션으로 감싸는 것만으로는 부족하다 — 같은 슬러그를 서로 다른 구획으로 보내는 두 요청이 겹치면 둘 다 “그 슬러그 없음” 을 읽고 둘 다 검사를 통과한 뒤, 두 번째 upsert 가 첫 번째가 방금 만든 행을 UPDATE 로 덮는다. READ COMMITTED 에서 이건 이상 현상이 아니라 기본 동작이다. 같은 이유로 replace구획 잠금(faq:scope:<audience>[:<orgSlug>])을 잡는다: 두 replace 가 엇갈리면 각자의 upsert 와 각자의 deleteMany 가 섞여 어느 쪽도 보내지 않은 합집합이 남고, 응답만 보면 둘 다 성공이다. 구획 잠금은 replace 만의 것이 아니다 — 일반 publish 도 자기 항목이 속한 구획을 잡아 replace 와 직렬화한다(안 잡으면 replace 의 정리와 엇갈려 방금 실린 항목이 사라지거나 지워졌어야 할 항목이 남고, 어느 쪽도 응답에 나타나지 않는다). 잠금 순서는 구획 먼저, 슬러그 나중, 각 집합은 정렬 순 — 레지스트리의 “딜 먼저, 이름 나중” 과 같은 교착 방지 규율이다. publish 는 항목 수와 무관하게 상수 왕복이다: 잠금 1(정렬된 키 배열을 unnest 로 한 문장) + 기존행 조회 1 + ON CONFLICT 배치 upsert 1 (+ replace 면 정리 1). 개별 upsert + 키별 잠금이던 초안의 실측은 1건 5회 · 100건 203회 · 500건 1003회였고, 그 왕복 내내 잠금을 쥐고 있어 지연만이 아니라 경합도 함께 길어졌다(왕복 하나가 1ms 만 돼도 Prisma 인터랙티브 트랜잭션 기본 한도 5초에 닿는다). P2002 은 방어층으로 남겨 두되 삼키지 않는다 — 다만 재독은 트랜잭션 밖에서 한다. 유니크 위반은 트랜잭션을 중단 상태로 만들어 그 안의 다음 쿼리가 전부 25P02 로 거절되므로, “누가 가져갔는지” 를 그 안에서 물으면 원래 오류가 25P02 로 바뀌어 원인이 사라진다. 그래서 applyFaqPublish 는 던지기만 하고 라우트가 롤백된 뒤 평범한 클라이언트로 재독해 409 로 번역한다.

선택적 제어 플래그(replace·confirmEmpty)는 있으면 boolean 이어야 한다replace: "true" 같은 값을 x === true 로 강등하면 정리가 통째로 안 도는데 응답은 성공이고, 지워졌어야 할 항목이 남았다는 사실은 아무 데도 안 적힌다.

구획 비우기{items: [], replace: true, scope: {...}, confirmEmpty: true} (스크립트로는 --clear <audience> [--clear-org <slug>]). --clear-orgtenant 구획에서만 받는다 — 비-tenant 에 붙이면 즉시 거부한다(조용히 무시하면 화면에는 “product:acme 를 비운다” 고 찍히는데 실제로는 acme 와 무관한 product 전체가 지워지고, 그 차이는 지워진 뒤에야 드러난다).

삭제된 조직의 고아--clear-org-id <uuid>(API 로는 scope.orgId)로 청소한다. ownerOrgId 에 FK 가 없다는 것은 — 의도된 설계다 — 조직 행이 사라져도 그 조직의 FAQ 가 남는다는 뜻이고, 그때 슬러그는 어느 조직에도 해석되지 않아 관리 API 가 그 행들에 도달할 다른 손잡이가 없다. 이 형태는 그 경우에만 쓴다: orgSlug 와 배타이고, uuid 형식을 검사하며 소문자로 접는다(uuid 는 hex 라 대소문자가 같은 값인데, 접지 않으면 대문자로 붙여넣은 id 가 저장된 행과 어긋나 아무것도 안 지우는 조용한 no-op 이 된다)(해석을 일부러 건너뛰는 경로라 형식이 유일한 방어이고, 오타는 아무것도 안 지우는 조용한 no-op 이 된다). docs 에서 한 구획의 항목을 전부 지웠을 때 서빙에서도 사라지게 하는 유일한 경로다. 항목이 하나도 없으면 대상 구획을 유도할 수 없어 scope 명시가 필요하고, confirmEmpty 를 따로 요구하는 이유는 빈 배열이 실수의 기본형이기 때문이다 — 파서가 아무것도 못 뽑았거나(마커 오타), 잘못된 파일을 가리켰거나, 스크립트가 중간에 실패해도 결과는 똑같이 items: [] 이고, 셋 중 어느 것도 “이 구획을 전부 지워라” 를 뜻하지 않는다.

데이터 모델: PlatformFaq (slug pk, audience product\|axe-tenant\|tenant, ownerOrgId, title, body, sortOrder, publishedBy). SkillBundle 과 같은 org 평면 밖 표다 — 테넌트 축 FK 가 없고 RLS 도 붙지 않는다. product 행은 정의상 어느 테넌트에도 속하지 않아 nullable FK 로 만들면 RLS 정책이 표현할 수 없는 행이 되기 때문이다. 대신 마이그레이션이 CHECK 둘을 건다: audience 어휘, 그리고 tenantorgSlug 일관성(tenant 는 orgSlug 필수, 나머지는 NULL 강제). 인덱스는 (audience, ownerOrgId) 복합 하나다 — 가시성 질의의 두 절(선두 컬럼만 쓰는 audience IN (…) 과 둘 다 쓰는 tenant 절)을 함께 커버하므로 단일 audience 인덱스는 그 접두라 따로 두지 않는다. sortOrder 는 컬럼이 INTEGER 라 int32 상한을 애플리케이션이 함께 본다 — 안 보면 초과 입력이 400 이 아니라 Postgres 발 500 으로 돌아가 원인이 응답에서 사라진다.

소유 축은 Organization.id(ownerOrgId)이지 슬러그가 아니다. 슬러그는 바뀌고 재사용되므로, 슬러그로 매칭하면 고객 A 가 acme 를 반납하고 고객 B 가 그것을 물려받는 순간 A 의 FAQ 가 B 에게 보이고 A 는 자기 항목을 잃는다 — 아무도 아무것도 안 했는데 격리가 깨지는 종류다. 컬럼 이름이 organizationId 가 아닌 이유는 이 저장소에서 그 이름이 테넌트 축(NOT NULL + FK + RLS)을 뜻하는 예약어이고 스키마 테스트가 그 이름을 스캔하기 때문이다. 사람이 쓰는 어휘는 여전히 슬러그이고(docs 마커·CLI 인자), publish 경로가 그것을 id 로 해석해 저장한다(못 찾으면 400).

가시성은 뷰어의 조직 id + 슬러그로 갈린다 — product 전부 ∪ (ownerOrgId 가 일치하는) 자기 tenant 행 ∪ (슬러그가 axe 면) axe-tenant. axe-tenant불변 id 로 판정한다: BLUEPRINT_AXE_ORG_ID(AXE 조직의 uuid)와의 동치. SkillBundlecanSeeAxeTenant 는 토큰의 tenant_id claim(슬러그)을 보지만 그 관례를 여기서 잇지 않는 이유는 같은 취약점을 상속하기 때문이다 — 슬러그는 바뀌고 재사용되므로 언젠가 axe 슬러그를 배정받는 비-AXE 조직이 AXE 내부 FAQ 를 받게 된다. 미설정 = 전면 차단(fail-closed): 고객 인스턴스는 이 값을 갖지 않는 것이 옳은 상태이고, AXE 인스턴스에서 설정을 잊으면 내부 FAQ 가 안 보일 뿐 남에게 새지 않는다. BLUEPRINT_ORG_ID 와 혼동하면 안 된다 — 그쪽은 인스턴스마다 다른 값이라 그것으로 판정하면 모든 고객이 자기 인스턴스에서 axe-tenant 를 보게 된다.

두 audience 가 모두 id 축이 되면서 뷰어 해석에서 슬러그 조회가 사라졌다 — 턴마다 돌던 Organization 조회 하나가 함께 없어진다. 슬러그 조회가 실패해도 자기 테넌트 항목은 계속 보인다(잃는 것은 axe-tenant 가시성뿐 — 정직한 축소). 테넌트는 도구 인자가 아니다(인자면 Teams 입력이 자기가 볼 테넌트를 고른다 = confused deputy). 테넌트를 못 구하면 product 까지만 보이고, 가시성 밖 슬러그를 지목한 조회는 403 이 아니라 “없음” 으로 답한다(403 은 목록이 감춘 사실을 도로 알린다).

봇은 이 층에 영구히 읽기 전용이다. 쓰기 도구를 봇 표면에 두면 Teams 메시지 한 줄이 곧 전 테넌트가 읽는 제품 문서가 되고, 그 경로에는 사람의 승인이 없다. 이 불변식은 테스트가 네 축(도구 이름·개수·스키마·실제 Prisma 호출)으로 동결한다.

본문의 SSOT 는 이 docs 다. PlatformFaq 는 서빙 캐시이지 원고가 아니다 — scripts/publish-faq.mjs --from <mdx> 가 docs 의 <!-- faq slug=… audience=… --> 구획을 뽑아 publish 한다. 토큰은 axe login 이 쓰는 ~/.axe-cli/token 에서 읽는다 — 값을 받는 --token 옵션은 없다(argv 에 실린 토큰은 셸 히스토리와 ps 출력에 남고 스크립트가 끝나도 남는다). 다른 토큰이 필요하면 --token-file <경로> 또는 --token-stdin.

그 캐시 토큰은 정본 origin(https://axe.axelabs.ai)에만 나간다. --base 로 다른 origin 을 가리키면 명시 토큰(--token-file/--token-stdin)을 요구한다 — ~/.axe-cli/token 은 정본 플랫폼의 bearer 라, 오타 하나나 남이 준 URL 하나로 플랫폼 자격증명이 제3자 서버에 넘어가고 그 사실은 요청이 나간 뒤에야 드러난다. 평문 http 는 정본이든 커스텀이든 거부하고(bearer 가 그대로 흐른다), 예외는 loopback 개발 주소뿐이다. --replace--from 을 요구한다 — 내장 SEED 는 부트스트랩용이라 구획의 정본이 될 수 없고, 둘을 묶으면 docs 에서 자란 product 구획 전체가 3건으로 되돌아간다(그 파괴 의도가 명령줄에는 --from부재로만 드러난다). 파서에 기본값은 없다: audience 가 빠지거나 오탈자면(미지 속성·속성 중복 포함) 그 항목의 오류로 배치 전체가 중단된다. 폐합도 필수다 — 닫는 마커를 빠뜨린 구획이 다음 구획을 삼키면 그 내용이 앞 항목의 audience 로 나가고(예: 닫히지 않은 product 구획이 뒤따르는 tenant 구획을 흡수해 한 고객의 내부 절차가 전 테넌트로 publish 된다), 경고도 없고 항목 수도 맞아 보인다(오류는 전부 모아 한 번에 보고하고, 부분 publish 는 하지 않는다). 속성 중복에 last-write 를 두지 않는 이유도 같다 — audience=tenant audience=product 가 조용히 뒤엣것으로 나가면 편집자가 화면에서 보는 것과 배포되는 것이 어긋나고, 그 차이는 배포된 뒤에야 드러난다. 예전처럼 누락을 product 로 메우면 오탈자 한 번(audiece=tenant)이 한 고객용 절차를 전 테넌트에 배포하는 경로가 되고, 그 본문은 대개 다른 고객에게는 틀린 안내다. 아직 ship 훅 드리프트 게이트가 없어서(스킬 채널의 axe skills check 에 해당하는 자리) docs 만 고치고 publish 를 잊으면 봇이 옛 절차를 계속 답한다 — 스크립트의 TODO(B-bp-faq-stale-gate) 가 그 자리다.

Teams 채널 폴링 (흡수 2단계, 2026-08-16)

봇이 채널 멘션에 응답한다. 채팅(1:1·그룹)은 Graph 웹훅 구독이지만 채널은 60초 폴링이다 — 채널 메시지 구독의 위임 스코프 제약 때문이다.

항목
등록 위치src/instrumentation.tsrunsBotWorkloads() 게이트 안쪽. role=web 프로세스는 등록 자체가 불가능하다
폴러src/lib/teams/channel-poller.ts
큐 분리src/lib/teams/inbound-queue-channel.tsCHANNEL_QUEUE_PREFIX 로 채널 항목을 채팅 큐와 가른다. inbound-queue.ts 접촉면은 import 1줄 + findUnsweptChatIds 제외 1줄뿐
신규 위임 스코프ChannelMessage.Read.All · ChannelMessage.ReadWrite (AXE 테넌트 admin consent 2026-08-16 완료)
신규 AppSettingteams_channel_poll_watermarks (채널별 마지막 처리 시각) · teams_channel_default_workspace

게이트가 role 인 이유. 폴러는 멘션을 claim 한 뒤 Claude 를 spawn 한다. web·bot 양쪽에서 등록되면 같은 채널을 두 프로세스가 폴링하며 claim 을 경합하고, web half 가 이기는 턴은 spawn 가드에 막혀 죽는다. 고객 fork 가 등록부를 게이트 밖에 둬서 실제로 그 사고가 났다(2026-08-15). 그쪽은 TEAMS_CHANNEL_POLL_DISABLED 킬스위치로 막았으나, 정본은 그 스위치를 흡수하지 않았다 — 게이트가 둘이면 다음 사람이 어느 쪽이 정본인지 모른다. tests/channel-poller-role-gate.test.ts 가 등록 위치와 킬스위치 부재를 빌드 게이트에서 고정한다.

워터마크 초기화. 워터마크가 없는 채널은 첫 사이클에 폴링 시작 시각으로 초기화만 하고 과거 메시지를 처리하지 않는다. 배포 직후 폭주가 없는 이유다.

v1 한계 (코드 주석에 명시): 20-root/20-reply 확장 창 밖의 답글은 놓칠 수 있다. 채널 composite id 를 받는 chat-aware 툴(graph_chat_messages 등)은 unknown chat 처럼 degrade 한다.

Graph 토큰 vending — 고객 상주 봇 (D-ops-100 첫 슬라이스, 2026-08-29)

고객 미니의 상주 봇은 클라우드 RDS 의 GraphToken 을 직접 읽어 복호화했다. 그 전제 (“봇과 클라우드가 같은 AGENT_SECRET_KEY”)가 컷오버 후 깨졌고(재로그인이 클라우드 키로 재암호화 → 고객 봇 3/3 복호화 실패, Teams 봇 전면 불능), 교정은 키를 맞추는 것이 아니라 봇이 그 행을 아예 안 보는 것이다 — D-ops-100(봇 = 클라우드 인증 API 클라이언트)의 첫 실행 조각.

항목
라우트POST /api/internal/bot/graph-token클라우드 web half 전용 (bot allowlist 밖 + vendor_misconfigured 가드)
인증Authorization: Bearer <테넌트 자격> → sha256 → BotApiCredential (org 스코프, revokedAt 독립 회수)
응답{status, accessToken, expiresAt, userId}refreshToken 이 실릴 수 있는 경로 0 (테스트가 재귀 단언으로 동결)
봇 envBOT_GRAPH_TOKEN_VENDOR_URL + BOT_GRAPH_TOKEN_VENDOR_KEY — 설정 시 봇은 DB 조회·복호화·MSAL 을 일절 타지 않고 vendor 호출 + 인메모리 캐시(만료 5분 전 재발급)
민팅npx tsx scripts/mint-bot-credential.ts --org <slug> --name <name> --sha256 <hex64> — 비밀 원문은 받지도 출력하지도 않는다(해시만 입력, 로그 잔존 0). --revoke <id> 지원
관측graph-token-healthdecrypt_failed 를 분류·경보한다 — 종전에는 expiresAt 만 봐서 복호화 불능을 healthy 로 오보했다

org 는 요청 본문이 아니라 자격에서만 도출한다(I1). 타 org principal 은 존재를 노출하지 않고 404. refresh 의 writer 는 클라우드 하나로 수렴한다 — 서로 다른 키를 쥔 두 프로세스가 같은 행을 쓰던 위험이 사라진다. 설계 SoT = blueprint docs/plans/bot-api-client-tenancy.md.

컨테이너 부팅 (cold start)

부팅 스크립트는 더 이상 npx tsx 를 부르지 않는다 (blueprint 다음 ship, 2026-09). npx 는 이미지에 없는 패키지를 매 콜드 스타트마다 레지스트리에서 받아 온다 — 그 지연이 배포의 헬스 게이트 창을 넘겨 2026-09-04 두 번의 swap 을 PARTIAL 로 만들었다(사용자 영향은 없었지만, 새 컨테이너가 창 안에 healthy 를 못 찍으면 배포는 완료로 서지 못한다). tsx 는 이제 프로덕션 의존성이라 이미지에 구워져 있고, 부팅이 그것을 쓰는 네 자리(법인 Entity 재조정 시드 · 고아 Graph 구독 정리 · 중단된 ack 회수 · 스킬 sync)는 전부 이미지 자신의 node_modules/.bin/tsx 를 부른다 — 부팅 경로가 바깥 레지스트리를 타지 않는다. 되돌아가는 길은 정적 테스트가 막는다: 부팅 스크립트에 npx tsx 가 다시 나타나면 그 테스트가 실패한다.

Admin vs Settings 경계

두 경로는 다루는 수준이 다르다. 헷갈리면 “조직에 영향을 주는가, 나에게만 영향을 주는가” 로 갈라 보면 된다.

경로수준
/axe/admin/*조직멤버 관리, 권한 부여, 감사 로그
/axe/settings개인개인 토큰, 알림 환경설정, 연결 상태

관리자 경로는 역할이 admin 인 계정만 접근한다. 위 표에서 “관리자 전용” 으로 표시한 도구들도 호출 시점에 같은 역할을 다시 확인한 뒤에야 동작한다.

테넌트 경계 — 표 70개 전 분류 완결 (2026-08-21 S5~S7 · 2026-08-25 레지스트리 +2 · 2026-08-27 협업 세션 +1 · 2026-08-28 세션 치움 +1 · 2026-08-29 봇 자격 +1)

blueprint 는 공유 인스턴스에 고객을 테넌트 행으로 담는다(Tier A). 그 경계를 세우는 장치는 하나가 아니라 둘이고, 둘 다 있어야 성립한다.

무엇을 하나없으면
DB — RLS 정책ENABLE+FORCE ROW LEVEL SECURITY, org_only … TO blueprint_app, current_setting('blueprint.org') 비교필터가 아예 없다
앱 — 스코프src/lib/db.ts 의 프록시가 질의마다 트랜잭션을 열고 SET LOCAL "blueprint.org" 를 세운다정책이 비교할 값이 NULL → 0행 또는 42501

앱은 필터링을 하지 않는다. GUC 를 세울 뿐이고 거르는 것은 전적으로 정책이다. 그래서 정책이 없는 표는 스코프 안에서 돌아도 전 테넌트 행을 돌려준다 — “스코프 안에서 질의했다” 가 “경계가 있다” 를 뜻하지 않는다.

표 70개의 현재 분류

  • 축 보유 36표organizationId 컬럼 + org_only 정책(직접 축). 신원 평면 5표는 bootstrap_read … TO blueprint_boot 를 추가로 갖는다(축을 구하는 조회가 자기 정책에 막히는 순환을 끊는 자리 — 로그인·토큰 발급·봇 자격). BotApiCredential 은 D-ops-100 첫 슬라이스(20260829000000)에서 이 분류로 신설됐다 — 고객 상주 봇의 테넌트 자격 표라 secretHash 조회가 곧 “어느 테넌트인가” 의 답이고, 그 답 이전에는 GUC 가 없다(사전-테넌트 IdP 표와 같은 순환·같은 처방). 신규 표 관례대로 org_only·org_pinned 두 정책 + 롤 GRANT(boot 는 SELECT 하나)를 같은 마이그레이션에 명시한다. Skill 은 S7-A 에서 이 분류로 승격됐다(20260821170000 — 컬럼 신설 + 행-유도 백필: creator-org → 단일-배정-org → 미귀속 캐시 행은 삭제가 아니라 _s7_skill_quarantine 격리. 유니크는 전역 name 이 아니라 (organizationId, name)). Skill.source('db' 기본, 부팅 sync 만 'file' 스탬프)가 디스크-캐시 행과 DB-생성 행을 가른다 — prune 은 source='file' 만 본다.
  • 파생 26표 — 축 컬럼이 없고 부모를 통해 축이 결정된다(Agent·User·Session· Issue·Workspace). 부모 경유 EXISTS 정책 — 20260821050000 으로 적용 완료(S6). SessionPost 은 D-bp-collab-1 에서 이 분류로 신설됐다 — 협업 세션의 게재물이라 축은 부모 Session.organizationId 가 이미 들고 있다. 신규 표라 default-privilege 미러가 없으므로 org_only·org_pinned 두 정책과 두 롤 GRANT 를 같은 마이그레이션에 명시한다(레지스트리 2표와 같은 관례). SessionDismissal 도 D-bp-collab-2 에서 같은 형상으로 신설됐다 — (세션, 멤버) 한 쌍이 “이 사람이 이 세션을 치웠다” 를 뜻하는 표라 축은 여기서도 부모 Session 이 준다(정책 두 벌 + 두 롤 GRANT 명시, 같은 관례).
  • IdP 3표OAuthAuthCode·OAuthRefreshToken·SessionHandoffCode. S7-B (20260821180000)에서 경계 완성: 읽기(코드 소비·refresh 부모 찾기·revoke·핸드오프 교환·userinfo)는 blueprint_boot(불투명 비밀이 곧 소지 증명, 컬럼-레벨 UPDATE 는 소비 컬럼 3개만), 쓰기(발급·회전·mint)는 앱 롤 + 토큰 주인의 축. 상세 = architecture/auth(내부 문서)의 “IdP 저장 평면” 절.
  • 전역 5표ModelDeprecation·McpSchema·OAuthClient·SkillBundle·PlatformFaq. 축이 없는 것이 맞다(공개 카탈로그 · IdP 클라이언트 평면 · audience 축으로 이미 모델링). 뒤의 둘은 같은 이유로 같은 형상이다 — product 행은 정의상 어느 테넌트에도 속하지 않아 nullable FK 로 두면 정책이 표현할 수 없는 행이 된다. PlatformFaqtenant 행은 대신 CHECK(tenantorgSlug 일관성) + 애플리케이션 가시성 게이트가 지고, 그 격리는 봇 턴 격리 스위트가 결과로 잰다.

정책 총계 = S5 33 + S6 24 + S7 4(Skill + IdP 3) + 레지스트리 2(RegistryObject· RegistryLink, 20260825000000, D-ops-99) + 협업 세션 2(SessionPost D-bp-collab-1 · SessionDismissal D-bp-collab-2) + 봇 자격 1(BotApiCredential, 20260829000000, D-ops-100) = 66표 org_only + 신원/IdP 의 bootstrap_read/write. 레지스트리 2표는 org_pinned 도 함께 받고(신규 표 default-priv 미러 폐기 이후라 명시 GRANT), 링크 양 끝은 (organizationId, id) 복합 FK 라 참조무결성 검사가 RLS 를 우회해 크로스테넌트 링크를 세우는 경로가 없다.

세션이 없는 경로는 축을 명시해야 한다

프록시는 요청 세션에서 org 를 구한다. 그래서 세션이 없는 시점에는 축을 손으로 넘겨야 한다 — 그 자리가 넷이다:

  1. sign-in 콜백 — 쿠키가 아직 없어 해석기가 구조적으로 null 이다. bootstrapPrismaUser.organizationId 를 읽어 runInTenant 으로 감싼다.
  2. Graph 토큰 접근 — 축의 출처가 요청자가 아니라 토큰 주인이다. 진입점 하나로 감싸면 트랜잭션이 MSAL refresh 왕복을 걸쳐 열린 채 남으므로 질의 단위로 연다.
  3. 크론·봇 턴 — 축은 인자로 이미 손에 있다. 본문에 Graph·Claude 왕복이 있어 트랜잭션으로 못 묶으므로 runWithAmbientOrg 로 축만 심고 트랜잭션은 질의마다 연다.
  4. 봇 자격 인증 (/api/internal/bot/graph-token) — 축의 출처가 자격 행 자체다 (secretHash → org). bootstrapPrisma 로 자격 한 줄만 읽고, 이후 principal 해석과 기록은 전부 그 org 의 runInTenant 안이다.

이 진입점 목록은 src/lib/tenant-boundary.test.ts 가 사유와 함께 동결한다 — 새 자리가 생기면 테스트가 먼저 실패한다.

함정

  • _prisma_migrations 의 “적용됨” 은 정책의 존재를 뜻하지 않는다. 정책 마이그레이션의 가드는 blueprint_app 롤이 없는 DB(단일 롤 self-host)에서 RETURN 하는데, 그래도 마이그레이션은 성공으로 기록된다. 판정은 pg_class.relrowsecurity 로 직접 세라.
  • 정책 없는 부모의 ON DELETE CASCADE 는 RLS 를 우회한다. 자식에 칸막이를 세워도 부모가 열려 있으면 삭제 방향은 막히지 않는다.
  • 파생 26표는 BLUEPRINT_TENANT_SCOPE_ENFORCE=1 아래에서도 앱이 던지지 않는다. enforce 카운터가 0이어도 그 표들이 안전하다는 뜻이 아니다 — 유일한 강제 장치는 정책이다.

고객이 산 서비스만 연다 (D-ops-116)

고객 등록부의 services_enabled 가 그 고객이 플랫폼 로그인으로 들어갈 수 있는 서비스를 정한다. 선언이 없으면 무제한이라 기존 고객의 동작은 그대로다.

blueprint 가 그 판정을 하는 자리는 넷이다.

자리하는 일그 자리만 빠지면
인가코드 발급 에 거절하고 표준 오류로 되돌린다사용자가 로그인 왕복을 다 마친 뒤 실패해 “권한 없음” 이 “로그인 고장” 으로 보인다
토큰두 그랜트 모두에서 거절한다 (접근·신원 토큰 둘 다)인가를 통과한 코드로 토큰을 받는다
스코프CLI·동적 등록 커넥터가 받는 토큰에서 허용집합 밖 서비스 스코프를 잘라낸다. 회전 토큰에도 잘린 값을 적는다커맨드라인 로그인 한 번이 안 산 서비스의 베어러가 된다 — 그 토큰의 대상이 형제 서비스가 검증하는 값이라서
화면레일·세션 폼·상단 메뉴에서 감춘다. 이웃 서비스를 사용자 신원으로 부르던 두 지점도 같은 판정을 앞에 둔다감춰도 직접 주소로 들어온 사용자가 깨진 인증 오류를 본다

등록부를 못 읽거나 한 고객 블록이 깨지면 그 고객만 건너뛰고 통과시킨다 — 손편집 오타가 전 고객의 로그인을 멈추면 안 된다. 대신 건너뛴 고객·카탈로그에 없는 서비스 이름·허용집합을 선언한 고객 수를 로그로 남긴다. 그 수가 0이면 낡은 사본을 읽고 있다는 뜻이다.

이 키가 닫는 것은 플랫폼 로그인 문 하나다. 자체 로그인 레인을 가진 서비스, 서비스별 직행 인증, 이미 발급된 토큰의 잔여 수명은 이 축 밖이다. 축의 전체 그림과 닫지 못하는 것의 목록은 내부 인증 문서에 있다.

관련 문서