/* * **승인을 지난 커맨드만 어댑터에 넘긴다** — 게이트가 서는 자리. * * ── 왜 이 자리인가 (2026-08-31) ────────────────────────────────────────── * 설계가 「기본 = propose-only」를 정해 두었는데 코드에 그 규율이 없었다. 인티그레이션 레인이 어댑터에 * 조치를 붙이려다 그것을 보고 멈췄다 — 게이트 없이 붙이면 트윈이 사람 승인 없이 현장에 작업지시를 * 내린다. 작업지시는 취소해도 이미 만든 것이 남는다. * * ── 확인을 여기서 한 번만 한다 ─────────────────────────────────────────── * 어댑터는 승인을 확인하지 않는다. 그 확인은 이 파일이 했고 **타입이 그것을 증명한다** * (`ApprovedCommand` 는 `approveCommand`·`asApproved` 로만 만들어진다). 어댑터가 또 확인하면 규칙이 * 두 벌이 되고, 한쪽만 고쳐지는 날이 온다. * * ── 저장소에 닿는 자리를 밖으로 낸다 ───────────────────────────────────── * 훅에서와 같은 이유다(§`HookStore`). 시험이 **재기동을 넣을 수 있어야** 한다 — 승인이 프로세스 안에만 * 살아 있으면 「승인된다」는 보이지만 「다시 세운 뒤에도 승인이 남아 있나」는 보이지 않는다. */ import { CMD, approveCommand, asApproved, commandFromSpec, nextCommandState, passThroughReversible, requiresApproval, type ApprovedCommand, type CommandApproval, type CommandOrigin, type CommandTypeSpec, type ISOTime, type TwinCommand } from '@operato/ops-contract' /** * 조치의 결과 — 어댑터가 돌려준다. * * 계약에도 같은 이름의 타입이 있는데(`contract.ts:3074`) **다른 것**이다. 그쪽은 커널 자극의 답이고 * 이쪽은 현장 지시의 답이다. 자극과 조치를 가른 것과 같은 갈림이다(§`propose` 의 거절). */ import type { RetryOutlook } from './retry-plan.js' export interface CommandAck { ok: boolean /** 저쪽 문서 · 작업 식별자. 되돌릴 때 무엇을 되돌릴지 말하려면 이것이 있어야 한다. */ ref?: string /** * **받아들였으나 저쪽이 덧붙인 말** — 실패가 아니고, 「끝났다」도 아니다. * * ── 왜 필요한가 (2026-09-02 실측) ────────────────────────────────────── * MES 에 조치 둘을 넘겼더니 트윈 쪽 두 행이 구별되지 않았다. * * WO-20260902-004 지시서 붙음 acked · ref 있음 · error 없음 * WO-20260902-005 지시서 못 붙음 acked · ref 있음 · error 없음 ← 같다 * * 005 는 사람이 MES 에서 지시서를 골라야 현장에 간다. 커넥터는 그 사실을 받았는데 **적을 칸이 * 없어서 로그로만** 남겼다. `error` 에 적으면 실패로 읽혀 커맨드가 `failed` 로 앉는다. * * **해석하지 않고 그대로 적는다.** 무엇이 남았는지는 저쪽 어휘이고, 커널이 그 방언을 알면 보편 * 계약이 깨진다 — 효과의 상태를 그대로 적는 것과 같은 규율(§`effect-match.ts`). */ note?: string /** * 그 말 중 **다음에 할 일** 한 줄 — 원인은 `note` 다. * * 왜 둘인가: 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾는다(UX 레인). 한 문장으로 주면 * 화면이 자르게 되고, **자르는 규칙이 화면마다 생긴다.** 어댑터는 이미 둘로 알고 있으니 붙이지 * 않고 따로 준다. */ noteNext?: string error?: string /** * **다시 해서 될 일인가** — 실패에만 답한다(§`RetryOutlook`). * * again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다 * after-fix 사람이 고친 뒤 그대로 나간다 설정 문제 * never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다 * * **문장에 담지 않는 이유**: 일꾼이 그것을 쓰려면 문장을 파싱해야 하고, 번역되면 깨진다. * `code` 를 따로 둔 것과 같은 이유다. * * 비어 있으면 **말하지 않은 것**이고 `never` 가 아니다. 그때 일꾼은 집지 않고 목록에 남긴다 — * 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이 포기한다. * * 타입을 좁게 둔다 — **어댑터가 답하는 값**이고, 여기서 넓히면 커넥터가 모르는 낱말을 보내도 * 타입이 통과시킨다. 표에서 읽는 쪽은 `string` 이다(DB 는 무엇이든 들 수 있으므로 * `isRetryOutlook` 으로 걸러 읽는다). */ retry?: RetryOutlook } /** * 저장소에서 되세운 커맨드 — 계약의 커맨드에 **호스트가 아는 한 칸**이 붙는다. * * `approvalRef` 를 계약에 넣지 않는다. 계약은 운영 도메인의 어휘(EPCIS·ISA-95)를 들고, 「승인 기록이 * 어느 업무 시스템의 몇 번 문서인가」는 이 저장소의 배선이다. 계약에 넣으면 커널을 쓰는 모든 소비처가 * 우리 업무 모듈의 존재를 알게 된다. */ export type StoredCommand = TwinCommand & { approvalRef?: string proposedBy?: string /** 규칙이 낸 것이면 그 근거가 된 관측(`Attention.id`) — 중복을 막는 열쇠다. */ causeRef?: string ruleRef?: string /** * 저쪽에서 실제로 무엇이 되었나 — **상태가 아니라 별개 사실이다.** * * `acked` 는 「저쪽이 답했다」이고 종착 상태다. 여기에 「현장에 나갔다」를 상태로 이어 붙이면 * `acked` 가 종착이 아니게 되고, 되돌아오는 관측이 없는 커넥터의 조치가 **영원히 끝나지 않은 * 것으로 보인다.** 그런 커넥터가 정상이다 — 관측을 되돌려 주는 능력은 커넥터의 성질이지 조치의 * 수명이 아니다. * * `status` 는 **해석하지 않고 그대로** 적는다(§`effect-match.ts`). 오더 상태 어휘는 도메인 * 소유이고, 어느 값이 「현장에 나갔다」인지는 현장마다 다르다. * * 이 칸이 비어 있는 것은 「효과가 없었다」가 아니라 **「모른다」**다. 넘기다 닿지 못한 조치는 * 식별자를 못 받으므로 이을 길이 아예 없다. * * ── 계약에도 같은 칸을 두었다 ────────────────────────────────────────── * `ops-contract` 의 `TwinCommand.effect` 가 이것이다. 다만 이 저장소는 그 패키지를 레지스트리에서 * 받으므로(`^0.9.0`), 다음 버전이 올라올 때까지 여기에 산다 — 위의 `approvalRef`·`causeRef` 와 같다. */ effect?: { status: string; at: string; seen: number } /** 받아들였으나 저쪽이 덧붙인 말 — 해석하지 않고 그대로(§`CommandAck.note`). */ dispatchNote?: string /** 그 말 중 다음에 할 일 한 줄 — 원인은 위 칸이다(§`CommandAck.noteNext`). */ dispatchNoteNext?: string /** * 다시 해서 될 일인가 — 어댑터가 실패에 답한 갈래(§`RetryOutlook`). 비면 말하지 않은 것이다. * * **여기는 `string` 이다** — 표에서 읽은 값이고 DB 는 무엇이든 들 수 있다. 좁은 타입으로 두면 * 낡은 행이나 손으로 고친 값이 타입상 참인 척한다. 판정하는 쪽이 `isRetryOutlook` 으로 걸러 * 읽고, 모르는 낱말은 「말하지 않은 것」과 같이 다룬다. */ retry?: string /** 몇 번 넘겨 봤나. 첫 시도가 1. */ attempts?: number /** 다음에 넘겨 볼 시각(ISO) — 물러섬을 표에 든다. 메모리에 두면 재기동에 사라진다. */ nextAttemptAt?: string } /** 커맨드가 사는 자리 — 재기동을 넘어야 한다. */ export interface CommandStore { load(domainId: string, id: string): Promise save(domainId: string, command: StoredCommand): Promise /** 새로 낸다 — `save` 는 있는 행만 고친다. 낼 길이 없으면 대기 줄은 영원히 비어 있다. */ create(domainId: string, command: StoredCommand): Promise /** * 결정을 기다리는 것들의 근거 — 규칙 판정이 중복을 피하려면 이것이 있어야 한다. * * 규칙을 쓰지 않는 소비처도 있으므로 없어도 된다. 없으면 판정에 빈 집합이 가고, **그때는 중복이 난다** * — 그 사실을 부르는 쪽이 알아야 하므로 조용히 빈 집합을 만들어 주지 않는다. */ openCauses?(domainId: string, instanceId: string): Promise> /** * 넘긴 지시의 식별자로 조치를 찾는다 — **폐루프가 이 하나에 걸린다.** * * 없으면 나간 지시와 돌아온 관측을 이을 수 없다. 그 사실을 부르는 쪽이 알아야 하므로 조용히 빈 * 지도를 만들어 주지 않는다(§`openCauses` 와 같은 규율). */ byDispatchRef?(domainId: string, refs: readonly string[]): Promise> /** * 효과를 기다리는 조치들 — **backfill 하는 길이 이것에 걸린다.** * * 유입 자리에서 잇는 것만으로는 부족하다. 우리가 내려가 있던 동안, 또는 배선이 아직 없던 동안 온 * 보고는 **영원히 사라진다.** 실제로 그렇게 됐다 — 배선을 만든 날 그 앞에 온 보고 하나를 못 이었다. * * 넘긴 식별자가 있는 것만 낸다. 없는 것은 이을 열쇠가 없다. */ awaitingEffect?(domainId: string): Promise /** * **넘길 차례를 기다리는 것들** — 승인만 되고 안 나간 것과, 실패해서 다시 해 볼 것. * * 일꾼이 이것으로 표를 읽는다. 없으면 일꾼이 아무것도 하지 않는다(옛 거동) — 저장소가 * 이 능력을 갖추지 않은 구현도 그대로 돌아야 한다. */ pendingDispatch?(domainId: string): Promise /** * 효과만 적는다 — **상태를 건드리지 않는다.** * * `save` 로 적으면 상태·승인·오류까지 함께 쓰이고, 유입 경로가 조치의 수명에 손을 대게 된다. * 관측이 조치를 되돌리거나 다시 넘기게 하는 길은 두지 않는다. */ recordEffects?(domainId: string, updates: readonly { commandId: string; status: string; at: string; seen: number }[]): Promise /** * 근거마다 마지막으로 조치를 낸 시각 — 승인 직후 같은 것이 다시 올라오는 것을 막는다. * * 없어도 된다. 없으면 판정이 결정 대기만 보고, **승인하는 순간 같은 조건이 다시 올라온다.** */ lastProposedAt?(domainId: string, instanceId: string): Promise> } /** * 조치를 낸다 — **대기 줄에 무언가가 들어오는 유일한 자리.** * * ── 왜 뒤늦게 생겼나 (2026-08-31) ──────────────────────────────────────── * 게이트와 승인 화면을 만들고 나서 물었다 — 「무엇을 승인하는 화면인가」. 커맨드 행을 **적는 코드가 * 하나도 없었다.** 자리는 있고 길이 없는 그 부류다. * * 종류를 선언된 것으로 제한한다. 모르는 종류를 받으면 되돌릴 수 있는지도 모르는 채로 게이트를 지나게 * 되고, 그러면 게이트가 시늉이 된다. */ export async function propose( store: CommandStore, domainId: string, specs: readonly CommandTypeSpec[], seed: { id: string instanceId: string type: string origin: CommandOrigin proposedAt: ISOTime payload?: Record /** 낸 사람. 사람이 냈으면 반드시 있어야 한다 — 없으면 자기 승인을 막을 수 없다. */ proposedBy?: string /** 규칙이 낸 것이면 그 근거. 이 값이 없으면 다음 주기가 같은 것을 또 올린다. */ causeRef?: string ruleRef?: string } ): Promise { const spec = specs.find(s => s.type === seed.type) if (!spec) { /* * **자극과 조치는 다른 어휘다.** * * 계약에 두 벌이 있다. `CMD` 는 커널 안에서 도는 자극이고(`resource.repair` = 모델에서 수리된 것으로 * 둔다), 여기서 다루는 것은 현장에 나가는 지시다(`production.order` = 실제로 만든다). 주의 신호가 * 권하는 것(`Attention.suggestedAction.command`)은 앞엣것이다. * * 둘이 섞이는 길이 실재한다 — 주의를 읽어 조치를 내는 코드를 쓰다 보면 그 값을 그대로 넘기게 된다. * 막히기는 하는데 **왜 막히는지 말하지 않으면** 부르는 쪽은 선언이 빠진 줄로 읽고 선언을 추가한다. * 그 순간 모델 안의 자극이 현장 지시가 된다. */ if ((Object.values(CMD) as string[]).includes(seed.type)) { throw new Error( `그것은 커널 안에서 도는 자극이다 (${seed.type}) — 현장에 나가는 지시가 아니다. ` + '모델 상태를 바꾸려면 커널에 자극을 넣고, 현장에 무언가를 시키려면 그 지시를 조치 종류로 선언한다' ) } throw new Error( `선언되지 않은 조치 종류다 (${seed.type}) — 선언된 것: ${specs.map(s => s.type).join(', ') || '(없음)'}. ` + '되돌릴 수 있는지를 모르는 채로 승인 게이트를 지나게 할 수 없다' ) } /* 사람이 낸 것이면 누구인지 있어야 한다 — 되돌릴 수 없는 조치는 누가 냈는지가 기록에 남는다. */ if (seed.origin === 'operator' && !seed.proposedBy?.trim()) { throw new Error('사람이 내는 조치는 낸 사람이 있어야 한다 — 누가 냈는지 남지 않는 조치는 받지 않는다') } /* 요구하는 칸이 없으면 계약이 던진다 — 빈 지시가 대기 줄에 쌓이면 승인하는 사람이 판단할 근거가 없다. */ return await store.create(domainId, { ...commandFromSpec(spec, seed), ...(seed.proposedBy?.trim() ? { proposedBy: seed.proposedBy.trim() } : {}), ...(seed.causeRef ? { causeRef: seed.causeRef } : {}), ...(seed.ruleRef ? { ruleRef: seed.ruleRef } : {}) }) } /** * 결재선에 올라간 자리를 커맨드에 적는다. * * 이것이 적힌 뒤로 직접 결정은 막힌다. 그래서 **올리기와 적기가 한 트랜잭션에 있어야 한다** — 올렸는데 * 적지 못하면 결재선에는 있고 커맨드는 직접 결정도 되는 상태가 남는다. */ export async function linkApproval( store: CommandStore, domainId: string, id: string, approvalRef: string ): Promise { const command = await mustLoad(store, domainId, id) const next: StoredCommand = { ...command, approvalRef } await store.save(domainId, next) return next } /** * 승인한다 — **직접 문**. * * 승인할 수 없는 상태면 계약이 던진다 — 여기서 답으로 돌려주지 않는 이유는, 실패를 답으로 주면 부르는 * 쪽이 그것을 무시할 수 있기 때문이다. */ export async function approve( store: CommandStore, domainId: string, id: string, approval: CommandApproval ): Promise { const command = await mustLoad(store, domainId, id) assertDirectDoorOpen(command) const approved = approveCommand(command, approval) await store.save(domainId, { ...approved, ...carried(command) }) return approved } /** 거절한다 — 여기서 끝난다. 되살아나지 않는다. */ export async function reject( store: CommandStore, domainId: string, id: string, approval: CommandApproval ): Promise { const command = await mustLoad(store, domainId, id) assertDirectDoorOpen(command) return await settle(store, domainId, command, false, approval) } /** * 결재선의 판정을 커맨드에 옮긴다 — **결재선 문**. * * 부르는 쪽은 앱의 브리지다. 올라간 자리를 함께 넘기게 해서, 다른 결재 건의 판정이 이 커맨드에 적히는 * 길을 막는다. * * 거절을 여기서 **끝으로** 다룬다. 업무 목록에서 「거절」은 되돌려 보내 고쳐 올리라는 뜻이지만, 조치에는 * 고칠 것이 없다 — 지시 내용은 제안될 때 이미 정해져 있다. 그러니 되돌려 받을 것이 없고, 게이트를 닫는 * 것이 맞다. 커맨드가 닫힌 뒤에 결재선에서 누가 승인해도 계약이 `rejected → approved` 를 허용하지 않는다. */ export async function settleByApproval( store: CommandStore, domainId: string, id: string, decision: { approvalRef: string; approved: boolean; by: string; at: string; note?: string } ): Promise { const command = await mustLoad(store, domainId, id) if (!command.approvalRef) { throw new Error(`결재선에 올라가지 않은 커맨드다 (${id}) — 이 판정이 어디서 왔는지 확인할 수 없다`) } if (command.approvalRef !== decision.approvalRef) { throw new Error( `다른 결재 건의 판정이다 (커맨드 ${id} 는 ${command.approvalRef} 를 기다리는데 판정은 ${decision.approvalRef} 에서 왔다)` ) } const approval: CommandApproval = { by: decision.by, at: decision.at, ...(decision.note ? { note: decision.note } : {}) } if (!decision.approved) return await settle(store, domainId, command, false, approval) const approved = approveCommand(command, approval) await store.save(domainId, { ...approved, ...carried(command) }) return approved } async function mustLoad(store: CommandStore, domainId: string, id: string): Promise { const command = await store.load(domainId, id) if (!command) throw new Error(`모르는 커맨드다 (${id})`) return command } /** 결재선에 올라간 커맨드는 직접 결정하지 않는다 — 문은 한 벌이다. */ function assertDirectDoorOpen(command: StoredCommand): void { if (!command.approvalRef) return throw new Error( `이 조치는 결재선에 올라가 있다 (${command.approvalRef}) — 승인·거절은 업무 목록에서 한다. ` + '여기서 결정하면 결재선에는 대기로 남고 조치는 나간 상태가 된다' ) } /** 승인 기록만 바뀌고 나머지는 그대로 간다 — 하나라도 흘리면 그 사실이 사라진다. */ function carried(command: StoredCommand): Partial { return { ...(command.approvalRef ? { approvalRef: command.approvalRef } : {}), ...(command.proposedBy ? { proposedBy: command.proposedBy } : {}), ...(command.causeRef ? { causeRef: command.causeRef } : {}), ...(command.ruleRef ? { ruleRef: command.ruleRef } : {}) } } async function settle( store: CommandStore, domainId: string, command: StoredCommand, approved: boolean, approval: CommandApproval ): Promise { const to = nextCommandState(command.state, approved ? 'approve' : 'reject') if (!to) throw new Error(`${command.state} 상태의 커맨드는 ${approved ? '승인' : '거절'}할 수 없다 (${command.id})`) const next: StoredCommand = { ...command, state: to, approval, ...carried(command) } await store.save(domainId, next) return next } /** * 어댑터에 넘긴다 — **승인을 지나지 않은 것은 여기서 막힌다.** * * `actuate` 는 `ApprovedCommand` 만 받으므로, 승인 없이 이 함수를 지나가는 길이 없다. 저장소에서 * 되세운 커맨드는 타입이 지워진 채로 오므로 `asApproved` 로 다시 확인한다 — 확인 없이 표식을 붙이면 * **재기동이 게이트를 여는 길**이 된다. * * 되돌릴 수 있는 커맨드는 사람 없이 지난다(시뮬·what-if). 그 판단도 계약이 한다. */ export async function dispatch( store: CommandStore, domainId: string, id: string, actuate: (command: ApprovedCommand) => Promise ): Promise { const command = await mustLoad(store, domainId, id) const ref = carried(command) /* * 되돌릴 수 있는 것은 제안 상태에서 바로 지난다. 되돌릴 수 없는 것은 승인 기록이 있어야 하고, * 없으면 여기서 던진다 — 조용히 건너뛰면 그 커맨드가 승인 없이 나간 것이 된다. */ const approved: ApprovedCommand = command.state === 'proposed' && !requiresApproval(command) ? passThroughReversible(command) : asApproved(command) const to = nextCommandState(approved.state, 'dispatch') if (!to) throw new Error(`${approved.state} 상태의 커맨드는 넘길 수 없다 (${id})`) /* * **넘기기 전에 「넘기는 중」을 먼저 적는다.** 넘기고 나서 적으면, 그 사이에 프로세스가 죽었을 때 * 저쪽은 받았는데 우리는 안 보낸 줄 알고 다시 보낸다. 현장이 두 번 움직인다. * * ── 지난 시도의 자국을 지운다 (2026-09-03, 인티그레이션 레인이 걸어서 찾음) ── * `failed → dispatch` 를 열어 둔 것의 짝이다. 다시 넘겨 성공했는데 **지난 실패의 `error` 가 그대로 * 남아** 한 행이 두 가지를 동시에 말했다. * * state acked 받았다 * error 「그쪽에 비밀값이 설정되지 않았습니다」 안 받는다 ← 지난 시도의 것 * * 화면이 그 행을 그리면 어느 쪽을 믿어야 하는지 알 수 없다. `dispatchNote` 도 같은 부류였다 — * 새 응답에 덧붙일 말이 없으면 옛 말이 남는다. * * 그래서 「넘기는 중」이 **이번 시도의 빈 종이**여야 한다. 셋 다 이번 응답이 채우는 값이다. * * `dispatchRef` 를 지워도 효과가 고아가 되지 않는다 — 식별자는 성공할 때만 붙고(§아래), 성공은 * `acked` 로 끝난다(종착). 그러니 다시 넘길 수 있는 커맨드에는 식별자가 없고 효과도 없다. */ const dispatching: StoredCommand = { ...approved, state: to, ...ref, dispatchRef: undefined, dispatchNote: undefined, error: undefined } await store.save(domainId, dispatching) let ack: CommandAck try { ack = await actuate(approved) } catch (e: any) { /* 던진 실패는 어댑터가 갈래를 말할 기회가 없었다 — `retry` 를 비워 두고 일꾼이 「모름」으로 읽는다. */ const failed: StoredCommand = { ...dispatching, state: 'failed', error: String(e?.message ?? e) } await store.save(domainId, failed) return failed } const settled: StoredCommand = ack?.ok ? { ...dispatching, state: 'acked', dispatchRef: ack.ref, ...(ack.note ? { dispatchNote: ack.note } : {}), ...(ack.noteNext ? { dispatchNoteNext: ack.noteNext } : {}) } : { ...dispatching, state: 'failed', error: ack?.error ?? '어댑터가 이유를 말하지 않았다', /* 다시 될 일인지 어댑터가 답했으면 적는다 — 일꾼이 다음 주기에 이 값을 본다. */ ...(ack?.retry ? { retry: ack.retry } : {}) } await store.save(domainId, settled) return settled }