/* * 커맨드 감사 기록 만들기 (순수) — **누가 무엇을 허락했고, 그래서 어떻게 됐나.** * * 승인 게이트가 앞을 막고, 이 기록이 뒤를 설명한다. 둘 다 있어야 "사람 손을 거쳤다" 를 증명할 수 있다. * * 무엇을 믿는가가 이 기록의 전부다: * · **신원·시각은 서버가 준다.** 클라이언트가 말한 신원을 그대로 적으면 위조가 자유로워 아무것도 * 증명하지 못한다. 세션이 아는 사용자와 서버 시각만 쓴다. * · **의도는 클라이언트가 준다** — 출처(origin)·근거(reason). 서버가 알 수 없는 정보이고, * 위조되더라도 신원 증명을 훼손하지 않는다. */ export type CommandOrigin = 'user' | 'ai' | 'automation' | 'unknown' export type CommandAuditInput = { /** 클라이언트가 실어 보낸 커맨드(신뢰하지 않는 부분을 포함한 원본). */ command: any /** 커널 판정. */ ack: { accepted?: boolean; errorCode?: string; error?: string } /** * **원본(어댑터)이 준 답** — 허락과 일어남 사이에 끼어 있는 것을 같은 기록에 닫는다 (ADR-0029 §1 결정 C). * * ── 왜 이 칸이 필요한가 ────────────────────────────────────────────────── * 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다 — 어댑터가 오류 없이 실패하면 원장에는 승인만 * 남는다. 그래서 어댑터의 답을 **같은 행**에 되적는다. * * 셋을 구별한다: * · 없음(`undefined`) — **원본에 묻지 않았다**(커널이 받은 명령이다). 「모른다」와 다르다. * · `{ answered: false }` — 물었는데 **답을 못 받았다** → `unknown` 으로 남긴다. 성공으로 적지 않는다. * · `{ answered: true, ok, code? }` — 원본이 답했다(적용·거절 모두 사실이다). */ source?: { answered: boolean; ok?: boolean; code?: string } /** 세션이 아는 사용자 — **서버만 채운다.** */ actor?: { id?: string; email?: string; name?: string } /** 서버 시각. 호출부가 주입해 테스트가 시간을 고정할 수 있게 한다. */ at: Date } /** `TwinAuditEvent` 에 그대로 넣을 수 있는 모양(엔티티를 몰라도 되게 순수하게 둔다). */ export type CommandAuditRow = { eventTime: Date commandId?: string commandType?: string origin: CommandOrigin reason?: string actorId?: string actorEmail?: string actorName?: string accepted: boolean errorCode?: string /** * 원본이 그래서 어떻게 했나 — `applied` · `refused` · `unknown`(물었지만 답이 없다). * **없으면 묻지 않은 것이다**(커널이 받은 명령). 0 이나 성공으로 채우지 않는다. */ sourceOutcome?: 'applied' | 'refused' | 'unknown' /** 원본이 준 사유 코드 — 있으면 그대로. 우리가 지어내지 않는다. */ sourceCode?: string args?: any argsTruncated: boolean } /** 신뢰할 수 있는 출처 표기로 정규화 — 모르는 값은 지어내지 않고 'unknown' 으로 둔다. */ export function normalizeOrigin(v: unknown): CommandOrigin { return v === 'user' || v === 'ai' || v === 'automation' ? v : 'unknown' } /** args 직렬화 상한(문자). 넘으면 자르되 잘랐다고 표시한다. */ const ARGS_MAX = 4000 export function buildCommandAudit(input: CommandAuditInput): CommandAuditRow { const { command, ack, actor, at, source } = input const argsText = safeJson(command?.args) const truncated = argsText !== null && argsText.length > ARGS_MAX return { eventTime: at, commandId: str(command?.commandId), commandType: str(command?.type), origin: normalizeOrigin(command?.origin), reason: str(command?.reason), /* 원본의 답 — 묻지 않았으면 칸을 만들지 않는다(모름과 구별한다). */ ...(source ? { sourceOutcome: sourceOutcomeOf(source), ...(source.code ? { sourceCode: source.code } : {}) } : {}), /* 신원 — 세션 값만. 커맨드에 실려 온 신원 주장은 쳐다보지 않는다. */ actorId: str(actor?.id), actorEmail: str(actor?.email), actorName: str(actor?.name), /* 결과 — 승인했는데 커널이 거절한 것도 감사 대상이다(무엇을 시도했는지가 남아야 한다). */ accepted: !!ack?.accepted, errorCode: str(ack?.errorCode), /* 인자는 원형 그대로. 너무 크면 잘린 문자열로 두되 잘랐다고 말한다. */ args: truncated ? argsText!.slice(0, ARGS_MAX) : command?.args ?? undefined, argsTruncated: truncated } } const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) function safeJson(v: unknown): string | null { try { return v === undefined ? null : JSON.stringify(v) } catch { return null /* 순환 참조 등 — 못 재면 자르지 않는다(원본을 그대로 둔다) */ } } /** * 원본의 답을 세 값으로 — **답이 없으면 `unknown`**(성공으로 적지 않는다). * * 이 함수가 있는 이유: 「물었지만 답이 없다」를 실패로도 성공으로도 적을 수 없다. 실패로 적으면 일어난 일을 * 없던 일로 만들고, 성공으로 적으면 없던 일을 일어난 일로 만든다. 셋째 값이 사실이다. */ export function sourceOutcomeOf(source: { answered: boolean; ok?: boolean }): 'applied' | 'refused' | 'unknown' { if (!source.answered || typeof source.ok !== 'boolean') return 'unknown' return source.ok ? 'applied' : 'refused' }