import { 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 declare 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; /** * 결재선에 올라간 자리를 커맨드에 적는다. * * 이것이 적힌 뒤로 직접 결정은 막힌다. 그래서 **올리기와 적기가 한 트랜잭션에 있어야 한다** — 올렸는데 * 적지 못하면 결재선에는 있고 커맨드는 직접 결정도 되는 상태가 남는다. */ export declare function linkApproval(store: CommandStore, domainId: string, id: string, approvalRef: string): Promise; /** * 승인한다 — **직접 문**. * * 승인할 수 없는 상태면 계약이 던진다 — 여기서 답으로 돌려주지 않는 이유는, 실패를 답으로 주면 부르는 * 쪽이 그것을 무시할 수 있기 때문이다. */ export declare function approve(store: CommandStore, domainId: string, id: string, approval: CommandApproval): Promise; /** 거절한다 — 여기서 끝난다. 되살아나지 않는다. */ export declare function reject(store: CommandStore, domainId: string, id: string, approval: CommandApproval): Promise; /** * 결재선의 판정을 커맨드에 옮긴다 — **결재선 문**. * * 부르는 쪽은 앱의 브리지다. 올라간 자리를 함께 넘기게 해서, 다른 결재 건의 판정이 이 커맨드에 적히는 * 길을 막는다. * * 거절을 여기서 **끝으로** 다룬다. 업무 목록에서 「거절」은 되돌려 보내 고쳐 올리라는 뜻이지만, 조치에는 * 고칠 것이 없다 — 지시 내용은 제안될 때 이미 정해져 있다. 그러니 되돌려 받을 것이 없고, 게이트를 닫는 * 것이 맞다. 커맨드가 닫힌 뒤에 결재선에서 누가 승인해도 계약이 `rejected → approved` 를 허용하지 않는다. */ export declare function settleByApproval(store: CommandStore, domainId: string, id: string, decision: { approvalRef: string; approved: boolean; by: string; at: string; note?: string; }): Promise; /** * 어댑터에 넘긴다 — **승인을 지나지 않은 것은 여기서 막힌다.** * * `actuate` 는 `ApprovedCommand` 만 받으므로, 승인 없이 이 함수를 지나가는 길이 없다. 저장소에서 * 되세운 커맨드는 타입이 지워진 채로 오므로 `asApproved` 로 다시 확인한다 — 확인 없이 표식을 붙이면 * **재기동이 게이트를 여는 길**이 된다. * * 되돌릴 수 있는 커맨드는 사람 없이 지난다(시뮬·what-if). 그 판단도 계약이 한다. */ export declare function dispatch(store: CommandStore, domainId: string, id: string, actuate: (command: ApprovedCommand) => Promise): Promise;