# AXE Labs Docs — 전체 본문 (LLM 단일 ingest 용)
생성: 2026-06-08T16:08:51.801Z
사이트: https://docs.axelabs.ai
각 페이지는 URL prefix + frontmatter + 본문 형식. 페이지 경계는 "" 마커.
---
# AXE Labs Platform
> 멀티테넌트 SaaS 플랫폼 — 액스코퍼레이션 주식회사 (`axec`) 가 운영하는 자체 인프라.
URL: https://docs.axelabs.ai/
# AXE Labs Platform
> 👋 **처음 오셨나요?** 본인 역할로 바로 이동하세요 — 이 사이트 외 다른 첨부·메시지 의존 없이 자력 완료 가능합니다.
>
> | 누구이신가요? | 가는 곳 | 소요 |
> |---|---|---|
> | **고객사 IT 담당자** (회사가 AXE Labs 도입 결정 → 자기 회사 macmini 준비 중) | [→ 고객사 IT 가이드](/partner) (4-step) | ~45 분 |
> | **신규 직원** (회사가 이미 AXE Labs 사용 중 → 본인 connector setup) | [→ 신규 직원 온보딩](/onboard) | ~10 분 |
> | **운영자 (액스코퍼레이션 주식회사)** | [→ 운영자 runbook](/ops/runbook/customer-onboarding) | — |
AXE Labs 는 **운영 주체 (액스코퍼레이션 주식회사) 가 다수의 customer 를 동일 패턴으로 호스팅** 할 수 있도록 설계된 멀티테넌트 SaaS 플랫폼입니다. 각 customer (현재 axe, 진행 중 realchoice) 는 자기 macmini 위에서 동일한 stack — frame · blueprint · vault · index — 을 실행하고, 운영 주체의 자동화 계정 (`ai@axellc.com`) 이 단일 CLI 와 SSOT 설정 파일(`customers.yaml`)로 전체를 관리합니다.
## 핵심 서비스
```
┌────────────────────────────────────────────────────────────────┐
│ AXE LABS — multi-tenant platform │
├────────────────────────────────────────────────────────────────┤
│ │
│ blueprint : AI-native workspace (Next.js + Claude SDK) │
│ frame : 회계 backend (Postgres + MCP) │
│ stream : S&OP MCP (sales · inventory · settlement) │
│ magnet : 마케팅 자동화 MCP (Meta / Naver / Threads) │
│ vault : Self-host Vaultwarden + Microsoft SSO │
│ index : (예정) 검색·전체 색인 layer │
│ │
└────────────────────────────────────────────────────────────────┘
```
각 서비스는 별도 git repo + Docker Compose 단위로 배포되며, customer 격리는 **OS-level (macmini 1대 = customer 1개)** 입니다.
## 문서 구성
| 섹션 | 대상 | 인증 |
|---|---|---|
| [플랫폼 아키텍처](/architecture) | 모든 독자 | 공개 |
| [서비스 카탈로그](/services) | 모든 독자 | 공개 |
| [직원 온보딩](/onboard) | 신규 직원 | 공개 |
| [고객사 IT 가이드](/partner) | 고객사 IT 담당자 | 공개 (민감 정보는 별도 채널) |
| [운영자 (내부)](/ops) | 운영자 (`ai@axellc.com`) | Microsoft Entra ID 로그인 |
| [운영 정책](/operations) | 운영자 + 감사인 | 공개 (정책 자체) |
## Quick Links
- **고객사 IT (도입 결정 후 첫 방문)**: [고객사 IT 가이드 — 4-step 흐름](/partner)
- **고객사 IT (Azure CLI 1줄)**: [Entra ID 앱 등록 §Option A](/partner/registration#option-a--cli-1-줄-권장)
- **신규 직원**: [Frame connector 4-step setup](/onboard/claude-frame-setup)
- **터미널 · Claude Code · Codex · CI**: [AXE CLI (`axe`) 설치 + 로그인 1회](/services/cli) — MCP 커넥터 불요, 토큰 하나로 전 서비스
- **운영자**: [신규 customer onboarding](/ops/runbook/customer-onboarding)
- **아키텍처 결정**: [DECISIONS](/ops/decisions)
## AI / LLM 진입점
- **[`/llms.txt`](https://docs.axelabs.ai/llms.txt)** — [llmstxt.org](https://llmstxt.org/) 스펙 페이지 인덱스. AI agent 가 사이트 구조를 파악하는 표준 entry point.
- **[`/llms-full.txt`](https://docs.axelabs.ai/llms-full.txt)** — 전체 페이지 본문을 1 파일로 concat (현재 ~190 KB). 단일 ingest 로 컨텍스트 전체 로딩.
- **[`/api/search?q=...`](https://docs.axelabs.ai/api/search?q=OAuth)** — JSON full-text search. score 기반 ranking + snippet + markdown_url. LLM 이 페이지 위치 찾을 때.
- **Per-page raw markdown** — 모든 URL 에 `.md` suffix (예: [`/services/frame.md`](https://docs.axelabs.ai/services/frame.md)). frontmatter + canonical URL 주석 포함.
- **[`/sitemap.xml`](https://docs.axelabs.ai/sitemap.xml)** — 전체 URL 목록.
- **[GitHub repo](https://github.com/soohunkang/axelabs-docs)** — mdx 원본 직접 조회.
## 도메인 / URL 컨벤션
```
{customer}.axelabs.ai/ ← blueprint (apex)
{customer}.axelabs.ai/frame/mcp ← frame MCP endpoint
{customer}.axelabs.ai/vault ← Vaultwarden
admin.axelabs.ai ← 운영자 콘솔 (Microsoft SSO 게이트)
docs.axelabs.ai ← 본 문서 (이곳)
```
* `axelabs.ai` — 플랫폼 (이 docs 가 있는 곳)
* `axellc.com` — corporate (이메일·기업 홈페이지, 플랫폼과 분리)
## 운영 메타
| 항목 | 값 |
|---|---|
| 운영 주체 | 액스코퍼레이션 주식회사 (`axec` entity, 자동화 계정 `ai@axellc.com`) |
| 1차 customer | axe (라이브 2025-01) |
| 2차 customer | realchoice (예정 2026-06-01) |
| 회계 기준 | KSME / K-GAAP |
| 시간대 | Asia/Seoul |
| 인증 | Microsoft Entra ID OAuth 2.0 / OIDC |
| 데이터 격리 | OS-level (customer/macmini) + schema-per-entity (DB) |
---
# 플랫폼 아키텍처
> AXE Labs 멀티테넌트 SaaS 플랫폼의 토폴로지, 도메인, 인증, 격리 모델.
URL: https://docs.axelabs.ai/architecture
# 플랫폼 아키텍처
AXE Labs 의 아키텍처는 **소규모 운영자(1인) 가 다수의 customer 를 일관된 패턴으로 호스팅** 할 수 있도록 설계되었습니다. 모든 결정은 [DECISIONS](/ops/decisions) 에 누적 기록되며, 본 페이지는 그 결과의 정적 요약입니다.
> 약어가 익숙하지 않으면 [용어 사전](/glossary) 먼저 참조. 핵심: **MCP** (Model Context Protocol, AI 가 외부 도구·데이터에 접근하는 표준), **OIDC** (OpenID Connect, 신원 확인), **PKCE** (Proof Key for Code Exchange, OAuth 코드 가로채기 방지).
## 6 가지 설계 원칙
1. **격리는 OS-level — customer 1개 = macmini 1대.** 다중 customer 가 한 머신을 공유하지 않습니다. SPOF (macmini 자체 장애) 는 cold storage 백업으로 흡수.
2. **테넌트 ID = 이메일 도메인 → `customers.yaml` 매핑.** 사용자가 `ai@axellc.com` 으로 로그인하면 `axellc.com → axe` customer, `realchoice.co.kr → realchoice` customer 로 자동 라우팅.
3. **Path-based 1-level subdomain.** `{customer}.axelabs.ai/{service}` 형식. 2-level wildcard SSL 무료 미지원 → path-based 로 회피.
4. **Tailscale 메시 + SSH key.** 운영자 ↔ customer macmini 간 push 기반 deploy. 자체 mTLS X.
5. **Microsoft Entra ID + customer 별 own tenant.** 각 customer 의 직원은 자기 회사 tenant 로 인증.
6. **GitHub source-of-truth + push deploy.** customer macmini 가 pull-polling 하지 않음.
## High-Level Topology
```
┌────────────────────────────────────────────────────────────────────────┐
│ │
│ 직원 노트북 (Windows / macOS) │
│ ├─ Claude Code 네이티브 앱 + Microsoft Entra ID 로그인 │
│ └─ claude.ai 측에 Custom Connector 1회 등록 │
│ │ │
│ ↓ (Anthropic backend OAuth flow) │
│ │
│ Microsoft Entra ID (customer tenant) │
│ - axe: 122fb574-... (axellc.com) │
│ - realchoice: <TBD> (realchoice.co.kr) │
│ │
│ ↓ (Bearer access_token, aud = MCP URL) │
│ │
│ Cloudflare DNS / Cloudflare Tunnel (axelabs-tunnel) │
│ │ │
│ ↓ (host.docker.internal:3712) │
│ │
│ ┌──── customer macmini (axe-macmini / realchoice-macmini) ────┐ │
│ │ │ │
│ │ axe-frame-proxy (Caddy, port 3712) ← blue/green selector │ │
│ │ │ │ │
│ │ ├──→ frame-mcp-blue :3710 (active) │ │
│ │ └──→ frame-mcp-green :3711 (passive) │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ frame-postgres :3700 │ │
│ │ │ │
│ │ blueprint :3100 axe-vaultwarden :443/vault │ │
│ │ stream :8780 magnet :8770 │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
```
## 인증 흐름 (간략)
```
1. 직원 → claude.ai Custom Connector → URL https://{customer}.axelabs.ai/frame/mcp
2. claude.ai 백엔드 → frame /mcp (no auth) → 401 + WWW-Authenticate
3. claude.ai → /.well-known/oauth-protected-resource → AS = Microsoft Entra
4. claude.ai → Microsoft authorize 엔드포인트 (PKCE S256 + client_secret)
5. 브라우저 → Microsoft 로그인 → consent → code 발급
6. claude.ai → Microsoft /token → access_token (aud = Application ID URI)
7. claude.ai → frame /mcp with Authorization: Bearer <access_token>
8. frame middleware → Microsoft JWKS RS256 검증 + audience match + entity 매핑
9. frame → MCP session 생성 → tools 호출
```
상세는 [인증 · 권한](/architecture/auth) 참조.
## 다음 단계
- [Topology · 네트워크](/architecture/topology) — Cloudflare Tunnel, Docker network, proxy 패턴 상세
- [도메인 · DNS](/architecture/domains) — Cloudflare zone, 1-level subdomain 결정 근거
- [인증 · 권한](/architecture/auth) — Microsoft Entra ID, JWT, dual-token model
- [데이터 격리 모델](/architecture/data) — schema-per-entity, audit_log, PII 암호화
- [배포 · 무중단](/architecture/deploy) — blue/green frame swap, axe-frame-proxy
- [백업 · DR](/architecture/backup) — restic 3-tier (local / ring P2P / cold SSD)
---
# Artifact 모델 · PARA 지식 레이어
> Blueprint 의 typed fact layer. PARA 와 결합해 work/knowledge 를 organize. MCPs/Teams/Mail/OneDrive 의 citation chain.
URL: https://docs.axelabs.ai/architecture/artifacts
# Artifact 모델 · PARA 지식 레이어
> **[2026-06-06 방향 갱신]** PARA 의 상위 설계 철학은 [/architecture/para-os](/architecture/para-os) 로 정리됨 (PARA=조직론, dispatch="집"/link-default, governance 결정로그, 죽은 딜 본질). 본 페이지의 **dispatch `copy-with-provenance` → `link-default` 로 revise** ([D-bp-para-1](/ops/decisions)), Area=workspace-container → 조직론 노드 + R/A 내포. 단 저장·citation·schema discovery 세부는 본 페이지가 유효한 reference.
>
> **[2026-06-06 구현 토폴로지 — [D-bp-rust-1](/ops/decisions)]** Artifact 레이어는 Blueprint 의 **첫 Rust organ** 으로 이관된다 (TS→Rust strangler-fig). 신규 substrate 서비스(Rust+axum)가 `blueprint-postgres` 의 **`substrate` schema** 에서 `artifact`/`artifact_link`/`mcp_schema` 를 소유 — `workspace_id`·`entity_id` 는 opaque 외부 ref(**Artifact-scoped 경계**), Workspace+paraLayer SoR 는 Prisma `public` 잔류. 모델 교정(처음부터 1급): **home/link("집")** = `artifact_link` 가 멤버십 본체 + **`annotation`**(Area별 해석), `parent_artifact_id` → **`derived_from_id`**(진짜 파생만), **2-level `scope`**(personal/shared), **citation 8종**(아래 7종 + `gate.decision` [D-gate-2](/ops/decisions)). 아래 본문의 Prisma SQL·"copy default"·"7 kind" 서술은 **현 TS(M6 Stage-1) 기록**이며 cutover(ADR §6 S3) 전까지 병존 — 권위 = [D-bp-rust-1](/ops/decisions) + ADR `docs/adr/blueprint-rust-migration.md`.
## 한 줄 정의
**Artifact = Blueprint 의 typed fact unit**. source 문서가 아니라 agent 가 직접 read 하는, 사전 추출된 사실 한 조각. per-field schema + per-field citation + confidence + audit trail + paraLayer scope 를 갖춤. Blueprint Postgres 거주 (D-config-16), large content / 원본 파일은 OneDrive · frame · hive · Teams · mail 등 원위치 유지하고 artifact 는 stable ID 로만 참조 — data 중복 0.
## 왜 필요한가
현재 Blueprint 의 knowledge layer 는 [ctx skill](https://docs.axelabs.ai/services/blueprint) 의 markdown PKM 한 단계뿐. agent 가 grep + cat 으로 markdown 본문을 읽어 사실을 추출하는 방식이라 다음이 모두 불가능:
| 격차 | 현재 (markdown only) | 결과 |
|---|---|---|
| Cross-functional query | "portfolio cos 중 Q4 churn > 15%" 를 grep 으로 못 풀음 | Area query 가 LLM 토큰 폭증 |
| Field-level provenance | 사실 한 줄의 출처 추적 불가 (markdown 안에 인용 표기 의무 X) | citation chain 끊김, IC 결정의 audit 불가능 |
| Time-travel | "Project Deal X 가 지난 주 이후 무엇 바뀌었나" 답 불가 | 의사결정의 시점형 archaeology 불가 |
| 결정론적 충돌 해소 | 두 agent 가 같은 markdown 동시 편집 시 last-write-wins | 사실 무결성 보장 X |
| Multi-agent concurrent read-write | markdown lock 없음 | 동시 작업 시 race |
격차의 본질: 인프라는 dev-co level (per-customer macmini isolation · blue/green deploy · OAuth-RP · restic backup · 7-stack 응집) 이지만 knowledge layer 만 single-operator PKM tool 수준에 머문다. AI-native fund 가 자기 의사결정의 SOT 로 쓰려면 typed fact layer 가 필요.
## PARA 와의 관계
PARA (Tiago Forte) 의 Project / Area / Resource / Archive 4 layer 각각에 typed artifact 집합이 거주. PARA layer 가 artifact 의 lifecycle 과 visibility 를 결정.
| Layer | 역할 | 대표 artifact 유형 |
|---|---|---|
| **Project** | 기한 + goal cross-functional 컨테이너 (IC for Deal X, Fund 2 fundraising, 2026 Q3 LP report) | `ICMemoArtifact` · `BoardMeetingNotesArtifact` · `LPCapitalCallArtifact` |
| **Area** | function 별 지속 reference state (CFO · IR · Sourcing · Portfolio). read 빈도 높음, 끊임없는 갱신 | `PortcoBoardKPIArtifact` (Portfolio Monitoring) |
| **Resource** | 재사용 가능한 versioned framework + template | `SectorFrameworkArtifact` |
| **Archive** | 기한 지난 Project + outdated resource. pattern detection corpus | 위 모든 종류의 frozen snapshot |
Project 가 closeout 되면 그 안의 artifact 들이 typed fact 단위로 Area / Resource / Archive 로 field-level dispatch. [M3 PARA Dispatch](/ops/roadmap#m3--blueprint-para-dispatch-d-bp-entity-1-pr-5) 가 현재 workspace-level (file-level) provenance (`sourceWorkspaceId` / `sourceArtifactPath` / `copiedAt`, [D-bp-entity-2](/ops/decisions)) 까지 land — Artifact 는 그 위에서 **field-level** 진화.
### Cross-PARA query 예시
현재 ctx markdown 으로 불가능하지만 artifact layer 위에서 자연:
- "portfolio cos 중 Q4 churn > 15%" — Area query (`PortcoBoardKPIArtifact.confidence ≥ 0.8`)
- "과거 deal 중 현재 Sentry 와 IRR trajectory 비슷한 것" — Archive cross-Project pattern match
- "portco Y 가장 최근 revenue, citation 포함" — Area lookup with citation chain
- "Project Deal X 가 지난 주 이후 무엇 바뀌었나" — time-travel diff
- "LP X 와 마지막 comm 후 변한 portfolio fact" — IR Area × Portfolio Area cross-query
## 데이터 모델
Blueprint Postgres 의 `Artifact` + `Citation` 테이블 (Prisma). schema 는 versioned (`schema_id` FK), content 는 JSONB (schema 별 typed field), citations 는 JSONB array.
```sql
CREATE TABLE artifact (
id uuid PRIMARY KEY,
schema_id text NOT NULL REFERENCES artifact_schema(id), -- MCP discovery schema (e.g., `frame.balance@1.0`, `hive.employee@1.0`) 또는 free-form (`freeform.llm@auto`). MCP authority — Blueprint 는 registry 아니라 discovery/mirror
workspace_id uuid NOT NULL REFERENCES workspace(id),
entity_id text NOT NULL REFERENCES entity(slug), -- D-bp-entity-3 NOT NULL
para_layer text NOT NULL CHECK (para_layer IN ('PROJECT','AREA','RESOURCE','ARCHIVE')),
content jsonb NOT NULL, -- schema 별 typed field
citations jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-field citation array
confidence numeric(3,2) NOT NULL DEFAULT 1.0, -- 0.00 ~ 1.00
audit_trail jsonb NOT NULL DEFAULT '[]'::jsonb, -- propose/review/confirm/edit events
parent_artifact_id uuid REFERENCES artifact(id), -- PARA dispatch fork chain
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz -- soft delete (Archive 진입 시 set)
);
CREATE INDEX artifact_workspace_idx ON artifact (workspace_id);
CREATE INDEX artifact_entity_para_idx ON artifact (entity_id, para_layer);
CREATE INDEX artifact_schema_idx ON artifact (schema_id);
CREATE INDEX artifact_parent_idx ON artifact (parent_artifact_id) WHERE parent_artifact_id IS NOT NULL;
```
`audit_trail` 의 한 entry 는 `{ event, actor, ts, diff?, prompt? }` 형식. 변경 history 가 row 안에 누적되며 `updated_at` 만 갱신 — pg_jsonschema validation 으로 무결성 가드 (확장 add-on).
## Citation 형태
Citation 은 외부 source 의 **stable ID** 만 가짐. data 자체는 원위치 (OneDrive driveItem / frame query / hive ID / index DB ref / gate decision cert / Teams chat+msg / mail thread+msg / external URL) — artifact 에 중복 0. 8 kind:
```jsonc
// content 의 field 별 1+ citation
{
"field": "revenue_q4_2025",
"citations": [
// 1) OneDrive Excel cell
{ "kind": "onedrive",
"drive_item_id": "01ABCD...",
"version": "v3",
"sheet": "Q4 Recurring",
"cell": "B14" },
// 2) frame typed query (회계 도메인)
{ "kind": "frame.balance",
"entity": "axec",
"account": "4001",
"period": "2025-Q4",
"queried_at": "2026-05-22T03:14:00Z" },
// 3) hive employee record (HR 도메인)
{ "kind": "hive.employee",
"entity": "axev",
"employee_id": "uuid-...",
"queried_at": "2026-05-22T03:14:00Z" },
// 4) Teams chat message
{ "kind": "teams.message",
"chat_id": "19:...",
"message_id": "1700000000000" },
// 5) Mail thread message
{ "kind": "mail.thread",
"thread_id": "AAMkAD...",
"message_id": "AAMkAD...." },
// 6) External web fetch (snapshot 시점 명시)
{ "kind": "external.web",
"url": "https://...",
"fetched_at": "2026-05-22T03:14:00Z" },
// 7) index typed query (투자 도메인 — D-index-6)
{ "kind": "index.financial_output",
"deal_id": "uuid-iippo",
"model_id": "uuid-iippo-v7-base",
"scenario_code": "weighted",
"output_code": "irr_loss_included",
"computed_at": "2026-05-06T10:00:00Z" },
// 기타 index.* sub-kinds: index.deal · index.fund_investment ·
// index.dd_finding · index.ic_decision · index.portfolio_kpi ·
// index.financial_model · index.valuation · index.lp_comm 등
// 8) gate 거버넌스 결정 (gate.decision SoR 의 mirror — D-gate-2)
{ "kind": "gate.decision",
"decision_id": "uuid-...",
"cert_id": "modusign-...", // 모두싸인 서명완료 cert (citation only)
"decided_at": "2026-06-06T01:00:00Z" }
]
}
```
각 kind 에 대응하는 **citation resolver** (`src/lib/artifact/citations/`) 가 stable ID 로 실 데이터를 fetch + cache. Trinity dashboard (Workspace × OneDrive × TeamsChat) 의 진화형 — 기존 GUID 3축 → field 단위 citation chain.
## MCPs 와의 분리
별 트랙. **MCP = domain-specific data system**, **Artifact + PARA = Blueprint 내부 work/knowledge organization meta-layer**.
| Layer | 책임 |
|---|---|
| frame MCP | 회계 도메인 데이터 (journal, balance, period) — schema-per-entity Postgres |
| hive MCP | HR 도메인 데이터 (employee, payroll, leave) — frame mirror |
| magnet / stream / 기타 MCP | 각 도메인 데이터 시스템 |
| **Artifact + PARA (Blueprint)** | **위 MCP 들이 발행하는 사실을 typed fact 로 organize. citation 으로 stable ID 참조** |
Artifact 가 frame 의 `query_balance` 결과를 자기 안에 복사하지 않음. citation `{ kind: "frame.balance", entity, account, period, queried_at }` 만 보유. agent 가 artifact read 시 resolver 가 frame MCP 를 다시 호출하거나 cache 사용. MCP backend 가 sole owner — artifact 는 reference + interpretation 의 home.
### Schema authority 분산
Schema 의 권위는 **MCP service** (frame · hive · magnet · stream · Matrix · …). 각 MCP 가 자기 도메인 schema 를 정의하고 `/schemas` endpoint 로 노출. Blueprint 는 그 schema 들을 **discovery / mirror** — fetch + cache + version. registry 아님. 새 MCP 추가 시 자기 schema 를 가지고 들어오므로 Blueprint 측 변경 0.
MCP 가 정한 규칙이 없는 영역 (IC memo / Board meeting / Trip / LP comm / Sector framework 등 free-form) 은 **LLM 자율** — extract 시점에 ad-hoc typed structure 결정하거나 free-form content + citation/confidence wrap 으로 보존. AXE 가 미리 schema 다 정의할 필요 없음.
| 영역 | Schema authority | 예 schema_id |
|---|---|---|
| 회계 (journal, balance, period, account) | **frame MCP** | `frame.balance@1.0` · `frame.journal@1.0` |
| HR (employee, payroll, leave, compensation) | **hive MCP** | `hive.employee@1.0` · `hive.payslip@1.0` |
| 투자 (deal, fund_investment, financial_model, dd_finding, ic_decision, portfolio_kpi, valuation, lp_comm) | **index MCP** | `index.deal@1.0` · `index.fund_investment@1.0` · `index.financial_output@1.0` ([14 schemas](/services/index/schema-catalog)) |
| 마케팅 (campaign, channel, lead) | **magnet MCP** | `magnet.campaign@1.0` |
| S&OP (forecast, plan) | **stream MCP** | `stream.forecast@1.0` |
| 모니터링 (health, alert) | **Matrix MCP** | `matrix.health@1.0` |
| 관계망 (person, interaction, relationship) | **cortex MCP** | `cortex.person@1.0` · `cortex.interaction@1.0` |
| Board meeting / Trip / Sector framework (도메인 외 free-form) | **LLM 자율** | `freeform.llm@auto` 또는 skill-defined (`ic.memo@1.0`) |
`/schemas` endpoint contract (각 MCP 가 표준화):
```jsonc
GET /schemas
{
"service": "frame",
"version": "1.2.0",
"schemas": [
{ "id": "frame.balance@1.0", "fields": { ... }, "description": "..." },
{ "id": "frame.journal@1.0", "fields": { ... }, "description": "..." }
]
}
```
Blueprint 의 `src/lib/artifact/schemas/` 가 각 MCP 의 `/schemas` 를 주기적으로 fetch → `artifact_schema` 테이블 upsert. 변경 감지 시 새 version row 추가 (기존 row 미변경 — 기존 artifact 의 citation chain 보존).
## ctx skill 의 진화
**대체 아님, 진화.** 기존 `ctx` (agent session delta → markdown append + parent KB propagation) 는 artifact store 의 **curation interface** 로 재정의.
```
source (OneDrive doc / frame query / Teams chat / mail thread / web fetch)
↓
ingest (skill: ingest, 별도 skill 신설 가능)
↓
typed extraction (schema-aware LLM extract, e.g., ICMemoArtifact §재무)
↓
proposed fact (audit_trail entry: { event: "propose", actor: "agent", ... })
↓
ctx review (user confirm / edit / reject — UX 가 markdown diff + citation hover)
↓
confirmed fact + audit (audit_trail entry: { event: "confirm", actor: "user", ... })
↓
agent reads via Query API (grep 안 함 — typed query + citation chain)
```
Markdown 은 보조 — review UX 의 diff 표면 + archive 보존 + emergency cat (DB 장애 시). primary read path 는 Query API.
### Markdown → artifact 점진 migration (AI-assisted)
기존 markdown PKM (`ctx/MEMORY.md` 의 누적 entry, project_*.md, feedback_*.md 등) 은 **목표는 전면 artifact 화**. 단 mass migration 안 함 — **항목 단위 AI-assisted ctx review** 로 점진:
```
기존 markdown 파일 1개 (예: ctx/MEMORY.md 의 한 entry)
↓
LLM 이 read → typed extract 시도 (MCP discovery schema 매칭 또는 LLM 자율 free-form)
↓
proposed artifact (audit_trail: { event: "migration_propose",
actor: "agent",
source_markdown: "ctx/MEMORY.md#L42-58",
ts: "..." })
↓
ctx review (사용자 confirm / edit / reject — UX 가 source markdown 발췌 + extracted typed fact 나란히)
↓
confirmed artifact + 기존 markdown entry 에 `` archive 마킹
↓
markdown 파일 전체 entry 가 모두 migrate 되면 그 파일 자체 deprecation
↓
신규 fact 는 처음부터 artifact 로 — markdown append 안 함
```
핵심 원칙:
- **점진** — 1주에 1 파일 또는 1 batch (10 entry) 수준. 인간 정비 cadence 안에서
- **AI-assisted** — LLM 이 자동 extract, 사람은 confirm 만 (인간 개입 최소화)
- **archive 마킹** — migrated entry 가 어떤 artifact 로 변환됐는지 trace 보존 (``). 이후 audit / 재migration 가능
- **신규 fact = artifact 처음부터** — 마이그레이션 완료 전이라도 새 사실은 artifact 로 직접 들어옴 (markdown append 옵션 안 줌)
## Workspace lifecycle
PARA 4 layer 는 Workspace 의 `paraLayer` 컬럼 (PROJECT / AREA / RESOURCE / ARCHIVE) 으로 표현. 각 Workspace 는 정확히 1 paraLayer 보유. lifecycle 의 핵심: **Dispatch ≠ Close** — 두 활동 분리.
### 4 paraLayer transition
```
create(paraLayer=PROJECT|AREA|RESOURCE)
│
▼
┌──────── PROJECT (working) ─────────┐
│ │
│ dispatch (anytime — copy/link) │
│ artifact/file 을 다른 Workspace 로 │
│ reclassify (any-to-any) │
│ │
└────── close (paraLayer 변경 1회) ────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
AREA RESOURCE ARCHIVE
(현재형) (재사용) (시점형)
│ │ │
│ reclassify bidirectional 가능 │
│ (ARCHIVE resurrect 포함) │
└─────────────────┴─────────────────┘
```
규칙:
- **create** — paraLayer 선택 (PROJECT 가 default, AREA/RESOURCE 도 직접 가능)
- **dispatch (anytime)** — Project 진행 중에도 file/artifact 를 다른 Workspace 로 흘려보냄. 3 mode: **link**(기본, 집 배정) / **copy_curate**(재사용본 저작→Resource) / **move**(소모성→Archive) — 아래 §Dispatch 3 mode
- **close (한 번)** — `workspace.paraLayer` 를 한 번 변경. PROJECT → ARCHIVE (보통) 또는 PROJECT → AREA/RESOURCE (reclassify). audit trail 에 close event 기록
- **reclassify (bidirectional)** — ARCHIVE → AREA 도 가능 (resurrect). 단 audit_trail 보존 — 누가 언제 왜 resurrect 했는지
### Dispatch 3 mode — link(기본) / copy-curate / move ([D-bp-para-1](/ops/decisions))
> dispatch = "집(home)" 배정 ([para-os §6](/architecture/para-os)). **link 가 기본** (artifact 몸통 1 + 집 N). 구 "copy-default"(`copy-with-provenance`)는 폐기 — copy 는 *재사용본 저작* 예외만.
| Mode | 의미 | 사용 사례 |
|---|---|---|
| **link** (기본) | `artifact_link` 에 (artifact_id, workspace_id, link_type, **annotation**, linked_at, linked_by) row 추가. artifact 본문 1 row, 여러 Workspace 가 share. `annotation` = Area별 해석 레이어 | 같은 PortcoBoardKPI 를 Portfolio Area + IC Project + LP Project 가 동시 참조 (각 Area 다른 annotation) |
| **copy-curate** | method/지식을 *새 Resource artifact 로 저작* (사실 복제 아님). lineage 는 `derived_from_id`(진짜 파생만, 구 `parent_artifact_id`) | 죽은 딜 경쟁분석 → 섹터 Resource 큐레이션, 펀드 결성 템플릿 1호→2호 |
| **move** | 소모성 working corpus → Archive → 폐기 (workspace 레벨 + OneDrive trinity-sync) | LoI 초안 v1~v5, dataroom 원본, 회의 스크래치 |
`artifact_link` 테이블:
```sql
CREATE TABLE artifact_link (
artifact_id uuid NOT NULL REFERENCES artifact(id) ON DELETE CASCADE,
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
link_type text NOT NULL CHECK (link_type IN ('reference', 'sub_project', 'cross_link')),
linked_at timestamptz NOT NULL DEFAULT now(),
linked_by text NOT NULL, -- user.id or agent.id
PRIMARY KEY (artifact_id, workspace_id)
);
CREATE INDEX artifact_link_workspace_idx ON artifact_link (workspace_id);
```
dispatch API (substrate, ADR §6 S1):
- `dispatch_artifact(artifact_id, to_workspace_id, mode="link"|"copy_curate"|"move", annotation?)` → **link**(기본) = `artifact_link` row INSERT(+annotation), **copy_curate** = 새 Resource artifact + `derived_from_id`, **move** = workspace 레벨 + OneDrive 이동
- `unlink_artifact(artifact_id, workspace_id)` → link 해제 (`artifact_link` row DELETE). copy_curate 산출은 별 artifact 라 unlink 대상 아님
### Area cardinality + AXEV 현재 seed
**Area 1개 / function**: 중복 function 의 Area 가 둘 생기지 않도록 UI/LLM 가드. 새 Area 만들기 시도 시 기존 Area 와 function overlap 검출 → "기존 `` 와 merge 권고" 모달. operator 가 override 시도 시 audit 기록.
AXEV 현재 Area seed (2026-05-23):
| Area | Function | 비고 |
|---|---|---|
| **Finance** | 회계 / 재무 / cash flow / 예산 | frame MCP 의 data 가 주 |
| **BOD** | 이사회 / 결의 / 거버넌스 | resolution + meeting note |
| **Legal** | 계약 / 컴플라이언스 / 등기 | 법무 사항 |
| **License** | AC (Advisory Committee) 라이센스 관리 | AC 관련 |
Resource = 0 (아직 없음). naturally emerge — 첫 Project close 시점 또는 첫 framework consolidate 시점에 생성.
### UX 흐름 4 페이지 분리
UI 측면에서 paraLayer 별 entry point 를 분리 (cross-PARA query 는 별 surface):
| URL | 역할 | 표시 |
|---|---|---|
| `/axe/projects` | 진행 중 Project 리스트 + close button | PROJECT only |
| `/axe/areas` | function 별 living knowledge 그리드 + last-updated heatmap | AREA only |
| `/axe/resources` | reusable template/framework | RESOURCE only |
| `/axe/archive` | 시점형 의사결정 archaeology + search | ARCHIVE only |
각 page 에 "+New Workspace" 시 paraLayer 가 그 page 에 fix (PROJECT page = 새 PROJECT, AREA page = 새 AREA …). 4 page 위에 cross-PARA query 는 Knowledge Overview dashboard 에서 진입.
Workspace create 시 paraLayer 선택 UI (mock):
```
┌─ New Workspace ──────────────────────────────┐
│ Name: [_______________________________] │
│ Entity: [axev ▾] │
│ paraLayer: ( ) Project ( ) Area │
│ ( ) Resource ( ) Archive │
│ │
│ ▶ Target close date (optional, PROJECT only) │
│ [____________] │
│ │
│ [Cancel] [Create] │
└──────────────────────────────────────────────┘
```
### OneDrive 폴더 mirror
trinity-sync 가 Workspace 의 paraLayer 변경을 OneDrive 폴더 이동으로 mirror. **2 시점** 에 file move 발생:
| 시점 | 동작 | 예 |
|---|---|---|
| **dispatch (file 별)** | artifact 에 attached 된 file 1개를 다른 Workspace 로 dispatch 시, 그 file 만 destination Workspace 의 OneDrive 폴더로 부분 이동 (copy mode = OneDrive copy, link mode = OneDrive shortcut/symlink) | IC artifact 의 spreadsheet 1개를 Portfolio Area 로 link |
| **close (workspace 전체)** | Workspace.paraLayer 변경 시 그 Workspace 의 OneDrive 폴더 전체가 destination paraLayer 의 root 로 이동 | `/Ventures/1. Project/Deal X/` → `/Ventures/4. Archive/Deal X/` |
trinity-sync 가 OneDrive driveItem 이동 trigger + Blueprint Workspace.drivePath 업데이트. paraLayer 변경 자동 propagation.
## Knowledge Overview dashboard
회사 지식체계 P → A/R → A 흐름을 한눈에 조망하는 별도 page (`/axe/knowledge`). 4 paraLayer page 위에 cross-PARA view.
### 3-pane layout
```
┌─────────────────────────────────────────────────────────────┐
│ Knowledge Overview (axev) │
├──────────────────┬──────────────────┬───────────────────────┤
│ Layer counts + │ Sankey: PARA │ Cross-PARA query 입력 │
│ state │ flow (지난 30일) │ │
│ │ │ "portfolio cos 중 │
│ PROJECT: 12 │ PROJECT │ Q4 churn > 15%" │
│ - active: 8 │ ↓ dispatch │ │
│ - closing: 2 │ copy: 23 │ [Query] [Save] │
│ - stale: 2 │ link: 15 │ │
│ │ ↓ │ 결과: 3 artifact │
│ AREA: 4 │ AREA RESOURCE │ - portco Y (0.18) │
│ - Finance │ ↓ │ - portco Z (0.17) │
│ - BOD │ ↓ reclassify │ - portco W (0.16) │
│ - Legal │ 3 (manual) │ [view citation] │
│ - License │ │ │
│ │ ARCHIVE: │ │
│ RESOURCE: 0 │ close: 5 │ │
│ ARCHIVE: 27 │ resurrect: 1 │ │
└──────────────────┴──────────────────┴───────────────────────┘
```
### Area health alert
각 Area 의 last-updated timestamp 기반:
- **stale** (30일 무변경) — Area 카드에 yellow ⚠ banner
- **inactive** (90일 무변경) — Area 카드에 red ⏸ banner + "이 Area 가 living 인지 재평가" 알림
운영자 review queue 에 자동 추가.
### 추가 view (drill-down)
| View | 표시 |
|---|---|
| **PARA flow over time** | 월 별 P→A/R/A dispatch count 추세 (lifecycle 활성도) |
| **Artifact graph** | citation chain 의 graph 시각화 (artifact ↔ citation source) — 의존성 spot |
| **Provenance trace** | 1 artifact 선택 → 그 artifact 의 모든 dispatch / link / migration history (audit_trail timeline) |
### 의도
Knowledge Overview 의 자체 가치 — **회사의 fact health 가 한눈에**. PARA OS 가 단지 "파일 정리" 가 아니라 살아있는 지식체계라는 것을 운영자/직원이 daily 로 확인할 surface. inactive Area, dispatch 끊긴 흐름, citation chain 깨진 artifact 가 시각적으로 detect 됨.
## MCP tool 분리 (paraLayer 별)
UI 4 page 분리와 같은 원칙으로 MCP tool 도 paraLayer 별 분리. **LLM intent clarity** — tool name 자체에 paraLayer 의도 드러남.
### Workspace level tools
| Tool | 역할 |
|---|---|
| `list_projects(entity?, state?)` | PROJECT only, state = active / closing / stale |
| `list_areas(entity?)` | AREA only, last_updated 정렬 default |
| `list_resources(entity?)` | RESOURCE only |
| `search_archive(entity?, query, since?, until?)` | ARCHIVE only, time-travel + full-text |
| `create_workspace(paraLayer, name, entity, target_close_date?)` | 신규 (4 paraLayer 어디든) |
| `close_workspace(id, dispatch_plan?)` | paraLayer 변경 1회 + 동시 dispatch 가능 |
| `reclassify_workspace(id, new_paraLayer)` | bidirectional reclassify (ARCHIVE resurrect 포함) |
### Artifact level tools
| Tool | 역할 |
|---|---|
| `get_artifact(id)` | 단일 artifact + citations + audit_trail |
| `list_artifacts(workspaceId, schema_id?, paraLayer?)` | filter by workspace / schema / paraLayer |
| `propose_artifact(workspaceId, schema_id, content, citations, confidence?)` | agent propose (audit_trail event: "propose") |
| `dispatch_artifact(artifact_id, to_workspace_id, mode="copy" \| "link")` | copy or link |
| `unlink_artifact(artifact_id, workspace_id)` | link 해제 (copy 는 별 artifact 가 됐기 때문 미적용) |
### Knowledge query
| Tool | 역할 |
|---|---|
| `query_knowledge(query, paraLayers?, entity?, since?)` | KnowQL-lite — cross-PARA / time-travel / confidence filter. agent 가 grep 안 함 |
이유: `list_workspaces(paraLayer=PROJECT)` 같은 generic tool 보다 `list_projects` 가 LLM 의 intent 명확. plan 단계 토큰 절감 + 잘못된 paraLayer 호출 가능성 차단.
## LLM 호출 + Access 정책
artifact 의 두 가지 access pattern 결정:
### Layer 1: LLM 호출 = Max plan OAuth 우선
Artifact extraction / proposing / review 의 LLM 호출은 **Claude Code OAuth token (Anthropic Max plan) 우선**. Anthropic API direct (per-token billing) 은 fallback / 대량 batch 용.
| Surface | 호출 방식 |
|---|---|
| Teams bot | Claude Code OAuth (Max plan session) |
| 로컬 Claude Code CLI | OAuth (operator session) |
| 웹 UI (review queue, ctx review) | OAuth (Max plan, 사용자 본인 session) |
| 대량 batch (한 번에 1000+ artifact extraction) | Anthropic API direct fallback |
| vision-heavy ingest (pdf/pptx 대량) | Anthropic API direct ([기존 패턴 `vision_ingest.py`](/services/blueprint)) |
**비용 측면** — per-token incremental cost 0 (Max plan 안 무제한). 대량 batch / vision-heavy 만 per-token billing 노출.
**기존 패턴 정합** — `vision_ingest.py` 의 API direct 호출은 대량 vision 처리 한정. 같은 원칙을 artifact extraction 으로 확장.
### Layer 2: Access = MCP tool 위주, REST 최소화
Artifact access 의 primary layer 는 **Blueprint MCP tool** (위의 `query_knowledge` / `get_artifact` / `dispatch_artifact` 등). 별도 REST endpoint proliferation 회피.
| Surface | Access |
|---|---|
| Claude Code (CLI / Teams bot / 웹 agent) | Blueprint MCP tool |
| 외부 agent (third-party MCP client) | Blueprint MCP tool (OAuth-RP) |
| 웹 UI (Blueprint Next.js) | 최소 REST endpoint (curation UX 용) |
| Cross-service backend (frame ↔ Blueprint event consumer) | Postgres direct (격리 X — 같은 instance) |
이유: MCP tool surface 한 곳으로 통합 = audit 한 곳, OAuth-RP 한 번 (D-bp-mcp-1), 권한 모델 한 번 (D-bp-entity-17 EntityRole). REST endpoint 가 늘면 권한 모델 중복 + audit silo + version drift.
## 저장 결정 + 대안 비교
**Primary storage = Blueprint Postgres 16** (D-config-16 cutover 완료). 신규 인프라 0. per-customer macmini isolation (D1) 안에서 Blueprint Postgres 동거.
Monolith first — 별도 `KnowledgeStore` service 분리 안 함. Blueprint 가 이미 PARA OS home, surface 분리는 추출 필요 시 나중.
| 대안 | 거절 사유 |
|---|---|
| **Graph DB (Neo4j)** | citation chain 이 graph 형태이긴 하나 query workload 가 hash-lookup + filter 위주. Postgres JSONB GIN index 로 충분. 신규 dependency 추가만 부담 |
| **Vector DB (Qdrant) primary** | 의미 검색은 필요 시 pgvector extension 으로. primary 로 두면 trans actional consistency / FK constraint / audit trail 다 잃음 |
| **ClickHouse** | analytical query OLAP 강점은 있으나 artifact 는 row count < 100k 수준의 transactional/curation workload. column-store 이점 X |
| **MongoDB** | JSONB 의 동등 기능 + sharding 미필요 + frame/hive 와 Postgres 일관성. 별 DB 추가 부담 |
| **SQLite (file-per-workspace)** | concurrent read-write 모델 (multi-agent) 미적합. Postgres advisory lock 으로 자연 |
## 확장 (필요 시 add-on)
기본 Postgres 위에 Phase 별 add-on 가능. 모두 신규 인프라 없이 extension install 수준.
| 확장 | 활성화 조건 |
|---|---|
| `pgvector` | semantic search (artifact content / citation text embedding) 필요 시. Stage 2 의 cross-PARA query 가 정확도 부족하면 도입 |
| `pg_jsonschema` | content + audit_trail JSONB validation 강제 시. schema_id 별로 JSON Schema 등록 → INSERT/UPDATE 시 reject |
| `temporal_tables` (또는 `timestamptz` column + soft archive) | time-travel query 정확도 필요 시. Stage 1 까지는 `updated_at` + `archived_at` 으로 충분 |
## 단계 (활성화 조건 충족 시 — 활성화 자체는 다음 결정)
[M3 PARA Dispatch](/ops/roadmap#m3--blueprint-para-dispatch-d-bp-entity-1-pr-5) land + [M1 multi-tenant](/ops/roadmap#m1--stage-0--1-외부-출시) 종료 가 자연스러운 entry. 단 평행 진행 가능 — 외부 surface 영향 0.
| Stage | 산출 | 기간 |
|---|---|---|
| **0. PoC** | filesystem-only 첫 schema (`ICMemoArtifact §재무`) — DB 변화 0. ctx 의 markdown diff UX 위에 typed extraction proof | ~3일 |
| **1. DB 진입** | `Artifact` + `Citation` Prisma migration, KnowQL-lite HTTP endpoint, Blueprint UI 탭 1개 | ~3-4주 |
| **2. Full integration** | 5종 schema (IC / Board / LP / Sector / KPI) active, PARA dispatch field-level engine, ctx 진화 (curation mode) | ~3-4주 |
## Open implementation decisions
본 페이지의 design 위에 implementation 단계에서 결정할 항목들. 각 항목마다 framework (방향성) 만 명시, 구체 결정은 deferred. **Implementation 단계에서 각 항목 결정 시 D-bp-artifact-N 추가 등재**.
| # | 항목 | Framework (방향성) |
|---|---|---|
| **B1** | Agent ↔ artifact API contract | Agent (Claude / Teams bot) 의 artifact propose 권한은 implementation 시 [D-bp-entity-17](/ops/decisions) EntityRole 위에 layer 추가 검토. propose 는 ctx review queue 경유 default — 직접 confirmed 진입은 별 권한 grant 필요 |
| **B2** | Schema evolution 호환성 | Schema versioning: MCP source schema 변경 = MCP 의 도메인 정책 따름 (frame/hive 각자 backward compat 책임). free-form artifact 는 `schema_id` 마다 append-only 보존 (`freeform.llm@auto-2026-05` 같은 timestamped variant). forced migration 도구는 implementation 시 결정 |
| **B3** | Concurrent write 정책 | optimistic lock (`updated_at` version check) + audit_trail conflict event. detail TBD — 첫 multi-agent concurrent write 발견 시 결정 |
| **B4** | Time-travel 정확도 | Stage 1 = `updated_at` filter (근사). Stage 2 = audit_trail event replay. snapshot table 신설 여부는 정확도 정당화 시점에 결정 (e.g., IRR 결정론 검증이 시점 정확도 요구할 때) |
| **B5** | Failure modes (degradation) | graceful degradation 원칙: (a) LLM extraction 실패 → markdown fallback (raw content + warning); (b) Citation resolver 실패 → cached value + warning chip; (c) Dispatch partial fail → savepoint rollback (all-or-nothing). detail TBD |
| **C1** | Cross-customer aggregation | per-customer macmini isolation ([D1](/ops/decisions)) 의도된 격리 유지. meta-layer aggregation (예: AXE 가 운영하는 N customer 의 PARA flow 집계) 미설계. 외부 GP 명시 요구 시 재검토 |
| **C2** | axec PARA 구조 | axec / axev / sys entity 각각 자체 PARA tree. axec Area 정의는 axec workflow 진행 시 자연 emerge (현재 axec workflow 비중 낮음 — axev 가 primary) |
| **C3** | Resource 외부 import | 외부 framework (e.g., Atomico VC playbook, McKinsey portfolio template) 의 RESOURCE 등재는 implementation 시 IP / license 정책 합의 후 결정. v1 = AXE 내부 사용만 |
| **C4** | Multi-language artifact | v1 = 한국어 우선 (모든 schema 정의 + UI 한국어). content 안 영문 field 가 자연 등장 시 (예: 외국인 employee record, 영문 LP comm) schema 별 multi-lang 결정 |
각 항목은 implementation PR 시점에 D-bp-artifact-N 으로 등재 + 본 표 update. framework 만으로 시작 가능 (구체 결정 deferred 가 작업 정체 trigger 아님).
## 관련 결정
- [D-bp-artifact-1~7](/ops/decisions) (본 페이지 등재 동반, 2026-05-23)
- [D-bp-entity-1](/ops/decisions) (Blueprint entity 개념 + PARA 4 layer schema)
- [D-bp-entity-2](/ops/decisions) (PARA dispatch workspace-level provenance — 본 페이지가 field-level 진화)
- [D-bp-entity-17](/ops/decisions) (EntityRole — propose/review 권한 layer 의 base)
- [D-config-16](/ops/decisions) (Blueprint Postgres — artifact 거주지)
## 관련 페이지
- [/services/blueprint](/services/blueprint) — Blueprint 서비스 페이지의 artifact 섹션
- [/ops/roadmap M3, M6](/ops/roadmap) — PARA dispatch + artifact layer
- [/architecture/data](/architecture/data) — 데이터 격리 모델 (entityId scope)
- [/ops/backlog](/ops/backlog) — M6 태그 항목들
---
# 인증 · 권한
> Microsoft Entra ID OAuth 2.0, frame JWT, dual-token model.
URL: https://docs.axelabs.ai/architecture/auth
# 인증 · 권한
## 3 가지 인증 경로
| 경로 | 사용 주체 | 토큰 | 검증 |
|---|---|---|---|
| **A. Microsoft Entra ID OAuth** | 직원 (Claude Code / claude.ai) | access_token (RS256, Microsoft 서명) | frame middleware: JWKS RS256 + aud match |
| **B. frame HS256 JWT** | 운영자 / 서비스 토큰 (cron job 등) | HS256 (FRAME_JWT_SECRET 으로 서명) | frame middleware: `audience=client_id` |
| **C. dual-token** | Blueprint agent + per-user 위임 | (agent_jwt, user_jwt) | intersection of permissions |
`iss` claim 으로 자동 dispatch — `https://login.microsoftonline.com/...` 시작이면 A, 아니면 B.
> **경로 D — Blueprint 플랫폼 토큰 (D-axe-idp-1, Phase 1+2 LIVE 2026-06-04)**: **Blueprint = 플랫폼 OIDC Provider** 가 4번째 신뢰 발행자로 합류 — 로그인 1회로 전 서비스. Blueprint 가 Entra 를 federate(기존 NextAuth 세션 재사용) + RS256 플랫폼 토큰 발행(`/oauth/*` + `/.well-known/{openid-configuration,jwks.json}` on `blueprint.axellc.com`). 각 서비스는 `iss=blueprint` 분기에서 Blueprint JWKS 로 RS256 검증(`aud=https://axe.axelabs.ai`), email→entity 는 기존 `customers.yaml` 그대로. **frame·hive·cortex·index·matrix + Blueprint 자체 MCP — 6개 서비스 전부 Blueprint 토큰 신뢰** (Python: `auth_blueprint.py` + `is_blueprint_iss` dispatch / Rust: `peek_iss`→`verify_rs256` vs Blueprint JWKS). 영속 설정(compose/`.env.local` 의 `BLUEPRINT_ISSUER` 기본값)·e2e 검증(`axe login` → `axe tools` GREEN). 비파괴 롤백 = 서비스 `BLUEPRINT_*` env 제거(unset=종전 Microsoft 경로). 설계·현행 SSOT: [/architecture/platform-identity](/architecture/platform-identity).
## 경로 A — Microsoft Entra ID (직원)
각 customer 의 Microsoft Entra ID tenant 에 **5 개의 app** 이 등록되어 있어야 합니다 (axe customer 2026-05-21 현재):
| App 이름 | 용도 | 토큰 종류 | 비고 |
|---|---|---|---|
| Frame MCP | 회계 backend 접근 | access_token (aud=client_id, v2) | public client + PKCE (`b7ead15d-...` Hive 패턴) |
| Hive MCP | HR backend 접근 | access_token (aud=client_id, v2) | public client + PKCE, `client_id=b7ead15d-2fea-4864-a5a8-b4b07d1629d4` |
| Blueprint MCP | platform MCP at apex | access_token (aud=URI, v1) | public client + PKCE, `/api/mcp` apex |
| Vaultwarden | password vault SSO | OIDC id_token | confidential, secret 보관 |
| Blueprint Web | web UI 로그인 | OIDC id_token | NextAuth |
자동 등록: `az ad app create` + `az rest --method PATCH /applications/APP_ID` (identifierUris + requestedAccessTokenVersion=2 + oauth2PermissionScopes.value="mcp.access"). 운영자 손작업 = `axe.axelabs.ai` 도메인 검증 1회만.
### Frame MCP app 설정 (현재 axe live)
```yaml
client_id: 137fc0ef-eb9f-4903-acbc-1a748add349c
application_id_uri: https://axe.axelabs.ai/frame/mcp # 도메인 검증 필요
tenant_id: 122fb574-7efa-476a-95b6-bee81bce2cce
platform: Web
redirect_uris:
- https://claude.ai/api/mcp/auth_callback
- https://claude.com/api/mcp/auth_callback
scopes:
- openid
- profile
- email
- https://axe.axelabs.ai/frame/mcp/mcp.access
allow_public_client_flows: true # PKCE 활성 (manifest `isFallbackPublicClient: true`)
# client_secret 발급 여부:
# - 직접-Microsoft path (현재 live): 발급 X (PKCE-only, claude.ai 가 secret 안 보냄)
# - OAuth proxy path (D-ops-15 dormant): 발급 + frame .env 에 보관
# access_token_version (manifest `requestedAccessTokenVersion`):
# - null/1 → access_token aud = Application ID URI (https://...)
# - 2 → access_token aud = client_id GUID
# frame middleware 는 양쪽 모두 수용 (audiences=[client_id, app_id_uri]).
```
### Azure App ID URI
Microsoft v2 endpoint 의 **resource indicator** 와 **scope URI prefix** 가 정확히 일치해야 Microsoft 가 token 발급. 발견된 함정:
- **AADSTS9010010**: scope=`api://137fc0ef-.../mcp.access` + resource=`https://axe.axelabs.ai/frame/mcp` → mismatch.
- 해결: domain verify (`axe.axelabs.ai` 에 TXT `MS=ms...` 등록) → Application ID URI 를 `https://axe.axelabs.ai/frame/mcp` (URL 형식) 로 변경 → scope 도 같은 prefix.
## frame OAuth-RP middleware
frame 서버의 `auth_oidc.py` 가 다음을 수행:
1. `Authorization: Bearer <access_token>` 헤더 추출
2. `iss` claim peek (서명 검증 전 unverified decode) → Microsoft 시작이면 A 경로
3. Microsoft JWKS fetch (1h TTL, kid miss 시 강제 갱신)
4. RS256 서명 검증
5. **audience 검증**: token `aud` 가 `{client_id GUID, Application ID URI}` 중 하나와 일치 (v1/v2 호환)
6. issuer 검증: `https://login.microsoftonline.com/{tenant_id}/v2.0`
7. `email` / `preferred_username` / `upn` 중 첫 비어 있지 않은 값 → email
8. `customers.yaml > user_entity_map[email]` → entity 권한 dict 매핑
9. `TokenClaims` 생성 → ContextVar 주입 → tool 호출에 사용
### RFC 9728 protected-resource metadata path
claude.ai Connector 와 다른 MCP client 가 OAuth challenge 시작 시 fetch 하는 metadata endpoint 는 **두 path** 에서 동일한 응답:
| Path | 용도 |
|---|---|
| `/frame/.well-known/oauth-protected-resource` | server-level (RFC 8615 base URL convention) |
| `/frame/mcp/.well-known/oauth-protected-resource` | resource-level (RFC 9728 — application_id_uri 의 sub-path). claude.ai Connector / Anthropic MCP client 가 이 path 를 fetch |
양쪽 모두 `_PUBLIC_PATHS` 로 unauthenticated 면제 + `inner.router` 에 같은 handler 마운트. 401 응답의 `WWW-Authenticate: Bearer realm="frame", resource_metadata="..."` 가 가리키는 URL 도 양쪽 모두 valid (D-ops-23, 2026-05-22 — 이전엔 resource-level path 401 → claude.ai Reconnect silently 실패).
### Schema discovery — auth-required
`/frame/schemas` (그리고 hive 의 `/hive/schemas`) 는 RFC 9728 metadata 와 달리 **auth-required** — `_PUBLIC_PATHS` 미포함. JWTAuthMiddleware 가 일반 MCP 호출과 동일 검증 적용. Blueprint artifact + PARA 지식 레이어 ([D-bp-artifact-1](/ops/decisions)) 가 fetch 시 자기 MCP 토큰 재사용. 자세한 endpoint contract = [services/frame § Schema discovery](/services/frame#schema-discovery--get-frameschemas).
## 권한 모델 — Scope
```
read < write < close < admin
```
- `read` — query_balance, list_journals
- `write` — post_journal, flag_uncertainty
- `close` — soft_close period, hard_close (운영자)
- `reopen` — 폐쇄된 period 재오픈 (드물게)
- `admin` — chart of accounts 변경, policy 마이그레이션
권한은 **entity 별로** 부여 (예: `{"axec": ["read", "write"], "axev": ["read"]}`).
## dual-token (CFO + accountant 패턴)
CFO 에이전트가 회계사 A 를 대신해 활동할 때:
```
Authorization: Bearer <CFO agent JWT> ← 광범위한 권한
X-User-Token: <accountant A JWT> ← axec 만, read+write
```
frame 의 `dual_authorize(agent, user, entity, scope)` 가 두 토큰의 권한 교집합으로 게이팅:
```python
effective_scopes = agent.permissions["axec"] ∩ user.permissions["axec"]
```
최소 권한 원칙 강제. agent 가 admin 이어도 user 가 read-only 면 read 만 가능.
## 운영자 자체 토큰
운영자 (`ai@axellc.com`) 가 CLI 에서 직접 호출 시:
```bash
docker exec frame-mcp-blue python -m frame.cli mcp-token \
--sub ai@axellc.com \
--customer axe \
--entity axec:read,write,close \
--entity axev:read,write,close \
--ttl 2592000 # 30 days
```
→ HS256 frame JWT 발급. 경로 B 로 검증.
운영자 토큰은 **Vaultwarden 의 `axe-frame-jwt` collection** 에 보관 (entity 별 vault).
## 함정
| 함정 | 결과 | 회피 |
|---|---|---|
| Application ID URI 와 scope prefix 불일치 | AADSTS9010010 | 두 값 같은 URL prefix |
| Allow public client flows: No + secret 없음 | AADSTS7000218 | Yes 토글 OR secret 입력 |
| accessTokenAcceptedVersion=2 + aud check on URL | mcp_client_invalid | **v2 강제 + middleware multi-issuer 양쪽 모두**: (1) Azure app 등록 시 `requestedAccessTokenVersion=2` 강제 (bootstrap.sh manifest PATCH 시점), (2) middleware 가 v1+v2 issuer 양쪽 수용 (defense in depth — v1 token 이 어떤 사유로 들어와도 graceful 처리). 단독 (1) = bootstrap PATCH 누락 시 사고. 단독 (2) = client 측 v1 token 발급 자유 잔존. |
| App ID URI 삭제 (cleanup 잘못) | AADSTS500011 resource principal not found | 복원 후 propagation 5-10분 대기 |
| 잘못된 client_id 입력 (vaultwarden vs frame_mcp) | AADSTS700016 application not found | customers.yaml 확인 |
| `az ad app create` 후 sign-in 시도 → 즉시 실패 | Entra trace ID + "Authorization with the MCP server failed". `az ad app create` 는 **application object 만 생성, Service Principal 자동 생성 X** | `az ad sp create --id ` 별도 호출 |
| SP 있는데 sign-in 시 같은 메시지 | Service Principal 존재해도 `requiredResourceAccess` 비어 있고 admin consent 안 받음 | manifest 에 `mcp.access` (self) + Graph `User.Read` (e1fe6dd8-ba31-4d61-89e7-88639da4683d) `requiredResourceAccess` 추가 후 admin consent grant |
| `az ad app permission admin-consent` → 403 Forbidden | 명령 실행 user 가 tenant admin role 없음 ("This operation can only be performed by an administrator") | (a) Portal UI: App registrations → 해당 app → API permissions → "Grant admin consent for {`{tenant}`}" 버튼, (b) admin user 로 `az login` 후 명령 재실행 |
| `az ad app credential reset --append` parsing 시 stderr 섞임 | JSON 출력에 progress 메시지 혼합 → `python3 -c "json.load"` 실패 | `--query 'password' -o tsv` 로 단일 필드 추출 |
| Vaultwarden SSO with Microsoft Entra ID → "Your provider does not send email verification status" | Entra ID id_token 에 `email_verified` claim 없음, Timshel fork 가 거부 | `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION: "true"` 추가 (D-ops-24, 2026-05-22) |
| Blueprint OAuth scope 추가 (`SCOPES` in `src/lib/graph.ts`) ship 후 admin consent grant 누락 | (1) 모든 user 의 GraphToken refresh 침묵 실패 — UI 상 "Connected" 표시 유지하나 실 호출 시 401/403, (2) 새 user OAuth flow 차단 `AADSTS65001 Consent_VersionMismatch`, (3) `acquireTokenByClientCredential` 캐시된 옛 토큰 (insufficient-scope) 계속 반환 | (1) ship 직후 `az ad app permission admin-consent --id ` 실행, (2) `docker restart blueprint-app-green` (또는 -blue) — MSAL in-memory 캐시 폐기, (3) 자동화 후보: `axe ship blueprint` post-deploy hook + Blueprint daily token health check ([B-blueprint-scope-change-admin-consent-runbook](/ops/backlog) 참조). 절차 상세 = 아래 [OAuth scope 추가 시 운영자 절차](#oauth-scope-추가-시-운영자-절차) |
| Frame/Hive MCP middleware 의 `_MICROSOFT_ISS_PREFIX` 가 v2 issuer prefix (`https://login.microsoftonline.com/`) 만 매치 (trap #33, Truvia 2026-05-25 보고) | Microsoft v1 access_token (iss=`https://sts.windows.net//`) 보내면 middleware 가 Microsoft 경로 미인식 → HS256 fallback path 진입 → `algorithms=["RS256"]` allowlist 가 RS256 token 을 잘못된 path 에서 reject → `The specified alg value is not allowed` | (1) Azure app 등록 시 `requestedAccessTokenVersion=2` 강제 (bootstrap.sh + 등록 후 manifest 검증 — Truvia 우회 = 3 MCP app 모두 manifest PATCH), (2) middleware 가 v1+v2 issuer 양쪽 수용 (Blueprint MCP 의 `config.py:76,81` + `auth_oidc.py:153` 패턴 mirror). 위치: `/Users/axe/frame/src/frame/mcp/http_server.py:141` + `/Users/axe/hive/src/hive/mcp/http_server.py:67`. 영구 fix = [B-trap-33-frame-hive-multi-issuer](/ops/backlog) (code portion 잔존, docs portion ✅ 2026-05-28) |
상세 troubleshooting: [/onboard/troubleshooting](/onboard/troubleshooting).
## OAuth scope 추가 시 운영자 절차
Blueprint Next.js app (`2b222356-1c36-48e0-96a3-2c5e0ecbf937`) 의 `SCOPES` array (`src/lib/graph.ts`) 변경 ship 시 다음 순서를 그대로 따를 것. 누락 시 위 함정 표 마지막 row 의 3 가지 증상 발현 (2026-05-26 D-bp-mcp-calendar-1 ship 후 발현 사례 있음).
```bash
# 1. SCOPES array 변경 commit + ship
cd /Users/axe/blueprint && axe ship
# 2. admin consent grant (운영자 = Global Admin 만 가능, soohun.kang)
az login --allow-no-subscriptions
az ad app permission admin-consent --id
# (client_id = Blueprint Next.js app, 예: 2b222356-1c36-48e0-96a3-2c5e0ecbf937)
# 3. blueprint-app 재시작 (MSAL in-memory 캐시 폐기)
ssh axe-macmini "cd /Users/axe/blueprint && docker compose restart blueprint-app-blue blueprint-app-green"
# 4. 검증 (token health)
# - /api/admin/graph-tokens (또는 /settings) 에서 각 user 의 expiresAt 갱신 확인
# - 본인 계정으로 Graph API 호출 1회 (e.g. /api/admin/broadcast-dm test)
# 5. user 측 reconnect 안내 (Teams DM)
# - admin consent 후에도 옛 refresh token 의 grant 가 새 scope 안 가짐
# - 각 user 가 /api/graph/auth 재방문 → 새 scope 포함 consent → 새 refresh token 발급
```
본 절차 가운데 step 3 (MSAL in-memory cache 폐기) 의 근본 원인은 [/ops/known-gaps#msal-acquiretokenbyclientcredential-토큰-캐시](/ops/known-gaps) 참조. Blueprint 의 Azure App 2 개 (Next.js app `2b222356-...` vs MCP custom connector `482598f7-540c-462c-9dfd-b957651eb804`) 구분도 같은 페이지에 있음 — 본 SCOPES 변경 및 admin consent 는 **Next.js app 측에만** 적용.
## app-only Application permission (send-as)
위 [OAuth scope 추가 시 운영자 절차](#oauth-scope-추가-시-운영자-절차) 는 **delegated** scope (`SCOPES` in `src/lib/graph.ts`, caller 본인 토큰) 변경용이다. 그와 별개로, **admin send-as** 기능 (caller `role=admin` 이 타 사용자 리소스에 write) 은 app-only token (`getAppOnlyClient()` = `acquireTokenByClientCredential`) 을 쓰며, **Application permission** + tenant admin consent 를 요구한다. delegated 와 달리 사용자별 re-consent 가 아니라 테넌트 1회 admin consent 다.
대상 App = **Blueprint Next.js app (`2b222356-1c36-48e0-96a3-2c5e0ecbf937`)** — MCP connector app (`482598f7-...`) 이 아니다 (함정 [/ops/known-gaps#blueprint-azure-app-id-혼동](/ops/known-gaps#blueprint-azure-app-id-혼동)).
| 기능 | Application permission | delegated 대응 (이미 SCOPES) | 결정 |
|---|---|---|---|
| calendar send-as (`create_event` 등 `as_user_email`) | `Calendars.ReadWrite` | `Calendars.ReadWrite` | D-bp-mcp-calendar-2 |
| `send_mail` send-as (`as_user_email`) | `Mail.Send` | `Mail.Send` (→ self 발송은 추가 consent 불필요) | D-bp-mcp-mail-1 |
```bash
# 1. permission 추가: Azure Portal → App registrations → Blueprint (Next.js)
# → API permissions → Add → Microsoft Graph → Application permissions
# → Mail.Send (well-known roleId b633e1c5-b582-4048-a93e-9f11b44c7e96)
# 2. tenant admin consent (Global Admin 계정 — AXE 테넌트는 soohun.kang 만)
az ad app permission admin-consent --id 2b222356-1c36-48e0-96a3-2c5e0ecbf937
# 3. MSAL client-credential in-memory 캐시 폐기 (옛 insufficient-scope 토큰 재사용 방지)
docker restart blueprint-app-blue # 또는 -green
```
consent 누락 시 send-as 호출은 Graph 403 → 코드가 `mail_send_not_consented` (mail) / `app_only_token_unavailable` (calendar) 로 surface. self (delegated) 경로는 본 permission 없이도 동작하므로 **send-as 만** 영향.
---
# @axe/ui 디자인 시스템
> AXE Labs 디자인 토큰 + 컴포넌트 라이브러리를 sync 스크립트 방식으로 소비. light/dark 토글, 5분 셋업, dogfood = axelabs.ai.
URL: https://docs.axelabs.ai/architecture/axe-ui
# @axe/ui 디자인 시스템
AXE Labs 의 단일 디자인 시스템 (`@axe/ui`). 색·간격·타이포·폰트 토큰 + 컴포넌트 클래스를 모든 React 소비자 (Blueprint · frame admin · hive admin · docs.axelabs.ai · axelabs.ai 회사 홈) 가 공유. npm registry 없이 **git+ssh 또는 sync 스크립트** 로 전파 ([D-axe-ui-1](/ops/decisions)).
> **본 페이지는 새 프로젝트에 적용하는 절차서**. 운영 중 변경 흐름은 [/ops/backlog](/ops/backlog) + [/ops/updates](/ops/updates).
## SSOT 경로
`/Users/axe/axelabs/` (github: `soohunkang/axelabs`) — 디자인 시스템 본가:
```
src/lib/
├── tokens/ colors · spacing · typography · fonts · index
└── styles/ reset · components
```
Canonical sync 스크립트: `/Users/axe/axelabs-docs/scripts/sync-axe-ui.mjs` — 그대로 복사 가능.
## 적용 절차 (Next.js 기준 5분)
### 1. sync 스크립트 복사
```bash
cp /Users/axe/axelabs-docs/scripts/sync-axe-ui.mjs PROJECT/scripts/
```
스크립트 안의 `axeLabsRoot` 가 `join(repoRoot, "..", "axelabs")` 로 잡혀 있음. 새 프로젝트가 `axelabs/` 와 같은 부모 디렉토리 (`/Users/axe/`) 에 있으면 그대로 OK. 다르면 그 한 줄만 수정.
### 2. `package.json` 의 `scripts` 에 4 줄 추가
```json
"sync-axe-ui": "node scripts/sync-axe-ui.mjs",
"check-axe-ui": "node scripts/sync-axe-ui.mjs && git --no-pager diff --stat -- app/_axe-ui/ ':!app/_axe-ui/VERSION' && git checkout -- app/_axe-ui/",
"predev": "node scripts/sync-axe-ui.mjs",
"prebuild": "node scripts/sync-axe-ui.mjs"
```
### 3. 최초 sync 실행
```bash
npm run sync-axe-ui
# → app/_axe-ui/{tokens,styles,VERSION} 생성됨
```
### 4. `app/globals.css` 첫 부분에 import
Nextra/theme CSS **뒤에** 와야 override 됨:
```css
@import "./_axe-ui/tokens/colors.css";
@import "./_axe-ui/tokens/spacing.css";
@import "./_axe-ui/tokens/typography.css";
@import "./_axe-ui/tokens/fonts.css";
```
### 5. (선택) 컴포넌트 클래스도 사용
```css
@import "./_axe-ui/styles/reset.css";
@import "./_axe-ui/styles/components.css";
```
### 6. `app/_axe-ui/` 전체를 git commit
Docker/CI 빌드 안에서는 `axelabs/` 경로가 없음 — committed 산출물로 동작 (스크립트가 graceful skip).
## 테마 토글 — class · attribute 둘 다 지원
axelabs HEAD `ff6eb27` (2026-05-22) 이후 두 전략 모두 자동 매핑.
### A) class 전략 — next-themes 기본값
HTML 의 root element 에 `class="light"` 또는 `class="dark"`. Nextra docs 같이 next-themes 쓰면 추가 설정 불필요.
### B) attribute 전략 — root element 의 `data-theme` 속성
직접 토글 시:
```ts
useEffect(() => {
document.documentElement.setAttribute("data-theme", mode);
}, [mode]);
```
둘 중 어느 쪽이든 토큰 정확히 매핑. OS `prefers-color-scheme` 도 명시적 light 선택을 덮어쓰지 않음.
## 검증
| 모드 | body bg | accent |
|---|---|---|
| light | `#f5f1e8` (cream) | `#821f3b` (claret) |
| dark | `#1a0610` (noir) | `#e3ff66` (neon) |
토글 시 즉시 반영. OS=dark + 사용자 "light" 선택해도 dark 로 안 어긋남.
## 최신 버전 체크 — 개발 중 SSOT 변경 감지
```bash
# 현재 sync 상태
cat app/_axe-ui/VERSION
# @axe/ui 0.1.0
# synced-at: 2026-05-22T...
# source: /Users/axe/axelabs
# source-sha: ff6eb27 ← axelabs HEAD
# source-lib-sha: ff6eb27 ← src/lib 마지막 변경 SHA
# source-lib-date: 2026-05-22 10:41:53 +0900
# 받을 변경 있나 (dry-run, 파일 안 건드림)
npm run check-axe-ui
# 빈 출력 = 최신
# "N files changed" = 받을 변경 있음
# axelabs 측에서 놓친 commit 목록
git -C /Users/axe/axelabs log SOURCE-LIB-SHA..HEAD -- src/lib/
# 실제로 받기
npm run sync-axe-ui && git add app/_axe-ui/ && git diff --staged
# 검토 후 commit: "sync(ui): @axe/ui bump to SHA"
# axe ship SERVICE
```
**자동**: `npm run dev` / `npm run build` 시 `predev`/`prebuild` hook 이 매번 sync 돌림. diff 가 생기면 그때 commit + ship 결정.
## 적용된 소비자
| 프로젝트 | 전략 | 비고 |
|---|---|---|
| `/Users/axe/axelabs-docs` | class | Nextra docs (본 페이지 포함) |
| `/Users/axe/axelabs/app/ui` | attribute | axelabs 메인 (디자인 시스템 자기 dogfood) |
| Blueprint | _예정_ | [B-axe-ui-blueprint-migrate](/ops/backlog) |
| frame admin / hive admin | _예정_ | 외부 customer rollout 전 |
## 관련 결정
- [D-axe-ui-1](/ops/decisions) — `@axe/ui` 단일 배포 채널 = git+ssh + axelabs.ai dogfood (Verdaccio / GitHub Packages / npm publish 모두 미사용 — 소비자 ≤3개 시점에서 부담만)
- [B-axelabs-ai-live](/ops/backlog) — axelabs.ai 도메인 라이브 배포 (production tag 핀 가능 시점 prereq)
- [B-axe-ui-v0.1-tag](/ops/backlog) — `v0.1.0` git tag (외부 소비자 stable 버전 시작점)
## 함정
| 함정 | 결과 | 회피 |
|---|---|---|
| sync 스크립트가 axelabs 경로 못 찾음 (Docker/CI) | sync graceful skip, committed `app/_axe-ui/` 사용 | 본 디렉토리 항상 commit + Dockerfile 의 `COPY . .` 에 포함 |
| Nextra theme CSS 가 토큰 override | 색 안 적용 | `@import` 순서 — `globals.css` 의 첫 부분 |
| Fontshare `@import` 의 `f[]=` drop (Blueprint 사례) | font 로드 실패 | `` 방식 + Clash Display 패턴 일치 |
| OS `prefers-color-scheme` 이 사용자 선택 override | 토글 후 OS 따라 깜빡 | axelabs `ff6eb27` 이후 자동 회피 (사용자 명시 우선) |
---
# 백업 · DR
> restic 3-tier (local · ring P2P · cold SSD), restore drill.
URL: https://docs.axelabs.ai/architecture/backup
# 백업 · DR
## 3-tier 전략
```
Tier A — 로컬 (실시간 보호)
restic repo /Users/axe/.axe/backups/local/
매일 03:00 KST, com.axe.backup.local
Tier B — P2P ring (cross-customer 양방향)
axe-macmini ↔ realchoice-macmini (SSH key, Tailscale)
매일 03:30 KST, com.axe.ring.push
Tier C — Cold SSD (offline 보호)
외장 SSD rotation /Volumes/axe-cold-{1,2,3}
mount 시 자동 sync
```
## Tier A — 로컬 (live)
| 항목 | 값 |
|---|---|
| Repo path | `/Users/axe/.axe/backups/local/` |
| Restic version | 0.18.1+ |
| Password | macOS Keychain (`axe.backup.restic.local`) |
| 백업 대상 | frame-postgres dump + **blueprint-postgres dump** (D-config-17 cutover 후, 2026-05-15~) + **hive-postgres dump** (D-hive-backup, 2026-05-21~ — Phase 1 조직/휴가 + Phase 3 payroll v2 실데이터) + **mysrt-postgres dump** (B-mysrt-backup-decision, 2026-06-06~ — users/jobs/push_subscriptions; SRT는 train/예약의 SoT일 뿐 계정·잡설정 SoT 아님) + **index-postgres dump** (D-index-51, 2026-06-06~ — `evidence_blob` = 죽은 딜 OneDrive/Blueprint 원본 삭제 후 **유일 사본**, 312MB bytea → ~633MB `pg_dumpall`, restic content-dedup 으로 ~274MB stored) + `.local/files/` (platform data) |
| 빈도 | 매일 03:00 KST (`com.axe.backup.local` launchd) |
| 현재 크기 | ~429 MiB raw-data (2026-06-06 — index-postgres 흡수 후, restic dedup 적용) |
| Excludes | `/Users/axe/.axe/bin/restic-excludes` |
### 명령어
```bash
# 수동 백업
axe backup --local
# 백업 상태 확인
restic -r /Users/axe/.axe/backups/local snapshots \
--password-file <(security find-generic-password -w -s axe.backup.restic.local)
# 복원 (dry-run)
axe restore --customer axe --from local --as-of 2026-05-20 --dry-run
```
## Tier B — P2P ring
axe ↔ realchoice 양방향 백업 (각자 다른 macmini 의 백업을 보관).
```
axe-macmini → realchoice-macmini 의 /Users/realchoice/peer-backups/axe/
realchoice-macmini → axe-macmini 의 /Users/axe/.axe/backups/peer/realchoice/
```
| 항목 | 값 |
|---|---|
| 전송 | restic `sftp:` backend (SSH key) |
| auth | SSH key (~/.ssh/id_ed25519, 비밀번호 없음) |
| 빈도 | 매일 03:30 KST (`com.axe.ring.push` launchd) |
| 검증 | bidirectional_ssh 2026-05-15 검증 완료 |
`customers.yaml` 에 ring backup 메타 등록:
```yaml
realchoice:
ring_backup:
ssh_user: "realchoice"
ssh_fqdn: "realchoice-macmini.tail090015.ts.net"
ssh_ip: "100.114.161.51"
receive_dir_at_peer: "/Users/realchoice/peer-backups/axe/"
receive_dir_at_self: "/Users/axe/.axe/backups/peer/realchoice/"
restic_version: "0.18.1"
disk_available_gib: 43
bidirectional_ssh: true
```
### 함정
- **Tailscale alone 으로는 충분하지 않음** — SSH key 가 별도 필요. Tailscale 인증 없어도 작동.
- **Cross-customer 백업 = 데이터 노출 risk** — 양측 운영자 모두 합의 + restic 암호화로 컨텐츠 보호.
## Tier C — Cold SSD (offline)
외장 SSD 를 분기별로 rotation. mount 가 감지되면 자동 sync.
| 항목 | 값 |
|---|---|
| Mount path | `/Volumes/axe-cold-{1,2,3}` (rotation) |
| Restic repo | `/Volumes/axe-cold-N/restic-repo/` (N = 1, 2, 3 — rotation index) |
| Password | 종이 메모 (vault 안에 넣지 말 것 — closed-loop 방지) |
| Rotation | 분기별 (Q1 = SSD #1, Q2 = SSD #2, ...) |
| Drill | 분기마다 자동 restore drill (`com.axe.restore-drill`, Jan/Apr/Jul/Oct 15 03:00 KST) |
### 왜 종이?
cold storage 의 password 를 self-host vault 에 넣으면 vault 가 사라졌을 때 cold storage 도 못 풉니다 (closed loop). 종이 + 운영자 머리 = 이중화.
## 복원 절차
### 시나리오 1 — frame DB 손상 (오타 / migration 사고)
```bash
# 1. 어제 03:00 KST 백업 복원
axe restore --customer axe --tier local --as-of 2026-05-20T03:00 --target frame-postgres
# 2. frame 재시작
docker compose restart frame-mcp-blue frame-mcp-green
# 3. 정합성 검사
docker exec frame-mcp-blue python -m frame.cli integrity-check --entity axec
```
### 시나리오 1b — blueprint DB 손상
```bash
# 1. 어제 03:00 KST 백업 복원
axe restore --customer axe --tier local --as-of 2026-05-20T03:00 --target blueprint-postgres
# 2. blueprint app + mcp 재시작 (PR #337 이후 blue/green pair)
docker compose -f /Users/axe/blueprint/docker/docker-compose.yml restart app-green blueprint-mcp-blue blueprint-mcp-green
# 3. 정합성 검사 (Prisma client round-trip)
docker exec blueprint-app-green node -e "const {PrismaClient}=require('@prisma/client'); new PrismaClient().workspace.count().then(n=>console.log('workspaces:',n))"
```
### 시나리오 1c — hive DB 손상
```bash
# 1. 어제 03:00 KST 백업 복원
axe restore --customer axe --tier local --as-of 2026-05-20T03:00 --target hive-postgres
# 2. hive 재시작 (Phase 1 조직/휴가 + Phase 3 payroll v2)
docker compose -f /Users/axe/hive/docker-compose.yml restart hive-postgres hive-mcp-blue hive-mcp-green
# 3. 정합성 검사 (axec/axev 테넌트별 employee count round-trip)
docker exec hive-postgres psql -U hive -d hive -c \
"SELECT 'axec' AS tenant, COUNT(*) FROM axec.employees
UNION ALL SELECT 'axev', COUNT(*) FROM axev.employees;"
```
### 시나리오 2 — macmini 자체 손실 (도난, 화재)
```bash
# 1. 새 macmini 셋업 (Tailscale 설치, axe CLI install)
# 2. ring peer 에서 받기
axe restore --customer axe --tier ring --from realchoice --as-of 2026-05-20
# 3. 또는 cold SSD 마운트 + restore
axe restore --customer axe --tier cold --as-of 2026-05-20
```
### 시나리오 3 — 사일런트 corruption
`frame integrity-check --entity axec` 가 detect 하면:
```bash
# 가장 가까운 valid snapshot 으로 부분 복원
axe restore --customer axe --tier local --table journal_line --as-of <last-valid>
```
## Restore drill (자동)
분기마다 자동으로:
1. 임시 staging container 에 어제 backup 복원
2. `frame integrity-check` 통과 여부 확인
3. 결과를 운영자 Slack 으로 보고
drill 자체가 실패하면 cold SSD rotation 시도 또는 ring peer 복원.
## 함정 모음
| 함정 | 결과 | 회피 |
|---|---|---|
| Vault 에 cold storage password 저장 | closed loop, vault 손실 시 cold 도 못 풂 | 종이 메모 |
| restic password 분실 | 백업 영구 손실 | Keychain + 종이 이중화 |
| backup excludes 누락 (`.local/files`) | 핵심 데이터 백업 안 됨 | restic-excludes 명시 |
| `docker exec frame-postgres pg_dump` 만으로 신뢰 | container 죽으면 dump 못 함 | volume 자체 백업 병행 |
| ring backup 단방향 | 한쪽 손실 시 복구 불가 | bidirectional_ssh 검증 필수 |
| index-postgres 백업 누락 | `evidence_blob` = 죽은 딜 OneDrive/Blueprint 원본 삭제 후 **유일 사본** → 영구 소실 | `axe-backup` index 블록 (D-index-51) + `index export-evidence` round-trip drill |
---
# 데이터 격리 모델
> schema-per-entity, customer 격리, audit_log, PII 암호화.
URL: https://docs.axelabs.ai/architecture/data
# 데이터 격리 모델
AXE Labs 는 **3-tier 데이터 격리**를 사용합니다:
| Tier | 격리 단위 | 구현 |
|---|---|---|
| 1. OS-level | customer | macmini 1대 = customer 1개 |
| 2. Database schema | entity (회사) | PostgreSQL schema-per-entity |
| 3. Row-level (일부) | tenant_id | RLS (magnet 등에서) |
## Tier 1 — OS-level (customer)
각 customer 는 자기 macmini 위에서 frame · blueprint 등 동일 스택을 실행합니다. customer 간 cross-talk 없음.
| customer | macmini | DB | schema 셋 |
|---|---|---|---|
| axe | `axe-macmini` | frame-postgres | shared + axec + axev + axtest |
| realchoice | `realchoice-macmini` (예정) | (별도) | shared + realchoice |
backup 도 macmini 별로 분리되며, customer 간 ring backup 은 **운영자 명시 승인 + 양방향 SSH 합의** 후에만.
## Tier 2 — Schema-per-entity (frame)
frame 의 PostgreSQL 안에는:
```
frame DB
├── shared (cross-entity)
│ ├── entity ← 등록된 entity 메타 (axec, axev, realchoice)
│ ├── format_profile ← 파일 포맷 캐시 (vendor + column_signature)
│ ├── account_template ← 표준 계정과목 (KSME)
│ ├── idempotency_record ← 멱등 키
│ └── oauth_authorization_codes ← OAuth proxy (D-ops-15, 현재 dormant)
│
├── axec (개별 entity schema)
│ ├── account, journal, journal_line
│ ├── raw_transaction, bank_account, source_file
│ ├── fiscal_period, open_item
│ ├── evidence, audit_log
│ └── ... (전체 16 개 migration)
│
├── axev (axec 와 동일 구조)
│ └── ...
│
└── axtest (테스트 전용)
└── ...
```
**Cross-entity 쿼리 불가**: 각 MCP tool 호출이 `SET search_path TO {entity_id}, shared` 로 격리. 사용자가 `axec` 권한만 가지면 `axev` schema 접근 불가.
### Migration 패턴
Alembic 의 dual-env:
| 디렉토리 | 적용 시점 | 대상 |
|---|---|---|
| `alembic/versions/` | `frame migrate` 시 1회 | `shared` schema |
| `alembic/entity_versions/` | `frame register-entity` 시 + 매 migrate | 각 entity schema (axec, axev, ...) |
`frame migrate` 실행 시:
1. `upgrade_shared()` → shared schema 마이그
2. 등록된 모든 entity 에 대해 `upgrade_entity(entity_id)` → entity schema 마이그
## Tier 3 — Row-level (선택적)
`magnet` 같이 multi-tenant 단일 schema 에서 RLS 사용:
```sql
-- magnet sql/050_multi_tenant_baseline.sql
ALTER TABLE campaign_metrics_daily ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON campaign_metrics_daily
USING (tenant_id = current_setting('app.tenant_id')::bigint);
```
DB 연결 단계에서 `SET app.tenant_id = <id>` 주입. 사용자가 잘못된 tenant_id 로는 read/write 불가.
frame 은 schema-per-entity 가 더 강한 격리 (cross-schema 권한 자체 분리), magnet 은 RLS 가 멀티 brand 통합 운영에 더 적합 → 서비스 별 적절한 격리 모델 선택.
## audit_log — 모든 쓰기 추적
각 entity schema 에 `audit_log` 테이블이 있으며, **모든 쓰기 (insert/update/delete)** 가 자동 기록됩니다.
```sql
-- entity-side audit_log 의 컬럼
audit_log (
id bigserial,
table_name text,
op text, -- INSERT | UPDATE | DELETE
old_data jsonb,
new_data jsonb,
actor text, -- frame.actor session var
entity_context text, -- 'axec'
ts timestamptz default now()
)
```
`frame.actor` 는 entity_session context manager 에서 설정되며, MCP tool 호출의 `TokenClaims.sub` 가 자동으로 들어갑니다.
### Append-only 강제
`raw_transaction`, `journal_line` 같은 핵심 테이블에는 DELETE/UPDATE 권한이 DB 사용자 단에서 REVOKE 되어 있습니다. 잘못된 분개는 `reverse_journal` 로 역분 (새 journal 생성), 절대 수정/삭제 안 함.
## PII 암호화 (pgcrypto)
개인정보 (security_holder, audit log 의 일부 raw_data) 는 `pgcrypto.pgp_sym_encrypt` 로 entity 별 별도 passphrase 로 암호화됩니다.
```yaml
# docker-compose.yml env
FRAME_PII_PASSPHRASE_AXEC: ${FRAME_PII_PASSPHRASE_AXEC:-}
FRAME_PII_PASSPHRASE_AXEV: ${FRAME_PII_PASSPHRASE_AXEV:-}
FRAME_PII_PASSPHRASE_REALCHOICE: ${FRAME_PII_PASSPHRASE_REALCHOICE:-}
FRAME_PII_SALT_DIR: /root/.frame
```
각 entity 의 passphrase 는 운영자 Keychain (`security add-generic-password`) 에 저장되고 컨테이너 launch wrapper 가 env 로 주입. **passphrase 분실 = PII 영구 손실** (이는 의도된 설계 — 운영자가 DB dump 만으로는 PII 복원 불가).
## Evidence blob 저장 (content-addressed file store, D-frame-2)
큰 파일 (evidence PDF·통장 xls·세금계산서 등) 은 Postgres bytea 가 아니라 **content-addressed file store** `.local/files///` 에 저장. DB 의 `evidence`/`source_file` row 는 `content_hash` (SHA-256) + **portable** `storage_url` (relative `//`) 만 보유.
- **컨테이너 마운트**: host `.local/files` ↔ 컨테이너 `/app/.local/files` bind mount (`FRAME_FILE_STORAGE_PATH`). read 시 `frame.ingest.pipeline.resolve_storage_path()` 가 portable url 을 live store 기준 absolute 로 매핑 → host·컨테이너 어디서 실행해도 같은 blob 해석.
- **금지**: `local://` (외부 OneDrive/Downloads 포인터 — 컨테이너 read 불가 + rename/move 시 깨짐), CWD 의존 absolute 경로 (host `/Users/...` vs 컨테이너 `/app/...` 불일치). 모든 ingest write-path (`pipeline`/`card`/`fund`/`hometax`/`entity_meta`/`resolution`/`ops.evidence`) 가 `_store_file_locally` 로 통일.
- **무결성 probe**: `GET /frame/health/storage` 가 모든 entity schema 의 evidence storage_url 을 resolve 해 blob 존재 확인 — bind-mount 회귀로 blob 이 사라지면 (2026-05-14 incident) 503 + missing-list. blob 은 절대 삭제 안 함 (append-only 정신).
## 정합성 검사
`frame integrity-check --entity axec` 가 4가지 검사를 수행:
1. **balance** — `SUM(debit) == SUM(credit)` per journal
2. **journal** — 모든 journal_line 이 valid account 참조
3. **account** — chart-of-accounts 와 journal_line 의 일관성
4. **fiscal_period** — period 의 starting/ending balance 정합
cron 으로 매일 자동 실행 + Slack alert.
---
# 배포 · 무중단
> blue/green frame swap, axe-frame-proxy, axe CLI deploy.
URL: https://docs.axelabs.ai/architecture/deploy
# 배포 · 무중단
## Blue/green 패턴 (frame)
frame 은 **두 개의 컨테이너가 항상 동시 실행**됩니다 — blue (3710) + green (3711). 한쪽이 active, 다른 한쪽이 passive.
```
┌──── frame-mcp-blue :3710 ◀ alias `frame-mcp` (active)
│
axe-frame-proxy ─── (Caddy upstream)
:3712 │
└──── frame-mcp-green :3711 (passive, 새 코드 받는 곳)
```
배포 시퀀스:
```
1. green container 에 새 코드 build + start
2. green 헬스체크 통과 대기 (poll /health/ready)
3. docker network alias `frame-mcp` 를 green 으로 이동
4. axe-frame-proxy 가 upstream 자동 reload (Caddy graceful)
5. blue 의 in-flight 요청 완료 대기 (60s grace)
6. blue stop → 다음 배포 시점에 새 코드로 build
```
다운타임: **0 초**. 한 시점에 < 1s 의 latency spike 는 수용.
## axe CLI deploy
운영자 머신에서:
```bash
# dry-run (변경사항 미리 표시)
axe deploy frame axe
# 적용
axe deploy frame axe --apply
```
`axe deploy frame {customer}` 가 위 6 step 을 자동 실행. 진행 상황은 stderr 에 step 별 로그.
## 함정
| 함정 | 결과 | 회피 |
|---|---|---|
| `docker compose up --build` (다운타임) | 30-60s 다운타임 | `axe deploy` 사용 |
| `docker compose restart` (코드 미변경 시) | 다운타임 + env 재로딩 X | 코드 변경 → `axe deploy` |
| cloudflared 의 SIGHUP | process 죽음 | host-side proxy 뒤로 분리 |
| blue/green 컨테이너 이름을 cloudflared 에 직접 명시 | swap 불가 | docker network alias 사용 |
| `--no-build` 잊고 deploy | 새 코드 적용 안 됨 | `axe deploy` 가 자동 처리 |
## docker-compose 구조
`/Users/axe/frame/docker-compose.yml`:
```yaml
services:
postgres:
image: postgres:16-alpine
container_name: frame-postgres
ports: ["3700:5432"]
frame-mcp-blue: &frame-mcp-blue
build: . (Dockerfile)
container_name: frame-mcp-blue
ports: ["3710:3710"]
environment:
FRAME_DEPLOY_COLOR: blue
FRAME_MCP_PORT: 3710
AZURE_FRAME_MCP_CLIENT_SECRET: ${AZURE_FRAME_MCP_CLIENT_SECRET:-}
# ... 기타 env
networks:
default:
aliases: [frame-mcp] # initial: blue active
artemis_default:
aliases: [frame-mcp]
frame-mcp-green:
<<: *frame-mcp-blue # YAML anchor 로 동일 설정 상속
container_name: frame-mcp-green
ports: ["3711:3710"]
environment:
FRAME_DEPLOY_COLOR: green
# ... 동일하지만 color=green
networks:
default: {} # green 은 alias 없음 (passive)
artemis_default: {}
```
## 무엇이 다운타임 0 을 가능케 하는가
1. **컨테이너 1대 만 build 중** — 다른 1대가 traffic 흡수
2. **docker network alias** 가 instant DNS 갱신 (DNS TTL 없음, in-memory)
3. **axe-frame-proxy 의 Caddy** 가 upstream 변경에 graceful (SIGHUP)
4. **cloudflared 가 build 와 무관** — 평소 라우팅 유지
## 다른 서비스 — blueprint
blueprint 은 단일 컨테이너 (현재). Next.js dev/build 시간이 짧고 (수십 초), 사용자 traffic 이 frame 만큼 critical 하지 않아 단순 deploy 사용:
```bash
cd /Users/axe/blueprint && pnpm build && pnpm start
# (launchd 또는 docker compose restart)
```
다운타임 발생 시 사용자가 보는 것은 "Connecting..." 메시지 수 초. 회계 작업과 같은 in-flight transaction 없음.
향후 blueprint 도 blue/green 으로 전환 예정 (D-config-13 패턴 확장).
---
# 도메인 · DNS
> Cloudflare zone, 1-level subdomain 결정, customer 도메인 매핑.
URL: https://docs.axelabs.ai/architecture/domains
# 도메인 · DNS
## 두 개의 zone
| Zone | 용도 | 상태 |
|---|---|---|
| `axelabs.ai` | **플랫폼** (이 docs, 모든 customer 서비스) | 2026-05-14 등록 |
| `axellc.com` | **corporate** (회사 이메일, 기업 홈페이지) | 기존 |
플랫폼 트래픽은 `axelabs.ai` 만 거치고, `axellc.com` 은 corporate 용도로 분리됩니다.
## URL 컨벤션
```
{customer}.axelabs.ai ← apex, blueprint
{customer}.axelabs.ai/frame ← frame MCP
{customer}.axelabs.ai/frame/mcp ← MCP endpoint (canonical resource URI)
{customer}.axelabs.ai/vault ← Vaultwarden
{customer}.axelabs.ai/index ← (예정)
admin.axelabs.ai ← 운영자 콘솔 (Microsoft SSO)
docs.axelabs.ai ← 본 docs
```
## 왜 1-level subdomain + path?
선택지 비교:
| 옵션 | SSL | URL 모양 | 운영 부담 |
|---|---|---|---|
| 1-level + path | ✓ 무료 (Cloudflare Universal SSL) | `axe.axelabs.ai/frame` | cloudflared path-aware 라우팅 |
| 2-level subdomain | ❌ 유료 ($10/cert/월) | `frame.axe.axelabs.ai` | 일관 wildcard 필요 |
| Service-only domain | △ | `frame.axelabs.ai` | customer 격리 불가 |
→ 1-level + path 채택. cloudflared ingress 의 `path` 매칭으로 service 분기.
## 함정 — Universal SSL wildcard 의 1-level 한계 (D-ops-39)
무료 Universal SSL 의 wildcard cert (`*.axelabs.ai`) 는 **1단 서브도메인만 cover**. 2단 (e.g. `ssh.axe.axelabs.ai`, `.axe.axelabs.ai`) 은 cert SAN 미일치 → edge 가 default cert 로 fallback → 클라이언트에서 `tls: handshake failure` / `SEC_E_ILLEGAL_MESSAGE` / `sslv3 alert handshake failure`.
**규칙**: HTTPS 노출되는 모든 hostname 은 `{name}.axelabs.ai` 의 **1단** 이어야 함. service 또는 sub-context 가 필요하면:
- ✅ **flat hostname** — `ssh-axe.axelabs.ai`, `docs-axe.axelabs.ai`
- ✅ **path under apex** — `axe.axelabs.ai/frame`
- ❌ **2단 subdomain** — `ssh.axe.axelabs.ai`, `frame.axe.axelabs.ai`
**증상이 보이면**: 클라이언트의 `cloudflared --version` 이 최신이고 시간 동기화도 정상인데 handshake failure 가 모든 클라이언트에서 일관되면 서버측 cert 문제. zone DNS records 에서 hostname dot count 확인. 함정 사례 → [/ops/known-gaps#cloudflare-universal-ssl-1-level](/ops/known-gaps#cloudflare-universal-ssl-1-level).
**유료 회피 옵션 평가**: Advanced Certificate Manager ($10/cert/월) 또는 zone delegation (`axe.axelabs.ai` 별도 zone) 모두 미채택. 무료 plan + flat-hostname 으로 충분.
## 현재 customer 별 할당
| customer | apex 도메인 | corporate 도메인 (이메일) |
|---|---|---|
| axe | `axe.axelabs.ai` | `axellc.com` |
| realchoice | `realchoice.axelabs.ai` | `realchoice.co.kr` |
## DNS records
axelabs.ai zone (Cloudflare):
```
CNAME axe .cfargotunnel.com ; orange-cloud ON (axelabs tunnel)
CNAME realchoice .cfargotunnel.com ; orange-cloud ON (realchoice tunnel)
CNAME admin .cfargotunnel.com ; orange-cloud ON
CNAME docs .cfargotunnel.com ; orange-cloud ON
CNAME ssh-axe .cfargotunnel.com ; orange-cloud ON (operator SSH)
CNAME www axelabs.ai
TXT axe "MS=ms10433167" ; Microsoft Entra domain verify
```
> `ssh-axe.axelabs.ai` 는 운영자/엔지니어가 macmini 에 SSH 진입하기 위한 Cloudflare Access 보호 endpoint. 클라이언트는 `cloudflared access ssh --hostname ssh-axe.axelabs.ai ...` 또는 `ssh user@ssh-axe.axelabs.ai` (ProxyCommand 설정 시).
> 이전 hostname `ssh.axe.axelabs.ai` 는 [위 함정](#함정--universal-ssl-wildcard-의-1-level-한계-d-ops-39) 으로 폐기 (D-ops-39, 2026-05-26). DNS record 와 `*.axe.axelabs.ai` 와일드카드 는 backlog 에서 일괄 정리.
Microsoft 도메인 검증 TXT 는 Application ID URI 에 `https://axe.axelabs.ai/frame/mcp` 같은 형식을 등록하기 위해 필요. 자세히는 [인증 · 권한](/architecture/auth#azure-app-id-uri).
## Customer 추가 시
1. `customers.yaml` 에 새 entry (예: `realchoice`)
2. Cloudflare zone 에 `A {customer}` record 추가 (proxy ON)
3. cloudflared config 에 ingress 규칙 추가
4. cloudflared 재시작 (1회만, ingress 안정 후 추가 변경 없음)
상세 절차는 [신규 customer onboarding](/ops/runbook/customer-onboarding).
---
# 결정 거버넌스 — gate (결재 + e-sign + 법무)
> gate 서비스의 결정→실행 거버넌스 아키텍처. record≠process, 서명 1-primitive, actuation ledger, Blueprint mirror, 그리고 narrowed Phase-1(gate-native 직접-build 암호 e-sign[CAdES-T]·내부검색·AXE-internal) vs Phase-2(외부 본인확인기관·flywheel·공개 SaaS·결제·멀티테넌트) 경계.
URL: https://docs.axelabs.ai/architecture/governance
# 결정 거버넌스 — gate
gate 는 한국형 **내부 전자결재 + e-sign + HTML 문서 + 법무검색**을 하나의 **AI-driven 결정→실행 파이프라인**으로 묶는 신규 **Rust + Postgres** 서비스다. [para-os §7](/architecture/para-os)(결정로그 ≠ 결재워크플로)의 물질화 — 성숙한 거버넌스 Area 가 독립 서비스로 졸업한 형태.
> 본 페이지 = gate **거버넌스 아키텍처 설계** (착수 직전 설계 + e-sign 엔진 빌트, 2026-06-07). 결정 SSOT = [D-gate-1](/ops/decisions)(scope·Phase) · [D-gate-2](/ops/decisions)(record SoR) · [D-gate-3](/ops/decisions)(actuation·e-sign 경계, e-sign 부분 D-gate-5 가 supersede) · [D-gate-4](/ops/decisions)(법무) · **[D-gate-5](/ops/decisions)(e-sign 직접구현 = 암호등급 자체 서명엔진, 빌트+검증 — D-gate-3 integrator 입장 supersede)**. 마스터플랜·착수 큐 = [B-bp-decision-pipeline-esign](/ops/backlog) (service=gate, roadmap M9). 설계 전모·리서치 결론은 gate machine 메모리 2파일.
## 1. Phase 경계 — narrowed Phase-1 (경로 A)
7라운드 설계 후 7-agent red-team 이 **풀설계**(결재 + 직접 e-sign + 법무 RAG flywheel + 공개 SaaS + 결제 + 멀티테넌트 = 1인이 동시에 짓는 6~7 규제제품)에 **NO-GO**(11 P0 / 16 P1 / 8 누락) 판정했다. 결정적 사실 — **11 P0 가 전부 "야심 5종"에 거주**한다. 따라서 narrowed Phase-1 은 대부분의 P0 를 *해소가 아니라 defer 로* 회피한다 ([D-gate-1](/ops/decisions)).
> **단, e-sign 은 예외 (2026-06-07 [D-gate-5](/ops/decisions))**: 운영자가 *"모두싸인의 기능을 직접, 암호등급으로 구현"* 지시 → e-sign 은 defer 가 아니라 **직접-build 로 빌트**됐다 (§6). red-team 이 직접-build 를 막았던 P0(HSM·HTML→PDF 결정성·custody)는 이제 **해소-또는-명시**: 결정성 = canonical decision bytes 로 *지금*(typst PDF render 는 later), HSM = trait abstraction *ready*(dev = software key), custody = `esign_seal` append-only + RFC3161 TSA. **진짜 잔여 Phase-2 = 외부 서명자 본인확인기관**(정보통신망법 §23-3, 자가구축 불가) — 그 외 직접 e-sign P0 는 닫혔다.
| 영역 | **Phase-1 (확정·착수 가능)** | **Phase-2+ (각자 게이트, defer)** |
|---|---|---|
| 결정로그 | gate `decision` SoR (record≠process) | — |
| 결재워크플로 | 기안→DOA→승인/반려/보류→시행 상태기계 | — |
| 문서 | HTML spine → PDF 렌더 | — |
| e-sign | **gate-native 직접-build 암호 서명엔진** (CAdES-T server-seal + RFC3161 + esign_seal evidence + verify_seal; 모두싸인 = optional alt provider) — [D-gate-5](/ops/decisions) | 외부 서명자 **본인확인기관** 통합(정보통신망법 §23-3) · *하드닝*: HSM·PAdES 서명-PDF·prod-key |
| 실행 | actuation ledger (멱등 + reconcile) | saga 보상 (e-sign+결제+분개) |
| 가로 거버넌스 | Blueprint `gate.decision` citation mirror | visibility-gated mirror 테이블 |
| 배포 대상 | **AXE-internal only** (realchoice/Truvia table-stakes) | 공개 SaaS (+tenant_id RLS·self-IdP) |
| 법무 | **pgvector-KR 내부검색만** (결재 자동주입 X) | Pinecone+Cohere+Gemini flywheel (+PIPA 자문) |
| 결제 | 없음 | 포트원/토스/Paddle (+전자금융/전자상거래) |
| 테넌시 | 없음 (owner_id RLS) | cross-tenant 축적 (+PIPA 가명정보) |
빌드게이트(코드보다 먼저) = [B-gate-bonin-id-contract](/ops/backlog)(본인확인기관 계약, 수주~수개월) · [B-gate-legal-counsel](/ops/backlog)(변호사법 §109 + PIPA 의견서) · [B-gate-phase2-gates](/ops/backlog).
## 2. record ≠ process — SoR = gate, Blueprint = mirror
- **결정 로그 (record)** — 무엇이·누가·언제·무슨 권한으로·뭘 근거로 결정됐나. append-only, 불변(수정 아닌 **supersede**). **SoR = gate `decision` 테이블** (서비스가 SoR 소유, [D-gate-2](/ops/decisions)).
- **결재 워크플로 (process)** — 기안 → 결재선(DOA) → 승인/반려/보류 → 시행. 그 위 policy-driven·agent-assisted 레이어. 워크플로 완료 → 결정로그 한 줄 방출.
**왜 gate 가 SoR 인가**: round-1 은 결정로그를 *Blueprint core primitive(가로)* 로 뒀으나 = AXE-internal 전제. standalone 외부제품 요구가 refine — record 의 집을 gate 로 옮겼다. 근거(grounded): [/architecture/artifacts](/architecture/artifacts) "MCP backend = sole owner, data 중복 0" · [/architecture/data](/architecture/data) "cross-service = 직접 DB read 금지, 명시적 신호채널(pg_notify/outbox)만" · [D-cortex-9](/ops/decisions) = service-owns-SoR + Blueprint visibility-gated mirror 가 이미 빌트(template 기성). Blueprint = `gate.decision` citation mirror(optional consumer 1종, 외부고객 = OFF, gate 단독 완결).
## 3. 핵심 테이블 (Blueprint-agnostic)
| 테이블 | 역할 |
|---|---|
| `document` | **HTML 작성 스파인**(풍성한 포맷) + 변수(AI/템플릿 fill) + rendered_pdf_ref(서명용) + version + kind(기안/계약/통지). 내부결재 + e-sign = 같은 document 의 두 phase. |
| `workflow` | 내부결재 상태기계: draft→상신→in_review(결재선 step)→승인/반려/보류→시행→done. |
| `approval_step` | 결재선 (kind: 승인/전결/협조/합의/대결 · state: pending/approved/rejected/held/skipped). **반려·철회·서명거절 역흐름 = 1급 시민**(red-team 누락 #8). |
| `doa_policy` | 전결규정 (entity · decision_type · amount_min/max → line jsonb). |
| `signature` | 내부+외부 서명 통합 primitive (provider ∈ `{internal, gate, modusign}` · signers · envelope_id · cert_ref · status). §4. |
| `esign_seal` | **암호 봉인 evidence** (append-only, 전자문서법 §5 custodian) — decision_id · sealed_bytes_hash · **CMS/CAdES-T DER**(detached 서명 + RFC3161 토큰) · signer_cert_ref · tsa_url · sealed_at. 시행 시 `esign.gate` actuator 가 1행 append, `verify_seal` 가 읽음. §4·§6. |
| `actuation` | 실행 ledger (actuator_kind · payload · idempotency_key · state · result_ref · reconciled_at). §5. |
| `event` | append-only audit. |
| `decision` | gate SoR terminal record (authority · actor · decided_at · basis citations · supersedes). canonical bytes = `esign_seal` 봉인 대상. |
## 4. 서명 = 한 primitive
내부 sign-off = 외부 e-sign = 같은 primitive `signature`. 누가·법적무게만 다르다. `provider ∈ {internal, gate, modusign}`:
- **`internal`** — 결재선 step 의 인증된 결재 승인 (UI sign-off). gate identity = 실지명의.
- **`gate`** (Phase-1, [D-gate-5](/ops/decisions)) — **gate-native 암호 봉인**. 시행 시 승인된 결정의 canonical bytes 를 단일 서버 서명 identity 로 **CAdES-T server-seal** → `esign_seal` 에 evidence 저장. 봉인 1건 = `signature(provider=gate)` + `esign_seal` 1행. §6.
- **`modusign`** (optional alt provider) — 외부 카운터파티 라운드트립이 필요할 때의 대체 경로 (`esign.send`/EsignStub). Phase-1 의 *대안*이지 기본 아님.
내부 결재 승인도, gate 서버 봉인도, 외부 모두싸인 e-sign 도 "행위자가 결정을 시점에 (법적으로) 확정"하는 한 종류의 행위 — [para-os §84](/architecture/para-os) 와 정합.
## 5. actuation ledger + reconcile (분산 일관성)
승인된 결정 → **actuator 호출** (`frame.post_journal` / `index.register_deal` 등; Area 서비스 = 결정 파이프라인의 actuator). 결정→실행은 분산 트랜잭션이므로:
- `actuation.idempotency_key = uuidv5(workflow + seq + actuator + payload_hash)` — gate 측 재시도 멱등.
- **reconcile 워커** (frame-worker 패턴, pg_notify) — inflight→done/failed 정합.
- ⚠️ **gate idempotency_key 는 *외부* 이중실행을 못 막는다** (red-team P0 #5) — `esign.gate` 봉인은 gate 내부 actuator라 `esign_seal`(decision_id 유일) append 가 멱등이지만, *외부* provider 경로(모두싸인 alt) 는 **provider-side idempotency 토큰** 필요. saga 보상(e-sign+결제+분개)은 결제 도입 Phase-2.
## 6. e-sign 경계 — Phase-1 = gate-native 직접-build 암호 서명엔진
> **2026-06-07 pivot ([D-gate-5](/ops/decisions), [D-gate-3](/ops/decisions) e-sign 경계 supersede)**: [D-gate-3](/ops/decisions) 은 e-sign 을 모두싸인 integrator 로 두고 직접-build 를 Phase-2 로 defer 했으나, 운영자가 *"모두싸인의 기능을 직접, 암호등급으로 구현"* 지시 → **Phase-1 이 직접-build 엔진**이다. 빌트+검증 완료 (이 세션).
**Phase-1 = BUILD (직접 구현, 작동·검증됨)** — gate 가 자체 암호 서명엔진을 갖는다 ([para-os §84](/architecture/para-os) 와 정합: gate = 결정 파이프라인의 actuator, 봉인은 `esign.gate` actuator):
- **① CMS/PKCS#7 CAdES-BES detached 서명** (RSA-2048 + SHA-256). `DocumentSigner` trait(**HSM-ready** abstraction) + `SoftwareSigner`(self-signed X.509). 적대적 리뷰(위조 도달경로 없음) + **openssl 양방향 interop**, 7 tests.
- **② RFC3161 신뢰 타임스탬프 → CAdES-T** (hand-rolled ASN.1, 단일 clean dep line). default = DigiCert RSA TSA; 한국 prod = **Koscom/CrossCert** config-swap. 9 tests + live round-trip.
- **⑤ 워크플로 배선**: **시행(execute)** 시 `esign.gate` actuator 가 승인된 결정을 **server-seal** — canonical decision bytes → CAdES-T → append-only **`esign_seal`** evidence 테이블 저장 (**전자문서법 §5 custodian**). 신규 **`verify_seal` MCP tool** = 서명 + 무결성 + 타임스탬프 검증. e2e: 결재→seal→verify valid · 저장 CMS openssl cross-verify 통과 · DB tamper→integrity fail.
**법적 근거 — 전자서명법 (2020, 공인 폐지)**: 사서명(private/internal e-sign)은 **실지명의 + 서명의사**면 유효, 운영 라이선스 불요. gate 매핑 = **실지명의 = 인증된 gate identity**(내부 서명자) · **서명의사 = 결재 승인 행위**. → 내부/사내 e-sign 은 본 엔진으로 **합법 완결**.
**모두싸인 = optional alternative provider** (기본 아님): 외부 카운터파티 라운드트립이 필요할 때 `signature.provider=modusign` 경로 (`esign.send`/EsignStub) 가 여전히 존재. gate-native 봉인이 Phase-1 기본, 모두싸인은 *대안*.
**잔여 하드닝 / Phase-2**:
- **외부 서명자 본인확인 = 진짜 Phase-2** — 방통위 지정 **본인확인기관** 통합([B-gate-bonin-id-contract](/ops/backlog), 정보통신망법 §23-3 자가구축 불가). 내부 서명자는 gate auth = 실지명의라 불요; 외부 신원 강증명만 게이트.
- **HSM** (cryptoki/PKCS#11) — `DocumentSigner` trait 가 이미 **abstraction-ready**, dev 는 software key. prod = HSM-backed `DocumentSigner` 구현 + **서명키 DR/escrow**(분실 = 전 과거서명 검증불가).
- **PAdES 서명-PDF 컨테이너** (③④) — typst **deterministic render** + pyHanko PAdES. 현재 봉인 대상 = decision canonical bytes(결정적); 사람이 읽는 sealed-PDF 는 later.
- **production 키관리** — dev 는 ephemeral key. prod = 영속 서버 서명 identity + 회전·백업.
**MUST NOT (Phase 무관, 불변)**: 자체 CA · per-user cert · 자체 TSA · "공인/인증" 주장. gate 는 **단일 서버 서명 identity** 로 봉인한다 (per-user cert 발급 없음) — 이것이 **ModuSign-recipe 모델**(provider 가 서버 서명으로 봉인, 사용자별 인증서 없이). 자체 TSA 도 안 만든다 — 외부 RFC3161 TSA(DigiCert/Koscom/CrossCert)에 위임.
**환경 변수** (signing identity + TSA): `GATE_ESIGN_KEY_PEM` / `GATE_ESIGN_CERT_PEM` (서버 서명 키·인증서 PEM, 또는 `GATE_ESIGN_KEY_DIR` 로 디렉터리 지정) · `GATE_TSA_URL` (RFC3161 TSA 엔드포인트, default DigiCert / 한국 prod Koscom·CrossCert). prod 의 `GATE_ESIGN_KEY_PEM` = **비밀** → `customers.yaml services.gate.secrets[]` 에 등재(blueprint OIDC 서명키 `blueprint/axe/oidc-signing-key` 패턴, [/architecture/secrets](/architecture/secrets)).
## 7. 법무 모듈 가드레일
gate 법무모듈 = **검색·요약·번역·템플릿(로폼: user-blank, 기계적)만** ([D-gate-4](/ops/decisions)).
**하드룰 — 변호사법 §109(형사)·§34(5)**: 법률상담/사안별 자문/대리/결과예측-as-advice **금지**. **공개 "AI 법률상담" 금지**(AI대륙아주 7개월 폐쇄·징계 선례). 변호사 노출 시 정액광고만(로톡 모델 — 알선료/성공보수/광고수익배분 금지). 모든 출력 "**법률 정보, 자문 아님**" disclaimer. cite = **retrieval-verified**(존재 + 시행일/선고일 temporal 강제). **결재 자동주입 차단** — AI 기안이 법령/판례를 auto-approve 경로에 주입하지 않음(red-team P0 #2).
**Phase-1 data**: 국가법령정보 OPEN API(open.law.go.kr, 이용제한없음·무료) + data.go.kr 판례(상용 OK) + HF `korean_law_open_data_precedents`(openrail). 대법원/헌재 + 법령 grade(하급심 = 엘박스/케이스노트 상용 walled 라 제외). SoR = gate Postgres, index = **pgvector-KR**(Pinecone 아님). `lbox_open`/`kcl` = CC BY-NC = 유료제품 금지.
**Phase-2 gate**: Pinecone + Cohere rerank + Gemini embed flywheel(LLM비용 사용자부과·DB 복리) + cross-tenant 축적 = **PIPA 처리근거·가명정보·국외이전(§28-8) 자문**([B-gate-legal-counsel](/ops/backlog)) 선결. B2B/internal-use 유지 = 최저위험.
## 8. 규제 경계 요약
| 법령 | 무엇을 강제하나 | Phase |
|---|---|---|
| 전자서명법 (2020, 공인폐지) | 사서명도 실지명의 + 서명의사면 유효 — 직접구현 합법, 운영 라이선스 불요. gate: 실지명의 = gate auth · 서명의사 = 결재 승인 | **Phase-1** (직접 e-sign 빌트, [D-gate-5](/ops/decisions)) |
| 전자문서법 §5 | 서명문서 + 메타 무결성 보관 (gate = evidence custodian via `esign_seal` append-only + RFC3161 TSA) | **Phase-1** (gate-native 봉인) |
| 정보통신망법 §23-3 | 본인확인 자가구축 불가 → 방통위 지정 본인확인기관 통합 필수 | Phase-2 (**외부 서명자 신원** 한정; 내부 서명자는 gate auth = 실지명의라 불요) |
| 변호사법 §109/§34 | 법률사무 무면허 금지 — 검색·정보 한정, 자문/대리/auto-주입 금지 | **Phase-1** (법무 활성 시) |
| 개인정보보호법 (PIPA) | cross-tenant 축적·국외이전·CI/DI = 처리근거·가명정보·DPIA | Phase-2 (flywheel/SaaS 전제) |
| 전자상거래법 (2025 개정) | 정기결제 명시동의·셀프해지·사전고지·다크패턴 금지 | Phase-2 (결제 전제) |
대법원 2017도13263(해시 무결성 → 서명시점 해시 + custody chain) · 민사소송법 §358(실제 서명행위 → 진정성립)이 증거능력 타깃. 제외문서 = 유언·부동산등기·공정증서·일부보증(민법 §428-2).
## 9. Phase-2 게이트 (deferred)
각 야심은 자기 P0/규제 게이트를 동반한다 ([B-gate-phase2-gates](/ops/backlog)):
1. **e-sign — 엔진은 Phase-1 빌트 ([D-gate-5](/ops/decisions)), 잔여만 Phase-2/하드닝**: (Phase-2) 외부 서명자 **본인확인기관** 통합(정보통신망법 §23-3) · (하드닝) HSM/KMS `DocumentSigner`(trait ready) + 서명키 DR/escrow · PAdES 서명-PDF(typst deterministic render + pyHanko) · production 키관리. *닫힌 항목*: 자체 PKI 서버서명·CAdES-T·RFC3161 타임스탬프·custody(`esign_seal`)·canonical-bytes 결정성·para-os §84 재결정 = §6 참조.
2. **Pinecone 법무 flywheel** — PIPA 자문 · 비식별/가명정보 · namespace 테넌트격리 · PII 임베딩 전 제거 코드강제.
3. **공개 SaaS** — tenant_id RLS substrate(owner_id RLS 재설계, red-team P0 #8) · self-IdP tenant바인딩/iss/aud/per-tenant 시크릿/MFA(P0 #9) · rate-limit·abuse · 서브도메인 격리.
4. **결제** — 전자금융/전자상거래법(동의·셀프해지·사전고지·다크패턴) · LLM 미터링 과금 · Paddle MoR 영세율. (Stripe = 한국법인 불가.)
5. **cross-tenant 축적** — PIPA 근거 확정 후.
## 10. 착수 순서
docs-first(본 페이지 + D-gate-1~5) → `cp -R cortex gate` scaffold(auth 3-branch ladder[HS256 / Blueprint OIDC / Entra] · `gate_app` NOSUPERUSER + `gate.actor` GUC RLS · append-only audit · blue/green caddy 상속, index 도메인 미상속) → 포트 41xx 배선(pg4100 / blue4110 / green4111 / proxy4112) → 7테이블 migration(6 + `esign_seal`) → 상태기계 → **`esign.gate` actuator**(gate-native CAdES-T server-seal + `esign_seal` + `verify_seal`, [D-gate-5](/ops/decisions) — 빌트) + 모두싸인 alt-provider actuator(Actuator trait + EsignStub → 모두싸인 sandbox, optional) → `customers.yaml services.gate.secrets[]`(+ `GATE_ESIGN_KEY_PEM` 서명키) · cloudflared · pre-push guard.
## 관련
- [/architecture/para-os](/architecture/para-os) §7 — 결정로그 ≠ 결재워크플로 (본 페이지의 상위 철학)
- [/architecture/artifacts](/architecture/artifacts) — Artifact · Citation 저장/스키마 (Blueprint mirror 토대)
- [/architecture/data](/architecture/data) — 데이터 격리 · cross-service 신호채널 규율
- [D-gate-1](/ops/decisions) · [D-gate-2](/ops/decisions) · [D-gate-3](/ops/decisions) · [D-gate-4](/ops/decisions) · [D-gate-5](/ops/decisions)(e-sign 직접구현) — 본 페이지의 결정 등재
- [B-bp-decision-pipeline-esign](/ops/backlog) — 마스터플랜 + 착수 큐 (service=gate, M9)
---
# MCP 서버 개발 체크리스트
> frame / hive / blueprint 누적 lesson — 양파껍질 5층 + Azure manifest 9 항목 + 운영 21 항목 (skill-integration 5 포함).
URL: https://docs.axelabs.ai/architecture/mcp-server-checklist
# MCP 서버 개발 체크리스트
claude.ai Custom Connector + Claude Code 가 받는 표준 MCP 서버를 추가할 때, **frame · hive 가 검증한 byte-by-byte 패턴을 1:1 으로 복사**해야 한다. 비슷하게 만들면 5층 양파껍질이 차례로 폭로된다 (Blueprint 2026-05-21 실제 사고).
> **핵심 원칙**: 새 MCP 서버는 `cp -R /Users/axe/frame/src/frame /tmp/...` 부터 시작하라. "유사하게" 가 아니라 "diff 가 없을 때까지". 단 4 단어 (`audience=[client_id, app_id_uri]`) 빠뜨려도 OAuth 통과 후 401 무한루프.
---
## 0. 가기 전 — 결정 (D-bp-mcp-1, 2026-05-21)
| 결정 | 이유 |
|---|---|
| **Python + FastMCP + Starlette + uvicorn** 으로 통일 | claude.ai 의 Streamable HTTP 가 frame 의 wire-level response 만 수용. Node port 시도 (visible response 동일) 도 silent reject. |
| DB 는 **기존 Prisma/Drizzle/SQLAlchemy schema 를 raw SQL 로 read-only access** | 별도 schema 필요 없음. SQLAlchemy `text("SELECT ... FROM \"User\" WHERE ...")` 패턴. |
| **OAuth Resource Server (RP)** 직접 — 자체 AS 구축 금지 | D-ops-15 dead-end: claude.ai 는 외부 AS 의 PKCE 우회 못함. 무조건 Microsoft Entra ID 직결. |
| **standalone 컨테이너** — main service 컨테이너에 mount X | claude.ai connector probe 가 Next.js 의 `vary: rsc, ...` auto-header 거부. 최소 header 의 standalone Python 필요. |
---
## 1. Azure Entra ID app registration — 9 항목
`az ad app create` 한 줄로 끝나지 않는다. 각 항목 확인.
```bash
# 1. app + sp 동시 생성
az ad app create --display-name "Blueprint MCP" --sign-in-audience AzureADMyOrg
az ad sp create --id APP_ID # ← 명시적 SP 생성, 자동 생성 안 됨
# 2. identifierUris (= App ID URI)
az ad app update --id APP_ID --identifier-uris "https://axe.axelabs.ai/blueprint/mcp"
# 3. accessTokenAcceptedVersion = 2 (v2 endpoint 가 표준)
az rest --method PATCH --uri "https://graph.microsoft.com/v1.0/applications/OBJECT_ID" \
--body '{"api":{"requestedAccessTokenVersion":2}}'
# 4. oauth2PermissionScopes (delegated permission)
az rest --method PATCH ... --body '{
"api": {"oauth2PermissionScopes":[{
"id":"RANDOM_UUID",
"value":"mcp.access",
"type":"User",
"isEnabled":true,
"adminConsentDisplayName":"Access Blueprint MCP",
"userConsentDisplayName":"Access Blueprint MCP"
}]}
}'
# 5. redirectUris (claude.ai 의 양쪽 도메인)
az rest --method PATCH ... --body '{"web":{"redirectUris":[
"https://claude.ai/api/mcp/auth_callback",
"https://claude.com/api/mcp/auth_callback"
]}}'
# 6. requiredResourceAccess (Microsoft Graph User.Read — id_token 의 email claim 발급)
az rest --method PATCH ... --body '{"requiredResourceAccess":[{
"resourceAppId":"00000003-0000-0000-c000-000000000000",
"resourceAccess":[{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]
}]}'
# 7. implicitGrantSettings.enableIdTokenIssuance = true
az rest --method PATCH ... --body '{"web":{"implicitGrantSettings":{"enableIdTokenIssuance":true}}}'
# 8. client_secret 생성 + vault push (stderr 분리 필수)
az ad app credential reset --id APP_ID --years 2 --query password -o tsv 2>/dev/null \
| axe secret push CUSTOMER/SERVICE/mcp-client-secret -
# 9. tenant admin consent (필요 시 Azure Portal 에서 1 클릭)
# User.Read + mcp.access 둘 다 grant
```
**함정 — `az` stderr 가 password 와 합쳐져 vault corrupt (Blueprint 270자 사건 2026-05-21)**: 반드시 `2>/dev/null` 로 stderr 분리.
**검증**:
```bash
az ad app show --id APP_ID --query "{accessTokenAcceptedVersion:api.requestedAccessTokenVersion, identifierUris:identifierUris, signInAudience:signInAudience}" -o json
```
frame · hive · blueprint 비교 시 차이 0 이어야 한다. 차이 있으면 fix.
**별개 — app-only write 기능의 Graph Application permission**: 위 9 항목은 **MCP connector app** (token 검증 + id_token email claim 용 `User.Read`) 한정이다. Blueprint 의 admin send-as write 기능은 app-only token (`getAppOnlyClient()`, client-credentials)을 쓰므로 **Blueprint Next.js app (`2b222356-1c36-48e0-96a3-2c5e0ecbf937`)** 에 Application permission 을 부여해야 한다 — connector app (`482598f7-...`) 이 아니다 (함정 [/ops/known-gaps#blueprint-azure-app-id-혼동](/ops/known-gaps#blueprint-azure-app-id-혼동)):
| 기능 | Application permission | 결정 |
|---|---|---|
| calendar send-as (`as_user_email`) | `Calendars.ReadWrite` | D-bp-mcp-calendar-2 |
| `send_mail` send-as (`as_user_email`) | `Mail.Send` | D-bp-mcp-mail-1 |
부여 후 `az ad app permission admin-consent --id 2b222356-1c36-48e0-96a3-2c5e0ecbf937` (Global Admin) → `docker restart blueprint-app-blue` (또는 -green; MSAL client-credential in-memory 캐시 폐기). 상세 = [/architecture/auth § app-only Application permission (send-as)](../architecture/auth). 비-admin caller 의 send-as 시도는 코드가 403 `send_as_forbidden` 로 차단하므로 self (delegated) 경로는 이 permission 없이도 동작.
---
## 2. cloudflared ingress — 1 줄 (path-based)
`/Users/axe/.axe/tunnels/axelabs/config.yml` 의 `ingress:` 에 prepend:
```yaml
- hostname: axe.axelabs.ai
path: ^/SERVICE/mcp(/.*)?$
service: http://host.docker.internal:PROXY_PORT
- hostname: axe.axelabs.ai
path: ^/SERVICE/\.well-known/oauth-protected-resource(/.*)?$
service: http://host.docker.internal:PROXY_PORT
```
**함정**: cloudflared 는 **path prefix 를 strip 하지 않는다**. 컨테이너가 `/SERVICE/mcp` 를 그대로 받음. 따라서 Starlette 라우트도 `/SERVICE/mcp` 에 mount.
---
## 3. Caddy reverse proxy (axe-SERVICE-mcp-proxy)
frame-proxy / hive-proxy 미러. cloudflared 가 graceful reload 미지원이라 사이에 끼움 (blue/green swap 시 sub-second).
`/Users/axe/.axe/SERVICE-mcp-proxy/Caddyfile`:
```
:80 {
reverse_proxy SERVICE-mcp:3000 {
lb_try_duration 5s
lb_try_interval 250ms
header_up Host {http.request.host}
}
encode gzip
log { output stdout; format console; level INFO }
}
```
**함정**: nested `handle_path` syntax 쓰지 마. 단순 `reverse_proxy` 만 (Blueprint Caddyfile syntax error 2026-05-21).
---
## 4. Python 코드 — frame `auth_oidc.py` + `mcp/http_server.py` 1:1 미러
### 4.1 `auth_oidc.py` — JWKS + 토큰 검증 (∼180 줄)
**필수 4 가지 (Blueprint 양파껍질 1·3 사건)**:
```python
# ① httpx.Timeout — 4-arg 또는 default 명시. partial kwargs 는 0.28 부터 ValueError.
_JWKS_HTTP_TIMEOUT = httpx.Timeout(connect=2.0, read=3.0, write=2.0, pool=5.0)
# ^^^^^^^^^^^^^^^^^^^^ 빠뜨리면 500
# ② audience = [client_id GUID, application_id_uri]
# v2 endpoint 의 access_token aud = client_id GUID (NOT App ID URI).
# PyJWT 가 list 의 any 매칭. 둘 다 줘야 v1/v2 양립.
expected_audiences: list[str] = [get_client_id(), get_app_id_uri()]
payload = pyjwt.decode(
token, public_key,
algorithms=["RS256"],
audience=expected_audiences, # ← list, 단일 string 금지
issuer=[get_microsoft_issuer(), get_microsoft_issuer_v1()],
options={"require": ["exp","iat","iss","aud","sub"]},
leeway=60,
)
# ③ JWKS cache + fail cooldown + kid-miss force refetch
# Microsoft 의 key rotation 대응. frame/auth_oidc.py:47-78 그대로.
# ④ email claim fallback 순서
email = (payload.get("email")
or payload.get("preferred_username")
or payload.get("upn") or "").strip().lower()
```
### 4.2 `mcp/http_server.py` — Starlette + FastMCP wiring (∼270 줄)
**필수 5 가지**:
```python
# ① lifespan forward — Starlette Mount 는 child lifespan 미전파
def build_app() -> Starlette:
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def _lifespan(app: Starlette):
async with mcp_app.router.lifespan_context(app): # ← 안 부르면
try:
yield # "Task group not initialized"
finally:
await db_shutdown()
# ② 401 reason 노출 (body + log)
def _unauthorized(request, request_id, reason):
log.warning("401 %s reason=%s", request.url.path, reason)
body = {"error": {
"code": _map_code(reason), # MISSING_TOKEN / INVALID_TOKEN / USER_NOT_PROVISIONED / UNAUTHORIZED
"message": reason, # ← 고정 string 금지. reason 그대로.
"context": {"request_id": request_id, "reason": reason},
...
}}
# WWW-Authenticate resource_metadata = APP_ID_URI 에서 /mcp suffix strip + /.well-known/...
# ③ FastMCP DNS-rebinding guard 우회 — main() 안에서 build_app() 전에:
allowed_hosts = os.environ.get("SERVICE_MCP_ALLOWED_HOSTS")
if allowed_hosts:
mcp.settings.transport_security.allowed_hosts = [h.strip() for h in allowed_hosts.split(",") if h.strip()]
# 안 하면 axe.axelabs.ai 가 421 Misdirected Request
# ④ Route mount: cloudflared 가 path 그대로 보내므로 `/SERVICE/` mount + 화이트리스트
routes = [
Route("/health", health),
Route("/SERVICE/health", health),
Route("/SERVICE/.well-known/oauth-protected-resource", oauth_protected_resource),
Mount("/SERVICE", app=mcp_app),
]
# ⑤ JWTAuthMiddleware 의 public-path 화이트리스트도 `/SERVICE/` prefix 포함
if path in ("/health", "/SERVICE/health", "/SERVICE/.well-known/oauth-protected-resource", ...):
return await call_next(request)
```
---
## 5. docker-compose.yml — 5 항목
```yaml
mcp:
build: { context: .., dockerfile: mcp/Dockerfile }
container_name: SERVICE-mcp
env_file: ../.env
environment:
- SERVICE_MCP_PORT=3000
- SERVICE_MCP_APP_ID_URI=${SERVICE_MCP_APP_ID_URI:-https://axe.axelabs.ai/SERVICE/mcp}
- AZURE_AD_TENANT_ID=${AZURE_AD_TENANT_ID:-122fb574-...}
# ① allowed_hosts (axe.axelabs.ai + 컨테이너 DNS + localhost)
- SERVICE_MCP_ALLOWED_HOSTS=SERVICE-mcp:*,localhost:*,127.0.0.1:*,host.docker.internal:*,axe.axelabs.ai
- SERVICE_MCP_ALLOWED_ORIGINS=https://axe.axelabs.ai,https://claude.ai,https://claude.com
networks:
default:
aliases:
- SERVICE-mcp
# ② artemis network 도 참여 (axelabs-tunnel 같은 네트워크여야 cloudflared 가 닿음)
artemis:
# ③ Python 이미지면 curl healthcheck (Node 잔재 `node -e fetch(...)` 금지)
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
depends_on:
postgres:
condition: service_healthy
```
**함정 (Blueprint 양파껍질 5 사건)**: healthcheck 가 Node 잔재면 컨테이너 영원히 `unhealthy`. Python 이미지면 curl 만.
---
## 6. customers.yaml entry — D-ops-17 매니페스트
```yaml
azure_apps:
CUSTOMER:
SERVICE_mcp: # D-SERVICE-mcp-1
client_id: "GUID"
application_id_uri: "https://axe.axelabs.ai/SERVICE/mcp"
scopes:
- "https://axe.axelabs.ai/SERVICE/mcp/mcp.access"
# client_secret 는 vault 에서 pull (env 명시)
client_secret_env: "AZURE_SERVICE_MCP_CLIENT_SECRET"
services:
SERVICE:
env_file: "/Users/axe/SERVICE/.env"
secrets:
- { env: AZURE_SERVICE_MCP_CLIENT_SECRET, vault: "SERVICE/CUSTOMER/mcp-client-secret", rotation_external: azure }
# 기타 비밀들 ...
```
`axe secret push PATH -` 로 push, `axe secret pull SERVICE` 로 deploy-time pull.
---
## 7. 등록 절차 — 사용자 화면 (claude.ai)
1. Settings → Connectors → **+ Add custom connector**
2. URL: `https://axe.axelabs.ai/SERVICE/mcp`
3. Client ID + Client Secret 입력 (vault 에서 `axe secret get SERVICE/CUSTOMER/mcp-client-secret -n`)
4. Microsoft 로그인 (axellc.com 계정) → 동의 → claude.ai 복귀
5. **성공 신호**: connector 상세에 tool 목록이 노출됨 (whoami, list_..., 등)
**실패 시 사용자 화면 메시지 → 진짜 원인 매핑** (Blueprint 5 fix 회고):
| claude.ai 메시지 | 실제 원인 | 진단 path |
|---|---|---|
| "the integration may not be available right now due to a temporary error" | server 가 500 응답 (verifier 자체 ValueError 등) | `docker logs SERVICE-mcp` traceback |
| "the integration rejected the credentials, so the connection was reverted" | server 가 401 응답 | log.warning `reason=...` 확인 (`§ 4.2 ②` fix 필수) |
| "Audience doesn't match" (응답 body) | `audience=` 가 list 아님 (`§ 4.1 ②`) | aud claim 확인 — v2 면 client_id GUID |
| "Task group is not initialized" (응답 body 또는 컨테이너 로그) | lifespan forward 누락 (`§ 4.2 ①`) | boot log 에 "StreamableHTTP session manager started" 있어야 함 |
| "Invalid Host header" + 421 | allowed_hosts 미설정 (`§ 4.2 ③`) | `BLUEPRINT_MCP_ALLOWED_HOSTS` env 확인 |
---
## 8. 운영 — 21 가지 체크포인트
배포 후 다음을 모두 확인. 어느 하나 실패 시 production 으로 안 보냄.
- **#1–#16 = 전송·인증·부팅 레이어** (frame · hive · blueprint 가 검증한 wire/OAuth/DB 항목).
- **#17–#21 = skill-integration 레이어** ([index](../services/index) 가 도입). MCP 서버가 *판단 도구* (propose_deal_closure 류 write tool + ic / pmc skill 호출) 를 노출하면, 도구가 idempotent 한지 · citation 이 roundtrip 하는지 · skill 끼리 충돌 시 audit 에 남는지 · 의존 down 시 graceful 한지 · schema 가 진화해도 옛 artifact 가 읽히는지를 추가로 검증한다. write tool 없는 read-only MCP (matrix 류) 는 #17–#21 면제.
| # | 검증 | 명령 | 정답 |
|---|---|---|---|
| 1 | 컨테이너 healthy | `docker ps \| grep SERVICE-mcp` | `Up X (healthy)` |
| 2 | health endpoint | `curl https://axe.axelabs.ai/SERVICE/health` | 200 OK |
| 3 | OAuth metadata | `curl https://axe.axelabs.ai/SERVICE/.well-known/oauth-protected-resource` | 200 + 정확한 issuer/scopes |
| 4 | Bearer 없음 → 401 | `curl -X POST https://axe.axelabs.ai/SERVICE/mcp` | 401 + `WWW-Authenticate: Bearer ... resource_metadata="..."` |
| 5 | fake Bearer → 401 with reason | `curl -X POST ... -H "Authorization: Bearer xxx"` | 401 + body.error.context.reason 노출 |
| 6 | boot log: session manager | `docker logs SERVICE-mcp` | `StreamableHTTP session manager started` |
| 7 | JWKS fetch 성공 | (첫 인증 호출 후) `docker logs` | `GET .../discovery/v2.0/keys "HTTP/1.1 200 OK"` |
| 8 | 컨테이너 DNS alias | `docker exec axelabs-tunnel ping -c1 SERVICE-mcp` | 응답 |
| 9 | Caddy → backend | `docker logs axe-SERVICE-mcp-proxy` | `upstream "SERVICE-mcp:3000"` |
| 10 | cloudflared ingress | `cloudflared tunnel info axelabs` | hostname 포함 |
| 11 | Azure app manifest | `az ad app show --id APP_ID` | accessTokenAcceptedVersion=2, identifierUris=URL 형식 |
| 12 | SP 존재 | `az ad sp list --filter "appId eq 'APP_ID'"` | non-empty |
| 13 | vault secret 유효 | `axe secret get PATH -n \| wc -c` | 40~50 자 (270 자면 stderr 섞임) |
| 14 | claude.ai 직접 등록 | 위 § 7 의 1~5 단계 | tool 목록 노출 |
| 15 | **DATABASE_URL 유효** (D-bp-mcp-3) | `docker exec SERVICE-mcp printenv DATABASE_URL` | `postgresql://...` 또는 `postgresql+asyncpg://...` (SQLite `file:./...` 면 = .env 잔재 → 제거) |
| 16 | **startup probe 통과** (D-bp-mcp-3) | `docker logs SERVICE-mcp \| grep "startup probe"` | `startup probe: DB reachable, schema accessible` (없으면 lifespan fail = DB 미접속). ⚠️ `/health/ready` endpoint 만 가진 service (frame · hive 2026-05-22 상태) 는 broken DB 로 부팅 가능 — first request 가 와야 fail → blue/green swap promote 위험. **lifespan 안에서 SELECT 1 1 블록 호출** 패턴 강제. `blueprint/mcp/src/blueprint_mcp/mcp/http_server.py:238-260` 1:1 미러. |
| 17 | **skill write tool idempotency** (D-index-13) | 동일 input 으로 write tool 2회 호출 (예: `propose_deal_closure(deal_id=X, idempotency_key=K)` 두 번) | 1회차 = 적용 + `(artifact_id, was_inserted=true)`, 2회차 = **`409` / `IdempotencyConflict`** (또는 `was_inserted=false` idempotent skip) — **두 번째 호출이 중복 ledger row 를 만들면 FAIL**. index 는 `idempotency_record` 테이블 (hive 패턴 mirror, 24h TTL) 로 atomic propose 의 race 차단 — `index/migrations/20260528000001_shared_schema.up.sql` + `error.rs::IndexError::IdempotencyConflict` (→ `StatusCode::CONFLICT`). agent 가 retry/재호출해도 안전해야 함 (네트워크 timeout 후 재시도 = 흔함). |
| 18 | **citation resolver roundtrip** (durable ≠ fragile) | propose 한 artifact 의 citation 을 다시 resolve → 원본 anchor 와 대조 | resolve 한 값이 원본과 **상대오차 ±0.001% 이내** (재무 수치는 bit-exact 권장). `durable` citation (anchor = sha256 / drive_item_id / frame journal_id 같은 stable ref) 은 100% 재현, `fragile` citation (텍스트 anchor·페이지 추정) 은 별도 카운트되어 노출되어야 함 — `index/src/html.rs` 의 `citation_durable_total` / `citation_fragile_total`. fragile 비율이 0 이 아니면 status board 에 표시 (숨기면 FAIL). |
| 19 | **cross-skill conflict → audit_trail** (silent resolve 금지) | 두 skill (예: ic 재무모델 vs frame 실적분개) 이 같은 metric 에 다른 값을 propose → conflict 감지 | 충돌이 **자동으로 한쪽을 골라 덮어쓰지 않고**, `artifact_event` append-only 감사 로그 (op=propose) + reconcile 레이어에 `{min, max, relative_gap, [{value, source_file, location}]}` 로 노출되어야 함. index 는 상대갭 **10% 초과** 시 metric group 을 conflict 로 flag (`artifact.rs` conflict guard) — "conflict 는 metric 레벨에서 표면화, 절대 silent 해결 안 함". 판단 도구가 충돌을 조용히 삼키면 FAIL. |
| 20 | **graceful degradation** (의존 down 시 markdown-only / warn) | 의존 서비스 1개 강제 정지 후 도구 호출 (예: skill 의 humanize sub-agent OR frame MCP upstream 차단) | 전체 pipeline 이 **panic / 502 로 죽지 않고** degrade — index ic skill 은 humanize 2회 실패 시 `degrade-to-warn` (결과 폐기 → `pre_humanize.attempt1` backup 으로 PDF 렌더 + 워터마크, 진행은 막지 않음, `skills/ic/SKILL.md`). blue/green Caddy 는 한 컬러 down 시 다음 요청을 살아있는 컬러로 자동 라우팅 (graceful reload). 의존 부재가 hard-fail 을 일으키면 FAIL. |
| 21 | **schema evolution 하위호환** (옛 artifact 읽힘) | `schema_version` 을 올린 새 코드로 **이전 버전 artifact** 를 read/list | 옛 row 가 그대로 역직렬화되어야 함 — index artifact 는 `schema_version integer NOT NULL DEFAULT 1` (`migrations/20260526210000_initial_artifact_schema.up.sql`) + back-compat shim (`artifact.rs`: 기존 caller 는 default provenance `skill="manual"`) + seed 가 "schema 가 실데이터와 backward-compatible" 임을 증명 (`seed.rs`). migration 후 옛 artifact 가 깨지면 FAIL. **acceptance gate**: index 는 `cargo test` (seed roundtrip + 23 live seed 역직렬화) 를 배포 게이트로 사용 — read tool 변경 시 이 gate 통과 필수. |
> **#17–#21 운영 모니터 (배포 후 24h)**: idempotency 위반 (중복 ledger) · fragile citation 급증 · 미해결 conflict · degrade-to-warn 발동 빈도 · schema 역직렬화 실패는 **status board 지표**다 (index `/index` status page 의 `artifact_event` append-only count + `citation_durable/fragile` + `conflict` 카운트). 첫 24h 동안 fragile 비율 · conflict 건수 · degrade 발동을 관찰하고, 비정상 spike 면 promote 보류. read-only MCP (matrix) 는 write tool 이 없어 #17·#19 비해당, #18·#20·#21 도 노출 도구 한정으로 N/A 처리 가능.
---
## 9. 디버깅 — 첫 번째 행동
증상이 무엇이든 **이것부터**:
```bash
docker logs SERVICE-mcp --since 5m 2>&1 | grep -iE "401|reason|warning|error|exception|task group|invalid" | head -30
```
그 다음 proxy:
```bash
docker logs axe-SERVICE-mcp-proxy --since 5m 2>&1 | grep -E '"status": [45]' | tail -20
```
응답 body 가 진단 정보 들어있는지 확인:
```bash
curl -X POST https://axe.axelabs.ai/SERVICE/mcp \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZXN0IjoxfQ.invalid" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' | jq .error
```
`.error.message` 와 `.error.context.reason` 이 모두 노출되어야 정상 (`§ 4.2 ②` 통과).
---
## 10. 양파껍질 회고 (Blueprint MCP, 2026-05-21)
5 단계 모두 frame 코드에 byte-by-byte 미러했더라면 0 단계.
| 차단 | 메시지 | 원인 | Fix |
|---|---|---|---|
| 1 | "integration may not be available" (500) | `httpx.Timeout(connect, read)` 2-arg | 4-arg form |
| 2 | (모든 401 이 같은 메시지) | `_unauthorized` body 가 reason 무시 | reason → body + log |
| 3 | "rejected the credentials" (401) | `audience=app_id_uri` 단일 | `audience=[client_id, app_id_uri]` |
| 4 | (auth 통과 후 첫 호출) `Task group not initialized` | Starlette Mount 가 FastMCP lifespan 미전파 | `lifespan=inner.router.lifespan_context` wrap |
| 5 | 421 `Invalid Host header: axe.axelabs.ai` | DNS rebinding guard | `mcp.settings.transport_security.allowed_hosts` env |
| 6 | "Authorization with the MCP server failed" (claude.ai UI, 종일 broken, D-bp-mcp-3 2026-05-22) | D-config-17 Postgres cutover 가 `.env` 의 `DATABASE_URL="file:./data/blueprint.db"` SQLite legacy line 안 지움 → mcp 가 받음 → SQLAlchemy `ArgumentError` deep in ASGI → 모든 Bearer 요청 silent 500 → claude.ai 는 일반적 OAuth fail 로 표시 → 운영자가 secret 만 의심 | (a) `.env` 의 stale `DATABASE_URL` line 제거 (compose default `${DATABASE_URL:-postgresql://...}` 적용), (b) `config.get_database_url()` fail-fast on non-postgres URL, (c) Starlette lifespan 에 `SELECT 1` startup probe → DB 미접속 시 healthcheck fail → blue/green swap 거부 |
**총 6 PR (#331~#335, #346)**, 약 2 시간. 같은 시간에 frame 1:1 copy + Postgres cutover env audit 했다면 30 분.
**Lesson 항구화**:
1. 새 MCP 서버 = `cp -R frame/src/frame /tmp/NEW` 부터.
2. 첫 PR 부터 `_unauthorized` reason 노출 — 진단 도구가 우선.
3. 등록 시도 시 claude.ai 의 generic 메시지 ↔ 컨테이너 로그 매핑 표 (`§ 7`) 참조.
4. **DB 종속 service 는 startup probe 필수** — lifespan 에서 `SELECT 1` 호출, 실패 시 raise → healthcheck fail → blue/green swap 거부. silent runtime 500 보다 명시적 부팅 실패가 훨씬 안전.
5. **env_file 잔재 검사** — 매 service launch 전 `docker exec SERVICE printenv DATABASE_URL` 가 정확한 형식인지 § 8 #15~16 으로 확인.
---
## 참고
- frame 구현: `/Users/axe/frame/src/frame/{auth_oidc.py, mcp/http_server.py}`
- hive 구현: `/Users/axe/hive/src/hive/{auth_oidc.py, mcp/http_server.py}`
- blueprint 구현: `/Users/axe/blueprint/mcp/src/blueprint_mcp/{auth_oidc.py, mcp/http_server.py}` (2026-05-21 standalone)
- index 구현 (skill-integration 레이어 #17–#21): `/Users/axe/index/src/{artifact.rs, error.rs, html.rs, seed.rs}` + `migrations/20260528000001_shared_schema.up.sql` (idempotency_record) — [/services/index](../services/index)
- 인증 흐름: [/architecture/auth](../architecture/auth)
- 비밀 매니페스트: [/architecture/secrets](../architecture/secrets) (D-ops-17)
- 결정 기록: [/ops/decisions](../ops/decisions)
---
# PARA OS — 일하는 방식과 지식의 구조
> Blueprint 의 PARA 는 파일 정리법이 아니라 조직론. Area(영속 기능) × Project(임시 TFT) matrix org 를 substrate 로, 지식이 claim·judgment·document 로 흐른다. dispatch="집"(link) 모델, governance 결정로그+결재워크플로, 죽은 딜 본질.
URL: https://docs.axelabs.ai/architecture/para-os
# PARA OS — 일하는 방식과 지식의 구조
Blueprint 의 PARA 는 파일 정리법이 아니라 **조직론**이다. Area(영속 기능) × Project(임시 TFT) 의 matrix org 를 substrate 로 삼고, 그 위에 지식이 흐른다. 도메인 서비스(frame/hive/index)는 성숙한 Area 가 물질화한 것, Blueprint 는 모든 Area 의 기본 substrate + 가로 결정로그.
> 본 페이지 = 설계 **철학·원칙** (방향). 저장·citation·schema 구현 세부는 [/architecture/artifacts](/architecture/artifacts), 결정 이력은 [D-bp-para-1](/ops/decisions). 일부 항목은 **열린 질문** (§10) — 확정 시 decisions 등재. SoT 메모 = `project_para_ai_os_philosophy.md` (Blueprint global memory).
## 1. PARA = 조직론
| Layer | 조직론적 정체 | 시간성 | 예 |
|---|---|---|---|
| **Project** | cross-functional TFT (동적 조직) — 목표 향해 모였다 끝나면 흩어짐 | 임시 | 딜, 펀드 결성, 유상증자, 분쟁 조정 |
| **Area** | 영속 기능 (정적 조직) — 통상의 부서 | 영속 | Finance · HR · Investment · Legal · IR · BOD |
| **Resource** | 일하는 방법·틀 — Project·Area 수행의 지식 기반 | 재사용 | DD 체크리스트, IC 템플릿, 섹터 프레임, 회사소개서 |
| **Archive** | 식은 이력 — 적극 탐색 불요 | 냉동 → 폐기 | 죽은 딜, 종료 프로젝트 |
Project 와 Area 는 matrix org 의 두 축이다 — **정적 기능 × 동적 태스크포스**. Project 가 전사에 던져지면 참여자가 emerge 하므로 "살아있는 팀"이자 "컨테이너"다 (둘 다).
## 2. 기하학 — Area 가 척추, R/A 는 Area 안
PARA 는 4 개 평등한 형제가 아니다. **Area 가 척추**이고, Resource 와 Archive 는 *각 Area 안에* 매달린다. Project 만 Area 들을 가로지른다.
```text
Area (영속 기능) ── 척추
├─ Live state 서비스화(frame/hive/index) | 미졸업(Blueprint artifact)
├─ Resource 그 기능의 방법·틀 (hot)
└─ Archive 그 기능의 식은 이력 (cold)
Project (TFT) ── Area 들을 가로지르는 동적 레이어
```
## 3. MCP = 물질화된 Area + 졸업 경로
도메인 서비스는 추상 서비스가 아니라 **Area 를 AI-native 로 물질화한 것**이다. frame = Finance Area, hive = HR Area, index = Investment Area. "폴더+artifact 만으로 Area 관점이 부족 → 개별 서비스화 + DB 적재로 UX 극대화" 가 그 동기.
- **기본 = Blueprint artifact** (인큐베이터). 새 테넌트·새 Area 는 전부 여기서 시작.
- **졸업 = 전용 MCP 서비스**. 트리거 = 저장이 아니라 **도메인 로직/operation + AI 상호작용** (frame=복식부기·마감, hive=급여계산, index=IRR). 단순 저장이면 Blueprint 로 충분.
## 4. Blueprint = 모든 Area 의 기본 substrate
| 층위 | 미졸업 / 무서비스 Area | 졸업 Area (frame/hive/index/gate) |
|---|---|---|
| Live state | Blueprint artifact | 서비스가 소유 |
| Resource | Blueprint | **서비스가 자기 도메인 Resource 소유** (index = DD 체크리스트·섹터 프레임·IC 템플릿) |
| Archive | Blueprint (cold) | **서비스가 자기 도메인 Archive 소유** (index = 죽은 딜) |
| 결정 로그 | gate SoR · Blueprint citation mirror (가로) | gate SoR · Blueprint citation mirror (가로) |
→ **Blueprint = 도메인 서비스가 *안 덮는* 것의 substrate**: (a) 미졸업·전용 서비스 없는 Area 의 live + R/A, (b) **cross-service 종합물**(보드팩·cross-domain 메모 — 각 서비스로 citation), (c) PARA fabric(workspace/dispatch/home·link) + **gate.decision mirror**(가로). 졸업한 서비스(index 등)는 자기 도메인 지식(**죽은 딜·섹터 프레임 포함**)을 *가져간다* — moat(죽은 딜 복리) = index, Blueprint = 토대 + 연결 조직.
## 5. 저장 substrate — 저장소(SoR) vs 검색 index 구분
벡터 DB·임베딩·rerank 은 **저장소가 아니라 검색 index**다. 진실 바이트가 아니라 원본 가리키는 임베딩만 둔다. 그래서 "OneDrive 냐 Pinecone 이냐" 는 양자택일이 아니라 "OneDrive(SoR) + 벡터 index".
| 대상 | 저장 (SoR) | 검색 |
|---|---|---|
| typed Area live (예: frame 잔액) | 서비스 DB | typed 쿼리 (벡터 불요) |
| Resource (hot) | 파일 / artifact | **벡터 index** (의미검색) |
| Archive (cold) | 싼 cold 보관 | 거의 안 함 — 폐기 전 *추출 지식만* 영구·queryable |
## 6. Dispatch = "집(home)" 모델
> **move 냐 copy 냐 link 냐 — 이 고민은 폴더가 강요한 가짜 3지선다다.** 폴더는 파일을 물리적으로 한 곳에만 둔다. 그래서 "투자계약서가 딜 소속이냐 Legal 소속이냐" 에 좋은 답이 없다.
전환: **artifact 는 몸통이 하나, 집은 여럿(link).** dispatch = 바이트 이동이 아니라 *집 배정*이다.
| 산출 종류 | 동작 | 예 (실제 워크스페이스) |
|---|---|---|
| 소유·소모성 | **move → Archive → 폐기** | LoI 초안 v1~v5, dataroom 원본, 회의 스크래치 |
| Area 영구 귀속 | **link / 정착** (canonical 은 Area, Project 는 참조) | 인감·고유번호증·통장(펀드 entity), 투자계약서(Legal), 서명 NDA, 최종 LoI, IRR claim(index) |
| 재사용 지식 | **copy-curate → Resource** (복제 아닌 재사용본 저작) | 섹터 경쟁분석, 펀드 결성 템플릿(1호→2호), 리턴모델 방법론 |
즉 **link 가 기본**(다중 집), move 는 소모성 working 파일만, copy 는 재사용본을 *새로 저작*할 때만. 이는 [D-bp-entity-2](/ops/decisions) 의 copy-with-provenance default 를 **link-default 로 revise** 한 것. cross-functional 분배는 별도 동작이 아니다 — Project 가 가로질러서 산출이 *원래부터* 여러 Area 소속이다.
## 7. Governance — 결정로그(record) vs 결재워크플로(process)
지식뿐 아니라 **결정**도 1급이다. 둘을 가른다:
- **결정 로그 (record)** — 무엇이·누가·언제·무슨 권한으로·뭘 근거로 결정됐나. append-only, 불변(수정 아닌 **supersede**), 모든 Area 가 cite. **record SoR = gate `decision` 테이블**(서비스가 SoR 소유) · **Blueprint = `gate.decision` citation mirror(가로 거버넌스)** — [D-gate-2](/ops/decisions) 로 refine(원래 "Blueprint core primitive" → service-owns-SoR + visibility-gated mirror, standalone 가능). frame 의 `register_resolution` / `query_effective_resolutions` / `link_journal_to_resolution` 이 회계 범위 결정로그 — 이를 가로로 일반화.
- **결재 워크플로 (process)** — 기안 → 결재선(DOA) → 승인/반려 → 시행 = 한국형 전자결재. 그 위 policy-driven·agent-assisted 레이어. 워크플로 완료 → 결정로그 한 줄 방출.
**서명은 한 종류**다: 내부 sign-off = 외부 e-sign = "행위자가 결정을 시점에 (법적으로) 확정". 서명은 결정 파이프라인의 **actuator** 로 실행되고, 봉인 결과를 결정에 link 한다. **Area 서비스 = 결정 파이프라인의 actuator** — 승인된 결정 → `frame.post_journal`/`index.register_deal` 호출, 결과를 결정에 link(앞으로) + 도메인 변경이 승인 결정을 cite(뒤로). 통합 구현 = [backlog B-bp-decision-pipeline-esign](/ops/backlog).
>
> **(2026-06-07 갱신 · [D-gate-5](/ops/decisions), [D-gate-3](/ops/decisions) e-sign 입장 supersede)**: 직접 서명엔진 build 는 *defer 되지 않고 당겨져 **구현**됐다* (암호등급). gate Phase-1 = **gate-native `esign.gate` actuator** — 시행 시 승인된 결정의 canonical bytes 를 **CAdES-BES → RFC3161 → CAdES-T** 로 server-seal(단일 서버 서명 identity, RSA-2048+SHA-256), append-only `esign_seal` evidence(전자문서법 §5 custodian) 저장 + `verify_seal` 검증. 법적 근거 = 전자서명법 2020 사서명(실지명의 = gate auth · 서명의사 = 결재 승인). 모두싸인 = optional alt provider 로 강등. **잔여**: 외부 서명자 본인확인기관(정보통신망법 §23-3) = 진짜 Phase-2; HSM(trait ready)·PAdES-PDF·prod-key = 하드닝. red-team 이 직접-build 를 막았던 P0(HSM·HTML→PDF 결정성·custody)는 해소-또는-명시(canonical bytes 결정성 now / HSM abstraction ready / custody = esign_seal + TSA). 상세 = [/architecture/governance](/architecture/governance) §6.
> 한국 시장에서 전자결재 + e-sign 은 **table-stakes** — 테넌트가 당연히 기대한다. internal 편의가 아니라 product requirement.
## 8. 본질 — 죽은 딜을 저장한다
live deal 관리는 누구나 한다 (moat 아님). **VC 지식의 압도적 대부분은 죽은 딜에 있다** — 시장맵·경쟁분석·섹터 thesis·valuation, 그리고 무엇보다 **judgment("왜 패스했나")**. 통상 이게 Archive 에 묻혀 증발한다. AI-native fund 가 **죽은 딜 지식을 재사용 Resource + queryable judgment corpus 로 harvest** 하면 복리로 쌓인다 ("이 섹터 딜 N 건 봤고 패턴은 이렇다").
그래서 Archive 모델이 정밀해진다: **raw 파일은 싸게 폐기, 추출 지식(judgment + 큐레이션된 섹터 Resource)은 영구·queryable.** 폐기 전 harvest 가 §6 dispatch 의 copy-curate 가 존재하는 *이유*다. 이것이 Investment Area 서비스(index)가 invested 딜뿐 아니라 **passed/dead 딜을 1급으로 저장**해야 하는 근거.
## 9. 관통 원칙
- **종류별로 가른다** — 한 상자에 여러 종류를 욱여넣으면 탁해진다. copy/link, Archive/Area, dispatch 가 다 그 증상이었다. 조직론 축(Area/Project/Resource/Archive) + 인식론 축(claim/judgment/document)으로 분해하면 녹는다.
- **claim · judgment · document** — claim(관측: source-cited, live, 갱신) · judgment(판단: author, 불변·supersede) · document(산출: 둘의 구성).
- **link 가 기본, 단일 출처** — copy 는 재사용본 저작에만.
- **결정은 불변 로그** — process(결재)가 record(결정로그)를 낳는다.
- **신규 코드 = Rust** (2026-06-06 사용자 지침).
## 10. 열린 질문 — 처리 현황
| # | 질문 | 상태 (2026-06-06) |
|---|---|---|
| 1 | 2-level scope (personal efforts + shared Areas) | **확정: personal + shared** ([D-bp-rust-1](/ops/decisions) — artifact `scope` 컬럼) |
| 2 | Governance servicization — Blueprint core 결정로그 vs 독립 서비스 | **확정: 독립 `gate` 서비스 + Blueprint citation mirror** ([D-gate-1](/ops/decisions)·[D-gate-2](/ops/decisions), §7) |
| 3 | Project staffing ("던지면 emerge") 메커니즘 | **후속 defer** ([D-bp-rust-1](/ops/decisions) — Project=컨테이너 + WorkspaceMember, emerge UX 는 후속) |
| 4 | Rust 적용 범위 | **확정: Blueprint 백엔드 점진 Rust 전환(strangler-fig), substrate=첫 organ, 프론트는 TS 유지** ([D-bp-rust-1](/ops/decisions)) |
**구현 토폴로지** (§4 substrate 가 *어떻게* 사느냐): PARA substrate 는 Blueprint 의 **첫 Rust organ** 으로, `blueprint-postgres` 의 `substrate` schema 를 소유하는 sidecar(axum)다 — frame/hive 식 독립 product-service 가 아니라 `axe ship blueprint` 한 경로 안의 내부 organ ([D-bp-artifact-4](/ops/decisions) "monolith first·신규 인프라 0" 을 *언어만 Rust* 로 refine). 상세 = [D-bp-rust-1](/ops/decisions) + ADR `docs/adr/blueprint-rust-migration.md`.
## 관련
- [/architecture/artifacts](/architecture/artifacts) — Artifact · Citation 저장/스키마 구현 세부 (본 페이지의 field-level 토대)
- [/architecture/governance](/architecture/governance) — gate 결정 거버넌스(결재 + e-sign + 법무) 상세 + Phase-1/2 경계 ([D-gate-1](/ops/decisions))
- [D-bp-para-1](/ops/decisions) — 본 페이지의 결정 등재 ([D-bp-entity-2](/ops/decisions) copy→link revise)
- [backlog B-bp-decision-pipeline-esign](/ops/backlog) — 결재 + e-sign 통합 서비스화
---
# 플랫폼 신원 (Blueprint = OIDC Provider)
> Blueprint 가 Entra 를 federate 하고 플랫폼 토큰을 발행 — 로그인 1회로 전 서비스. (D-axe-idp-1 — Phase 1+2 LIVE)
URL: https://docs.axelabs.ai/architecture/platform-identity
# 플랫폼 신원 — Blueprint = OIDC Provider
> **상태**: **Phase 1+2 LIVE** (2026-06-04). Blueprint OP(discovery·jwks·authorize·token) + RS256 서명키(vault `blueprint/axe/oidc-signing-key`) + `axe login` loopback-PKCE + **frame·hive·cortex·index·matrix + Blueprint 자체 MCP — 6개 서비스 전부 Blueprint 토큰 신뢰** (영속 설정, e2e 검증). `axe login` → `axe tools` GREEN. 본 페이지는 [D-axe-idp-1](/ops/decisions) 설계 SSOT 이자 현행 구현 기준.
> ⚠️ **발행자 자신이 마지막 resource server였다** (2026-06-04 fix): Blueprint MCP 는 토큰을 *발행*하면서도 *검증*은 Microsoft Entra 경로만 알아, 자기 플랫폼 토큰을 `unknown_kid` 로 401 했다 (kid = Blueprint OIDC 서명키, Microsoft JWKS 에 부재). frame 의 `auth_blueprint.py` + iss-dispatch 를 미러해 Blueprint MCP 도 자기 issuer 의 resource server 로 배선 (`BLUEPRINT_ISSUER=https://blueprint.axellc.com`). 교훈: OP 를 세울 때 **그 OP 의 자체 MCP 도 resource-server 목록에 포함**해야 한다.
> 비파괴 cutover 유지 (`BLUEPRINT_ISSUER` 미설정 시 서비스는 종전 Microsoft 경로). **인가 중앙화**(entity grant 토큰 삽입)는 여전히 Phase 3 — 현재는 인증만 Blueprint, 인가는 각 서비스 customers.yaml.
## 본질
오늘 외부/멀티에이전트 접근의 마찰은 **서비스마다 따로 인증**한다는 데서 온다. 직원·에이전트가
frame·hive·index·cortex·matrix 를 쓰려면 서비스별 MCP OAuth 를 각각 통과하고, frame·cortex 는
각자 [OAuth-RP 프록시](/architecture/auth) (D-ops-14/15) 를 따로 운영한다. 신원은 **N 곳에 흩어져** 있다.
**목표 한 줄**: 사람은 **한 번** SSO 로그인하고, 그 결과로 받은 **하나의 플랫폼 토큰**으로 **모든 서비스**를 쓴다.
거버넌스(누가·어떤 스코프·취소·감사)는 **Blueprint 한 곳**에 모인다.
그 한 곳이 **Blueprint** 인 이유:
- Blueprint 는 **이미 Entra 를 federate** 한다 (NextAuth Azure AD provider, [`src/lib/auth.ts`](/architecture/auth)). 신규 IdP 를 세우는 게 아니라 **기존 세션 위에 토큰 발행만 얹는다**.
- Blueprint 는 **이미 per-user 권한의 권위자**다 — `entityScopes` · `EntityRole` · `getEntityRolesForUser` 를 들고 있고, frame/hive 가 `/api/internal/entity-roles` 로 **이미 그걸 물어본다**. 토큰 거버넌스의 자연스러운 자리.
- Blueprint 는 **control plane** (구동 시스템). 서비스가 신뢰할 단일 발행자로 토폴로지상 맞다.
## 현재 상태 — 검증된 인증 표면 (2026-06-03 실측)
각 서비스가 incoming 토큰을 검증하는 방식. "Blueprint 신뢰" 추가 시 바꿀 지점과 난이도를 함께 표기.
| 서비스 | 스택 | validator (file) | iss-dispatch seam | HS256 경로 | "Blueprint 신뢰" 난이도 |
|---|---|---|---|---|---|
| **frame** | Python FastMCP | `src/frame/mcp/http_server.py:438` | ✅ `_is_microsoft_iss` → else HS256 | ✅ `FRAME_JWT_SECRET` | **낮음** — `elif _is_blueprint_iss` 한 가지 추가 |
| **hive** | Python FastMCP (frame fork) | `src/hive/mcp/http_server.py:250` | ✅ `_is_microsoft_iss` → else HS256 | ✅ `HIVE_JWT_SECRET` | **낮음** — frame 과 1:1 동일 패턴 |
| **cortex** | Rust axum | `src/auth.rs:214` | △ HS256 short-circuit → RS256 (MS hardcoded) | ✅ `CORTEX_JWT_SECRET` (배선됨) | **중간** — issuer 파라미터화 |
| **index** | Rust axum | `src/auth.rs:214` | ✗ RS256(MS) hardcoded, fallback 없음 | △ `INDEX_JWT_SECRET` 정의만·**미배선** | **중간** — issuer 파라미터화 + HS256 배선 |
| **matrix** | Rust axum (custom) | `src/auth.rs:17` | ✗ HS256 단일, iss 미검증 | ✅ `MATRIX_JWT_SECRET` 만 | **높음** — async refactor + RS256 + JWKS |
| **blueprint (자체 MCP)** | Python FastMCP | `mcp/src/blueprint_mcp/mcp/http_server.py` | ✅ iss-dispatch 추가됨 (2026-06-04) — Blueprint vs Microsoft | ✗ HS256 경로 없음 (Microsoft + Blueprint 만) | **낮음 (완료)** — frame `auth_blueprint.py` 미러. *발행자 본인이라 누락됐던 행* |
**핵심 발견**:
- frame·hive 는 이미 **iss 로 verifier 를 고르는 dispatch seam** 이 있다 — Blueprint 분기는 한 줄. Phase 1 첫 타깃 = **frame** (seam + `auth_oidc.py` JWKS 머신 재사용 + 이미 CLI GREEN 검증됨).
- Blueprint 측 OP 머신(`SignJWT`/JWKS endpoint/authorize/token)은 **전무** (greenfield). 단 `jose` 는 이미 dependency 이고 검증측 `createRemoteJWKSet` 패턴이 `src/lib/mcp-microsoft-rp.ts` 에 존재.
- customers.yaml 의 tenant 는 **고객사별로 다름** (realchoice = 별도 Entra tenant). → **OIDC-OP 는 per-deployment**: 각 고객 Blueprint = 자기 Entra 를 federate 하는 자기 issuer. AXE 자체 issuer = `https://blueprint.axellc.com`.
## 목표 아키텍처
```
┌─────────────────────── 사람 SSO 1회 ───────────────────────┐
│ ▼
axe login (CLI) ┌──────────────────────┐
loopback PKCE │ Microsoft Entra ID │
│ │ (customer tenant) │
│ 1. open browser /oauth/authorize └──────────┬───────────┘
▼ │ NextAuth (기존)
┌───────────────────────── Blueprint = OIDC Provider ─────────────┴──┐
│ GET /.well-known/openid-configuration (discovery) │
│ GET /.well-known/jwks.json (공개키 — 서비스가 fetch) │
│ GET /oauth/authorize getServerSession 재사용 → code (PKCE) │
│ POST /oauth/token code+verifier → 플랫폼 JWT(RS256)+refresh │
│ POST /oauth/register DCR (loopback + claude.ai allowlist) │
│ POST /oauth/revoke refresh 취소 │
│ 거버넌스: scope · EntityRole · 발행 audit · per-user/tenant │
└───────────────────────────────┬───────────────────────────────────┘
2. 플랫폼 토큰 (keychain) │ iss=blueprint, aud=platform, RS256
▼ ▼ (서비스는 Blueprint JWKS 로 검증)
┌──────────┐ Bearer ┌────────┬────────┬────────┬────────┬────────┐
│ axe CLI │ ───────► │ frame │ hive │ index │ cortex │ matrix │
└──────────┘ 하나의 └────────┴────────┴────────┴────────┴────────┘
토큰으로 전 서비스 iss=blueprint 분기 → Blueprint JWKS RS256 검증
```
**원칙**:
1. **Entra federation 은 재사용** — `/oauth/authorize` 가 `getServerSession(authOptions)` 로 기존 NextAuth 세션을 확인. 세션 있으면(=이미 Entra SSO 됨) code 발행, 없으면 `/login` 으로 bounce 후 복귀. Blueprint 가 Entra 와 직접 서버-투-서버 code 교환을 다시 짤 필요 없음 (frame `oauth.py` 의 `/oauth/callback` 단계가 Blueprint 에선 불필요 — 이게 frame 대비 단순화).
2. **하나의 토큰, 전 서비스** — `aud` = 플랫폼 audience 단일값. 서비스별 audience juggling 없음. 서비스 권한은 **scope** 가 가른다.
3. **서비스는 서명만 신뢰** — 각 서비스는 `iss=blueprint` 분기에서 Blueprint JWKS 로 RS256 검증. Phase 1 에선 **인증**만 Blueprint 로 (email vouch); **인가**(email→entity) 는 각 서비스 customers.yaml 그대로 → 비파괴. (entity grant 를 토큰에 심는 **인가 중앙화**는 Phase 3.)
## OIDC-OP 엔드포인트 (Blueprint Next.js 에 신설)
모두 `https://blueprint.axellc.com` (= `BLUEPRINT_OIDC_ISSUER`, 기본 `NEXTAUTH_URL`) origin 에 App Router route handler 로 추가. 전부 **공개**(인증 면제) — 단 authorize 는 세션을 요구.
| 엔드포인트 | 메서드 | 신규 파일 (제안) | 역할 |
|---|---|---|---|
| `/.well-known/openid-configuration` | GET | `src/app/.well-known/openid-configuration/route.ts` | RFC 8414 metadata (issuer, endpoints, jwks_uri, S256, RS256) |
| `/.well-known/jwks.json` | GET | `src/app/.well-known/jwks.json/route.ts` | 공개키 (서비스·CLI 가 RS256 검증) |
| `/oauth/authorize` | GET | `src/app/oauth/authorize/route.ts` | 세션 확인(getServerSession) → 단발 code 발행 (PKCE S256 필수) |
| `/oauth/token` | POST | `src/app/oauth/token/route.ts` | `authorization_code` + `refresh_token` grant → 플랫폼 JWT |
| `/oauth/register` | POST | `src/app/oauth/register/route.ts` | RFC 7591 DCR (loopback + claude.ai redirect allowlist) |
| `/oauth/revoke` | POST | `src/app/oauth/revoke/route.ts` | refresh token 취소 (governance) |
| `/oauth/device_authorization` | POST | (Phase 3) | RFC 8628 device-code (headless) |
frame [`oauth.py`](/services/frame) 가 **검증된 레퍼런스**다 (PKCE 단발 code·atomic 단일소비·S256 constant-time·DCR allowlist 전부 구현). Blueprint OP 는 그것을 그대로 따르되 **차이점 3 가지**:
1. HS256 대신 **RS256 서명** + JWKS 공개 (서비스가 secret 공유 없이 검증).
2. Microsoft 서버-투-서버 교환 단계 **제거** → 기존 **NextAuth 세션** 재사용.
3. **refresh token** 추가 (CLI UX "로그인 1회 후 한동안 유지" + 취소 지점).
## 플랫폼 JWT (access token) shape
RS256, `kid` 헤더. 클레임:
```jsonc
{
"iss": "https://blueprint.axellc.com", // BLUEPRINT_OIDC_ISSUER (per-deployment)
"sub": "", // User.entraOid — 안정적 cross-app 식별자
"email": "ai@axellc.com", // 소문자 정규화
"aud": "https://axe.axelabs.ai", // 플랫폼 audience (단일; 전 서비스 공통)
"scope": "openid profile email frame hive index cortex matrix",
"azp": "axe-cli", // 토큰을 받은 client_id (감사)
"jti": "", // 감사 + (선택) 취소 denylist
"iat": 1730000000,
"exp": 1730003600, // 1h
"ent": { "axec": ["read","write"], "axev": ["read"] } // Phase 3 (선택) — 현재는 서비스가 무시
}
```
- **access token**: 짧음 (1h). 만료 시 refresh 로 무중단 갱신.
- **refresh token**: opaque 랜덤 (JWT 아님), 서버측 저장(Prisma), **회전**(rotating — 사용 시 새 발급+구 폐기), 30d. → 취소·도난탐지 지점.
- **scope**: Phase 1 = 서비스 단위 grant (존재 = 그 서비스 접근). frame 의 entity별 read/write 인가는 customers.yaml email→entity 가 그대로 담당. (`frame:read` 같은 fine scope + `ent` 클레임 인가중앙화 = Phase 3.)
- **aud 단일값의 트레이드오프**: 한 토큰이 전 서비스 → 토큰 유출 시 blast radius 가 플랫폼 전체. 완화 = 짧은 exp(1h) + scope + refresh 취소 + loopback-only public client + PKCE 필수. (per-service aud 격리보다 편의를 택한 D-axe-idp-1 의 의도적 선택.)
## 서비스별 신뢰 이전 (trust migration)
각 서비스는 **feature flag** 로 Blueprint 신뢰를 켠다 — `BLUEPRINT_ISSUER` + `BLUEPRINT_JWKS_URL` (+ `BLUEPRINT_AUDIENCE`) **unset = 현행 동작 그대로** (비파괴·롤백 = env 제거).
### frame · hive (Python — 낮음, Phase 1·2)
`http_server.py` 의 dispatch 에 분기 한 줄 + verifier 모듈 신설:
```python
# http_server.py — 기존 if _is_microsoft_iss(iss): ... else: (HS256) 사이에 삽입
elif _is_blueprint_iss(iss): # iss == BLUEPRINT_ISSUER
payload = await verify_blueprint_token( # 신규 auth_blueprint.py
agent_token, jwks_url=BLUEPRINT_JWKS_URL,
issuer=BLUEPRINT_ISSUER, audience=BLUEPRINT_AUDIENCE,
)
email = extract_email(payload)
agent_claims = resolve_subject_to_claims(payload, email, customer_id=cid) # 기존 재사용
```
`verify_blueprint_token` = `auth_oidc.py` 의 JWKS fetch/cache(`createRemoteJWKSet` 대응, 1h TTL·kid-miss 강제갱신)를 그대로 미러, issuer/aud/exp 검증 후 payload 반환. email→entity 는 **기존 `resolve_subject_to_claims` 무수정 재사용** → 인가 모델 불변.
### cortex · index (Rust — 중간, Phase 2)
`auth.rs:verify_token` 의 단일 MS issuer hardcode 를 issuer-dispatch 로 리팩터:
```rust
let iss = peek_iss(token)?; // unverified payload iss
match trusted_issuer(&iss, &state.settings) {
Issuer::Microsoft => verify_rs256(token, ms_jwks, ms_iss, ms_aud).await,
Issuer::Blueprint => verify_rs256(token, bp_jwks, bp_iss, bp_aud).await, // 신규
Issuer::Self_ => verify_hs256(token, secret), // cortex 기존 / index 는 여기서 배선
_ => Err(Unknown),
}
```
`verify_rs256(token, jwks, iss, aud)` 헬퍼로 추출하면 MS·Blueprint 가 같은 코드 재사용. index 는 이참에 `INDEX_JWT_SECRET` HS256 도 배선(cortex `oauth_as.rs` 패턴 복사).
### matrix (Rust custom — 높음, Phase 2 후)
HS256 단일·동기 미들웨어 → ① async 화 ② iss peek ③ Blueprint RS256 분기 ④ JWKS fetch(reqwest 이미 보유) ⑤ `Claims` 에 `iss`/`aud` 필드 추가. ~80–120 LOC. seam 없음 → 마지막.
| 서비스 | flag env | 작업량 | Phase |
|---|---|---|---|
| frame | `BLUEPRINT_ISSUER`,`BLUEPRINT_JWKS_URL`,`BLUEPRINT_AUDIENCE` | ~80 LOC + tests | **1** (모델 증명) |
| hive | 동일 | ~60 LOC (frame 복사) | 2 |
| cortex | `BLUEPRINT_*` (settings) | refactor + ~80 LOC | 2 |
| index | `BLUEPRINT_*` + `INDEX_JWT_SECRET` 배선 | refactor + ~100 LOC | 2 |
| matrix | `MATRIX_BLUEPRINT_*` | async refactor ~120 LOC | 2 |
| blueprint (자체 MCP) | `BLUEPRINT_ISSUER`,`BLUEPRINT_AUDIENCE` (JWKS 파생) | ~150 LOC + 10 tests (frame 미러) | **완료 (2026-06-04)** |
> 위 표가 frame·hive·cortex·index·matrix 만 세고 **Blueprint 자체 MCP 를 빠뜨린 것**이 2026-06-04 의 401 버그 근원이었다 — issuer 가 곧 resource server 라는 사실이 자명해 보여 목록에서 누락됐다. 새 OP 를 세울 땐 *그 OP 의 MCP 도* 이 표의 한 행이다.
## `axe login` — loopback PKCE (CLI)
[AXE CLI](/services) 에 `axe login` (인자 없음) 추가. stdlib 만 (`http.server`·`webbrowser`·`hashlib`·`secrets`·`urllib`):
```
1. CLI: 랜덤 loopback 서버 기동 http://127.0.0.1:/callback
2. CLI: POST /oauth/register {redirect_uris:[loopback]} → client_id (또는 정적 axe-cli)
3. CLI: code_verifier 생성, challenge=S256, state 생성 → 브라우저 open
/oauth/authorize?response_type=code&client_id=…&redirect_uri=loopback
&code_challenge=…&code_challenge_method=S256&scope=…&state=…
4. 브라우저: (NextAuth 세션 없으면) Entra SSO 1회 → Blueprint 가 loopback 으로 302 ?code=…&state=…
5. CLI(loopback): code 수신, state 검증 → POST /oauth/token
grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=loopback
6. CLI: {access_token, refresh_token, expires_in} → keychain 저장. 브라우저엔 "닫아도 됨".
```
- **헤드리스(Codex/CI)**: `axe login --token ` / `AXE_TOKEN` env 공존 (gh 모델) — 변경 없음. 또는 Phase 3 **device-code**.
- **검증은 사람 브라우저 로그인 필요** → 단독 e2e 불가, 운영자 SSO 1회로 마무리.
- 토큰 자동 refresh: access 만료 시 CLI 가 저장된 refresh 로 `/oauth/token` (`grant_type=refresh_token`) 조용히 갱신.
## 거버넌스 (Blueprint 중앙)
- **scope**: `openid profile email` + 서비스 grant. 발급 시 Blueprint 가 user 의 `entityScopes`/`EntityRole` 로 허용 scope 를 결정 (consent UI 는 first-party CLI 라 Phase 1 생략 가능, 외부 third-party client 도입 시 추가).
- **revoke**: refresh token 서버측 저장 → `/oauth/revoke` 또는 admin UI 에서 폐기. access 는 짧아(1h) revoke latency = exp. 더 강한 즉시취소 필요 시 `jti` denylist (introspection 없이).
- **audit**: 모든 발행(누가·언제·client·scope·jti)을 append-only 로그. Blueprint 의 기존 로깅/DB 재사용.
- **per-tenant**: issuer 가 per-deployment 이므로 고객사 토큰은 그 고객 Blueprint 가 발행·관리 (sovereignty 정합).
## 키 관리
- RS256 keypair. private key = **vault** (`blueprint/axe/oidc-signing-key`, PKCS8 PEM 또는 JWK) → `BLUEPRINT_OIDC_PRIVATE_KEY` 로 주입. 공개키만 `/.well-known/jwks.json` 에 `kid` 와 함께.
- **회전**: JWKS 에 2 키(old+new) 동시 게시 → 신규 서명은 new `kid`, 검증은 둘 다 수용 → old 만료 후 제거. 서비스 JWKS 캐시 TTL(1h) 고려.
- `jose` (`generateKeyPair`/`importPKCS8`/`SignJWT`/`exportJWK`) 사용 — 이미 dependency.
## 데이터 모델 (Prisma 신설)
```prisma
model OAuthAuthCode { // authorize→token 단발 code (frame oauth_authorization_codes 대응)
code String @id
clientId String
userId String
codeChallenge String
redirectUri String
scope String
expiresAt DateTime
consumedAt DateTime?
}
model OAuthRefreshToken { // refresh + revoke + 회전
id String @id @default(cuid())
tokenHash String @unique // 평문 저장 안 함
userId String
clientId String
scope String
expiresAt DateTime
revokedAt DateTime?
rotatedTo String? // 회전 추적 (도난 재사용 탐지)
createdAt DateTime @default(now())
}
model OAuthClient { // 클라이언트 레지스트리 (axe-cli 정적 seed + DCR)
clientId String @id
name String
redirectUris String // JSON 배열
type String @default("public") // PKCE-only
createdAt DateTime @default(now())
}
```
## 비파괴 cutover (신·구 병행)
보안 핵심 → **점진·가역**. 어느 단계도 기존 claude.ai MS-OAuth 흐름과 frame/cortex 프록시를 **깨지 않는다**.
1. **Blueprint OP 추가** = 전부 신규 route. 기존 동작 0 변경.
2. **서비스별 Blueprint 신뢰 추가** = iss 분기 1개 ADD. MS·HS256 경로 불변. flag(`BLUEPRINT_ISSUER`) unset 이면 무시 = 현행. → **서비스 단위로 독립 ship·롤백**.
3. **per-service 프록시 폐기는 나중** — frame/cortex 의 claude.ai DCR 프록시(D-ops-14/15)는 Blueprint OP 가 충분히 증명될 때까지 **공존**. claude.ai 커넥터 흐름 내내 유지. 통합(claude.ai 를 Blueprint OP 로 이전 + 프록시 제거)은 Phase 2 말~3.
롤백 = 해당 서비스 `BLUEPRINT_*` env 제거 후 recreate. 토큰 발행 중단 = Blueprint route 비활성.
## Phase 계획
- **Phase 1 (모델 증명) ✅ 2026-06-04**: Blueprint OP(discovery·jwks·authorize·token·register·revoke) + RS256 키(vault) + Prisma 모델 + `axe-cli` 정적 client + `axe login` loopback PKCE + **frame** Blueprint 신뢰. → 운영자 브라우저 SSO 1회로 `axe login` → `axe frame tools` GREEN 증명 완료.
- **Phase 2 ✅ 2026-06-04**: hive·cortex·index·matrix trust 이전 — 5개 서비스 전부 LIVE + 영속(`BLUEPRINT_ISSUER` compose/.env.local 기본값) + e2e(`axe tools`). claude.ai → Blueprint OP 이전 + frame/cortex 프록시 폐기는 미평가(잔여).
- **Phase 3**: 인가 중앙화(`ent` 클레임 + fine scope, 서비스가 customers.yaml 대신 토큰 grant 사용) + 감사 UI + headless device-code(RFC 8628, `B-axe-cli-device-code` — 미구현) + 키 회전 자동화.
## 미해결 / 결정 필요
- **issuer 도메인**: 현재 `blueprint.axellc.com` (NEXTAUTH_URL). axelabs.ai 도메인 이전 시 issuer 변경 = 발행된 토큰·서비스 신뢰설정 일괄 영향 → 이전 **전에** 확정하거나 `auth.axelabs.ai` 안정 alias 고정.
- **claude.ai 통합 시점**: claude.ai 커넥터를 Blueprint OP 뒤로 옮기면 per-service 프록시 폐기 가능. 단 claude.ai DCR redirect allowlist·Mcp-Session-Id 흐름 재검증 필요 (B-axe-cli 의 public 멀티스텝 403 함정과 연동).
- **인가 중앙화 범위**: entity grant 를 토큰에 심는 순간 customers.yaml 의 email→entity 가 Blueprint 로 이동 → 큰 마이그레이션. Phase 1 은 인증만, 인가는 그대로 두는 게 안전.
## 관련
- 결정: [D-axe-idp-1](/ops/decisions) (본 설계) · [D-axe-cli-1](/ops/decisions) (CLI·토큰모델) · [D-ops-14/15](/ops/decisions) (per-service 프록시 — 통합 대상)
- 현행 인증: [/architecture/auth](/architecture/auth) (3 경로 — Blueprint = 4번째 trusted issuer 로 합류)
- 레퍼런스 구현: frame `src/frame/mcp/oauth.py` (검증된 OAuth 2.1 AS + PKCE + DCR)
- 백로그: [B-axe-idp-1](/ops/backlog)
---
# Playbook 패턴 (AI session 던지기 표준 형식)
> docs.axelabs.ai 의 행위 단위 페이지 (셋업/배포/공지/복구) 가 사람과 AI 양쪽의 단일 entry point 가 되도록 하는 맨 위 prompt block + 본문 구조 표준. 새 playbook 작성 시 본 SSOT 따른다.
URL: https://docs.axelabs.ai/architecture/playbooks
# Playbook 패턴
docs.axelabs.ai 의 일부 페이지는 단순 reference 가 아니라 **행위 단위** (셋업 / 배포 / 공지 / 복구) 다. 이런 페이지는 사람이 직접 read 해서 따라가는 것 외에, **사용자가 자기 AI session 에 URL + 짧은 prompt 를 던져 step-by-step interactive 로 진행**시키는 방식이 표준이다 ([D-docs-playbook](../ops/decisions)).
> 본 페이지 = 새 playbook 작성 시 따를 형식의 SSOT. 형식 drift 가 곧 페이지마다 사용자 mental model 재학습 비용 = 패턴 가치 훼손이라 박제한다.
## 무엇이 playbook 이고 무엇이 아닌가
| 페이지 성격 | playbook? | 이유 |
|---|---|---|
| **셋업** (SSH 진입, vault setup, MCP connector 추가) | ✅ | 사용자가 단계 따라가야 끝남 |
| **배포** (release-flow, customer onboarding D-day) | ✅ | 운영자 손에서 N 명령 순차 실행 |
| **운영자 공지** (Teams DM broadcast, mail broadcast) | ✅ | 한 번에 N 사람 대상으로 행위 |
| **복구** (vault recovery, postgres restore) | ✅ | 사고 시 명령 순서 + 검증 매 step |
| **decision / 결정 기록** (D-ops-*) | ❌ | 사실 기록. 행위 트리거 아님 |
| **아키텍처/모델** (domains, data, artifacts) | ❌ | reference. 사용자가 행위로 변환 안 함 |
| **backlog / known-gaps / updates** | ❌ | 메타 운영 — 페이지가 entry point 지만 행위 단위 X |
판단 기준: "본 페이지를 끝까지 따라가면 사용자 환경 또는 외부 시스템 상태가 변하는가?" → 변하면 playbook.
## 표준 형식
### Frontmatter
```yaml
---
title: <행위 명사구 또는 짧은 페이지명>
description: <한 줄 — 행위 + 외부 dependency + 완료 기준. 본 페이지 fetch 한 AI 가 prereq 판단할 수 있게 명시>
playbook: true
---
```
`playbook: true` 는 machine-readable marker — 후속 build-time catalog 자동 생성 / search filter / 외부 agent 가 "행위 단위 페이지만" 골라낼 때 anchor. 본 SSOT 의 "현 playbook 목록" 표가 수동 인덱스, frontmatter 가 자동 인덱스의 단일 진실.
### Sidebar marker
해당 페이지의 `_meta.js` entry title 앞에 `⚡ ` prefix 추가 — Nextra sidebar 에서 사람이 페이지 열기 전 "이 페이지는 AI 던지기 가능한 행위 단위" 식별:
```js
// content/onboard/_meta.js (예)
export default {
index: '신규 직원 가이드', // 안내, marker 없음
'ssh-access': '⚡ SSH 로컬 작업', // playbook
'vault-setup': '⚡ Vault setup (KDF + 4 client)', // playbook
troubleshooting: '문제 해결', // reference, marker 없음
}
```
### 본문 구조 (필수 순서)
```mdx
# <행위 명사구>
(선택) 1-2 문장 페이지 정체성 — 무엇을 하는 페이지인지, legacy 페이지가 있다면 cross-link.
## AI 요청 프롬프트
```
<표준 prompt block — 다음 절 참고>
```
본인 AI session = Claude Code / Cursor / ChatGPT 데스크탑 / Claude.app / 기타.
페이지 본문 = 사람이 직접 read 도 가능, AI 도 참고. AI 가 본 페이지 fetch 후 위 진행 순서대로 사용자와 step-by-step interactive 풀어나감.
## Prereq
- <권한 / 자격>
- <환경 / 머신 / 네트워크>
- <사전 자료 (email, key, manifest 등)>
## Step 1: <첫 단계>
## Step 2: ...
(또는 단계가 크면 `## Phase 1 (...)` / `## Phase 2 (...)` 패턴)
## 함정 정리
| # | 함정 | 우회 |
|---|---|---|
| 1 | <증상> | <대응> |
## 완료 후 (선택)
-
- <후속 회신 / Ship Log / 운영자 통보>
```
### 표준 prompt block 템플릿
코드 블록 안의 내용:
```
https://docs.axelabs.ai/ 따라 <행위 한 줄 요약> 해줘.
진행:
1. <환경/머신 진단 — OS, 기존 설치 여부, 권한>
2. <페이지 Prereq 확인 + 조건 분기 (해당 시 skip)>
3. 페이지의 각 Step 명령 실행 + 검증, 매 step 결과 받고 다음
4. 함정 발생 시 페이지 "함정 정리" 표 따라 우회
5. <완료 후 done criteria — 운영자 회신 / Ship Log / 검증 산출물>
```
**고정 element** (모든 playbook 동일):
- 첫 줄 = 페이지 URL + "따라" + 행위 + "해줘"
- step 3 = "페이지의 각 Step 명령 실행 + 검증, 매 step 결과 받고 다음" (낱말 그대로)
- step 4 = "함정 발생 시 페이지 '함정 정리' 표 따라 우회" (낱말 그대로)
**가변 element**:
- step 1-2 = 행위에 따른 사전 진단 (운영자 broadcast 는 "Blueprint LIVE 확인" / SSH 셋업은 "OS 진단" 등)
- step 5 = 완료 기준 (Teams 회신 / Ship Log / 운영자 통보 등)
### 표준 꼬리 (prompt block 다음 2줄)
```
본인 AI session = Claude Code / Cursor / ChatGPT 데스크탑 / Claude.app / 기타.
페이지 본문 = 사람이 직접 read 도 가능, AI 도 참고. AI 가 본 페이지 fetch 후 위 진행 순서대로 사용자와 step-by-step interactive 풀어나감.
```
**낱말 그대로** 복붙. AI session 후보 4 개 순서 고정 (Claude Code · Cursor · ChatGPT 데스크탑 · Claude.app · 기타).
## 현 playbook 목록
표 = [`scripts/generate-playbook-catalog.mjs`](https://github.com/axelabs-ai/axelabs-docs/blob/main/scripts/generate-playbook-catalog.mjs) 가 frontmatter `playbook: true` 페이지 인덱싱해서 marker 사이 자동 채움. `package.json` 의 `prebuild` 가 자동 hook — `npm run build` 마다 갱신. ad-hoc 호출은 `npm run catalog`. **script idempotent** (변경 없을 때 file 안 만짐) 라 매 build 마다 working tree dirty 0. **본 표 직접 편집 X** — 새 playbook 추가 = 페이지 frontmatter 만, build (또는 catalog) 한 번 → 표 + JSON 갱신 → 결과 commit.
machine catalog = [public/playbooks.json](/playbooks.json) (count + URL + title + action + audience, deterministic, no timestamp).
{/* playbook-catalog:start */}
| Playbook | 행위 | 대상 |
|---|---|---|
| [AXE MCP connectors (claude.ai + Claude Code 자동 sync)](/onboard/claude-connectors) | claude.ai 의 Custom Connector 등록에 Bitwarden 브라우저 확장 + AXE Vault `MCP Connectors` collection 활용 | 직원 / customer 직원 |
| [Microsoft 365 connector](/onboard/m365-connector) | OneDrive · Outlook · Teams 통합 (Anthropic 제공) | 직원 / customer 직원 |
| [SSH 로컬 작업 (Claude Code / Cursor / 기타 AI session)](/onboard/ssh-access) | AXE macmini 에 SSH 진입해 로컬 작업하는 표준 절차 | 직원 / customer 직원 |
| [Vault setup — KDF rotation + 4 client + Bitwarden Authenticator](/onboard/vault-setup) | AXE Vaultwarden 의 SSO→MP unlock 정상화 (KDF rotation, 옛 user 만) + 데스크톱/Chrome/Mobile 3 client + Bitwarden Authenticator 표준 setup | 직원 / customer 직원 |
| [Cloudflared 재기동](/ops/runbook/cloudflared) | cloudflared 가 죽었거나 config 변경 시 5초 다운타임 절차 | 운영자 |
| [신규 Customer Onboarding](/ops/runbook/customer-onboarding) | 운영자 측 자동화 + 수동 touchpoints | 운영자 |
| [Frame DB 복구](/ops/runbook/db-recovery) | frame-postgres 손상/migration 사고/silent corruption 대응 | 운영자 |
| [Blue/Green Deploy](/ops/runbook/deploy) | frame 무중단 배포 절차 | 운영자 |
| [직원 퇴사](/ops/runbook/employee-offboarding) | 퇴사한 직원의 모든 access 차단 절차 | 운영자 |
| [신규 직원 등록](/ops/runbook/employee-onboarding) | customer admin 으로부터 신규 직원 추가 요청 받았을 때 운영자 절차 | 운영자 |
| [macmini 손실](/ops/runbook/macmini-loss) | customer macmini 도난/화재/완전 손실 시 1-day 복구 | 운영자 |
| [운영자 broadcast — Teams DM 으로 임직원 1:N 공지](/ops/runbook/operator-broadcast) | Blueprint `/api/admin/broadcast-dm` REST 로 bot identity (ai@axellc.com) → AXE 임직원 1:N Teams DM | 운영자 |
| [Release flow (axe ship)](/ops/runbook/release-flow) | 코드 변경 → 운영 반영까지의 release-gate | 운영자 |
| [Secret Rotation](/ops/runbook/secret-rotation) | 모든 비밀 회전의 단일 명령 — axe secret rotate | 운영자 |
| [Vaultwarden 복구](/ops/runbook/vault-recovery) | self-host vault 복구, OIDC 깨짐 대응, sso_nonce 수동 패치 | 운영자 |
{/* playbook-catalog:end */}
신규 playbook 추가 흐름 = (1) 페이지 frontmatter `playbook: true` + 본문 표준 형식 + sidebar `⚡ ` prefix → (2) `npm run build` (또는 `npm run catalog`) 한 번 실행 → 본 표 + JSON 자동 갱신 → (3) git commit 묶음.
## 함정 / drift 회피
| 함정 | 우회 |
|---|---|
| AI session 후보 나열 순서를 자기 임의로 바꿈 (예: Claude.app 을 첫 자리) | 본 SSOT 의 4 후보 순서 (Claude Code · Cursor · ChatGPT 데스크탑 · Claude.app) 낱말 그대로 |
| 꼬리 2줄을 1줄로 압축 (vault-setup·operator-broadcast 의 초기 drift) | 2줄 분리 유지 — 읽기 부담 ↓ + AI 가 첫줄 (후보 식별) 과 둘째줄 (행위 지시) 별도 cue 로 파싱 |
| `## AI 요청 프롬프트` 헤딩 변형 (옛 `🤖 AI 에 던질 prompt (...)` 잔존 / 다른 단어로 paraphrase) | 낱말 그대로. 사람 + AI 둘 다 visual anchor 로 사용 — 일치 안 하면 "이 페이지 playbook 인가?" 재판단 비용 |
| prompt block 안 첫 줄에 URL 안 적고 페이지 안에서 "위 페이지" 식 indirect 참조 | URL 명시 — AI 가 본 prompt 만 보고 페이지 fetch 가능해야 self-contained |
| step 3-4 의 표준 문구를 paraphrase ("각 step 따라가 줘" / "함정 표 보고" 등) | 낱말 그대로 — AI 가 페이지의 `## 함정 정리` 표를 anchor 로 매핑하는 cue |
| 페이지 본문에 prompt 안 step 과 실제 `## Step N` heading 의 번호 불일치 | prompt step 5 = 페이지의 마지막 행위, prompt 안 step N = 페이지 Step N 으로 1:1 매핑 권장 |
## 새 playbook 추가 체크리스트
신규 페이지가 위 "무엇이 playbook 인가" 판단으로 playbook 이면:
1. Frontmatter title + description + **`playbook: true`** 작성 (description 한 줄에 행위 + dependency + 완료 기준)
2. `## AI 요청 프롬프트` 헤딩 + code block + 표준 꼬리 2줄 복붙 — code block 안만 행위에 맞게 가변
3. `## Prereq` / `## Step N` 또는 `## Phase N` / `## 함정 정리` / `## 완료 후` 순서 — 누락 시 본 SSOT 표 한 줄로 reject
4. 본 페이지 (`architecture/playbooks`) 의 "현 playbook 목록" 표 = **script 자동 생성, 수동 편집 X** — `scripts/generate-playbook-catalog.mjs` 가 frontmatter `playbook: true` 페이지 인덱싱해서 marker 사이 표 + `public/playbooks.json` 갱신. `prebuild` 자동 hook 또는 `npm run catalog` (ad-hoc) → 결과 commit. script idempotent 라 build 마다 dirty 0
5. 해당 폴더의 `_meta.js` entry title 앞에 `⚡ ` prefix 추가 (sidebar marker)
6. 페이지가 운영자 행위면 `content/ops/runbook/`, 직원/customer 행위면 `content/onboard/` — 위치 혼동 시 sidebar 카테고리 가시성 ↓ + catalog audience 매핑 잘못
## 관련 결정
- 본 SSOT — [D-docs-playbook](../ops/decisions)
- backlog ritual — [/ops/backlog](/ops/backlog) (다른 entry point. playbook 페이지는 backlog 와 별 channel — playbook = 행위 트리거, backlog = 행위 큐)
---
# 비밀 관리 — vault → 서비스 흐름
> 모든 서비스 비밀의 SoT 는 Vaultwarden. customers.yaml 매니페스트가 매핑 SSOT. axe ship 가 배포 직전 vault → env_file 동기화 강제.
URL: https://docs.axelabs.ai/architecture/secrets
# 비밀 관리 (D-ops-17)
> AXE 플랫폼의 모든 서비스 비밀 (DB password, OAuth client_secret, JWT signing key, 외부 API token) 은 **Vaultwarden 이 canonical store**. 서비스 컨테이너는 vault 를 직접 모르고, **`axe ship` 가 배포 직전 vault → env_file 로 동기화** 한 뒤 docker compose 가 env_file 을 읽음.
## 핵심 결정
| # | 결정 | 이유 |
|---|---|---|
| D-ops-9 | Vaultwarden = canonical secret store | 기존 결정 — Tier-1 vault |
| D-ops-17 | Deploy-time pull (runtime X) | runtime vault 호출은 blast radius + bootstrap 문제. deploy-time 만 의존성 발생, 서비스 코드는 vault 모름 |
## 데이터 흐름
```
┌──────────────┐ ┌─────────────────────────────────────────┐
│ Vaultwarden │ ← canonical SoT │ /Users/axe/.axe/customers.yaml │
│ (axe.axelabs │ │ services..secrets[]: │
│ .ai/vault) │ │ - env: FRAME_DB_PASSWORD │
└──────┬───────┘ │ vault: "frame/axe/db-password" │
│ │ - env: AZURE_FRAME_MCP_CLIENT_SECRET│
│ bw CLI │ vault: "frame/axe/oauth-..." │
│ (BW_SESSION │ rotation_external: azure │
│ from Keychain) └─────────────────────┬───────────────────┘
│ │ (manifest read)
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ axe ship (release-gate) │
│ step 1: docs-check │
│ step 2: branch + clean │
│ step 3: commits to push │
│ step 4: confirm │
│ step 5: git push │
│ step 6: deploy │
│ ├─ pre-deploy [a]: axe secret check ← abort 시 stop│
│ ├─ pre-deploy [b]: axe secret pull ← env_file write│
│ └─ docker compose up -d ... (env_file = pulled content) │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ /Users/axe//.env(.local) │ ← AUTO-GENERATED, mode 600,
│ FRAME_DB_PASSWORD="..." │ git-ignored
│ AZURE_FRAME_MCP_CLIENT_SECRET="..." │
│ ... │
└────────────────────────────────────────┘
│
▼
docker container env (frame-mcp-blue, etc.)
```
## 매니페스트 스키마
`customers.yaml` 의 `customers.CUSTOMER.services.SERVICE.secrets[]`:
```yaml
services:
frame:
env_file: "/Users/axe/frame/.env.local" # axe secret pull 의 write target
secrets:
- env: FRAME_DB_PASSWORD # 환경변수 이름 (docker-compose 가 보는)
vault: "frame/axe/db-password" # Bitwarden item name (axe secret get 가 보는)
- env: AZURE_FRAME_MCP_CLIENT_SECRET
vault: "frame/axe/oauth-client-secret"
rotation_external: azure # 회전 시 Azure portal 갱신 필요
# ...
```
| 필드 | 필수 | 의미 |
|---|---|---|
| `env_file` | ✓ | `axe secret pull` 가 작성할 절대 경로. docker-compose.yml 의 `env_file:` 지시자와 일치 |
| `secrets[].env` | ✓ | 환경변수 이름 (서비스 코드가 `os.getenv("...")` 로 보는 이름) |
| `secrets[].vault` | ✓ | Vaultwarden item 이름. 컨벤션: `SERVICE/CUSTOMER/SHORT` (예: `frame/axe/db_password`) |
| `secrets[].rotation_external` | ✗ | 외부 시스템 식별자 (`azure`/`github`/`meta`/`anthropic`/`naver`/`slack`). 설정 시 `axe secret rotate` 가 portal URL 안내 |
| `bootstrap_only` | ✗ | `true` 면 service 자체가 vault 인 경우 (vault 가 자기 자신 못 읽음 — chicken-and-egg). check/pull skip |
## CLI 명령
| 명령 | 동작 |
|---|---|
| `axe secret check SERVICE` | 매니페스트 vs vault 보유 비교 → 누락 출력. 누락 시 exit 1 |
| `axe secret pull SERVICE` | 매니페스트 순회 → bw 로 fetch → `env_file` atomic write (mode 600) |
| `axe secret push ENV_NAME --service SVC --value VALUE` | 매니페스트로 vault 경로 lookup → 신규 생성 or password 필드 갱신. 대형/PEM/파일-소스 비밀은 `--value-stdin` 으로 stdin 주입 (값이 argv/ps 에 안 뜸): `grep ^KEY= .env \| cut -d= -f2- \| axe secret push KEY --service SVC --value-stdin` |
| `axe secret send ENV_NAME --service SVC [--to RECIP]` | 매니페스트 lookup → vault GET → `bw send` 로 1회용 링크 생성 → URL stdout. 기본 `-d 1 -a 1 --hidden`. 사람에게 전달 전용 |
| `axe secret rotate ENV_NAME --service SVC` | 새 값 (외부 provider 입력 or 자동 생성) → vault PUT → pull → ship 트리거 |
| `axe secret get VAULT_NAME` | 단건 조회 (저레벨, 기존 명령) |
| `axe secret list` | vault 전체 item 표 (값 출력 X) |
| `axe secret status` | bw session 상태 확인 |
`--customer` 기본값 `axe`. realchoice 합류 시 명시.
## 매니페스트 — 현재 상태 (axe customer)
| service | env_file | 비밀 개수 |
|---|---|---|
| frame | `/Users/axe/frame/.env.local` | 9 (DB password, JWT, OAuth client_secret, PII passphrase × 2, Claude OAuth, 롯데카드 포털 ID/PW — host-only 스크래퍼 [D-frame-lottecard-scraper](/ops/decisions)) |
| hive | `/Users/axe/hive/.env.local` | 5 (DB password, JWT, PII passphrase × 2, OAuth client_secret) — frame 동일. confidential client (PKCE + secret, accessTokenVersion=2, isFallbackPublicClient=true) |
| blueprint | `/Users/axe/blueprint/.env` | 14 (DB, agent secret, Anthropic admin, OAuth, NextAuth, GH token, cron, frame token, **OIDC 서명키 `blueprint/axe/oidc-signing-key` — D-axe-idp-1 RS256 base64 PKCS8**, ...) |
| stream | `/Users/axe/stream/.env` | 2 (DB, Truvia master key) |
| magnet | `/Users/axe/magnet/.env` | 10 (DB, HMAC, Meta × 3, Naver × 3, Slack, Threads × 2) |
| matrix | `/Users/axe/matrix/.env.local` | 3 (DB password, JWT secret, console API token) — [D-matrix-1](/ops/decisions) |
| vault | `/Users/axe/.axe/vault/.env` | 1 (Vaultwarden 자기 자신 OAuth client_secret) — `bootstrap_only: true` |
합계 **42 개 비밀** 매니페스트 등재. (현재 vault 안 실제 item 수와 매니페스트 양은 별개 — `axe secret check SVC` 로 갭 확인.)
> **gate (착수 예정, 아직 미배포 — 위 표 미포함)**: gate 가 배포되면 `services.gate.secrets[]` 에 **e-sign 서버 서명키** `GATE_ESIGN_KEY_PEM`(vault `gate/axe/esign-signing-key`, RSA-2048 PKCS8 PEM — blueprint OIDC 서명키 패턴과 동형) + 짝 인증서 `GATE_ESIGN_CERT_PEM`(공개 X.509, 비밀 아님이라 env/파일이면 충분) 등재 필요 ([D-gate-5](/ops/decisions) gate-native CAdES-T 서명엔진, [/architecture/governance](/architecture/governance) §6). 키 디렉터리 지정 변형 = `GATE_ESIGN_KEY_DIR`. `GATE_TSA_URL`(RFC3161 TSA, default DigiCert / 한국 prod Koscom·CrossCert) = 비밀 아님(공개 엔드포인트). ⚠️ 서명키 분실 = 전 과거 봉인 검증불가 → **DR/escrow** 필수(하드닝, [/architecture/governance](/architecture/governance) §9). dev = ephemeral key (vault 불요).
## 신규 customer 매니페스트 추가 (realchoice 등)
> **현재 상태 (2026-05-23)**: `customers.yaml` 의 `services:` 섹션은 **`axe` customer 만 등재**. realchoice 는 customer 메타블록 (legal_name, tailscale_host, sso.apps 등) 만 있고 secrets 매니페스트 부재. 즉 `axe secret push --customer realchoice` 가 manifest lookup 실패. 이 갭이 D-day "1-shot onboard" 의 가장 큰 막힘 → [B-onboard-customers-add](/ops/backlog) + [B-onboard-azure-pack](/ops/backlog).
신규 customer 합류 시 `customers.yaml` 의 `services:` 섹션에 customer 별 슬롯을 미리 등재해야 `axe secret push` 가 동작합니다. realchoice 템플릿:
```yaml
services:
frame:
realchoice:
env_file: "/Users/realchoice/frame/.env.local"
secrets:
- env: FRAME_DB_PASSWORD
vault: "frame/realchoice/db-password"
- env: FRAME_JWT_SECRET
vault: "frame/realchoice/jwt-secret"
- env: AZURE_FRAME_MCP_CLIENT_SECRET
vault: "frame/realchoice/oauth-client-secret"
rotation_external: azure
- env: FRAME_PII_PASSPHRASE_REALCHOICE
vault: "frame/realchoice/pii-passphrase-realchoice"
- env: CLAUDE_CODE_OAUTH_TOKEN
vault: "frame/realchoice/claude-oauth"
blueprint:
realchoice:
env_file: "/Users/realchoice/blueprint/.env"
secrets:
- env: AZURE_AD_CLIENT_SECRET
vault: "blueprint/realchoice/azure-client-secret"
rotation_external: azure
- env: NEXTAUTH_SECRET
vault: "blueprint/realchoice/nextauth-secret"
# ... (axe customer 의 12 개 패턴 동일 복제)
vaultwarden:
realchoice:
env_file: "/Users/realchoice/.axe/vault/.env"
bootstrap_only: true
secrets:
- env: AZURE_VAULTWARDEN_CLIENT_SECRET
vault: "vaultwarden/realchoice/oauth-client-secret"
rotation_external: azure
hive:
realchoice:
env_file: "/Users/realchoice/hive/.env.local"
secrets:
- env: HIVE_DB_PASSWORD
vault: "hive/realchoice/db-password"
# ... axe 동일 패턴
```
> **`axe customers add {customer}` 명령** ([B-onboard-customers-add](/ops/backlog)) 가 이 템플릿을 자동 생성하도록 만들면 운영자 손작업 단계 2 제거 가능. customer IT 회신 (8 개 값) JSON 한 덩어리를 받아 `axe customer ingest {customer} azure-pack.json` 한 줄에 (a) customers.yaml 슬롯 작성 + (b) 3 개 client_secret 을 vault 로 push 까지 묶는 게 [B-onboard-azure-pack](/ops/backlog) 의 본질.
## 회전 (rotation) 흐름
```
axe secret rotate AZURE_FRAME_MCP_CLIENT_SECRET --service frame
[1/4] external rotation required (provider: azure)
portal: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/...
action: rotate secret in the provider, then paste the NEW value below.
the OLD value will remain valid until step 4 (rotation overlap window).
new value: ********
[2/4] writing new value to vault item 'frame/axe/oauth-client-secret'
✓ updated existing vault item
[3/4] axe secret pull frame
✓ frame: pulled 7 secrets → /Users/axe/frame/.env.local (mode 600)
[4/4] deploy with new secret
run `axe ship frame` now? (yes/no): yes
→ frame blue/green swap with new secret
Note: in the azure portal, REVOKE the OLD secret value now that the new
one is verified working in production. Both are valid until you delete the old.
```
회전 순서가 중요:
1. **외부 provider 먼저** — Azure 가 새 값 발급 → 운영자가 복사
2. **vault PUT** — Bitwarden 에 새 값 저장 (이전 값 덮어쓰기)
3. **env_file refresh** — `axe secret pull`
4. **service redeploy** — `axe ship` (blue/green = 무중단)
5. **OLD secret revoke** — Azure portal 에서 이전 값 삭제 (overlap window 닫기)
## Pull 동작 — merge-mode (D-ops-18 의 결과)
`axe secret pull SERVICE` 는 env_file 을 **머지** 합니다:
- 매니페스트의 키 → vault 값으로 **in-place update**
- 매니페스트 외 키 / 주석 / 빈 줄 → **그대로 보존**
- 매니페스트엔 있는데 파일엔 없는 키 → 파일 끝에 append
→ 비밀 외 config (예: `AZURE_AD_CLIENT_ID`, `NEXTAUTH_URL`, `DATABASE_URL` 등 운영자가 hand-maintain 하는 항목) 가 pull 호출로 사라지지 않음. **초기 구현은 전체 덮어쓰기였고 2026-05-21 첫 blueprint pull 에서 9개 config 가 wipe 되는 사고 발생 → 코드 패치 + D-ops-18 등재**.
출력 예:
```
✓ blueprint: merged 13 secrets into /Users/axe/blueprint/.env (13 updated, 0 added, non-managed lines preserved)
```
## Rotation — az CLI 기반 (D-ops-19)
Azure 의 `client_secret` 회전은 6 단계 az cli 자동화. Portal UI 0 회 (단, app 의 **Owner** 가 운영자로 박혀 있어야 함):
```bash
APP_ID= # e.g. Frame MCP: 137fc0ef-...
OLD_KEY_ID=$(az ad app credential list --id $APP_ID --query "[?starts_with(hint,'')].keyId | [0]" -o tsv)
# 1. Azure 새 secret 추가 (--append, OLD 유지 = overlap window)
NEW=$(az ad app credential reset --id $APP_ID \
--display-name "rotated " --years 2 --append \
--query password -o tsv)
# 2. vault PUT
axe secret push --service --value "$NEW"
# 3. pull (merge — config 보존)
axe secret pull
# 4. 무중단 swap
axe deploy axe --apply # frame / hive
# 또는
axe blueprint upgrade axe --apply # blueprint
# 5. 검증 — 컨테이너 env 의 새 secret prefix 가 OLD 와 다른지
docker exec env | grep
# 6. 운영 안정 확인 후 OLD revoke
az ad app credential delete --id $APP_ID --key-id $OLD_KEY_ID
```
**App Owner 전제조건**: portal-등록 app 은 default 로 owner 없음 → az cli 권한 X. 신규 az cli 등록 app 은 호출자가 owner 자동 박힘. 기존 portal-등록 app 들은 portal Owners 탭에서 운영자 (`ai@axellc.com`) 명시 추가 필요. 본 플랫폼 현황:
| App | appId | Owner |
|---|---|---|
| frame_mcp | `137fc0ef-...` | ai@axellc.com (2026-05-21 추가) |
| hive_mcp | `b7ead15d-...` | ai@axellc.com (등록 시 자동) |
| blueprint_mcp | `482598f7-...` | ai@axellc.com (등록 시 자동) |
| Blueprint Graph (better-auth) | `2b222356-...` | ai@axellc.com (2026-05-21 추가) |
| vaultwarden | `9d0dc49b-...` | ai@axellc.com (2026-05-21 추가) |
## 사람에게 전달 — Bitwarden Send
`axe secret get` 의 결과는 vault → 디스크/컨테이너 경로용. **사람 (신규 직원, customer admin) 에게 보낼 때는 평문 절대 금지** — Teams DM, 이메일, 위키 모두 영구 보존되는 채널. Vaultwarden 의 1회용 링크 (Bitwarden Send) 로 전달한다.
### `axe secret send` (권장)
```bash
axe secret send --service [--to ]
```
매니페스트 lookup (`customers.yaml services..secrets[].env → .vault`) → bw GET → bw send 파이프. stdout 에 Send URL 만 (상태 메시지는 stderr) — `axe secret send ... | pbcopy` 로 클립보드 직행. 기본값:
| 플래그 | 의미 | 기본값 |
|---|---|---|
| `-d N` / `--days N` | N일 후 자동 삭제 | `1` |
| `-a N` / `--access N` | 최대 N회 열람 후 무효 | `1` |
| `--to <라벨>` | Send name 에 `(라벨)` 추가 (운영자 추적용, 실 ACL 아님) | 없음 |
| `--password
` | 추가 비밀번호 잠금 | 없음 — 외부 customer 대상 시 권장 |
| `--customer ` | customer 식별 (기본 `axe`) | `axe` |
stdin 으로 값 주입 → argv 에 secret 노출 0 (ps, shell history 안 박힘).
```bash
# 예: 한진우에게 Blueprint MCP secret 전달
axe secret send AZURE_BLUEPRINT_MCP_CLIENT_SECRET --service blueprint --to jinwoo
# ✓ Send created: blueprint AZURE_BLUEPRINT_MCP_CLIENT_SECRET (jinwoo)
# expires in 1d / max 1 access(es)
# https://vault.axelabs.ai/#/send/abc123.../def456...
```
### 다인 × 다 secret 배치
`-a 1` 이면 한 사람이 열면 끝나므로 **(수신자, secret) 1쌍당 링크 1개**:
```bash
for who in taehun jinwoo soohun; do
for env in AZURE_BLUEPRINT_MCP_CLIENT_SECRET AZURE_HIVE_MCP_CLIENT_SECRET; do
svc=$(echo $env | awk -F_ '{print tolower($2)}')
echo "=== $who / $env ==="
axe secret send $env --service $svc --to $who
done
done
```
위 6 링크가 stdout 에, 각 헤더는 stderr 로 분리 출력. 운영자 본인 (soohun) 도 동일하게 새 Custom Connector 등록 흐름이 필요하면 포함.
### 자동 발사 — `/api/admin/broadcast-dm`
`axe secret send` 출력 URL 을 운영자가 Teams 에 손으로 paste 하지 말고 **Blueprint bot (ai@axellc.com) 이 1:1 DM 으로 직접 발사**. 엔드포인트 [`POST /api/admin/broadcast-dm`](https://blueprint.axellc.com/api/admin/broadcast-dm) 이 each email → AAD lookup → `POST /chats` (oneOnOne, Graph idempotent) → `POST /chats/\{id\}/messages` 수행.
```bash
# 1. URL 발급 (이미 `/tmp/sends.txt` 로 캐시했다고 가정)
# 2. 수신자별 메시지 합성 + DM 발사 (예: 강태훈 한 명)
CRON=$(grep -E "^CRON_SECRET=" /Users/axe/blueprint/.env | cut -d= -f2-)
URL_BP=$(grep "^taehun|blueprint|" /tmp/sends.txt | cut -d'|' -f3)
URL_HV=$(grep "^taehun|hive|" /tmp/sends.txt | cut -d'|' -f3)
TEXT="태훈님, Blueprint + Hive Custom Connector 등록 부탁드립니다 ...
Blueprint Secret: $URL_BP
Hive Secret: $URL_HV"
curl -s -X POST http://localhost:3100/api/admin/broadcast-dm \
-H "Authorization: Bearer $CRON" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg e taehun.kang@axellc.com --arg t "$TEXT" \
'{emails:[$e], text:$t, contentType:"text"}')"
# → {"summary":{"total":1,"sent":1,...}, "results":[{...,"chatId":"19:...","messageId":"..."}]}
```
**같은 텍스트를 N명에게 broadcast**: `emails:[a,b,c]` 한 호출. 다른 텍스트면 N 호출 (위 패턴 반복).
| 필드 | 의미 |
|---|---|
| **Endpoint** | `POST /api/admin/broadcast-dm` (Blueprint, host `:3100` 또는 `blueprint.axellc.com`) |
| **Auth** | `Authorization: Bearer $CRON_SECRET` (Blueprint `.env`). NextAuth 세션 불필요 |
| **Body** | `\{ emails: string[], text: string, contentType?: "text" \| "html" \}` 기본 `"text"` (HTML escape 안전) |
| **AAD lookup** | `User.aadObjectId` (NextAuth 첫 sign-in 시 자동 채워짐). 없으면 `skipped` |
| **Idempotent** | Graph `POST /chats` 가 같은 (bot, target) 쌍이면 같은 chat id 반환 → 재호출해도 새 채팅방 안 생김 |
| **Self-DM 차단** | target AAD = bot AAD 면 `skipped` (bot=ai@axellc.com 본인은 호출 불가) |
상세 코드: `/Users/axe/blueprint/src/app/api/admin/broadcast-dm/route.ts`.
#### 함정
| 함정 | 결과 | 회피 |
|---|---|---|
| 수신자가 메시지 보면 **sender = AI (ai@axellc.com)** 이라 사람-요청 같지 않음 | "왜 AI 가 connector 등록 시킴" 혼동 | 메시지 본문 첫 줄에 운영자명 명시 ("수훈 대표 요청으로 ...") |
| `User.aadObjectId` 비어 있는 신규 직원 → `skipped` | DM 미발사, 운영자 로그 확인 안 하면 모름 | NextAuth Blueprint 첫 sign-in 1회 선행. 또는 응답 JSON 의 `summary.skipped > 0` 가드 |
| `CRON_SECRET` env 미설정 | 503 응답 | Blueprint `.env` 의 `CRON_SECRET` 확인. Phase 2 deploy 에서 자동 생성됨 |
### 웹 UI 대안
`https://vault.axelabs.ai` 로그인 → 좌측 **Send** → **+ New Send** → Type: Text, Text: secret 붙여넣기, Deletion: 1 day, Maximum Access Count: 1, Hide text by default ✓ → Save → **Copy link**. CLI 가 막혔거나 매니페스트 외 임시 비밀에만 사용.
### 함정
| 함정 | 결과 | 회피 |
|---|---|---|
| `bw send` 를 raw 로 호출하면서 `axe secret get` 에 `-n` 누락 | trailing `\n` 이 base64/URL 인코딩 시 secret 변형 → OAuth 거부 | `axe secret send` 사용 (내부에서 stdin 으로 처리) |
| Send URL 자체를 다시 위키/메일에 영구 보존 | 1회 사용 후 URL 무효지만 메타데이터 (라벨) 노출 | URL 은 1회용 채널 (DM, SMS) 만 사용 |
| `-d` 너무 길게 (예: `-d 30`) | 수신자가 잊고 안 받으면 secret 이 30일간 노출면 유지 | `-d 1` 기본, 수신 확인 후 `bw send delete ` |
| 수신자가 받기 전에 운영자 본인이 열어버림 | `-a 1` 소진 → 수신자 재발급 필요 | URL 검증 X — 발급 후 바로 송부, 수신자 동의 확인 후 만 |
| Send 만든 직후 `bw sync` 안 한 두 번째 macmini 에서 안 보임 | revoke 가 한쪽에만 적용 | 운영자는 한 기기에서만 Send 관리, 또는 매 작업 후 `bw sync` |
| vault locked 상태 (timeout) | `axe secret send` 가 시작 시 status 검사 → "vault is locked" 명시 + unlock 명령 안내 | `axe vault unlock` → Keychain 갱신. **TTY 없는 에이전트/cron 컨텍스트면 osascript 다이얼로그 자동 팝업** (운영자가 master password 입력) — 더 이상 "운영자가 터미널에서 직접" punting 불필요 (2026-06-04 root-cause fix). `--gui` 강제 / `--tty` 레거시 프롬프트 |
| bw locked 상태에서 raw `bw get password X` 직접 호출 | interactive prompt 시도 → `ERR_USE_AFTER_CLOSE` crash + **exit 0 + 빈 stdout** (misleading) | `axe secret send`/`get` 만 사용 — pre-check 가 status 가드 |
| **bw 2025.7.0 (D-ops-28 다운그레이드 line) 의 `bw send` 가 옵션 (`-d` / `-a` / `-n` / `--hidden`) 함께 주면 stdin pipe 거부** — `echo X \| bw send -d 1 -a 1 -n "..."` → `error: missing required argument 'data'`. `--help` 의 `echo "text" \| bw send` 예시는 **옵션 0 인 호출에만** 동작 | raw `bw send` 사용한 1세대 `axe secret send` 가 exit 1 로 죽음 (2026-05-23 발견, 1세대 라이브 첫 호출 시) | 2세대 구현 = `bw send create` 서브커맨드 + base64-encoded JSON template 을 stdin 으로 (`\{type:0, name, deletionDate, maxAccessCount, text:\{text, hidden\}, password\}`) — encoded blob 도 stdin 이라 argv 노출 0. `accessUrl` 필드 parse 해서 반환 |
## 운영자 → vault 비밀 입력 패턴 (osascript hidden-dialog)
운영자가 **외부에서 발급받은 비밀 (PAT, API key, OAuth client_secret, SaaS token)** 을 vault 에 한 번에 영구 저장해야 할 때의 표준 패턴. [D-ops-40](/ops/decisions) axe.3 release (2026-05-26) GHCR PAT 보관 시 정착.
> **같은 osascript 다이얼로그 기법이 vault *unlock* 에도 적용됨** (2026-06-04): `axe vault unlock` 은 TTY 가 없으면 (Claude Code / cron / CI) 자동으로 hidden-text 다이얼로그를 띄워 master password 를 받아 `bw unlock --passwordenv` 로 unlock → BW_SESSION 을 Keychain 에 저장. 덕분에 **어느 에이전트 세션이든 한 명령으로 vault unlock 을 트리거**할 수 있고 운영자는 팝업에만 답하면 됨 (master password 는 argv/ps/history 어디에도 안 남음). 근인: 기존 `bw unlock --raw` 는 프롬프트에 TTY 가 필요해 non-TTY 호출자가 unlock 불가 → 매번 운영자에게 punting 했음.
>
> **파일/대형/PEM 비밀**(예: 플랫폼 OIDC RS256 서명키)은 다이얼로그(single-line) 대상이 아니므로 `... | axe secret push --service --value-stdin` 으로 stdin 주입 (값이 argv/ps 에 안 뜸).
### 원칙
| 원칙 | 구현 |
|---|---|
| 비밀 값 = AI context 미진입 | bash subprocess 안에서만 env var, AI 가 받는 output 은 `✓ saved/✗ failed` 만 |
| shell history 미기록 | `osascript display dialog` (zsh history 우회) |
| 화면 / 스크롤백 미노출 | `hidden answer` 옵션 |
| clipboard 미사용 | dialog → 직접 env var (clipboard hop 없음) |
| TTY 불필요 | macOS Apple Events 로 GUI dialog — `forkpty: Device not configured` 환경에서도 동작 |
| 저장처 단일 SoT | AXE Vaultwarden organization collection (keychain 금지 — [D-ops-ops-17](/ops/decisions) + [B-frame-keychain-to-vault](/ops/backlog)) |
| 메모리 즉시 정리 | bash subprocess 종료 시 env var 소멸 + 명시 `unset` |
| idempotent | 같은 name 으로 재실행 시 UPDATE (overwrite), 없으면 CREATE |
### 한 블록 (복붙 → argument-pack 만 수정)
```bash
# === argument-pack: 이 부분만 task 별로 갱신 ===
ITEM_NAME='' # 예: ghcr-axelabs-ai-pull-pat
COLLECTION_ID='' # bw list collections --organizationid | jq 로 확인
ORG_ID='' # 예: 0c5d8bbd-ad85-42b4-8b8a-2849031981b1 (AXE)
LOGIN_USERNAME='' # 예: axe-labs-ai
LOGIN_URI='' # 예: https://ghcr.io
DIALOG_PROMPT='<운영자에게 보여줄 한 줄 설명>'
DIALOG_TITLE='