/* * **허락과 일어남 사이를 같은 행에 닫는다** — ADR-0029 §1 결정 C (2026-08-19). * * ── 무엇이 열려 있었나 ────────────────────────────────────────────────────── * 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다: 어댑터가 오류 없이 실패하면 원장에는 승인만 남는다. * 그리고 구동 문(`controlTwinSource`)은 감사를 **아예 남기지 않았다** — 자극을 바꾸고 배속을 올리는 것은 * 그 트윈의 사실을 바꾸는 행위인데, 나중에 「무엇이 이 공장을 흔들었나」에 답할 수 없었다. * * ── 세 값을 구별한다 ──────────────────────────────────────────────────────── * · 칸이 **없음** — 원본에 묻지 않았다(커널이 받은 명령). * · `unknown` — 물었는데 답이 없다. **성공으로 적지 않는다.** * · `applied`·`refused` — 원본이 답했다(둘 다 사실이다). * * 셋을 뭉개면 둘 중 하나가 된다: 일어난 일을 없던 일로 만들거나, 없던 일을 일어난 일로 만든다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { buildCommandAudit, sourceOutcomeOf } from '../server/service/twin-audit/command-audit.ts' const at = new Date('2026-08-19T05:00:00.000Z') test('묻지 않았으면 칸을 만들지 않는다 — 커널이 받은 명령이 그렇다', () => { const row = buildCommandAudit({ command: { type: 'order.hold', origin: 'user' }, ack: { accepted: true }, at }) assert.equal('sourceOutcome' in row, false, '없음은 「모른다」가 아니라 「묻지 않았다」다') assert.equal('sourceCode' in row, false) }) test('물었는데 답이 없으면 unknown — 성공으로 적지 않는다', () => { const row = buildCommandAudit({ command: { type: 'source.stimulus', origin: 'user' }, ack: { accepted: true }, source: { answered: false }, at }) assert.equal(row.sourceOutcome, 'unknown') }) test('답이 있으면 그대로 적는다 — 거절도 사실이다', () => { const applied = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: true }, at }) assert.equal(applied.sourceOutcome, 'applied') const refused = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: false, code: 'not-declared' }, at }) assert.equal(refused.sourceOutcome, 'refused') assert.equal(refused.sourceCode, 'not-declared', '원본이 준 사유를 그대로 적는다') }) test('사유가 없으면 사유 칸도 없다 — 지어내지 않는다', () => { const row = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: true }, at }) assert.equal('sourceCode' in row, false) }) test('판정 함수가 세 값만 낸다 — ok 가 참/거짓이 아니면 모른다', () => { assert.equal(sourceOutcomeOf({ answered: true, ok: true }), 'applied') assert.equal(sourceOutcomeOf({ answered: true, ok: false }), 'refused') assert.equal(sourceOutcomeOf({ answered: false }), 'unknown') assert.equal(sourceOutcomeOf({ answered: true }), 'unknown', '답했다고 하면서 값이 없으면 모르는 것이다') assert.equal(sourceOutcomeOf({ answered: true, ok: undefined as any }), 'unknown') }) test('신원은 여전히 서버만 채운다 — 원본의 답이 그 규율을 흔들지 않는다', () => { const row = buildCommandAudit({ command: { type: 'source.pause', origin: 'user', actorId: 'forged', actorEmail: 'forged@x' }, ack: { accepted: true }, source: { answered: true, ok: true }, at, actor: { id: 'u1', email: 'real@x' } }) assert.equal(row.actorId, 'u1') assert.equal(row.actorEmail, 'real@x') }) /* * ── 구동 문이 실제로 남기는지 (소스로 지킨다) ─────────────────────────────── * 순수 함수만 시험하면 「기록을 만들 수 있다」까지만 지킨다. 남기지 않으면 그 함수는 아무 일도 하지 않는다. */ test('구동 문이 게이트 뒤에서 원본의 답까지 남긴다', () => { const src = readFileSync(fileURLToPath(new URL('../server/service/twin-control/twin-control-mutation.ts', import.meta.url)), 'utf-8') const code = src .split('\n') .filter(line => !/^\s*(\*|\/\/|\/\*)/.test(line)) .join('\n') /* 게이트는 남는다(결정 C 의 절반). */ const gates = code.match(/@privilege\(category: "twin", privilege: "mutation"/g) ?? [] assert.ok(gates.length >= 2, `구동 문에도 게이트가 있어야 한다(발견 ${gates.length})`) /* 원본의 답을 같은 행에 닫는다. */ assert.match(code, /source: \{ answered: typeof result\?\.ok === 'boolean', ok: result\?\.ok, code: result\?\.code \}/) assert.match(code, /type: `source\.\$\{action\}`/, '커맨드 채널과 같은 어휘로 적는다') /* 기록 실패를 삼키지 않는다. */ assert.match(code, /source control audit not recorded/) }) test('엔티티가 그 두 칸을 든다 — 비어 있음이 뜻을 갖는다', () => { const src = readFileSync(fileURLToPath(new URL('../server/service/twin-audit/twin-audit-event.ts', import.meta.url)), 'utf-8') assert.match(src, /sourceOutcome\?: string/) assert.match(src, /sourceCode\?: string/) assert.match(src, /the source was not asked/, '비어 있음의 뜻을 필드 설명이 말한다') })