Skip to Content

Blueprint

한 줄 소개: 사람 운영자와 AI 에이전트가 같은 공간에서 일하는 통합 워크스페이스. 프로젝트·이슈·세션·지식이 한 곳에 모이고, 에이전트는 MCP 도구로 그 공간과 사용자의 일정·메일·Teams에 접근한다. 2026-10-07 공식 원격 tools/list에서 53개 도구를 확인했다. 2026-10-06에는 46개였고 원메일 릴리스로 일곱 도구가 추가됐다. 실제 dot 연결·호출 검증은 별도로 남아 있다.

주소

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

MCP 커넥터 등록

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

  1. 클라이언트의 커넥터 추가 화면에서 위 MCP 엔드포인트 주소를 넣는다.
  2. Microsoft 계정 로그인 창이 뜬다 (표준 OAuth + PKCE). 조직 계정으로 승인한다.
  3. 승인이 끝나면 서버에 실제 등록된 도구가 클라이언트에 나타난다. 2026-10-07 공식 AXE 클라이언트로 원격 53개와 새 메일 도구 7개를 확인했다. 이 결과는 dot 자체의 연결 검증과 구분한다.

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

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

OpenAI MCP OAuth — 환경 변수와 승인된 rollout

OpenAI 연결은 별도 endpoint https://axe.axelabs.ai/blueprint/mcp/openai와 metadata https://axe.axelabs.ai/blueprint/.well-known/oauth-protected-resource/mcp/openai를 사용한다. 기존 /blueprint/mcp의 Microsoft·CLI discovery는 그대로 보존한다. 다음 세 값은 2026-10-08 별도 승인된 AXE 운영 적용 계약이며 이 문서 배포 시점에는 runtime 적용과 실제 dot 호출 검증이 남아 있다.

환경 변수코드 기본값승인된 AXE 적용값·경계
BLUEPRINT_MCP_RESOURCE_OAUTH_ENABLED미설정이면 OFF, 정확히 1일 때만 ON1 — 별도 child resource OAuth 활성화
BLUEPRINT_MCP_OAUTH_RESOURCEhttps://axe.axelabs.ai/blueprint/mcp/openaihttps://axe.axelabs.ai/blueprint/mcp/openai — code·refresh·access token audience와 MCP 검증에 exact URI 결속
BLUEPRINT_MCP_OAUTH_ALLOWED_TENANTS빈 목록, 허용 0개axe — 활성 사용자와 현재 Organization.slug·User.tenantSlug가 모두 exact axe인 경우만 허용; Microsoft tenant GUID·mailbox 증거와 구분

승인된 스키마는 public.OAuthAuthCode.resource·public.OAuthRefreshToken.resource의 nullable TEXT 두 컬럼뿐이다. default·backfill·역할·GRANT·RLS 변경은 없으며 기존 NULL grant와 CLI 호환 경로를 보존한다. exact ChatGPT callback은 https://chatgpt.com/connector_platform_oauth_redirect다. DCR·PKCE S256·canonical issuer·기존 서명키를 재사용하고 RFC 9207의 같은 iss를 성공·오류 authorization 응답에 반환한다. 다른 callback ID URI는 이 승인에 포함되지 않는다. 새 Microsoft scope·키·Azure App Identifier URI·ACL·실메일 변경도 포함하지 않는다.

공식 순서는 문서 배포 → 운영자 registry blueprint.extra_env 세 값과 axe ship axe-cli → Blueprint clone migration 검사와 axe ship blueprint다. 수동 .env 수정이나 직접 운영 SQL 실행으로 대체하지 않는다. 배포 후 별도 discovery·OAuth·MCP 호출을 확인하고 공식 클라이언트의 실제 dot 호출 완료는 독립 증거로 기록한다. OpenAI 인증 기준 .

새 resource의 UserInfo·code/refresh ID token은 email_verified=false다. 원래 로그인과 현재 DB 이메일의 일치를 확인해도 이메일 소유권 증명이 생기지는 않으므로 기업 workspace의 verified-domain 제한은 현재 미충족이다. 서비스 허용 정책은 발급·refresh에서 검사하며 이미 발급된 access token에는 변경이 최대 1시간 TTL까지 늦게 반영될 수 있다. UserInfo는 현재 정책을 다시 확인하고 기능 OFF·조직 allowlist·활성 계정과 현재 조직/tenant 결속은 MCP 호출에도 적용한다.

MCP 도구 — 운영 확인 53개 (2026-10-07)

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

도구분류설명
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) 로 좁힐 수 있고 — entity 필터는 그 법인의 세션에 법인 미지정 협업 세션(조직 공용, 예: 조직 간 짝 세션) 을 함께 싣는다(정확 일치였을 때 그런 세션은 어느 법인을 골라도 안 보였다. NULL 갈래는 collab 으로 한정 — chat·agent 세션은 법인 개념이 없다) —, 응답에 법인·서비스 슬러그와 Teams 방(collabChatId·collabChatUrl)이 함께 나온다. 모르는 법인 슬러그는 0행으로 거르지 않고 not_found 로 거절한다 — 보드에서 0행은 “아무도 시작 안 했다” 로 읽혀서 오타와 빈 보드가 같은 화면이 된다. 빈 값(entity="")도 무필터로 넓히지 않고 bad_request 다 — service·status 축과 같은 규율
list_issuesread이슈 목록 (필터 가능)
get_issueread이슈 상세
list_agentsread등록된 에이전트
list_skillsread등록된 스킬
search_peopleread멤버 검색 (이름·이메일)

테넌트 커머스 DB 조회 (orbit)

조직에 커머스(orbit) 읽기 전용 조회가 열려 있을 때만 쓸 수 있다. 열려 있지 않은 조직의 호출은 not_configured 로 거절된다. 조직마다 자기 DB 만 읽는다.

도구분류설명
orbit_sql_readonlywrite(실행 기록)자기 조직 커머스 운영 DB 에 SELECT·WITH 한 문장. 읽는 대상은 가림 뷰 ro_mall.* 뿐이다. 표·열 이름은 원 표와 같고, 접두사 없이 쓰면 ro_mall 로 간다. 손님 이름·전화·메일·주소·손님이 쓴 글은 가려지고, 자격·토큰은 빠진다. 인자 sql, max_rows(1~1000, 기본 200)
orbit_sql_runsread같은 조직의 최근 실행 기록 — 누가·언제·SQL·행 수·소요 시간·결과. 조회 결과 행은 저장하지 않는다. 인자 limit(1~200)
  • 실행 방식: 읽기 전용 트랜잭션 안에서 커서 하나로만 돌리고, 끝나면 늘 되돌린다. 조회마다 새 연결을 연다. 문장 15초·잠금 대기 3초·응답 1MB 상한이 있다. 여러 문장이나 쓰기가 섞인 WITH 는 DB 가 거절한다. advisory 잠금 함수와 U& 표기는 도구가 거절한다.
  • DB 계정: 가림 뷰만 읽는 전용 계정이다. 원 표 권한이 없고 기본값이 읽기 전용이다. 규칙에 없는 새 표·새 열은 뷰에 나오지 않는다.
  • 기록: 실행 전에 기록을 먼저 쓰고, 그 기록을 못 쓰면 실행하지 않는다. SQL 머리에 실행 번호 주석이 붙어 DB 감사 로그의 문장과 이어진다.
~/axe-cli/axe blueprint call orbit_sql_readonly --args '{"sql": "SELECT order_no, status, total FROM orders ORDER BY created_at DESC", "max_rows": 20}' ~/axe-cli/axe blueprint call orbit_sql_runs --args '{"limit": 20}'

캘린더

도구분류설명
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행씩 남는다 (호출자 · 대리발송 여부 · 수신자 수 · 제목 · 상태).

Outlook workflow — 고정 manifest와 재개

D-bp-mcp-mail-workflow-1 · 구현·모의 검증·원메일 운영 배포 완료, dot 실호출 미검증. 2026-10-07 릴리스 6e329a43의 app·MCP·substrate health, 원장/RLS·최소 권한과 공식 AXE transport의 53개 등록·새 메일 도구 7개를 확인했다. 같은 transport의 존재하지 않는 operation 조회는 Graph 호출 없이 operation_not_found를 반환했다. 실계정 Microsoft 신원·메일 조회와 이동은 실행하지 않았다. 설치된 로컬 CLI 성공을 원격 dot 연결 완료로 보지 않는다.

새 도구주요 입력동작
get_mail_identity없음서버가 연결된 Microsoft 계정·Entra tenant·본인 mailbox ID 확인
list_mail_messagesfolder_id?, search?, filter?, page_size, cursor?본인 폴더의 목록/검색(보낸 사람 포함)과 opaque 페이지 커서
get_mail_messagemessage_id본인 사서함의 메시지 1건 읽기 — 보낸 사람·받는 사람·참조·수신 시각·제목·본문(텍스트)·첨부 여부와 첨부 이름·크기(내용은 주지 않음). 읽기 전용
list_mail_foldersparent_folder_id?, page_size, cursor?본인 사서함의 폴더·정확한 ID 조회
prepare_mail_movemailbox_id, source_folder_id, destination_folder_id, message_ids[]각 ID의 원본 폴더·읽음 상태를 확인하고 고정 manifest와 승인 URL 생성; 이동 없음
get_mail_operationoperation_id, reconcile?기본 저장 상태 조회; reconcile=true는 Graph 읽기로 불명 결과 확인·원장 갱신 (MCP write)
execute_mail_moveoperation_id, manifest_digest동일 호출자·승인·범위·유효성을 검증하고 기존 작업 실행/재개
cancel_mail_operationoperation_id미실행 작업 중단; 이미 확인된 이동 결과는 유지

순서는 신원 → 폴더/메일 조회 → 정확한 ID 목록 준비 → 사람이 승인 URL에서 manifest 확인·승인 → 같은 operation/digest 실행 → status로 결과 확인이다. 준비 결과의 approvalUrl은 Blueprint의 로그인 세션을 요구하며 승인자와 작업 소유자의 동일성을 검증한다. CLI/MCP에는 승인 bool을 받는 문이 없다. 승인된 작업은 서버에 저장되어 다른 호출 세션에서도 동일한 작업으로 이어진다. manifest는 사서함·테넌트·원본/목적지 폴더·종류·건수· 정확한 메시지 ID와 준비 시 읽음 상태에 묶인다. 실행 시 검색을 다시 돌려 신규 메일을 넣지 않는다. 대상·digest가 바뀌거나 승인이 취소·거부·만료되면 실행을 거부한다. 준비 상한은 메시지 1,000개이며 manifest 기본 유효기간은 1시간이다. 사람 승인 유효기간은 승인 후 최대 30분이며 manifest 만료보다 길어질 수 없다. 목록 커서는 같은 호출자·tenant/mailbox·query·page size에 결속되고 30분 유효하므로 다음 페이지에도 같은 인자를 보내야 한다. 원본/목적지 폴더 또는 준비 시 메시지 상태가 바뀌면 작업을 halt하고 새 manifest와 승인이 필요하다. 읽음 상태를 강제로 덮어쓰지 않는다.

작업 원장 데이터 모델

원메일 릴리스의 Prisma 모델 **MailWorkflowOperation**은 작업 하나를 한 행으로 저장한다. approval·items·executions는 별도 DB 컬럼이 아니라 record JSON의 내용이다.

Prisma 필드저장·검증 목적
id준비 때 정한 operation ID. 조회·실행·재개가 같은 행을 사용한다
organizationId, ownerId현재 조직과 호출자 소유자를 고정한다. Organization·User FK는 onDelete: Restrict로 감사 참조를 보존한다
manifestDigest고정 manifest의 SHA-256 digest. JSON의 작업 digest와 사람 승인 범위를 대조한다
record (JSON)tenant·mailbox·원본/목적지 폴더·정확한 메시지 ID·읽음 상태·건수·종류·만료를 고정한 manifest, 승인 상태/승인자/digest/유효기간의 approval, 원본/새 ID·시도별 성공/실패/불명 결과의 items, 실행 ID/시각의 executions와 작업 상태를 저장한다
version기본 0인 CAS 버전. 읽었던 버전과 맞을 때만 갱신해 경쟁 저장을 거부한다
leaseToken, leaseExpiresAtnullable 실행 lease와 만료. 현재 lease에 결속한 갱신으로 동시 실행과 오래된 실행자의 기록을 제한한다. Graph I/O는 DB 트랜잭션 밖에서 수행한다
createdAt, updatedAt생성·갱신 시각. (organizationId, ownerId, createdAt) 인덱스로 소유자별 원장을 조회한다

Graph JSON batch의 하위 요청 상한은 20개다. HTTP batch 응답 전체 성공 여부와 별개로 각 하위 요청의 상태를 기록한다. execute_mail_move는 호출당 한 batch만 처리하며 미완료 작업은 같은 operation/digest를 다시 호출해 이어 간다. 성공·실패·결과 불명을 분리한다. 429는 확정 거부로 기록하고 Retry-After에 따른 dueAt 이후 같은 항목만 재시도하며 시도 상한은 5회다. 서버가 fetch 전에 요청을 보내지 않았음을 확인한 transport 예산 초과도 requestNotSent=true로 기록한 항목만 안전하게 재시도한다. 전송 여부가 불명인 예산 초과는 재이동하지 않는다. 5xx/timeout은 이동이 완료됐을 수 있으므로 결과 불명으로 유지하고 immutable ID로 목적지·읽음 상태를 대조한다. 원본 폴더에 아직 보인다고 재이동하지 않는다. 작업 재개와 대기 시간은 호출자가 관리하며 새 폴링 루프·데몬은 만들지 않는다. 원본 ID·이동 후 ID를 연결해 저장하며 목적지 폴더와 읽음 상태를 확인한다. 실행 세션/연결 중단 뒤에는 같은 operation의 결과를 조회하고 승인이 유효한 미완료 작업을 같은 digest로 이어 간다. 명시적 cancel이나 대상 변경으로 halted된 원작업은 재개하지 않는다. 현재 결과를 확인해 새 manifest와 승인을 준비해야 한다. 이미 검증된 성공을 다시 이동하지 않으며, 응답을 잃은 요청은 상태를 대조해 결과가 확인되기 전까지 성공/실패로 단정하지 않는다.

현재 범위는 연결된 본인 사서함이다. 이메일 별칭이 같은 mailbox/tenant의 증거는 아니다. 관리자 send_mail의 app-only send-as 권한을 메일 정리 권한으로 확대하지 않는다. 기존 Graph 연결과 서버의 토큰 갱신 계층만 사용한다. 연결 권한 부족을 새 OAuth 동의나 ACL 수정으로 자동 해결하지 않는다. 새 원장·RLS·최소 실행 ACL의 51줄 SQL은 2026-10-06 사용자가 운영 적용을 승인했다. 2026-10-07 원메일 공식 릴리스의 migration·운영 catalog로 적용을 확인했다. 이 문서 보완 자체는 운영 DB·권한을 변경하지 않는다. MailWorkflowOperation에는 RLS를 ENABLE·FORCE하고, org_only는 blueprint_app의 blueprint.org와 organizationId를, org_pinned는 blueprint_pinned의 public.pinned_org()와 organizationId를 USING·WITH CHECK로 결속한다. 두 실행 역할에는 SELECT·INSERT·UPDATE만 부여하고 기존 default privileges가 줄 수 있는 DELETE· TRUNCATE를 명시 회수한다. 존재하는 blueprint_boot의 원장 권한은 모두 회수한다. 승인한 51줄 SQL의 bytes는 유지했고 운영 적용 결과도 확인했다. 새 OpenAI OAuth의 두 nullable 컬럼·callback·세 활성화 값은 2026-10-08 별도로 승인받은 범위다.

원장은 완료·취소 뒤에도 manifest·소유자·digest·감사 참조를 보존한다. 계정 detach 절차는 원장의 owner FK와 JSON 감사 참조가 남으면 fail closed로 차단한다. 원장을 자동으로 다른 계정에 옮기거나 고쳐 쓰거나 삭제하지 않는다. 계정 분리·보존 기간·원장 수명 변경은 정확한 대상과 감사 보존 방법을 정해 별도로 검토해야 한다.

원격 공식 경로는 위 MCP 커넥터 URL https://axe.axelabs.ai/blueprint/mcp다. OAuth metadata https://axe.axelabs.ai/blueprint/.well-known/oauth-protected-resource/mcp의 resource는 같은 URL이다. 실제 dot 커넥터에서 OAuth → tool discovery → 신원 조회가 완료되어야 dot 연결 검증 완료다. raw GET의 edge/method 거부와 인증된 공식 MCP 클라이언트 접근은 별도로 측정한다. Blueprint가 저장하는 manifest 승인과 dot 플랫폼의 필수 승인도 별도다. 플랫폼이 요구하는 승인을 앱이 이전하거나 건너뛰는 API는 제공하지 않는다.

오류는 사용자 취소, 승인 거부, 인증 만료, 권한 부족, 호출 제한, 네트워크 오류, 결과 불명을 구분해 반환한다. 원인이 확인되지 않으면 reasonKnown=false와 원인 미확인을 표시하고 requestId·가능한 executionId·operation ID를 남긴다. 실행 timeout은 이동 실패의 증거가 아니므로 get_mail_operation으로 먼저 확인한다. reconcile=true(CLI status --reconcile)는 취소·승인 만료 뒤에도 immutable ID의 실제 폴더·읽음 상태를 조회해 결과와 새 ID를 기록한다. 메일을 이동하지 않으며 신규 이동의 승인 경계를 열지 않는다. 관측 원장 저장 때문에 MCP 쓰기 도구로 분류하고 dot 플랫폼의 필수 확인은 별도다. 모의 Graph/전용 테스트만으로 개발을 검증하며 실계정 이동·삭제·발송은 정확한 대상·동작에 대한 별도 승인이 필요하다.

Teams

도구분류설명
get_teams_messagereadTeams 메시지 딥링크 → 본문·발신자·시각 + 첨부·인용·인라인 이미지 ID. 읽기 규칙은 read_teams_chat 과 같다 — 관리자이거나, 링크가 가리키는 방이 자기가 참여한 협업 세션의 방이어야 하고 그 밖은 forbidden. 방 id 와 메시지 id 는 링크 경로(/l/message/<방>/<메시지>)에서 뽑아 가져오기 전에 판정한다. 관리자가 아니면 판정한 두 값으로 다시 만든 링크만 서버에 보내므로 읽히는 방이 곧 판정한 방이다. 경로에서 두 값을 뽑을 수 없는 링크(형식이 깨진 주소 포함)는 판정할 수 없어 관리자가 아니면 forbidden 이다
get_teams_hosted_contentread인라인 이미지를 이미지 블록으로 반환 (모델이 그대로 본다). chat_id 에 같은 읽기 규칙을 적용한다. 관리자가 아니면 chat_id 가 정확한 채팅 id 여야 하고, 메시지 id 와 인라인 이미지 id 는 각각 경로 한 마디(영숫자와 + = _ - 만 — / 나 .. 불가)여야 한다
get_teams_attachment_fileread공유 파일 첨부를 텍스트 또는 base64 로 가져오기 (기본 50 MB, 상한 100 MB). 첨부 주소(content_url)에는 방이 드러나지 않으므로 관리자가 아니면 message_url(그 첨부를 실은 메시지의 딥링크)이 필수다 — 그 링크가 get_teams_message 와 같은 규칙을 통과해야 하고 content_url 이 그 메시지의 첨부 중 하나여야 한다. 관리자는 생략할 수 있다
list_teams_chatsread최근 채팅 목록 — 채팅별 최근 발신자·미리보기. 옵션으로 제목·유형·멤버까지 보강. 관리자 전용
read_teams_chatread한 채팅의 히스토리 읽기 — 최근 N개(기본 30, 상한 200), 특정 시점 이후 슬라이스, 오래된 것부터. 관리자 전용이고 예외는 하나다: 협업 세션의 참여자(만든 사람 · 초대 명단에 있는 사람 · 그 세션에 게재한 적이 있는 사람)는 자기 세션의 방(get_session 이 주는 collabChatId)만 읽는다. 그 밖의 방, 그리고 채팅 id 를 뽑아낼 수 없는 입력은 종전대로 forbidden 이고, 호출자의 조직 축이나 사용자 id 가 없으면 참여 여부를 묻지 않고 거부한다(fail-closed). 방 id 는 시스템만 세션에 묶으므로 참여자가 다른 방을 세션에 걸어 이 예외를 넓힐 수 없다
send_teams_messagewrite봇 신원으로 채팅에 발송. 봇이 멤버인 채팅만. mentions(이메일 최대 10개)를 주면 본문 첫 줄에 실제 @멘션을 단다 — 같은 조직 사용자만. 관리자 전용
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 역할). 관리자 전용

봇 신원 발신은 조직 전체에 나가는 성격이라 관리자만 쓴다. 시도마다 감사 로그가 남고, 시험발송도 마찬가지로 남는다. 조회 쪽에서는 채팅 목록과 채팅 히스토리(list_teams_chats · read_teams_chat)가 조직을 가로질러 메시지 본문을 읽으므로 관리자 전용이다 — 협업 세션 참여자가 자기 세션 방을 읽는 예외 하나만 열려 있다. 세션 프롬프트가 “방에 실행할 요청이 있으면 묻지 말고 실행하라” 고 하는데 방을 못 읽으면 에이전트가 되묻기로 귀결되기 때문이다. 딥링크로 메시지·인라인 이미지·첨부를 푸는 세 도구도 같은 규칙을 따른다 — 관리자, 또는 자기 협업 세션의 방.

이연 발송

도구분류설명
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) 행과 철자가 같은 재등재도 새 객체로 발급된다 — 은퇴한 slug 는 주소가 아니라 역사다(contract 마이그레이션 20261016000000_registry_slug_exact_unique_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 로 후보 목록 반환(자동 생성 안 함)

데이터 커널 타입 여섯 개(core.legal_entity·core.objective·core.roadmap_item·core.task·core.achievement·core.recommendation, 온톨로지 v1.2)는 레지스트리에 등록할 수 없다 — 이 타입들의 신원과 링크는 레지스트리가 데이터 커널에 합류할 때까지 Blueprint 데이터 커널이 보유하므로(D-ops-157), registry_register_object 는 이 타입을 거부한다.

생명주기는 DB 물리 계층이 강제한다 — 활성 링크 보유 workspace 의 ARCHIVE/DELETE/신원(id·organizationId) 변경과 RegistryObject.type 변경은 trigger 가 거부하고, slug 유일성은 active 한정 CI partial unique(lower(slug)) 하나가 담보한다 — expand(20260827000000)가 그것을 더하고 구 writer 호환용 전-상태 exact 유니크를 남겼으며, contract(20261016000000)가 창 유입분 재정본화 → clean 단언(위반 시 목록과 함께 배포 정지, 자동 해소 없음) → exact 유니크를 조회용 비-유니크 인덱스로 교체했다.

워크스페이스 이름도 같은 형태로 제약이다 — (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). 전이는 앞으로 한 칸씩(active → done → archived)만 가고, 되돌림은 active 로만 간다(reopen) — archived → done 역주행과 active → archived 건너뛰기는 bad_request 로 거절된다(force 도 이 표는 넘지 못한다 — force 가 넘는 것은 미답 질문 게이트뿐이다). 거절문은 순서와 되돌림 명령 axe blueprint session reopen <id> 를 함께 준다 — 역주행이면 reopen 한 뒤 다시 한 칸씩 앞으로 가고, 건너뛰기면 먼저 done 으로 올린다(이미 active 인 세션에 reopen 은 아무것도 바꾸지 않는다). 같은 값으로의 갱신은 전이가 아니라서 멱등으로 통과한다. 서비스 재분류는 이름처럼 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). 짝 사본 게재물은 pairAuth·pairFromOrg 도 싣는다 — 아래 “짝 사본의 인증 갈래” (사본이 아니거나 기록 이전 사본은 null)
post_session_logwrite이번 대화 기록을 세션에 게재 — 본문(마크다운) · origin(어느 로컬에서 돌았는지) · 제목 · asks(이 게재가 상대에게 질문을 건다 — 답, 즉 다른 사람의 게재가 오기 전까지 update_session 의 done·archived 전이가 거절된다(force=True 로만 넘음). 질문을 던진 쪽이 스스로 닫아 상대의 답이 갈 곳을 없앤 것이 실사고였다, D-bp-collab-10) · refs(이 게재가 가리키는 티켓·백로그·결정·다른 게재물 — T-<slug> · B-<slug> · D-<slug> · post:<uuid>, 중복을 걷어 최대 20개. 한 교신 건이 티켓 본문·보드 게재·Teams 방에 갈라져 어느 것도 전부를 안 담던 것을 게재물 쪽에서 잇는다. 형식이 틀린 값은 게재 전에 거절하고, 형식은 맞지만 이 조직에 없거나 내려간 post: 는 게재를 막지 않고 응답의 refWarnings 로 알린다 — 오탈자가 조용한 유령 참조가 되는 것만 막는다. 서버는 티켓·백로그·결정 원장을 읽지 않으므로 T-/B-/D- 의 실재는 확인하지 않는다. get_session 이 게재물마다 refs 를 돌려주고, 짝 세션 복제도 그대로 나르되 사본의 멱등 판정(해시)에는 넣지 않는다. agent CLI 는 session post <id> --ref <id>(반복)로 싣고, session show 가 게재물마다 참조: 줄을 찍는다(피드 안의 게재물이면 그 번호를 옆에 단다), blueprint 다음 ship, 2026-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)을 낼 근거가 사라진다. 내려간 게재물은 미답 질문 판정에서도 질문·답 어느 쪽으로도 세지 않고(오게재한 질문이 done 을 영영 막지 않게), 붙어 있는 로컬에 지시로도 요청으로도 전달되지 않는다. CLI 는 ~/axe-cli/axe blueprint session post-delete <id> <post_id> (blueprint 다음 ship, 2026-10)
dismiss_sessionwrite다 본 세션을 내 목록에서만 치운다(되돌리기 포함). done 인 세션에만 허용되고, 관여자 전원이 치우면 그 세션은 자동으로 archived 가 된다

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

내리기는 웹에도 같은 문으로 있다 (blueprint 다음 ship, 2026-10) — 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() 가 실제로 만든 파서 트리를 걷고(진입 파서 → blueprint → session → 각 서브커맨드), 그 서브커맨드가 무엇을 부르는지는 번들을 돌려서 기록한다. 도는 동안 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/** 어느 화면에서든 셸의 세션 패널 위젯에 현재 법인의 협업 세션 목록이 뜬다. 목록 검색과 새 세션 만들기도 여기서 한다(D-ops-122 T2 — 예전 우측 레일 “세션” 섹션을 대체한다). 세션을 누르면 가운데(하단 프롬프트가 캔버스 전체로 펼쳐진 세션 화면)에서 열린다(D-bp-collab-12). 사람 지시는 >, 에이전트 게재는 ⏺, 받음·진행은 ⎿ 줄로 Claude Code 대화처럼 쌓이고, 위 줄에 붙어 있는 로컬 에이전트와 그 상태(대기 중·작업 중)가 보인다. 입력칸에 친 글은 그 세션에 web 출처로 게재되고, ? 로 시작하면 세션 기록을 맥락으로 AXE AI 에게 묻는다(세션엔 안 올라감). 목록 행에도 붙어 있는 로컬이 초록 점으로 표시된다. 실시간으로 붙지 않고 show·post 로만 일하는 로컬은 최근 6시간 안의 게재(출처 claude-code@·codex@·cli@)로 <출처> · N분 전 게재 · 실시간 아님 으로 보인다 — “연결된 로컬 없음” 은 붙기도 최근 로컬 게재도 없을 때만이다.
  2. 프롬프트를 복사해 로컬 에이전트에 붙여넣고 엔터 — 가운데 세션 화면 위 줄의 “프롬프트 복사”(또는 Sessions 상세의 같은 버튼). 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@…). 세션을 클릭하면 가운데 화면에 게재물이 시간순 채팅형 피드로 쌓인다 — 누가 · 어느 로컬에서 · 언제 한 작업인지가 게재물마다 붙는다. 3-1. 웹 지시를 실시간으로 받는다 — 붙기(attach) (D-bp-collab-12) — 에이전트는 session show 뒤에 ~/axe-cli/axe blueprint session attach <id> --wait --take-over 를 백그라운드로 돌려 둔다(출처는 CLI 가 대화별로 정한다 — claude-code@<호스트>#<대화 다이제스트>, Codex 는 codex@<호스트>#<스레드 다이제스트> — 같은 호스트의 두 대화가 수신 자리를 두고 부딪히지 않게). 웹에서 지시나 참여자 요청이 올라오면 이 명령이 그것을 출력하고 끝나 에이전트를 깨우고, 에이전트는 처리 → session post → 다시 붙기(--take-over 없이)를 반복한다. Codex 는 사람이 다른 터미널에서 attach <id> --codex-thread <Codex 스레드 id> --take-over 을 띄워 두면 지시가 codex queue 로 실행 중인 Codex 스레드에 들어간다(스레드 id 는 Codex 안에서 session show 를 실행하면 Codex 붙기 줄에 채워져 나온다). 웹 게재는 지시와 요청으로 갈린다 (D-bp-collab-15) — 협업 세션에는 여러 참여자가 있고 각자 자기 로컬(Claude Code·Codex)을 자기 Blueprint 로그인(로컬 CLI 의 토큰 사용자)에 연동한다. 지시 = 내가 웹에 쓴 글(출처 web — 같은 계정으로 웹·로컬을 써도 갈린다)이다. 내 계정에 연동된 내 로컬이 받아 실행한다. 요청 = 다른 참여자가 웹에 쓴 글이다. 붙어 있는 로컬도 깨어나 읽지만 실행 지시는 아니다 — 에이전트는 읽고 판단해 session post 로 답하거나, 운영자 결정이 필요한 것은 운영자에게 올린다. 짝 세션의 복제 게재(작성자 계정 없음)와 봇 게재는 지시도 요청도 아니다 — 단 짝 조직이 --asks 로 건 게재의 사본은 요청이다(3-7). 붙기는 요청을 [참여자 요청 N건 — <세션>] 머리 아래 지시([웹 지시 N건 — <세션>])와 따로 출력한다(agent CLI 0.1.89+ — 그 이전 CLI 는 내 지시만 받는다). 같은 사람이 한 세션에 로컬을 둘 이상 연동하면 마지막에 연동한 하나만 받고, 앞의 것은 ‘넘겨받음’(attach 종료 코드 3)으로 끊겨 다시 붙지 않는다. “연동” 은 복사 프롬프트의 첫 session attach <id> --wait --take-over 다(takeover=1). 지시를 처리·게재한 뒤 다시 붙을 때는 --take-over 를 빼며, 그때 같은 계정의 다른 로컬이 이미 수신자면 거절(종료 코드 3)되어 뒤늦게 다시 붙은 옛 로컬이 수신자 자리를 빼앗지 않는다. 수신자 자리는 계정마다 따로라 출처 이름(claude-code@<호스트>)이 같은 다른 사람의 로컬과 부딪히지 않는다. 보드 훅은 내 계정의 로컬이 대기·작업 중이면 전달하지 않고, session show 는 내 게재에 · 나, 다른 참여자의 web 게재에 · 참여자 요청(지시 아님 — 읽고 답한다) 을 붙인다. 내 로컬이 연동돼 있지 않으면 게재만 되고, 화면이 “프롬프트 복사” 로 연동하라고 안내한다. 붙은 로컬이 없을 때 올린 지시는 쌓였다가 다음에 붙는 에이전트가 거기서부터 받는다. 이미 돌고 있는 로컬 세션(붙기 전 프롬프트로 시작한 세션 포함)은 보드 훅(~/axe-cli/axe blueprint board hook install — SessionStart·UserPromptSubmit·PostToolUse·Stop)이 받는다: 훅이 그 Claude 세션이 맡은 협업 세션(붙여넣은 핸드셰이크 머리 · session post/attach)을 대화 기록에서 찾아, 아직 처리하지 않은 web 지시와 참여자 요청을 다음 도구 사용·프롬프트 때 맥락에 넣고 턴이 끝나려 하면 멈추지 않게 돌려 세운다. 붙어 있지 않으면 그때 attach 를 띄우게 해 이후는 실시간이 된다. 넣은 지시·요청은 attach 와 같은 로컬 원장에 기록해 두 번 전달되지 않는다. 데몬·주기 실행은 없다 — 붙기는 사람이 연 로컬 세션이 살아 있는 동안만이다(D-ops-103 개정). 3-2. 공용 호스트에서 SSH 로 연 대화 = 그 사람 계정의 로컬 (D-bp-collab-13) — 여러 사람이 SSH 로 들어와 쓰는 공용 작업소는 호스트 CLI 가 한 계정(호스트 계정)으로 로그인돼 있다. 사람 A 가 자기 노트북에서 SSH 로 그 호스트에 Claude Code 대화를 열면, 그 대화는 예전에는 호스트 계정으로 동작해 A 가 웹에 쓴 지시가 그 대화로 가지 않았다(지시는 계정 기준이다). 이제 “프롬프트 복사” 가 복사한 사람의 계정 키(로그인 주체의 해시 — 비밀이 아니다)를 머리줄(· 계정 <키>)과 attach 줄(--account <키>)에 싣는다. 대화의 CLI 계정이 그 키와 다르면 session attach 는 수신 자리·실시간 연결에 닿기 전에 멈추고, 연동 링크와 확인 코드를 출력한 뒤 종료 코드 5로 끝난다. 에이전트는 그 링크를 사람에게 보여 주고 같은 attach 를 다시 백그라운드로 띄운다. 사람이 노트북 브라우저에서 링크를 열어 [연동] 한 번을 누르면(로그인이 안 돼 있으면 Microsoft 로그인을 먼저 거친다), 그 대화 전용 자격(최대 7일)이 생기고 기다리던 attach 가 그 사람 계정으로 붙는다 — 웹 화면은 ‘내 로컬 실시간’ 이 된다. 이후 그 대화의 모든 ~/axe-cli/axe 호출과 보드 훅이 그 자격을 쓰고, 같은 호스트의 다른 대화와 호스트 계정은 그대로다. 대화가 끝나면(Claude Code 의 SessionEnd) 보드 훅이 그 자격을 폐기하고, 다시 열면 첫 attach 에서 1클릭으로 재연동한다. 거절·만료면 종료 코드 6이고 호스트 계정으로 몰래 붙지 않는다. 같은 OS 사용자를 함께 쓰는 호스트에서는 대화 자격 파일을 동료에게서 숨길 수 없다 — 이 장치는 신원 혼선과 수신 자리 탈취를 없애는 것이고, 근본 격리는 사람별 OS 계정이다. 보드 훅은 대화가 맡은 협업 세션의 계정 키가 지금 계정과 다르면 그 계정의 지시를 그 대화에 넣지 않고, 연동 명령을 안내한다. Codex 스레드도 스레드마다 자기 자격을 갖는다(agent CLI 0.1.84+, D-bp-collab-14) — 사람이 터미널에서 attach <id> --codex-thread <스레드 id(UUID)> --account <계정 키> 로 붙으면 계정이 다를 때 그 스레드를 연동하는 링크가 나오고(종료 코드 5), [연동] 뒤 같은 명령을 다시 실행하면 그 계정으로 붙는다. 스레드 안에서 login --conversation --account <계정 키> 로 연동해도 된다. 붙기와 스레드는 같은 자격을 쓰고, 스레드 이름으로는 연동할 수 없다. 연동된 대화·스레드는 웹 설정의 ‘연동된 대화’ 카드에서 보고 해제한다. 3-3. 수신 경계 보강 (D-bp-collab-13 같은 배포) — 출처 web 은 사람이 웹 화면에서 쓴 게재 전용으로 예약된다: 쿠키 로그인 화면의 게재는 본문 값과 관계없이 서버가 web 으로 찍고, MCP post_session_log·짝 복제 등 화면 밖 경로에서 온 web(대소문자·공백 무관)은 거절한다. 짝 세션으로 복제되는 사람 지시는 pair-web@<보낸 조직> 출처로 저장된다(작성자 계정 없음 — 지시도 요청도 아니다. --asks 사본만 요청이다 — 3-7). 쿠키 인증 세션 쓰기(게재·이름/상태 변경·삭제·치움)는 같은 출처 요청만 받는다(Sec-Fetch-Site: same-origin, JSON 본문). ‘작업 중’ 으로 남은 수신 자리의 연결이 끊긴 채 15분이 지나면 같은 계정의 다른 로컬이 연동 없이 그 자리를 이어받는다(살아 있는 연결은 계속 보호된다). 화면의 작업 중 표시는 작업 중 · N분 전 받음 으로 받은 시각을 보이고, 로컬 게재 머리에는 작성자 · 출처 가 붙는다. heartbeat 시각만 바뀐 경우에는 실시간 알림을 보내지 않는다(15초 + 지터 대체 갱신은 유지). 3-4. 붙기는 엣지의 일시 오류에 죽지 않는다 (agent CLI 0.1.89+, D-bp-collab-15) — 엣지 터널은 하루에도 몇 번 연결을 다시 맺고, 그 십수 초 사이에 스트림을 열면 HTTP 502·530 같은 상태가 돌아온다. session attach --wait 는 스트림 열기와 첫 세션 조회에서 HTTP 408·429·502·503·504·520~527·530 을 일시 오류로 보고, 같은 받은 기록 위치(커서·본 목록 — 받은 것을 다시 받지도, 끊긴 사이 올라온 것을 놓치지도 않는다)에서 1→2→4…→30초 백오프로 다시 붙는다. 기다림의 상한은 --max-wait 하나다. stderr 에는 첫 실패(붙기 일시 오류: HTTP 502 — …), 연속 10회마다, 복구(다시 붙었습니다 (…)) 때 한 줄씩만 낸다. 서버의 hello 를 받으면 백오프가 1초로 돌아간다. --take-over 는 실제 hello 를 받은 뒤에야 소비되므로, 첫 연결이 502 로 실패해도 재시도가 넘겨받기를 그대로 보낸다. 401 은 지금처럼 조용히 갱신해 다시 붙고, 404·400·500·그 밖의 4xx 는 즉시 끝난다(진짜 서버 결함은 드러나야 한다). 세션이 종료돼(archived) 서버가 연결을 닫으면 “세션이 종료되어 붙기를 끝냅니다” 를 내고 종료 코드 0, 넘겨받음(replaced)은 종료 코드 3 이다. 0.1.88 이하 CLI 는 HTTP 오류 하나에 끝나므로 ~/axe-cli/axe self-update 로 올린다. 3-5. 참여자가 로컬 AI 로 올린 요청도 붙어 있는 로컬을 깨운다 — --asks 게재만 (agent CLI 0.1.92+, D-bp-collab-16) — 참여자는 웹뿐 아니라 자기 Claude Code·Codex 로도 보드에 요청을 올린다. 붙기는 그런 게재 중 다른 사람 참여자가 session post … --asks 로 올린 것(답이나 실행을 기다리는 게재)을 참여자 요청으로 받는다 — 웹 요청과 같은 표시(참여자 요청 — 지시 아님)·같은 받은 기록이다. --asks 없는 보고·답·노트는 깨우지 않는다: 양쪽 AI 가 다 붙어 있으면 서로의 보고로 끝없이 깨우기 때문이다. 그래서 다른 참여자(운영자 등)에게 무언가를 부탁하는 게재에는 --asks 를 붙인다(“프롬프트 복사” 전문과 에이전트 지침이 이것을 가르친다). 내 계정의 다른 로컬이 올린 asks, 봇·AI 계정의 asks 는 요청이 아니다(짝 세션 사본은 3-7). 붙기는 asks=1 을 함께 보낸 CLI 에만 이 게재를 싣고, 옛 CLI 는 지금처럼 웹 게재만 받는다. 세션 조회(session show·get_session)는 게재마다 작성자가 사람인지(authorHuman)를 실어, 프롬프트 훅이 붙기와 같은 집합을 고른다. 3-6. 서비스 화면·코드 변경 요청은 운영자가 아니라 그 저장소 PR 로 (D-layer-78) — 세션 방의 Teams 봇은 AI 커미티에 편입된 저장소(index·hive·frame·cortex·blueprint·layer)의 화면·기능·코드 변경 요청을 “운영자 작업”으로 접수하지 않는다. 대신 요청자에게 자기 AI 로 그 저장소에 PR 을 내라고 안내한다 — 커미티가 심의하고 문제가 없으면 승인·반영·배포까지 한다. “프롬프트 복사” 전문과 에이전트 지침도 같은 규칙을 말한다. 쓰기 권한이 없을 때만 권한을 요청한다. 3-7. 짝 조직이 --asks 로 건 요청도 붙어 있는 로컬을 깨운다 (agent CLI 0.1.97+, D-bp-collab-18) — 두 조직이 짝 세션으로 일하면(각자 자기 세션에 게재하고 상대 게재는 사본으로 건너온다) 상대 조직의 게재는 언제나 작성자 계정이 없는 사본이다. 그래서 3-5 의 “다른 사람 참여자” 판정에 걸리지 않아, 짝 조직의 AI 가 --asks 로 요청해도 붙어 있는 로컬이 깨지 않았다. 이제 짝 조직에서 건너온 asks 사본은 참여자 요청이다 — 웹 요청·로컬 asks 요청과 같은 표시(참여자 요청 — 지시 아님)·같은 받은 기록·같은 24시간 재생 바닥이다. 붙기 출력·프롬프트 훅·session show 는 작성자 라벨(이름 (조직))에 더해 짝 조직 을 단다. asks 없는 사본(보고·답)은 지금처럼 깨우지 않는다. 붙기는 pair=1 을 함께 보낸 CLI 에만 이 사본을 싣고(옛 CLI 는 그대로), 세션 조회(session show·get_session)는 게재마다 짝 사본인지(pairPeer)를 실어 프롬프트 훅이 붙기와 같은 집합을 고른다. 보낸 조직을 그 조직의 키가 증명한 사본만 짝 조직 요청으로 친다 — 배포의 공유 키로 들어온 사본은 그 키 주인(배포 조직)을 주장할 때만이다. 자동 배포 요청 블록이 든 요청에는 “실행기가 처리 — 직접 배포하지 않는다” 를 단다. 받는 보드는 5초 넘게 늦게 도착한 사본을 도착 시각으로 세운다 — 읽는 쪽의 받은 위치가 게재 시각 순서라, 늦은 사본이 원본 시각으로 서면 이미 지나간 자리에 끼어 아무도 받지 못한다(자동 배포 요청 사본은 실행기의 시각 판정 때문에 원본 시각을 지킨다). 5분 넘게 미래인 원본은 받지 않는다. 자동 배포 실행기가 단 답(출처 axe-autodeploy@…)은 그 계정의 글이어도 ‘답했다’ 로 치지 않는다(agent CLI 0.1.98+) — 받은 기록 없이 처음 붙을 때의 기준선·종료 판정의 열린 요청·미답 질문에서 그보다 먼저 온 요청을 접지 않고, 실행기 글은 자기가 post: 참조로 가리킨 배포 요청 하나만 처리한 것으로 친다. 실행기 글은 결과 보고라 요청으로 세지 않는다 — 짝 조직 보드에 건너간 사본도(서버가 보낸 조직을 증명한 것만, 출처 라벨만으로는 아니다) 그쪽 종료 판정의 열린 요청이 아니다. 실행기가 참조로 받은 배포 요청은 붙기·훅이 요청으로 다시 전하지 않는다. 짝 사본을 가리키는 post: 참조는 상대 보드로 보낼 때 그쪽 원본 id 로 옮긴다 — 실행기 결과 글이 상대 보드에서도 그 배포 요청을 가리킨다. 실행기 게재는 그 호스트의 수신함 ‘본 수’ 기준선도 옮기지 않는다.
  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 로 내려가 모두의 목록에서 사라진다. 5-1. 요청자는 보드에서 끝을 선언할 수 있다 — ‘종료 선언’ 버튼 (D-bp-collab-5 Web 채널, 2026-10-16) — 세션 화면 위 줄의 “종료 선언” 을 누르고 확인 대화상자에서 한 번 더 누르면 보드에 종료 선언 (<내 이름>, Web) note 가 남는다. 에이전트의 종료 판정(session show 의 종료 판정 줄)은 이 note 를 Teams 방에서 한 선언과 같은 요청자 종료 선언으로 읽는다. 방을 나간 요청자도 이 버튼으로 선언할 수 있다. 선언은 세션 상태를 바꾸지 않는다 — 상태는 생성자·admin 이 “완료로 표시” 로 따로 바꾼다. 출처는 blueprint-web@board 로 남아 지시 출처 web 과 섞이지 않는다. 두 선언 출처(blueprint-bot@teams · blueprint-web@board)는 서버의 선언 경로만 쓰고, MCP post_session_log · 내부 게재 · 짝 복제는 그 출처를 거절한다(대소문자·공백 무관). 연속 클릭·중복 가드도 제목이 아니라 출처와 종류(note)로 선언을 가린다 — 제목만 흉내 낸 게재가 사람의 선언을 막지 못하게 하려는 것이다. 게재는 추가만 되므로 남긴 선언은 지울 수 없고, 확인 대화상자가 그 점을 먼저 알린다. 버튼은 사람 계정에만 보인다 — 에이전트 멤버와 AI 신원(사람이 소유한 봇 계정, 대리 보기 포함)은 서버가 403 으로 막고, 화면은 서버가 내려 준 같은 판정으로 버튼을 가린다. 최신 게재가 이미 종료 선언이면 새로 쓰지 않고(연속 클릭), 보관된 세션에는 남길 수 없다(먼저 재개).

상태는 active → done → archived 세 단계이고, done 이 아카이빙 승인 게이트다 — 아직 도는 세션은 치울 수 없다(정리가 아니라 숨기는 것이라서). 이 순서는 전이표로 강제된다 — 앞으로 한 칸, 어디서든 active 로(= session reopen <id>, 모인 치움도 초기화), 제자리(멱등)만 열려 있다. MCP update_session(bad_request)과 웹 PATCH /api/sessions/{id}(409)가 같은 표를 쓰고, 권한 판정 뒤·미답 질문 게이트 앞에서 판정한다. 되돌릴 때 archived → done 으로 바로 가면 보드에서 내려간 세션이 다시 서고, 다시 내리려면 관여자 전원이 또 치워야 한다 — 그래서 되살림은 active 한 곳으로 모은다. 봇 원장 세션은 종전대로 active ↔ archived 를 오간다. 상태 전이(세 방향 전부)는 생성자·admin 전용이고, 치움은 조직 구성원 누구나 자기 뷰에서 한다. 전역 archived 를 한 사람이 한 번에 누르지 않는 이유도 같다: 그 한 번은 결과를 아직 못 본 참여자의 목록에서도 세션을 지운다. 관여자 = 만든 사람 · 참여자 · 게재자이고, 계정 없이 도는 에이전트 멤버는 치울 수단이 없어 이 집합에서 빠진다(D-bp-collab-2). 전이표 판정은 잠근 행의 status 로 한다. 잠그기 전에 읽은 값만 보면, 그 사이 마지막 사람의 치움이 세션을 자동 보관(archived)했을 때 반복된 done 요청이 낡은 done 을 보고 통과해 archived → done 으로 되돌린다. 그래서 done·archived 로 가는 전이는 웹 PATCH(협업 세션)와 update_session(force 여도 — force 가 넘는 것은 아래 미답 질문 게이트뿐이다) 모두 세션 행을 FOR UPDATE 로 다시 읽고 그 값으로 전이표를 한 번 더 잰다. 거절 응답은 잠금 전 판정과 같다(409 · bad_request). 치움 경로도 같은 행을 FOR UPDATE 로 잡으므로 잠근 뒤 읽은 값은 자동 보관이 커밋된 뒤의 상태다. active 로 되살리는 요청은 어느 상태에서든 허용이라 잠그지 않는다. Teams 방의 종료 선언을 읽어 세션을 닫는 스윕은 status='active' 를 조건에 실은 compare-and-set 으로만 세션 행을 쓴다(done 전이든 레일 순서용 시각 갱신이든) — 0행이면 스캔 뒤 다른 경로가 먼저 세션을 옮긴 것이므로 종료 note 도 방 미러도 만들지 않고 건너뛰며, 감사 로그에 건너뛴 사실만 한 줄 남긴다.

미답 질문 게이트는 상태 전이의 세 입구 모두에 선다 (D-bp-collab-10, 2026-09 착지). 입구는 MCP update_session · CLI session done · 웹 PATCH /api/sessions/{id} 셋인데 웹만 비어 있어, 브라우저에서는 미답 질문이 몇 건이든 세션이 조용히 닫혔다. 이제 웹도 협업 세션을 done·archived 로 옮기는 요청을 권한 판정·값 검사 뒤에 막는다(닫을 수 없는 사람에게 무엇이 열려 있는지 먼저 알릴 이유가 없다) — 미답 질문이 있으면 409 open_asks 를 MCP 봉투와 같은 필드(openAsks 건수 · 가장 오래된 질문 oldest)와 함께 돌려준다. active 로 되살리는 요청은 게이트를 거치지 않는다. 웹에는 우회 플래그가 없다 — 의도적 강행은 보드에 ‘완결 강행’ note 를 남기는 CLI session done <id> --force 한 곳으로 모으고, 거절 메시지가 그 명령을 안내한다. 세기와 전이는 한 트랜잭션이다 — update_session(force 없이)과 웹 PATCH 는 done·archived 로 옮길 때 세션 행을 먼저 잠그고(FOR UPDATE) 미답 질문을 센 뒤 같은 트랜잭션에서 전이한다(두 입구가 같은 정의·같은 잠금). 잠그지 않으면 아직 커밋되지 않은 질문 게재를 못 본 채 0 을 세고 닫는다. 게재 쪽은 따로 잠그지 않는다: 게재가 세션 행에 거는 키 공유 잠금이 이 잠금과 부딪혀, 먼저 들어온 게재는 커밋까지 기다렸다가 세어지고 잠금 뒤의 게재는 닫힌 세션에 선다. 완결 세션에 게재하는 것은 원래 허용이므로(방에 “완결 세션에 작업 기록이 추가됐다” 는 통지가 서고, 이어서 할 일이면 보드에서 재개한다) 이 게이트가 막는 것은 닫는 순간까지 도착한 질문이다 — 닫힌 뒤의 질문은 세션을 되살릴지 사람이 정한다. ‘미답’ 의 판정 SQL 은 MCP 모듈의 정본 한 문장이고, 웹 라우트와 Teams 방 블록의 미답 수는 그 문장의 바이트 사본을 돌린다. 빌드의 테스트가 두 문자열의 일치를 대조하므로 방과 session show 가 서로 다른 수를 말하지 않는다.

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

세션은 서비스별로도 갈린다 (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), 이벤트는 기존 DeferredTask 에 session:<id> 센티널로 큐잉돼 기존 1분 sweep 이 배송한다(dm:<email> 과 같은 궤도, 새 폴링 0). 방은 첫 이벤트가 발송될 때 sweep 이 lazy 로 연다(advisory lock + CAS — 경합해도 세션당 방은 하나, 고아 방은 TeamsSendLog 에 남는다) 그래서 세션 생성 직후 몇 초 안의 게재도 유실되지 않는다. 큐잉은 배포 조직의 세션만(봇이 단일 테넌트 신원이라 남의 조직 세션명을 이 테넌트 방에 싣지 않는다) — 웹과 MCP 가 같은 배포 축으로 판정한다: MCP 컨테이너도 앱과 같은 줄로 고객 식별자를 받는다(B-bp-mcp-deployment-axis-env). 그 전에는 MCP 가 “운영자 계정이 있는 조직” 을 배포 조직의 대리로 썼고, 운영자 계정이 배포 조직이 아닌 곳에 있으면 sweep(배포 조직만 본다)이 영영 안 집는 큐 행이 생길 수 있었다. 운영자 초대는 그 조직에 운영자 계정이 있을 때만. 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 와 같은 급 — 이 세션의 기록이 조직 밖으로 나간다는 결정이라 보드의 공용 어휘가 아니다), 그 서브커맨드가 없던 동안 짝의 두 번째 절반을 걸 길이 사실상 없었다.

짝 사본의 인증 갈래 (2026-10-27). 사본 수신 라우트(POST /api/internal/collab-sessions/pair-posts)는 두 키를 받는다 — 보낸 조직 자신의 키(짝 키: 키가 보낸 조직을 증명하고, 본문이 다른 조직을 주장하면 403)와 이 배포의 공유 서비스 키(보낸 조직은 본문의 자기신고이고, 세션이 선언한 짝과의 대조만 거친다). 어느 쪽으로 들어왔는지가 사본 행에 남는다: SessionPost.pairAuth(pair-key | service-key) · pairFromOrg. MCP get_session(곧 agent CLI session show --json)과 내부 GET /api/internal/collab-sessions/<id> 가 게재물마다 이 둘을 싣는다. 기록 이전 사본과 사본이 아닌 게재물은 null 이다 — 어느 키로 왔는지 모르는 것이 사실이다. 사본을 근거로 무엇을 실행하는 쪽은 pair-key 만 믿어야 한다: 공유 서비스 키는 형제 서비스들이 함께 갖고 있어 보낸 조직을 증명하지 못한다. 첫 소비자는 운영 호스트의 자사몰 배포 요청 실행기(axe orbit-deploy, D-ops-155)다 — 아래 “서명 연결 전용 이벤트”.

그 방은 찾을 수 있어야 하고 초대할 수 있어야 한다 (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_chat 은 409 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).

  • GET — org(조직 슬러그) · entity(법인 슬러그, 생략 가능 — 값이 오면 그 법인의 세션 + 법인 미지정 세션(조직 공용). 웹 보드 GET /api/sessions?mode=collab&entity=… 와 같은 술어다) · service(서비스 슬러그, 정확 일치 — NULL 미포함) · status(active 기본 = 구 소비자와 바이트 호환 | open = active+done, 레일 도트가 완료 행을 보여야 해서 소비자가 명시적으로 넓힌다 | all) · limit 로 좁혀 최근 갱신 순 세션 요약(이름 · 법인 · 서비스 · 상태 · 게재 수 · 마지막 게재 시각·출처)을 돌려준다. entity 와 org 는 부재와 공백을 가른다. 부재는 종전 계약 그대로 — 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 이다. kind 는 transcript 고정(보내면 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 을 한 줄로 접으므로 둘이 갈릴 수 있고, 나중에 이 행을 읽는 사람에게 필요한 것은 방에 실제로 붙은 이름이다). 축을 못 구한 호출자는 403 caller_unknown 이다 — 감사 원장의 축은 배포 축으로 메우지 않으므로(남의 테넌트에 섞인 한 줄이 그 원장을 통째로 못 믿게 만든다) 축 없는 개명은 원장 없는 개명이 되고, 그래서 성공이 아니라 거절이다.

문이 둘이다 — 테넌트별 자격과 공유 비밀 (B-bp-rename-chat-per-org-credential, 2026-10). 이 라우트와 형제 셋(send·create-chat·remove-member)은 공유 내부 비밀 하나가 문이었고 조직 축은 본문의 callerEmail 이 골랐다 — 축이 주장이라, 비밀을 든 쪽이 그 조직 구성원 이메일 하나만 알면 그 조직의 이름으로 원장이 섰다. 이제 같은 넷이 Graph 토큰 vending(아래)과 같은 테넌트별 자격(BotApiCredential, D-ops-100)도 받는다. 자격 문에서 축은 자격 행의 조직이고 callerEmail 은 귀속(누가 시켰나)으로 내려가되, 그 조직 사람이 아니면 403 caller_not_in_credential_org 다(남의 조직 사람과 없는 사람이 같은 답). 봇의 Graph 자격은 테넌트를 가리지 않으므로 자격 문은 자기 조직 안에서만 움직인다 — 방을 건드리는 셋은 방이 그 조직에 묶여 있어야 하고(협업 세션 방 또는 워크스페이스 결속 방, 아니면 404 chat_not_found), 방을 여는 create-chat 은 넣을 사람이 전부 그 조직 사람이어야 한다(아니면 403 member_not_in_org). 공유 비밀 문은 종전 그대로다(오늘의 호출자 — 공유 MCP 의 send_teams_message·create_teams_chat 과 그것을 감싼 두 CLI, 운영자의 직접 호출 — 가 전부 그 문이다). 옛 문은 호출자 전환과 테넌트별 비밀 발급이 끝난 뒤 env BLUEPRINT_TEAMS_ORG_CREDENTIAL_ONLY=1 로 닫는다 — 켜면 공유 비밀은 403 org_credential_required, 자격 문은 그대로다. 원장 TeamsSendLog 의 각 행은 이제 인증한 자격의 id(credentialId, 공유 비밀 문이면 비어 있음)를 함께 싣는다. 그래서 자격 문으로 연 방은 그 개설 성공 행이 곧 그 조직의 결속이 되어 같은 조직의 자격으로 이어서 쓸 수 있고, 공유 비밀 문이 쓴 행은 축이 주장이라 결속 근거가 되지 못한다.

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

내부 API — 봇 Graph 구독 생존

GET /api/internal/teams/subscription-health (blueprint 다음 ship, 2026-10) — Teams 봇의 Graph 변경 알림 구독이 살아 있는지를 기계가 묻는 읽기 전용 창구다. 구독이 끝나면 봇 컨테이너는 정상으로 떠 있어도 웹훅이 아예 오지 않는다. 이것은 봇 부팅 실패와는 다른 고장이다. 같은 정보를 보여 주는 화면용 GET /api/teams/setup 은 admin 세션 전용이라 서비스 키로 열 수 없었고, 그래서 합성 스모크가 이 고장을 감지하지 못했다(B-ops-smoke-bot-subscription-liveness).

  • 인증 — 내부 Bearer 키(공유 서비스 키)만 받는다. 신규 비밀은 없다. 사람별 플랫폼 토큰은 받지 않는다. 축이 배포 조직이므로, 다른 테넌트의 사람 토큰으로 이 배포의 봇 상태를 읽을 수 있게 하면 안 되기 때문이다.
  • 축 — 배포 조직 하나. 묻는 내용이 “이 배포의 봇 구독이 살아 있나” 이고, 구독을 갱신하는 6시간 주기 sweep 과 같은 축을 쓴다.
  • 판정 — 라우팅 에이전트마다 활성 구독 중 가장 늦게 끝나는 것을 기준으로 ok · stale(잔여 12시간 미만) · expired · missing 로 가른다. sweep 은 24시간 이내에 만료되는 구독을 48시간으로 연장하므로, sweep 이 정상이면 잔여는 18시간 밑으로 내려가지 않는다. 12시간 미만이면 갱신이 두 번 이상 연속 실패했다는 뜻이다. 에이전트가 없는 옛 단일 봇 행은 세지 않는다. 전체 status 는 한 에이전트라도 missing·expired 이거나 라우팅 에이전트가 0이면 down, stale 이 있으면 degraded, 나머지는 ok 다.
  • 응답 — { status, agents: [{ email, state, expiresAt }], checkedAt, staleBelowHours }. 구독 id·clientState·resource 는 싣지 않는다. 나쁜 상태도 정상적인 답이므로 HTTP 는 200 으로 고정한다. 5xx 로 내리면 호출자는 본문 없이 상태 코드만 받게 된다.

내부 API — 개인 todo · 승격 (blueprint#436)

개인(private) 할 일 PersonalTodoItem 은 웹 세션 전용이었는데, agent CLI(axe blueprint todo … --private)가 쓰도록 내부 Bearer 라우트가 열렸다. 신규 비밀은 없다.

라우트동작
GET · POST /api/internal/axe-todo/personal내 개인 할 일 목록 · 생성
GET · PATCH /api/internal/axe-todo/personal/[id]상세 조회 · 수정
POST /api/internal/axe-todo/personal/[id]/promote개인 → 공유 승격. { entitySlug, assignee? }
GET /api/internal/ops/todos/[id] · PATCH …/[id]/meta공유 할 일 상세 · 메모·테마·기한·파일 링크 수정 (todo show · todo edit)
  • 개인 할 일 라우트는 사용자 토큰만 받는다. 개인 할 일에는 “본인” 이 있어야 하는데 공유 서비스 키에는 본인이 없으므로 401 이다. 모든 호출은 호출자 본인 조직 스코프 안에서 돌고, 조직을 못 구하면 409(재로그인)다. 남의 것과 없는 것은 둘 다 404 라 찔러 보는 요청이 둘을 가르지 못한다. 공유 할 일 두 라우트(ops/todos)는 서비스 키(?org= 필수)도 받고, 관리자가 아닌 사용자 토큰은 본인 법인 스코프의 할 일만 다룬다. 스코프 밖 조회는 없는 것과 같은 404, 수정은 쓰기 전에 403 이다.
  • 승격은 한 방향이고 한 트랜잭션이다. 개인 → 공유는 모든 필드가 그대로 옮겨가는 무손실 변환이지만, 공유 → 개인은 담당자·보고자·코멘트·라벨을 조용히 버리게 되므로 제공하지 않는다. 승격은 개인 행을 먼저 잠그고 읽은 뒤, todo add 와 같은 규칙으로 공유 할 일을 만들고 개인 행을 지운다. 생성과 삭제는 함께 커밋되거나 함께 되감긴다. 그 사이에 커밋된 수정은 공유 할 일에 실리고, 뒤에 온 수정은 404 를 받으므로 편집이 삭제와 함께 사라지지 않고 공유 할 일이 둘로 생기지도 않는다. 담당자를 찾을 수 없거나 모호하면 400 이다.
  • 메타데이터는 공유 보드 규칙으로 검증한다. 승격이 옮겨 싣는 개인 메타와 PATCH …/meta 가 같은 스키마를 지난다. 파일 링크는 http(s) 또는 UNC 만 받고, 메모·테마에는 길이 상한이 있으며, 상태는 아는 값만 받는다. 타임라인은 시작 ≤ 끝이어야 하는데, 한쪽만 보낸 수정은 저장된 반대쪽과 합친 값으로 순서를 본다. 위반하면 400 이고 아무것도 쓰지 않는다(승격이면 개인 행이 그대로 남는다). 판정은 대상 행을 잠근 뒤에 읽은 저장값으로 한다.

AXE 방향성 문서 — /axe/direction (blueprint#439)

AXE 그룹의 방향성·목표(OKR 통합 로드맵) 문서다. 조회는 로그인 사용자 전원, 편집·복원은 관리자와 위임 편집자(editor 역할)가 한다(blueprint#441). 일반 멤버는 보기만 한다 — 저장(PUT)은 본문을 읽기 전에 403 이다. 권한은 세션의 원 역할로 판정한다(editor 를 멤버로 접지 않는다). 그 밖의 관리자 화면은 여전히 관리자 전용이다.

  • AXE 조직 전용. 기본 문서가 그룹 내부 목표라서 다른 조직에는 이 문서가 없다. 화면도 GET·PUT /api/axe/direction 도 404 다. PUT 은 본문을 읽기 전에 판정한다. 레일 항목도 같은 판정으로 숨긴다.
  • 개정은 append-only 원장에 쌓인다. 저장할 때마다 문서 전체 스냅숏 한 행(AxeDirectionRevision — 누가·언제·변경 메모)이 쌓인다. 순서의 기준은 조직별 개정 번호 seq 다. 조직 단위 잠금 아래에서 최신 seq + 1 을 매기고, 시각은 표시용으로만 쓴다. 과거 개정 복원도 새 개정으로 쌓이므로 이력은 지워지지 않는다. 런타임 DB 롤에는 이 표에 대한 수정·삭제 권한이 없다.
  • 충돌하면 409. 저장 요청은 편집을 시작할 때의 기준 개정 id 를 함께 보낸다. 그사이 다른 사람이 저장했으면 409 와 최신본을 받는다. 기준은 저장에 성공할 때까지 바뀌지 않으므로 그냥 다시 저장해도 다시 409 다. 남은 길은 둘이다. 최신본을 불러오면서 내 초안을 버리거나, 확인을 거쳐 덮어쓴다.
  • 저장 안 한 초안을 지킨다. 탭 닫기·새로고침·링크 이동·뒤로/앞으로를 하려 하면 확인을 묻는다. 명령 팔레트나 법인 전환처럼 확인을 띄울 수 없는 화면 안 이동에서는 초안이 그 탭 안에 조직·사용자별로 남아 있다가, 돌아오면 이어서 편집할 수 있다. 저장 응답이 늦게 와도 그사이 더 고친 초안이나 변경 메모를 덮지 않는다.
  • 세부목표마다 진행 줄(blueprint#442). 로드맵의 각 세부목표 아래에 상태 배지(예정·순항·주의·지연·완료)와 한 줄 요약이 붙는다. 상세가 있으면 그 줄을 눌러 행 전체 폭으로 펼치고, “진행 상세 모두 펼치기/접기” 로 한꺼번에 연다. 편집 중에는 모든 세부목표에 상태·요약·상세 입력칸이 열린다. 셋 다 비면 저장하지 않으며 상세는 8,000자까지다.
  • 목표 칸마다 현시점 수치. 로드맵의 각 목표 칸(세부목표 × 연도)에서 타겟 아래에 “현시점 수치: 값 (기준일 YYYY-MM-DD)” 한 줄을 보인다. 편집 역할은 편집 모드에 들어가지 않아도 타겟이 있는 칸마다 값·기준일 입력칸을 쓸 수 있다. “현시점 수치 저장” 버튼이나 입력칸의 Enter 로 저장하며, 고친 칸 전부가 개정 하나로 쌓인다. 기준일을 비워 두면 값을 입력할 때 그날 날짜를 채우고, 저장할 때도 빈 기준일은 저장한 날로 채운다. 값은 200자 이하 자유 글이고 기준일은 달력에 있는 날짜여야 한다. 어기면 400 이다. 저장 규칙은 문서 편집과 같다(개정 원장, 409). 그사이 다른 사람이 저장했으면 덮어쓰지 않고 알린다. 다른 사람이 건드리지 않은 칸의 입력은 남기고, 같은 칸을 먼저 바꿨으면 내 입력을 적용하지 않은 채 목록으로 보여 준다. 저장하지 않은 현시점 입력은 초안처럼 지킨다. 이동하거나 새로고침할 때 확인을 묻고, 명령 팔레트나 법인 전환으로 화면을 벗어났다 돌아와도 남아 있다. 저장 중에 고친 값도 잃지 않는다. 일반 멤버에게는 값만 보인다. 이 기능 전에 저장된 개정에는 현시점 수치가 없으며, 미입력으로 읽는다.
  • 입력 상한. 요청 본문이 512 KiB 를 넘으면 파싱하기 전에 413 이다. 문서는 아는 필드만 골라 새 객체로 다시 짓는다(모르는 키는 버린다). 배열별 개수, 문서 전체 노드 수, 텍스트 바이트에도 상한이 있다.

우측 패널의 프롬프트 카드

2c(D-ops-122 T2) 부터 Blueprint 화면에는 우측 컨텍스트 레일이 없다. 이 카드의 복사 블록은 프롬프트 바의 /handoff 가 서비스 화면에서 같은 순서(조직 스위치 → 준비 판정 → 고지 확인 → 원장 → 텍스트)로 내준다. 아래 원장·주소 규칙은 /handoff 에도 그대로 적용된다.

우측 컨텍스트 레일 — 위 “세션” 섹션과 같은 패널 — 의 슬롯 ② 에 로컬 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-tenant 는 tenant_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.md 는 settingSources: ["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)으로 한정해 배치 밖 행을 정리
GET /api/faq/digest플랫폼 Bearer + 위와 같은 퍼블리셔 목록, 또는 공유 서비스 키 (읽기라 사람 귀속이 필요 없다)서빙 중인 전 행의 {slug, audience, orgSlug, hash} — 본문 없이 내용 해시만. docs↔서빙 드리프트 대조 전용
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-org 는 tenant 구획에서만 받는다 — 비-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 어휘, 그리고 tenant↔orgSlug 일관성(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)와의 동치. SkillBundle 의 canSeeAxeTenant 는 토큰의 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 구획을 뽑아 publish 한다. docs 는 MDX 라 HTML 주석을 문법 오류로 읽으므로 구획은 MDX 주석으로 표시한다 — 줄 하나에 {/* faq slug=… audience=… */} 로 열고 {/* /faq */} 로 닫는다(HTML 주석 표기 <!-- faq … --> 도 같은 뜻으로 받지만 .md 원고 전용이다). 토큰은 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)이 한 고객용 절차를 전 테넌트에 배포하는 경로가 되고, 그 본문은 대개 다른 고객에게는 틀린 안내다. docs↔서빙 드리프트 게이트 (스킬 채널의 axe skills check 자리): axe ship blueprint 가 docs 정본(axelabs origin/main)의 FAQ 원고를 publish-faq.mjs --check 로 서빙 중인 행과 대조하고, 어긋나면 ship 을 막고 publish 명령을 안내한다. 원고 파일은 docs 아래에서 줄 머리에 여는 마커가 있는 .mdx/.md 를 전부 고르고(이 단락처럼 문법을 인라인 코드로 언급하는 페이지는 고르지 않는다) 합본 하나로 대조한다. 판정 기준은 “그 원고 전부를 한 배치(--from a.mdx --from b.mdx … --replace)로 publish 하면 서빙이 바뀌는가” 다 — 한 구획을 두 파일이 나눠 가질 때 파일마다 --replace 하면 서로의 항목을 지우므로 publish 도 --from 을 여러 번 받아 한 배치로 싣고, 두 파일에 같은 슬러그가 있으면 배치 전체를 거절한다. 원고가 다루는 구획(audience, tenant 는 +orgSlug) 안의 행만 보고, 그 밖의 행(다른 고객의 tenant 항목·부트스트랩 시드)은 드리프트로 치지 않는다. 대조는 아무것도 쓰지 않고 GET /api/faq/digest 의 해시만 읽는다. 원고가 깨져 있으면(파서 오류) 그것도 막는다 — publish 도 할 수 없는 상태이기 때문이다. 서빙 쪽을 못 읽으면(엔드포인트 미배포·자격·네트워크) 막지 않고 경고 한 줄로 내린다: 그 실패는 ship 하는 blueprint 와 무관하고, 막으면 이 엔드포인트를 싣는 ship 자체가 막히는 교착이 된다.

GitHub 웹훅 (/api/git-webhook)

GitHub 이 Blueprint 로 미는 이벤트를 한 라우트가 받는다. X-Hub-Signature-256 을 배포 환경이 주입한 웹훅 서명 비밀로 검증하고, 값이 없으면 503 으로 닫힌다(fail-closed). 폴링·데몬 없이 이벤트 1건에 1회 반응한다.

이벤트보내는 저장소하는 일
pushblueprint · axe-tenant-content사이드카 git fetch → 스킬 rsync → /api/git-events SSE 로 호스트 skills-sync 에 알림

AI 커미티의 판정·원장 이벤트는 이 라우트가 아니라 아래 “AI 커미티 GitHub App” 절의 전용 라우트가 받는다(옛 check_run·pull_request 분기는 2026-10-05 퇴역).

서명 연결 전용 이벤트 — orbit_deploy (/api/git-events, D-ops-155)

/api/git-events SSE 는 운영 호스트의 skills-sync 가 붙는 스트림이다. 그 연결이 커미티 결과 서명 키로 만든 HMAC 헤더를 실을 때(커미티 replay 와 같은 문, 같은 IP 당 1분 1회)에만 orbit_deploy 이벤트가 실린다. 서명 없는 연결은 이 이벤트를 구독조차 하지 않는다 — 게재물 id 가 공개 스트림에 흐르면 세션 활동이 샌다.

  • 프레임: 정확히 {"v":1,"post":"<uuid>"}(소문자 uuid 가 아니면 내지 않는다). 힌트일 뿐이다. 호스트 실행기는 이 값을 믿지 않고 보드를 다시 읽어 모든 관문을 스스로 판정한다.
  • 언제 나가나: 짝 사본이 새로 삽입(201) 됐고, 그 세션이 이 조직 AppSetting orbit.autodeploy.sessions(세션 id 의 JSON 배열, 기본 비어 있음 = 꺼짐)에 있고, pairAuth=pair-key · 보낸 조직이 realchoice · 본문에 요청 펜스(axe-orbit-deploy)가 있을 때만. 멱등 재생(200)과 서비스 키 사본은 내지 않는다. 이때 같은 트랜잭션에 운영자 Teams 접수 DM 이 함께 큐잉된다(D-ops-103 — 접수를 아는 원장이 알린다). 프레임은 커밋 뒤에 나간다.
  • 놓친 프레임: 서명 연결이 붙을 때마다, 72시간 안의 그런 요청 사본 가운데 이 조직의 게재(사본이 아닌 글)가 post:<id> 참조로 답하지 않은 것을 다시 보낸다(오래된 순, 최대 50). 실행기는 요청을 받자마자 그 참조를 단 “받음” 글을 올리므로 보드 자체가 재전송 억제 표지다 — 새 원장·커서·폴링은 없다. 이 재전송이 실패하면 경고 한 줄을 남기고 실시간으로만 이어 간다(같은 연결의 커미티 이벤트를 흔들지 않는다).
  • 호스트 쪽: skills-sync 가 프레임을 정규식으로 확인해 ~/.blueprint/orbit-deploy-triggers/hint-<post>.json(0600, 원자 쓰기)으로 바꾸고, hostprobe 워처(WatchPaths 한 줄)가 axe orbit-deploy dispatch 를 1회 부른다. 그 디렉토리는 운영 호스트에서 설치 스크립트의 --orbit 으로만 생긴다 — 다른 Mac 에서는 아무 일도 없다. 10분 주기 폴백 동기화는 이 힌트를 쓰지 않는다. 새 상주 프로세스는 없다.

봇 토큰 만료 사전 경보: 원장 적재가 GitHub 토큰이나 matrix 토큰을 쓸 때마다 남은 기간을 본다(GitHub 은 응답 헤더의 만료 시각, matrix 는 토큰의 만료 클레임). 30일 미만이면 운영자에게 Teams DM 을 토큰별 하루 1건 보낸다. 주기 점검은 없다 — 쓰지 않는 토큰은 보지 않는다. 토큰 만료가 헬스체크를 초록으로 둔 채 원장 0행으로만 드러난 사고(2026-09-23)의 재발 방지다.

AI 커미티 GitHub App (/api/committee/github/webhook)

2026-09-26 부터 운영 중(첫 저장소 index). 옛 /api/git-webhook 커미티 경로는 2026-10-05 퇴역했다.

AI 커미티의 판정 체크런·PR 코멘트·원장 기록은 GitHub App axe-committee 명의로 플랫폼이 게시한다. 저장소 CI(커미티 워크플로)는 판정 아티팩트만 만들고, App 키도 쓰기 권한도 갖지 않는다 — 테넌트 저장소 관리자가 워크플로를 고쳐 플랫폼 신원을 빼낼 경로가 없다. App 배달은 apex https://axelabs.ai/api/committee/github/webhook 으로 오고, 받는 쪽은 클라우드 web half 뿐이다(Teams 봇 워커는 이 라우트도 App 키도 없다). 스킬 동기화용 /api/git-webhook 과는 라우트·서명 비밀이 다르다.

검증 (fail-closed, 검증 단계의 네트워크 호출 0). 웹훅 서명 비밀이 주입되지 않았으면 503. 본문을 읽기 전에 서명 헤더가 sha256=<64 hex> 형태가 아니면 401, 설치 대상 헤더(X-GitHub-Hook-Installation-Target-Type = [redacted], …-Target-ID = 이 App 의 숫자 ID)가 맞지 않으면 403, 본문이 2 MiB 를 넘으면 413. 이어 서명 불일치 401. 한 클라이언트 IP 의 검증 실패가 1분에 20회를 넘으면 그 뒤의 실패만 429 — 검증을 통과한 배달은 스로틀하지 않는다(GitHub 송신 IP 는 다른 모든 웹훅과 공유된다). ping 은 200.

테넌트 = 데이터. 배달의 숫자 계정 ID(installation 계열은 installation.account.id, 그 외는 repository.owner.id)를 고객 매니페스트의 github_accounts[].id 와 대조해 테넌트를 정한다. 조직 이름(login)은 바뀌거나 재사용될 수 있어 대조에 쓰지 않는다. 매니페스트가 읽히고 형태가 맞는데 그 계정이 어느 테넌트에도 없으면 200 {ignored} 로 끝나고 토큰 발급·게시는 0 이다. 매니페스트를 못 읽거나 형태가 어긋났거나 한 계정이 두 테넌트에 적혀 있으면 — 모르는 것이지 미매핑이 아니므로 — 503 + 운영자 알림 1건이고 아무것도 큐에 넣지 않는다. installation·installation_repositories 는 예외 — 미매핑이어도 운영자 DM 1건(unmapped)을 보내고, 토큰 발급은 0 이다. 원장의 customer 는 매핑된 테넌트이고, 작업 큐와 운영자 알림은 플랫폼 운영 조직에 둔다(테넌트 쪽 워커가 플랫폼 작업을 줍지 않도록).

이벤트하는 일설치 토큰 권한(해당 저장소 1개)
workflow_run (completed, 커미티 판정 워크플로 · 기본 브랜치)판정 아티팩트를 읽어 체크런 axe-committee/verdict(반영 후보 = success, 그 외 neutral) · PR 코멘트 1개(업서트) · matrix committee_record 를 한 번에. 취소·건너뛴 run 은 게시하지 않는다. 이번 시도의 판정 아티팩트가 목록에 없을 때, 판정 잡이 판정 없이 끝났으면(실패·취소·건너뜀) neutral verdict unavailable, 판정 잡이 성공했거나 상태를 모르면 아직 보이지 않는 것이라 실패(502)로 남겨 재전송을 기다린다. 판정이 원장 필수 항목(판정·티어·PR·head·run)을 못 갖추면 아무것도 게시하지 않고 실패(502)로 남긴다. 체크런은 external_id = <run>:<attempt>:<pr> 로 멱등(목록 끝 페이지까지 확인), 코멘트는 늘 커미티 표지를 싣고 PR 별 잠금 안에서 PR head 가 그대로일 때만Actions 읽기 · Checks 쓰기 · Pull requests 쓰기
check_run (rerequested, 이 App 의 체크런)체크런의 Re-run = 실패·취소된 잡이 있으면 그 잡만, 없으면 판정 잡만 재실행(리뷰어 LLM 비용 0). PR head 가 움직였거나 run 이 이미 그 체크런의 시도를 넘었으면(재실행이 이미 시작됨) 아무것도 재실행하지 않는다. 잡 목록은 끝 페이지까지 읽고, 끝까지 못 읽으면 재실행하지 않고 실패(502)로 남긴다. 새 시도가 끝나면 위 게시가 한 번 더 돈다Actions 쓰기 · Pull requests 읽기
pull_request (closed, merged)병합된 head 의 원장 행 전부에 merged: <merge sha> 노트(멱등). 원장 조회가 한 번에 다 읽히지 않을 만큼 행이 많으면 일부만 적지 않고 실패(502)로 남긴다. GitHub 호출 0없음
installation · installation_repositories운영자 DM 1건(계정·매핑 테넌트 또는 unmapped·저장소 목록). 저장 상태 없음없음

check_suite 는 구독하지 않는다(푸시마다 배달만 늘고 효용이 없다) — 스위트 수준 Re-run all checks 는 이 체크런에 반응하지 않으니 체크런 자체의 Re-run 을 쓴다. 리뷰를 새로 받으려면(리뷰어 재실행) 새 push 를 한다.

신원·권한. 모든 신원 판정은 숫자 App ID 로 한다(체크런 app.id, 코멘트 performed_via_github_app.id) — slug 문자열은 쓰지 않는다. App 권한은 Actions·Checks·Pull requests 읽기/쓰기와 Metadata 읽기뿐이고 Contents·Issues·Workflows·Administration·Secrets 는 없다. 작업마다 App JWT(RS256)로 설치 토큰을 필요한 권한·저장소 1개로 좁혀 발급하고, 1시간 토큰을 프로세스 메모리에만 둔다. 사용자 OAuth·client secret 은 쓰지 않는다. 원장 쓰기는 matrix 의 커미티 전용 스코프 토큰으로 한다(기록·조회 두 도구만, 사람 판정 도구는 거부). 이 경로가 쓰는 비밀 셋 — 웹훅 서명 비밀 · App 개인키 · matrix 커미티 토큰 — 은 테넌트에 속하지 않는 플랫폼 전역 항목이고 클라우드 web half 에만 렌더된다(봇 워커 env 에는 없다).

자동 승인과 반영 (D-ops-134). 커미티는 기여자 PR 의 승인 주체이기도 하다. 판정이 반영 후보 이고 저장소의 자동 반영 모드가 켜져 있으면, 판정 게시(체크런·코멘트·원장)가 모두 끝난 뒤 이 라우트가 같은 App 명의로 승인 리뷰를 단다 — 정확히 그 head 커밋에 결속되고, 새 push 가 오면 무효가 된다(권한은 늘지 않는다 — Pull requests 쓰기). 이어 승인 신호를 /api/git-events SSE 의 committee 이벤트(저장소 번호·PR 번호·head 해시만 담는 힌트)로 내보내고, 운영 호스트가 그 PR 을 다시 검증해 반영·배포한 뒤 결과를 서명된 POST /api/committee/ship-result 로 보고한다. 이 결과 라우트는 서명(시각 ±5분)·사전에 발송된 승인 신호가 있는 PR·고정 형식의 값만 받고, 클라우드 web half 에서만 열린다(봇 워커는 404). 보고를 받으면 PR 에 결과 코멘트를 남기고, 반영됐으면 PR 을 닫는다. 커미티 자기 장치·비밀·호스트가 사람의 선택 없이 실행하는 파일을 건드린 PR 은 자동으로 승인하지 않고 운영자에게 넘긴다.

반응 모델. 서명된 배달 1건 → 작업 1행(id 는 X-GitHub-Delivery 유도) → 그 요청 안에서 1회 실행하고, 응답 코드가 결과다(200 완료·중복, 409 실행 안 함(다른 요청이 처리 중이거나, 첫 배달 뒤 계정의 테넌트 매핑이 바뀌어 저장된 작업이 지금 라우팅과 다름 — 후자는 실패로 기록하고 운영자에게 알리며 저장된 작업은 고쳐 쓰지 않는다), 502 실패, 504 기한 초과, 500 부작용은 끝났으나 완료 기록 실패, 503 큐 오류·테넌트 조회 불가). 실행에는 GitHub 응답 시한(10초)보다 짧은 기한이 있어, 기한이 지나면 호출을 끊고 끝낼 수 없는 쓰기는 시작하지 않는다. 폴링·주기 스윕·타이머·catch-up 은 없다. 제자리 재시도는 멱등 호출(조회·코멘트 본문 교체)의 429/5xx 에 한해 짧게(최대 2회)만 하고, 체크런·코멘트 생성과 재실행 요청은 반복하지 않는다. 판정 게시의 세 쓰기(체크런·코멘트·원장)는 동시에 돈다. 실패·기한 초과는 즉시 dead-letter + 운영자 알림이고 GitHub 에도 실패한 배달로 남는다. 일시적 실패(기한 초과·GitHub 429/5xx·네트워크·아티팩트 미표시·원장 쓰기 일시 장애)면 응답 뒤 같은 배달의 재전송을 GitHub 에 배달당 한 번 요청한다(자기 재전송 — GitHub 이 배달을 목록에 올리기까지 약 1초라 짧게 최대 4번 찾는다, D-ops-132). 재전송된 배달이 또 실패하면 다시 요청하지 않는다. 배달이 앱에 닿기 전에 끊기면(엣지 오류 — 수신기가 실패를 모른다) 다음 배달이 회수한다: 서명 검증된 배달에 응답한 뒤 프로세스당 10분에 한 번, GitHub 의 배달 기록에서 수신기가 처리하는 이벤트 중 최근 72시간·2분 이상 지난·재전송 없는·수신기 작업 행이 없는 실패 배달을 찾아 배달당 한 번만(DB 표지 행을 먼저 기록) 재전송을 요청하고 운영자에게 알린다(한 번에 최대 5건, D-ops-133). 표지 행은 매번 DB 에서 직접 정리해, 재전송이 통과하지 못했거나 결과를 확인할 수 없는 배달은 배달당 한 번 운영자에게 알린다. 다음 배달이 오지 않으면 아무것도 돌지 않는다. 그 뒤의 회수는 사람의 1-input — App 설정의 Recent Deliveries → Redeliver 가 실패한 행과 실행 도중 끊긴 행(짧은 lease 경과 뒤)을 다시 실행하며, 재실행은 먼저 현재 상태(체크런 external_id·코멘트 목록)를 읽는다. 결과 기록은 실행권 토큰으로 보호돼, 늦게 끝난 실행이 새 실행의 결과를 덮지 않는다. 완료 기록이 실패하면 운영자 알림에 배달 id 가 실리고, 재전송은 현재 상태를 먼저 읽어 부작용을 반복하지 않는다(재실행 요청은 run 이 이미 새 시도로 넘어갔으면 보내지 않는다).

커미티 킷 호스트. 커미티의 판정기·프롬프트·스키마(composite action .github/committee/kit/)와 잡 그래프(재사용 워크플로 committee-gate-reusable.yml·committee-review-reusable.yml)도 이 저장소에 있다. 소비자 저장소는 두 재사용 워크플로를 40자 SHA 로 고정한 얇은 호출부와 저장소별 프로필만 두므로, 수신기와 킷의 계약(잡 이름·아티팩트·경로)을 한 ship 으로 맞춘다. 재사용 워크플로 안의 잡은 review / judge (…) 처럼 호출 잡 이름이 앞에 붙어 보이고, 수신기는 앞이 붙은 이름과 안 붙은 이름을 둘 다 판정 잡으로 받는다.

설치 범위. 지금은 Only on this account(axelabs-ai 조직)로, 커미티 워크플로가 실제로 도는 저장소만 설치 목록에 둔다(현재 index — 새 저장소는 그 저장소의 커미티 워크플로가 main 에 들어간 날 추가). 테넌트 매핑이 데이터라, 두 번째 테넌트가 계정 매핑과 자기 LLM 키를 갖추면 App 설정에서 Any account 로 바꾸는 것으로 끝난다(코드 변경 0).

전환 창(종료). 옛 저장소 웹훅 경로와 App 경로가 공존하던 기간(2026-09-26 ~ 10-05)에는 한 PR 에 체크런·코멘트가 둘씩 생길 수 있었다. 옛 경로는 2026-10-05 퇴역했고 이제 App 명의 하나만 남는다.

AI 커미티 — 환경 변수(비밀 아님)

월 지출 상한과 격상 라운드 스위치는 비밀이 아닌 설정값이다.

환경 변수코드 기본값의미
COMMITTEE_MONTHLY_CAP_USD200리뷰어 석 키 발급의 월 누적 상한(USD, UTC 월). 넘으면 발급 거부(402) — 리뷰는 사람 검토로 넘어간다
COMMITTEE_ESCALATION미설정(꺼짐)shadow 일 때만 격상 라운드 석 키를 발급한다(섀도 — 판정에 반영 안 함)

Teams 채널 멘션 — 푸시 + 폴링 예비 (흡수 2단계 2026-08-16 · 푸시 2026-10-28)

봇이 채널 멘션에 응답한다. 채팅(1:1·그룹)은 에이전트별 /me/chats/getAllMessages 위임 구독이고, 채널은 채널 단위 위임 구독(푸시) 이 1차 경로, 폴링이 예비다.

항목값
푸시 구독채널마다 1개 (Graph 한도: 앱 × 채널당 구독 1개) — /teams/{team}/channels/{channel}/messages, 그 채널을 폴링하는 에이전트의 위임 토큰(ChannelMessage.Read.All), changeType=created, 리소스 데이터 없음(암호화 인증서 불요 — 알림을 받으면 메시지를 GET), 만료 2일(Graph 상한 3일), 라이프사이클 URL = 기존 라이프사이클 라우트. 채널이 보이는 라우팅 에이전트가 하나면 $filter=mentions/any(u: u/mentioned/user/id eq '{에이전트}') 로 그 에이전트 멘션만 받고, 여럿이면 필터 없이 받아 코드의 멘션 게이트가 거른다. 필터가 400 으로 거절되면 필터 없이 만든다
구독 생성폴 사이클이 끝난 뒤 백그라운드로(자체 중복 실행 방지, 1회 최대 3건) 발견한 채널에 없는 구독을 만든다(ensureChannelSubscriptions) — 느린 생성이 60초 예비 폴링을 막지 않는다. 에이전트 구성이 바뀌어 기존 구독이 멘션을 다 덮지 못하면 옛 구독을 지우고 새로 만든다. 실패한 채널은 30분 뒤 다시 시도한다. 구독 조회가 실패한 사이클은 아무것도 만들지 않고 전 채널을 60초로 폴링한다
갱신·재생성기존 갱신 sweep 이 채팅·채널 구독을 함께 갱신한다. 일시 오류(429·5xx·네트워크)면 행을 그대로 두고 다음 sweep 이 다시 시도한다. 그 밖의 실패·reauthorizationRequired 실패·subscriptionRemoved 는 옛 구독을 지운 뒤(best-effort) 저장된 resource 로 종류를 판정해 같은 종류로 재생성한다(채널 구독을 채팅 구독으로 재생성하지 않는다). missed 면 그 채널을 즉시 한 번 폴링한다(폴 사이클이 도는 중이면 다음 틱으로 미룬다)
웹훅·라이프사이클 검증우리 조직의 활성 구독이고 clientState 가 정확히 같을 때만 처리한다(clientState 가 없으면 거절). 비활성 행의 라이프사이클 이벤트는 재생성하지 않는다
폴링 주기크론 틱 60초. 채널에 그 채널의 에이전트 멘션을 다 덮는 살아 있는 구독이 있으면 5분마다, 아니면(생성 거절 403·미생성·만료) 60초마다 폴링한다. 폴링은 루트 페이지를 워터마크(−5분)까지 따라간다 — 한 번에 25쪽까지 읽고, 더 깊은 백로그는 워터마크 옆에 저장한 커서로 다음 사이클에 이어 읽는다(24시간 전보다 뒤로는 가지 않는다). 알림의 URI 인코딩된 채널 키는 디코드해 맞춘다. 푸시가 있는 채널에서 폴링이 90초 넘은 멘션을 먼저 잡으면 “push missed” 를 로그로 남기고 그 채널을 1시간 동안 60초로 되돌린다
중복 방지푸시와 폴링이 같은 단일 메시지 처리기(processChannelMessage)를 쓴다 — inbound 행 선점(first-write-wins) + 그 행의 배타 lease. 늦게 온 쪽은 “처리 중”(busy) 또는 “이미 처리”(skipped)로 빠진다
동시 턴한 채널의 답은 한 번에 하나다 — 채널별 프로세스 내 FIFO 레인 + Claude 호출 동안 DB 세션 턴 리스(다른 프로세스가 쥐고 있으면 그 턴은 ephemeral). 채널 세션(채널 × 에이전트)의 대화 기록이 섞이지 않게 하기 위해서다
관리자 해지관리자 화면의 전체 구독 해지 뒤에도 채널 구독은 폴러가 30분 안에 다시 만든다(의도). 채널 푸시를 끄려면 봇 프로세스를 내려야 한다
등록 위치src/instrumentation.ts — runsBotWorkloads() 게이트 안쪽. role=web 프로세스는 등록 자체가 불가능하다
코드src/lib/teams/channel-poller.ts(발견·폴링·푸시 처리) · src/lib/teams/subscription.ts(구독 종류·생성·갱신)
큐 분리src/lib/teams/inbound-queue-channel.ts — CHANNEL_QUEUE_PREFIX 로 채널 항목을 채팅 큐와 가른다. 채널 행 lease(leaseChannelClaim)도 여기 있다
위임 스코프ChannelMessage.Read.All(읽기·구독) · ChannelMessage.ReadWrite(ack 제자리 수정) — 추가 동의 없음
데이터 모델변경 없음 — GraphSubscription.resource 문자열이 종류를 담는다
AppSettingteams_channel_poll_watermarks (채널별 마지막 처리 시각) · teams_channel_default_workspace

왜 채널 단위 구독인가. 위임 토큰으로 안 되는 것은 테넌트 전체 구독(/teams/getAllMessages, 애플리케이션 권한 전용)뿐이다. 채널 단위 구독은 위임 ChannelMessage.Read.All 로 만들 수 있고, 멘션 필터로 그 에이전트가 불린 메시지만 알림이 온다. 알림 지연은 보통 10초 미만이다. 폴링을 15초로 줄이는 안은 호출 한도와 별개로 Microsoft Teams API 의 폴링 요건에 어긋날 수 있어 택하지 않았다.

구독이 거절되면. 첫 생성이 403(권한·보호 API)으로 거절되면 로그에 [channel-push] subscription … refused (403 …) — channel stays on 60 s polling 이 남고 그 채널은 60초 폴링을 그대로 유지한다. 응답 지연이 푸시 이전(평균 30초·최대 60초)으로 돌아갈 뿐 멘션을 놓치지는 않는다.

게이트가 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 가 등록 위치와 킬스위치 부재를 빌드 게이트에서 고정한다.

워터마크 초기화. 워터마크가 없는 채널은 첫 사이클에 폴링 시작 시각으로 초기화만 하고 과거 메시지를 처리하지 않는다. 배포 직후 폭주가 없는 이유다. 푸시도 15분보다 오래된 메시지는 처리하지 않고 폴링(워터마크)에 맡긴다.

실패 재시도. 웹훅은 채널 알림을 응답(202) 전에 대기 행으로 저장한다(저장 실패면 500 으로 재전송을 받는다). 그 뒤의 메시지 조회·처리가 실패하거나 프로세스가 죽어도 행이 남고, 폴 사이클마다 재시도 소비자가 미해결 채널 행을 다시 조회해 같은 처리기로 답한다(1회 최대 5건, 마지막 시도 60초 뒤, 24시간 이내, 시도 5회 상한). 폴링 창과 무관하다.

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

Graph 토큰 발급 테넌트 — GraphToken.tid

데이터 모델: GraphToken 에 tid(nullable)가 있다 — 저장된 delegated 토큰 쌍을 발급한 Entra 테넌트다. 로그인과 Settings 의 Microsoft 연결이 ID 토큰의 tid 로 채우고, 갱신 성공이 응답에 tid 가 있을 때 고친다. 갱신(refresh)은 이 컬럼의 테넌트 endpoint 로 상환하고, 컬럼이 빈 예전 행만 저장된 access token 을 디코드한다. 백필은 없다 — 예전 행은 다음 로그인·연결·갱신에서 채워진다.

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-health 가 decrypt_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' 만 본다.
  • AxeDirectionRevision (blueprint#439) — 「AXE 방향성」 문서(위 절)의 append-only 개정 원장. 축 보유 표이고, 조직의 seq 최대 행이 현재본이다((organizationId, seq) 유니크). 행이 없으면 코드에 둔 기본 문서를 보여 주므로 시드나 백필이 없다. 신규 표 관례대로 org_only·org_pinned 두 정책과 롤 GRANT 를 같은 마이그레이션에 명시한다. append-only 는 권한으로 강제한다 — blueprint_app·blueprint_pinned 는 SELECT·INSERT 만 갖고(UPDATE·DELETE·TRUNCATE 회수, RLS 는 TRUNCATE 를 막지 못한다), blueprint_boot 은 권한이 없다.
  • 파생 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 로 두면 정책이 표현할 수 없는 행이 된다. PlatformFaq 의 tenant 행은 대신 CHECK(tenant↔orgSlug 일관성) + 애플리케이션 가시성 게이트가 지고, 그 격리는 봇 턴 격리 스위트가 결과로 잰다.

정책 총계 = 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 이다. bootstrapPrisma 로 User.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이면 낡은 사본을 읽고 있다는 뜻이다.

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

셸 레일의 서비스 전환기는 허용집합이 아니라 표시 집합을 그린다(2026-10-13, @axe/ui 0.43.0 매니페스트 viewer.services). 표시 집합 = 카탈로그 ∩ 허용집합(카탈로그 순서)이고, 테넌트가 axe 가 아니면 matrix 를 뺀다 — matrix 웹 보드는 운영자 전용이라 MCP 로 matrix 를 받는 고객도 웹에서는 열 수 없다. 허용집합을 선언하지 않은 고객은 무제한이다 — axe 는 필드 없음(카탈로그 전체), 그 밖의 테넌트는 카탈로그 전체에서 matrix 와 opt-in 항목(orbit·magnet — 명시될 때만 선다)을 뺀다. 판정 자체가 실패하면 전환기만 blueprint 하나로 닫는다(로그인 문은 위 규칙대로 열어 둔다). 같은 목록이 플랫폼 OP 가 서비스에 내주는 신원·접근 토큰의 services 클레임(문자열 배열, tenant_id 옆, 무제한이면 생략)으로도 실려, 서버렌더 레일을 가진 서비스가 같은 전환기를 그린다. 고객 등록부의 service_hrefs(서비스 key → https 주소, @axe/ui 0.44.0)는 파싱 때 검증되고(카탈로그 key·그 key 의 허용 오리진만, 어긋나면 경고 후 버림) 표시 집합에 든 key 만 매니페스트 viewer.serviceHrefs 와 토큰 service_hrefs 클레임으로 실린다 — opt-in 항목은 이 목적지가 있어야 전환기에 선다. 셸 위젯을 품을 수 있는 부모 오리진에는 테넌트 인스턴스 오리진을 아직 넣지 않는다. 표시용이지 권한이 아니다 — 집행은 위 네 자리와 각 서비스의 테넌트 관문이 한다.

에이전트 실행과 Teams 턴에 붙는 원격 도구는 실행 조직의 것만 붙는다. matrix 와 AXE 의 frame 은 자격이 사용자별이 아니라 배포 하나의 공유 서비스 자격이라 AXE 조직의 실행에만 붙는다 — 다른 조직의 실행에 붙이면 그 조직이 AXE 의 원장·관제에 닿는다. 전용 frame 인스턴스를 가진 고객 조직의 실행은 frame 을 그 고객의 인스턴스(https://frame.axelabs.ai/t/<tenant>/frame/mcp, 공개 주소 — 고객 봇은 고객 호스트에서 돈다)에 그 조직 자신의 서비스 토큰(조직 설정 행, 인스턴스가 자기 서명키로 발급한 읽기 전용)으로 붙인다. AXE 의 토큰·주소는 어느 경로로도 고객 실행에 실리지 않고(배포 조직 행·부팅 환경값을 읽지 않는다), 고객 토큰은 AXE 인스턴스로 가지 않는다. 토큰 행이 없으면 frame 도구를 허용한 에이전트에 “토큰 미프로비저닝” 안내가 붙는다. 조직 판정·조직 조회가 실패하면 붙이지 않는다.

stream·magnet 은 반대 방향이다(D-ops-152, 2026-10-22). 두 서버는 고객이 자기 쪽에서 운영하는 서비스라 고객 조직의 실행에만 붙고 AXE 조직의 실행에는 붙지 않는다 — AXE 조직이면 그 설정을 읽지도 않는다. 붙는 조건은 그 조직이 자기 조직 설정에 서버 주소와 서비스 토큰을 넣었을 때뿐이다. 주소가 없으면 조용히 꺼지고(다른 고객 조직은 기본 꺼짐), 주소만 있고 토큰이 없으면 붙이지 않고 그 도구를 허용한 에이전트에 “토큰 미프로비저닝” 안내를 붙인다. 부팅 환경값·배포 조직 설정은 읽지 않으며, 등록 전에 서버 도달과 인증을 점검한다. Teams 턴에만 붙고 웹·예약 실행에는 붙지 않는다. 고객 봇에는 다음 봇 이미지 공급부터 적용된다.

노출하는 것은 조회 도구뿐이다. 서버 도구 목록에서 조회로 판정한 것만 에이전트에 보이고, 나머지(쓰기·게시·실행, 남의 댓글·답글 같은 제3자 소셜 원문 조회)는 턴에서 이름째 제거된다. 조회 도구라도 쓰기를 켜는 인자나 다른 테넌트를 고르는 인자가 실리면 거부하고, 셸·웹 조회로 두 서버에 직접 닿는 호출도 거부한다 — 이 거부들은 권한 판정의 관찰(shadow) 모드에서도 집행된다. 쓰기를 확실히 닫는 경계는 서버가 발급하는 읽기 범위 토큰이다. stream 은 2026-10-25 부터 이 토큰으로 붙는다 — 서버가 읽기 목록 밖의 도구를 거부하고, Blueprint 노출 목록은 그 읽기 목록의 부분집합으로 고정돼 있다. magnet 은 아직 서버 쪽 계층이 없어 서비스 토큰이 쓰기까지 여는 단일 토큰이다. 그래서 magnet 의 “읽기 전용” 은 MCP 도구 경로에서만 보장되고, 토큰이 봇 셸에서 닿을 수 있어 셸 직접 도달 거부는 문자열 기반 다층 방어일 뿐 경계가 아니다. 서버 쪽 읽기 범위 토큰이 생기면 조직 설정의 토큰 값만 바꿔 넣으면 다음 턴부터 갈아탄다.

응답 보안 헤더 — CSP 는 nonce 기반

앱 응답의 Content-Security-Policy 는 요청마다 새 nonce 를 붙여 미들웨어가 낸다. script-src 는 'self' 와 그 nonce 뿐이고 'unsafe-inline'·'unsafe-eval' 은 없다(개발 모드만 'unsafe-eval'). 인라인 스크립트는 전부 nonce 를 받으며, 정적 파일은 nonce 를 받을 수 없어 public/terminal.html 의 스크립트는 외부 파일로 분리했다. style-src 는 React 인라인 스타일 때문에 'unsafe-inline' 을 유지한다. object-src 'none' · base-uri 'self' · frame-ancestors 'self'(X-Frame-Options SAMEORIGIN 과 같은 뜻)가 함께 나가고, Access-Control-Allow-Origin 은 어디에도 없다 — 이 둘은 테스트로 고정돼 있다. 외부 스크립트가 0개라 'strict-dynamic' 은 쓰지 않는다(Cloudflare 의 same-origin /cdn-cgi/ 스크립트가 'self' 로 계속 동작). 단일 워크스페이스 셸의 위젯 경로 두 개(/shell/panel · /shell/prompt)만 등재 서비스 오리진 정확 목록으로 frame-ancestors 를 열고 그 두 응답에서만 X-Frame-Options 를 뺀다. 목록은 서비스 계약에서 한 함수로 파생하며(와일드카드 금지, vault 제외), 위젯이 부모를 검증하는 오리진 표도 같은 함수에서 나온다. 아래 “셸 위젯” 절 참조.

셸 위젯 — /shell/panel · /shell/prompt (D-ops-122 T2 2a·2b·2c)

단일 워크스페이스 셸의 세션 목록과 프롬프트 바는 Blueprint 가 서빙하는 iframe 위젯 두 벌이다. 각 서비스 화면의 킷 런타임이 이 두 문서를 띄우고 init 메시지로 화면 맥락을 넘긴다. 두 경로만 frame-ancestors 가 'self' 와 서비스 계약에서 뽑은 정확한 https 오리진 목록으로 열린다. 와일드카드는 쓰지 않고 vault 는 목록에서 뺐다. 이 두 경로는 X-Frame-Options 를 보내지 않으며, 나머지 전 경로는 frame-ancestors 'self' 와 X-Frame-Options: SAMEORIGIN 을 그대로 유지한다. Access-Control-Allow-Origin 은 여기서도 붙지 않는다. 위젯 문서의 서버 렌더에는 신원 판정 재료(뷰어 subHash·이름)만 실리고 세션 데이터는 없다. 위젯은 킷의 메시지 검증기를 통과한 init 을 받은 뒤, 부모 화면의 계정 subHash 가 자기 것과 같을 때만 데이터를 가져온다. 계정이 다르면 잠금 화면을, 로그인하지 않았으면 로그인 안내를 보여 주고 이때 데이터 호출은 한 번도 하지 않는다. 목록은 GET /api/shell/sessions 로 가져온다. 뷰어 조직의 active·done 협업 세션 가운데 내가 치운 세션을 빼고, 법인 필터·검색·커서 페이지를 지원하며 프롬프트 원문은 싣지 않는다. 초안과 “현재 세션” 포인터는 ShellComposer 표가 조직·사용자·법인 축별로 저장하고, /api/shell/composer 로 읽고 쓴다. 초안은 서버가 revision 을 매기는 compare-and-set 으로만 저장되고, 탭이 내려갈 때 확정되지 않은 초안은 그 탭(문서)만의 로컬 보관 항목으로 남아 다른 탭의 보관본을 덮거나 지우지 않는다. 포인터 순서는 클릭 시각이고, 한 문서 안의 순서는 ShellSelectionMark 표의 문서별 순번 워터마크가 지킨다 — 같은 문서의 늦게 닿은 옛 클릭은 기록되지 않는다. 생성 요청이 도는 사이 사용자가 다른 세션을 고르거나 화면 법인이 바뀌면 늦게 온 생성 응답은 현재 세션을 바꾸지 않는다. 이 쓰기 경로는 Sec-Fetch-Site: same-origin 과 application/json 을 요구한다. 세션 생성(POST /api/sessions)과 게재(POST /api/sessions/{id}/posts)는 선택 필드 bind 를 받는다. 위젯이 결속한 신원이나 법인이 서버 판정과 다르면 409 shell_bind_mismatch 로 거절하고, bind 가 없으면 종전과 똑같이 동작한다. 로그인 안내의 복귀는 /shell/return?to= 를 거친다 — 목적지가 https 이고 서비스 계약 오리진일 때만 그리로 보내고 아니면 워크스페이스 홈으로 보낸다(이 라우트는 위젯 경로가 아니라 X-Frame-Options 를 유지한다). 프롬프트 바는 Claude Code 처럼 쓴다. 평소에는 > 입력칸 한 줄이고, 글자를 치는 순간 캔버스 전체 높이로 펼쳐져 대화가 입력칸 위로 쌓인다(펼치기 버튼은 없다). @ 화면 이동(> 로 시작해도 같다), /clear, /new, /open, /help, /handoff, 그리고 평문 질문(ask)을 지원하고, / 와 @ 후보는 입력칸 바로 아래에 뜬다. 세션 패널은 목록만 맡고(붙어 있는 로컬 에이전트가 있으면 초록 점), 세션을 누르면 프롬프트가 펼쳐져 세션 모드가 된다 — 세션 게재 대화·게재·초안 저장(compare-and-set)과 ? 질문이 거기서 일어난다(D-bp-collab-12). 실시간은 DB 가 알린다: SessionPost INSERT 와 Session.collabPresence 변경을 트리거가 pg_notify('blueprint_collab', {세션 id · 조직 id · 종류}) 로 알리고(웹·MCP·봇·짝 복제 어느 경로로 써도 같다, 본문은 싣지 않는다), Blueprint 각 프로세스가 전용 연결 하나로 LISTEN 해 SSE 로 민다. 알림 연결이 끊기면 5초마다 다시 읽어 놓치지 않는다. 스트림은 셋이다 — GET /api/sessions/{id}/stream?after=<게재 id>(세션 화면: 게재·연결 상태, 결속 헤더 필수), GET /api/shell/sessions/stream(목록: 조직 안 세션 변화 알림, 연결 상태 만료 포함), GET /api/sessions/{id}/attach?origin=&after=&seen=&mode=wait|follow(에이전트: 플랫폼 Bearer 토큰, 출처 web 게재만 지시로 내준다). 세 스트림 모두 20초 ping, 30분 뒤 재연결이다. Session.collabPresence(JSONB, 신규 표 없음)는 출처별 연결 세대·대기·작업 중·받은 지시를 표시용으로만 담는다 — 대기는 마지막 신호 100초, 작업 중은 받은 뒤 6시간이 지나면 보이지 않고, 옛 연결의 늦은 신호는 새 연결 상태를 덮지 않는다. 지시를 처리했다는 확인은 서버가 아니라 에이전트 CLI 가 든다: 처리한 지시 id 를 로컬 원장에 남겨 재연결 때 seen=(최대 200개)으로 보내고, 서버는 늦게 커밋된 앞 지시를 놓치지 않도록 커서 앞 10초를 겹쳐 다시 읽되 seen 에 든 것은 빼고 오래된 순서로 한 번에 최대 100개씩 보낸다. CLI 커서는 정렬상 더 뒤인 지시를 처리했을 때만 전진한다. 이 갱신은 updatedAt 을 건드리지 않는다(목록 순서 불변). MCP get_session 메타에도 presence 가 실려 session show 가 “연결된 로컬” 줄을 찍는다. 붙기가 없어도 show·post 로 일하는 로컬은 “없음” 으로 단정하지 않는다 — 목록 응답의 lastLocal(최근 6시간 안의 claude-code@·codex@·cli@ 출처 최신 게재)과 세션 스트림 presence 의 같은 값으로 행·세션 화면 위 줄·session show 가 <출처> · N분 전 게재 · 실시간 아님 을 찍고(초록 점 없음), 둘 다 없을 때만 “최근 로컬 활동 없음” 이다. 세션 화면 위 줄은 Sessions 상세와 같은 조작을 갖는다 — 상태(진행 중·완료·보관) · 완료로 표시/다시 열기/보관 해제(생성자·관리자, 미답 질문이 있으면 완료가 거절되고 사유가 한 줄로 뜬다) · 치우기/치움 취소(완료 세션, 전원) · 이름 변경(제목 더블클릭) · 미답 질문 N건 · Teams 방. 세션을 누르면(우측 패널·/open·세션 열기) 세션 주소 /axe/sessions/<id> 로 이동하고(주소·뒤로가기·공유), 그 주소가 곧 이 세션 화면이다 — 닫기/Esc 는 세션 목록으로. 다른 사람·CLI 가 상태나 이름을 바꾸면 열린 화면에 반영되고, 보관되면 닫힌다. 세션 화면 위 줄의 “프롬프트 복사” 는 Sessions 상세와 같은 협업 핸드셰이크(세션 자신의 법인 · show 뒤 attach)를 클립보드에 넣고, 막히면 같은 글을 상자로 보여 준다. 이 브라우저의 사용자가 아직 AXE CLI 를 셋업하지 않았으면 세션 패널 맨 위에 설치 안내 카드가 뜬다.

평문을 입력하고 Enter 를 누르면 POST /api/shell/prompt 가 답을 SSE 로 흘려보낸다. 서버는 모델을 부르기 전에 차례로 확인한다: 결속한 계정이 지금 세션과 같은가, 뷰어 테넌트에 배정된 AI 키가 있는가(키가 없는 테넌트에서는 질문이 사유와 함께 닫힌다), 화면 서비스의 AI 정책이 platform 인가(클라이언트가 보낸 값이 아니라 서버가 수집한 색인에서 다시 읽고, 모르면 닫는다), 현재 고지 문안에 대한 동의가 있는가, 같은 사용자의 진행 중인 질문이 없는가. 하나라도 어긋나면 사유를 담은 refused 이벤트 하나로 끝나고 아무것도 전송하지 않는다. 통과하면 전송 사실(해시·바이트·모델·토큰 수·도구 이름)을 원장 ShellAiAudit 에 먼저 커밋한 뒤 모델을 부른다. 이 원장은 추가만 되는 표다 — 앱 롤에는 INSERT·SELECT 권한만 있고, 동의를 거두거나 사용자를 지워도 남는다. 모델이 쓰는 도구는 코드에 닫혀 있다. 메뉴 이동·세션 열기·세션 만들기는 제안 칩으로만 오고 사용자가 눌러야 실행된다. 여기에 화면 서비스 하나(hive 또는 index)의 읽기 도구가 더해지는데, 뷰어 본인 이름으로 발급한 토큰으로 부르고 결과는 저장하지 않는다. 답의 Markdown 은 이미지를 그리지 않는다(답 안의 주소로 자동 요청이 나가지 않게). 질문과 답 문장은 ShellAskTurn 에 보관한다. 30일이 지난 문장은 모델 입력과 화면에 다시 쓰지 않고, 그 워크스페이스에서 다음에 질문이 전송되거나 동의·철회·이어서 보기가 일어날 때 지운다(별도 스케줄러는 없다). 화면을 옮겨 위젯이 새로 뜨면 “이어서” 로 GET /api/shell/prompt/{turnId} 에서 진행 중인 답을 이어 받는다 — 답을 만드는 프로세스가 다른 인스턴스이면 저장된 진행분을 짧은 간격으로 다시 읽는다. 답을 만드는 동안 Esc 를 누르면 POST /api/shell/prompt/{turnId}/stop 이 이 프로세스의 생성을 멈추고 원장을 aborted(사유 stopped)로 닫는다. 다른 인스턴스가 만드는 답이면 409 not_local 이고, 화면은 따라가기만 멈춘다. /clear 는 화면의 대화를 비우고 그 시각(since)을 이후 질문에 실어, 서버가 모델에 넣는 이전 대화를 그 뒤의 것으로만 좁힌다(보관 기한보다 넓히지는 않는다). 첫 질문에는 무엇이 국외(Anthropic)로 전송되는지 적은 고지 전문과 번호 선택지(1. 동의하고 보내기 · 2. 보내지 않기)가 대화 안에 뜨고, POST /api/shell/consent 가 그 문안 버전으로 ShellAiConsent 에 기록한다. 버전은 문안의 해시라서 문안이 바뀌면 모두 다시 동의한다. 워크스페이스의 AI 전송 동의 화면(/axe/ai-consent)은 JavaScript 없이 동작하는 폼으로 동의를 거둔다. 폼은 렌더한 계정에 결속돼 있어 그사이 계정을 바꾸면 아무것도 지우지 않는다. 거두면 진행 중인 답이 끊기고 모든 버전의 동의와 보관한 질문·답 문장이 지워지며, 원장은 남는다.

@ 이동은 현재 서비스의 메뉴에 다른 서비스의 공개 내비 색인을 더한다. 각 서비스가 무인증으로 공개하는 axe-shell.json(AxeShellIndex@1)을 Blueprint 서버가 모은다. 요청이 올 때 캐시가 낡았으면 그때 다시 받고(10분 신선·ETag 재검증), 스케줄러는 없다. 이름 있는 User-Agent 를 쓰고 리다이렉트는 따르지 않으며, 크기 한도를 넘는 응답은 그 서비스 색인을 무효로 돌린다. 서비스·오리진·버전이 맞지 않는 색인은 통째로 버리고, 모르는 항목만 있으면 그 항목만 버린다. GET /api/shell/index 는 이 색인을 뷰어 기준으로 거른다 — 테넌트 허용 서비스인지, 화면 법인의 서비스 카탈로그에서 쓸 수 있는지, 항목의 최소 역할을 만족하는지를 차례로 보고, 문서당 한 번 처음 @ 를 칠 때만 부른다. AI 정책은 응답에 싣지 않는다. 색인은 제안일 뿐이고 최종 인가는 도착한 서비스가 한다. /handoff 는 D-ops-114 프롬프트 복사 카드와 같은 블록을 서비스 화면에서 내준다. POST /api/shell/handoff 는 카드와 같은 순서(조직의 외부 프롬프트 스위치 → 준비 판정 → 고지 확인 → 원장 → 텍스트)를 밟고, 화면 주소는 결속 서비스의 등재 마운트 아래일 때만 받아 오리진과 경로만 싣는다. 원장 ContextPromptRelease 에는 nullable service 열이 생겼다(기존 카드 행은 NULL). 결속 헤더(X-Axe-Shell-Sub)는 동의·이어서·중단·색인·handoff 요청에 모두 필요하고, 쓰기는 2a 와 같이 Sec-Fetch-Site: same-origin 과 application/json 을 요구한다. 예외는 JavaScript 없이 보내는 동의 철회 폼 하나로, form 인코딩과 렌더 계정 결속 필드를 받는다.

2c 부터 Blueprint 워크스페이스(/ 와 /axe/**)도 다른 서비스와 같은 킷 셸 런타임(@axe/ui 0.42.1 AxeShell) 위에서 돈다. 예전의 Blueprint 전용 레일·상단 바·우측 컨텍스트 패널·커맨드 팔레트는 없어졌다. 서버는 역할·허용 서비스·법인 목록 같은 사실만 모으고, 매니페스트(AxeShellManifest@1)는 화면을 옮길 때마다 클라이언트가 같은 순수 함수로 다시 만든다. 메뉴 목적지와 역할별 노출 규칙은 옛 레일과 같다. 테스트가 옛 출력 표와 그대로 대조하고, 모든 역할·서비스 조합의 매니페스트가 킷 검증기를 통과하는지도 확인한다. 세션 목록과 프롬프트 바는 Blueprint 자신이 서빙하는 두 위젯을 같은 오리진 iframe 으로 띄운다. Blueprint 자기 매니페스트의 AI 정책은 platform 이다. 색인 수집기는 Blueprint 항목을 HTTP 로 받지 않고, 같은 메뉴를 프로세스 안에서 뷰어와 무관한 공개 목적지만으로 만든다. 그래서 관리자·편집자 전용 화면, 서비스 게이트가 걸린 화면, HR·Workflows 는 다른 서비스 화면의 @ 후보에 나오지 않는다. Blueprint 화면 안에서는 로컬 메뉴로 나온다.

화면에서 달라진 점은 다음과 같다. ⌘K 는 이제 프롬프트 바에 초점을 주고 @ 화면 후보를 연다. 화면 이동은 @ 후보(현재 메뉴와 서비스 색인)에서 고른다. 관리자 하위 화면은 더 이상 검색 후보가 아니다. 법인 전환은 킷 셸의 법인 선택기가 <지금 경로>?entity=<key> 로 이동하는 GET 폼이다. 전환하면 그 화면의 필터 쿼리는 초기화되고, 페이지가 법인을 정하는 순서(주소 ?entity= → 마지막 법인 → 기본 법인 → 첫 법인)는 그대로다. 계정 전환은 레일 계정 메뉴에서 /axe/account/switch 로 간다. 이 화면은 JavaScript 없이 동작하는 폼으로, 로그인한 사람이 소유한 계정 사이를 재로그인 없이 전환하고, ‘다른 계정으로 로그인’(계정 선택 창)도 제공한다. 폼은 같은 오리진 요청(Sec-Fetch-Site: same-origin)만 받고, 전환이 끝나면 원래 화면으로 돌아간다. 돌아갈 주소는 같은 오리진의 경로만 받는다. 로그아웃은 레일 계정 메뉴에서 플랫폼 로그아웃(/oauth/logout)으로 가서 Blueprint 세션과 등록된 서비스 세션을 함께 끝낸다. 테마의 정본은 킷 쿠키 axe_theme(light·dark·system)다. 루트 레이아웃은 이 쿠키를 먼저 읽고, 값이 없으면 옛 theme 쿠키를 따른다. system 이면 테마 속성을 붙이지 않고 OS 설정을 따른다. 레일 접힘 상태는 킷 쿠키 axe_rail_collapsed 로 옮겼다. 예전 브라우저 저장값은 읽지 않으므로 한 번 펼쳐진 상태로 시작한다. 계정 메뉴의 언어 선택은 킷 셸에 해당 행이 생길 때까지 빠진다.

플랫폼 로그아웃 — /oauth/logout (D-ops-120 ⑤)

Blueprint 는 플랫폼 로그인의 로그아웃 주소 https://axelabs.ai/oauth/logout?return=<돌아갈 주소> 를 제공한다. 서비스의 로그아웃 버튼이 이 주소로 보내면 Blueprint 로그인 세션을 끝내고, 등록된 서비스(현재 hive)마다 플랫폼이 서명한 일회용 로그아웃 토큰(그 서비스 앞으로만 유효, 수명 1분)을 실은 요청을 브라우저가 보내게 해 그 서비스의 세션도 함께 끝낸 뒤 최대 3초 안에 돌아갈 주소로 이동한다. 돌아갈 주소는 AXE 서비스 화면이나 Blueprint 안의 경로만 받고(정규화 뒤 다른 호스트로 읽히는 경로는 거부), 그 밖은 워크스페이스 홈으로 보낸다. 몰래 로그아웃시키는 것은 막혀 있다 — 사람이 주소창에 입력했거나 AXE 서비스 화면에서 넘어온 페이지 이동일 때만 바로 로그아웃하고, 다른 사이트에서 온 요청이나 페이지 안에 이미지·프레임으로 끼워 넣은 요청에는 확인 화면을 먼저 보여 준다. 로그인 세션이 없는 요청에는 서비스 로그아웃 토큰을 만들지 않는다. 로그아웃 토큰은 로그인 토큰과 같은 서명 키로 만들지만 로그인에 쓸 수 없는 형태다. 운영자는 롤백 스위치로 서비스 세션 연쇄 종료 없이 Blueprint 세션만 끝내는 동작으로 되돌릴 수 있다. Blueprint 화면의 레일 계정 메뉴 로그아웃도 이 주소로 간다(D-ops-122 T2 2c).

CLI 대화 연동 · 호스트 로그인 — 기기 승인 /oauth/device (D-bp-collab-13 · D-bp-collab-14)

브라우저를 직접 열 수 없는 대화(공용 호스트에 SSH 로 연 Claude Code 대화 · Codex 스레드)가 사람의 계정을 얻는 경로다. 브라우저 없는 호스트의 CLI 로그인(axe login --device, 호스트 로그인, D-bp-collab-14)도 같은 경로를 쓴다. 표준 기기 승인(RFC 8628)을 따른다.

엔드포인트하는 일
POST /oauth/device_authorization에이전트 CLI(axe-cli 클라이언트 전용)가 연동 요청을 만든다. 라벨·기대 계정 키를 싣고, 10분짜리 확인 코드와 승인 주소를 받는다. 선택 필드 kind(conversation 기본 · host)를 받아 응답에 그대로 돌려준다. host 요청에는 코드가 박힌 주소(verification_uri_complete)를 주지 않는다 — 사람이 승인 화면에서 코드를 직접 입력한다.
GET /oauth/device?user_code=…사람의 승인 화면. 로그인한 계정 · 요청한 대화·기기 라벨(요청 기기가 보고한 값) · 요청 시각 · 확인 코드를 보여 준다. 문구는 종류에 따라 다르다 — 호스트 로그인은 ‘요청한 기기’ 와 “그 기기의 모든 대화·명령이 최대 30일 내 계정으로 동작” 경고, [로그인 승인] 버튼. GET 은 아무것도 바꾸지 않는다 — 메신저 링크 미리보기가 승인하지 못한다.
POST /oauth/device/approve같은 출처 폼의 [연동]·[로그인 승인]/[거절]. 대리 보기 중이거나 기대 계정 키가 로그인 계정과 다르면 승인하지 않는다.
POST /oauth/token (grant_type=urn:ietf:params:oauth:grant-type:device_code)CLI 가 5초 간격으로 묻는다. 승인되면 한 번만 교환되고 access(1시간) + refresh 를 받는다. refresh 는 대화 7일 · 호스트 로그인 30일, 둘 다 발급 시점부터 절대 — 회전해도 연장 안 됨.
GET /api/cli/linked-conversations로그인한 본인의 연동 목록 — 연동된 대화(Claude Code 대화·Codex 스레드)와 호스트 로그인. 항목마다 종류(conversation·host) · 이름 · 연동 · 최근 갱신 · 만료. 쿠키 세션 전용, 대리 보기 중 거부.
POST /api/cli/linked-conversations같은 출처 JSON {"id": …} 로 그 연동을 해제 — 체인의 살아 있는 refresh 를 폐기한다. 본인 것만; 없거나 남의 것이면 똑같이 404. 이미 받은 access 는 최대 1시간(그 연동의 만료를 넘지 않음). 대리 보기 중 거부.

discovery 문서(/.well-known/openid-configuration)가 device_authorization_endpoint 와 device_code grant 를 알린다. 요청 행은 사용자 정보가 없는 전역 표에 해시로만 남고, 승인하면 기존 인가 코드와 같은 원자 소비 경로로 교환된다. 서비스 허용집합·테넌트 경계는 일반 로그인과 같다. 기대 계정 키는 반공개 값이라 피싱을 막지 못한다 — 엉뚱한 계정으로 잘못 승인하는 것을 막는 장치다. 피싱은 경고문 · 라벨·시각·코드 대조 · 10분 1회 · 절대 수명(대화 7일 · 호스트 로그인 30일) · 웹 해제로 줄인다. 호스트 로그인은 사용해도 연장되지 않는다 — 원격으로 속아 승인한 로그인이 쓰이는 동안 영구화되지 않게 하기 위해서다. 공유 SSH 호스트에 이미 호스트 계정이 있으면 CLI 가 요청을 그 계정 키에 묶어, 다른 계정은 승인할 수 없다(바꾸려면 CLI 에서 --host).

웹 설정(/axe/settings)의 ‘연동된 대화’ 카드(‘CLI 액세스’ 바로 아래)에서 내 연동된 대화와 호스트 로그인(‘호스트 로그인’ 표시)을 보고 [해제]한다. 해제하면 그 연동은 다음 갱신부터 실패하고, 이미 받은 access 토큰은 최대 1시간 안에 끝난다. 흐름과 CLI 쪽 저장은 AXE CLI 페이지에 있다.

데이터 모델: OAuthDeviceGrant 에 kind(conversation 기본 · host, CHECK 제약) 를 더했다 — 승인 전 요청을 담는 전역 표라 사용자 축 컬럼이 없고 RLS 도 붙지 않는다(행은 만료 24시간 뒤 지워진다). OAuthRefreshToken 에는 deviceKind(nullable, 같은 두 값만 허용) 를 더해 발급 때 요청의 kind 를 복사하고 회전할 때마다 이어받는다 — 연동 종류와 수명(대화 7일 · 호스트 30일)의 지속 기록이다. deviceLabel 이 있으면 기기 체인(절대 만료)이고, 그중 deviceKind 가 비어 있으면 종류 기록 이전의 대화 연동이다. deviceLabel 이 없는 행은 브라우저 axe login 체인이라 ‘연동된 대화’ 목록에 나오지 않는다.

Task 승인 확인 — /oauth/stepup/callback (Blueprint Browser, D-ops-126 · D-ops-127 · D-ops-131)

Blueprint Browser 에서 Task(에이전트가 사람 클릭 없이 쓸 수 있는 승인 범위)를 만들거나 넓힐 때, Blueprint 는 그 사람의 Microsoft 로그인을 직접 확인한다. Blueprint 웹의 로그인 세션으로 대신하지 않는다. 브라우저는 전용 클라이언트 blueprint-browser 로 /oauth/authorize 를 부르고(PKCE 필수, 브라우저 자기 기기의 루프백 주소로만 돌아온다. prompt 는 보내지 않거나 prompt=login·max_age=0 짝으로만 보낸다), Blueprint 는 Microsoft 로 곧장 보낸 뒤 /oauth/stepup/callback 에서 결과를 확인한다: 등록된 조직의 활성 계정인지(그 Microsoft 계정으로 Blueprint 에 한 번 로그인해 연결돼 있어야 한다), 로그인 시각이 조건에 맞는지(그리고 MFA 모드라면 MFA 를 거쳤는지). 맞을 때만 1분짜리 일회용 code 를 내주고, 브라우저는 그것으로 5분짜리 확인서(로그인 시각, Microsoft 계정 식별자, 그리고 acr — Blueprint 가 새 로그인을 요구했으면 urn:axe:acr:reauth, 새 로그인을 요구하지 않아 기존 Microsoft 로그인을 이어 쓸 수 있었으면 urn:axe:acr:sso, MFA 까지 확인했으면 urn:axe:acr:mfa. sso 는 Microsoft 가 실제로 로그인 화면을 띄웠는지까지 알려 주지는 않는다)를 받는다. 이 확인서는 그 브라우저 앞으로만 유효해 다른 AXE 서비스를 부르는 데 쓸 수 없고, 갱신 토큰도 없다. 확인이 끝날 때마다 본인에게 Teams 메시지가 한 번 간다 — 본인이 한 것이 아니면 곧바로 알 수 있게 하기 위해서다.

확인 방식은 운영자 설정 STEPUP_ENTRA_MODE 로 고르며, 비워 두면 이 기능은 꺼져 있다(요청하면 “일시적으로 사용할 수 없음”). reauth 모드(D-ops-127)는 승인할 때마다 Microsoft 로그인을 다시 받는다. sso 모드(D-ops-131)는 Microsoft SSO 를 그대로 쓴다 — Blueprint Browser 가 승인 창 전용으로 Microsoft 로그인 상태를 기억하므로(.10 부터), 그 창에서 처음 한 번 로그인한 뒤로는 승인할 때 비밀번호를 다시 묻지 않는다. 다만 그 로그인이 90일보다 오래됐으면 받지 않고, 예전 브라우저 버전은 sso 모드에서도 매번 새로 로그인한다. 두 모드 모두 Blueprint 는 로그인 시각만 확인하고 MFA 를 따로 판정하지 않는다 — MFA 는 그 조직의 Microsoft 조건부 액세스 정책이 로그인 단계에서 요구하는 대로다. MFA 까지 Blueprint 가 확인하는 모드도 남아 있다; 그중 조건부 액세스 인증 컨텍스트로 확인하는 방식(STEPUP_ENTRA_AUTH_CONTEXT)은 그 조직의 Microsoft 관리자가 인증 컨텍스트와 MFA 정책을 걸어야 한다.

관련 문서