import type { ApprovedCommand, RefusalCode, RefusalParams } from '@operato/ops-contract' import type { ReferenceStep } from './reference-progress.js' import type { ReferenceMaster } from './reference-master.js' /* * `fetchLinks` 가 돌려주는 관계 그룹. 정의는 이것을 소비하는 쪽에 하나만 둔다 * (`external-resolver.ts`). 여기서 같은 모양을 다시 선언하면 한쪽만 수정되는 날이 온다. */ import type { ExternalIncoming as ReferenceLinkGroup } from '../twin-model/external-resolver.js' export type { ReferenceLinkGroup } import { twinWarn } from '../../engine/log.js' /* * 레퍼런스 어댑터 계약 (reference-management P1) — 정적 마스터 등록(`registerReference`)을 * "어댑터 타입" 등록으로 일반화한다. 레퍼런스 = 데이터(TwinReference 행), 어댑터 = 동작. * 아웃바운드 액추에이션은 **별개 어댑터가 아니라 이 어댑터의 반대 면**이다(`actuate`) — 같은 연결이 * 사실을 읽고 조치를 내리므로 인증과 설정이 한 자리에 있어야 한다(command-routing §8.1). * seam: openLiveFeed 레코드 → 커널 face2-adapter.ingest → CanonicalEnvelope 까지가 여기, * 이후 projector 구동은 face2-inbound-live 소유. * 설계 SoT: operato-twin/design/plans/reference-management.md §2.2·§4.2. */ /** 연결 설정(어댑터별 스키마) — 엔드포인트·자격·파라미터. */ export type ConnectionConfig = Record /** discoverSites 가 반환하는 사이트/공장 서술자 — N 발견(§2.2). 1:1 은 길이 1 배열. */ export interface SiteDescriptor { /** 안정적 사이트 정체성 키(소스의 plant code 등) — 공간 정합 1차 키. */ siteId: string name?: string /** 사이트별 시스템(소스가 시스템을 넘나들 때). 없으면 레퍼런스 system 사용. */ system?: string geo?: { lat: number; lon: number } hint?: any } /** 연결 폼 필드 서술 — UI 가 이 스키마로 connectionConfig 입력 폼을 렌더한다(어댑터가 스키마 제공). */ export interface AdapterConfigField { key: string label: string placeholder?: string required?: boolean secret?: boolean // 비밀번호/토큰 — 마스킹 입력 multiline?: boolean // JSON/매핑 등 여러 줄 입력(textarea) help?: string // 필드 아래 도움말 default?: string // 폼 프리필 기본값(예시 매핑/URL 등) /* * 값이 정해진 목록 안에 있을 때 — 화면이 고르게 그린다(타이핑하지 않는다). * * 손으로 적는 칸에 닫힌 값을 받으면 오타가 그대로 저장되고, 그 오타는 오류로 나오지 않는다. * 발전소 종류(`sell` | `self-consumption`)가 그렇다 — 틀리면 계량 방향이 반대가 되는데 어디에도 * 표시되지 않는다. * * `label` 은 번역 키를 쓸 수 있다(화면이 `meta()` 로 푼다). `value` 는 저장되는 값이라 번역하지 않는다. */ options?: { value: string; label: string }[] /* * 값의 종류가 정해져 있지만 목록이 너무 커서 `options` 로 실을 수 없을 때 — 화면이 그 종류에 맞는 * 고르는 칸을 그린다. * * 'timezone' IANA 시간대. 화면이 `Intl.supportedValuesOf('timeZone')` 로 목록을 만든다. * 400개가 넘고 표준이 갱신되므로 우리가 목록을 들고 있지 않는다. * 이름이 곧 값이자 표시라 번역하지 않는다. * * 자유 입력으로 두면 `Asia/seoul` 같은 오타가 저장되고, 그 오타는 오류로 나오지 않는다. */ kind?: 'timezone' } /** 커넥터 메타데이터 — picker·연결 폼용 표시 정보. */ export interface AdapterMeta { label?: string domain?: string // 'wms' | 'yms' | 'mes' (분류) available?: boolean // false = 준비 중(picker 비활성) configSchema?: AdapterConfigField[] ootbNote?: string // OOTB 매핑 안내 문구 /* * 근거 수준(정직성 1급 표기 — "실제 API 명세에 근거하는가"를 UI·피치가 오해 없이 보이게). * 'vendor-doc' = 벤더 공개 API 문서의 엔드포인트·필드명에 정합(docUrl 로 출처 명시). 실 시스템 검증은 별도. * 'standard' = 개방 표준(EPCIS 2.0 / ISA-95)에 정합 — 벤더 독립. * 'facsimile' = 시스템 어휘를 흉내낸 근사치(공개 문서 미정합). 실 연동 시 매핑 재정합 필요. * 미지정은 facsimile 로 간주(가장 보수적). */ grounding?: 'vendor-doc' | 'standard' | 'facsimile' docUrl?: string // grounding='vendor-doc' 의 근거 문서 URL(출처 추적) } /** * 원본이 **선언하는 능력** — 「이 원본으로 무엇을 할 수 있나」 (ADR-0029 §8, 2026-08-19). * * ── 왜 능력으로 선언하나 ──────────────────────────────────────────────────── * 실 WMS 는 스스로 사실을 낸다. 시뮬레이터는 **구동해 줘야** 움직인다 — 자극·속도·정지·초기화. 그런데 * 계약에 그 자리가 없어서 통제가 `TwinEngine` 안에 뚫려 있었다(`controlTwinScenario` 가 런타임의 시나리오 * 엔진을 직접 잡았다). 그러면 ADR-0029 의 판정 문장(*"실물이 같은 계약으로 할 수 있는가"*)에 걸린다: * 실 WMS 는 일시정지될 수 없으니 그 통제면은 영원히 「어댑터 규약의 결손」으로 남고, 규칙이 그 면을 * **숨게** 만든다. * * 그래서 뒤집는다: 통제는 **선언된 능력**이다. 선언하지 않은 원본에는 그 문이 **아예 열리지 않는다** — * 그것이 결손이 아니라 **사실**이다(실 WMS 는 정지되지 않는다). 이 레포가 이미 그 규율로 굴러간다 * (`capability-contract`: concrete 강결합 대신 능력 선언). * * · `live` — 돌아가는 피드를 낸다(`openLiveFeed`). * · `control` — 구동을 받는다(`control`) — 자극·속도·정지·초기화. */ /** * 이 원본에 무엇을 요구할 수 있나. * * · `live` — 관측을 이어서 낸다 * · `control` — 시뮬레이터를 움직인다(자극·배속·정지). 실 시스템은 갖지 않는다 * · `actuate` — **승인된 조치를 받는다.** 현장에 작업지시가 나가는 유일한 축이다 * * ── `actuate` 를 늦게 넣은 이유 (2026-09-02) ──────────────────────────────── * 어댑터에 `actuate` 구현은 있었는데 이 축에 그 이름이 없었다. 그래서 셋이 동시에 조용했다. * * 화면 「이 원본이 지시를 받나」를 물을 수 없다 — 능력 목록에 그 이름이 없다 * 등록 구현했는데 선언 안 한 것을 알려 주지 않는다 — 검사 목록에 그 이름이 없다 * 넘김 리졸버가 어댑터를 찾지도 않고 무조건 거절했다 — 커넥터는 이미 만들어져 있었다 * * 실제로 MES 커넥터가 `actuate` 를 다 만들어 두었는데, 넘기면 「구현이 없습니다」라고 답했다. * 자리는 있고 길이 없는 상태였고, 이 저장소에서 가장 자주 나는 결함 부류다. */ /** * 커넥터가 선언하는 능력. * * `idempotent-actuation` 은 다른 셋과 성질이 다르다 — 무엇을 할 수 있나가 아니라 **두 번 해도 * 같은가**다. 일꾼이 실패한 조치를 다시 넘길지 정할 때 이것만 본다(§`retryDecisionOf`). * * 선언하지 않으면 **거짓으로 읽는다.** 이 한 자리에서만 「모르면 안전한 쪽」이 거짓이고, 그 * 안전한 쪽이 「재시도하지 않음」이다 — 참으로 가정하면 현장에 지시가 두 건 선다. * * 선언에는 근거가 있어야 한다. operato-plant 는 MES 가 `correlationId` 로 기존 행을 찾아 같은 * 이름을 돌려주기 때문에 참이다 — 우리가 조심해서가 아니라 저쪽 동작이 그래서다. 저쪽이 그것을 * 바꾸면 이 선언이 거짓이 된다. */ export type ReferenceCapability = 'live' | 'control' | 'actuate' | 'idempotent-actuation' /** 원본에 보내는 구동 명령 — **시뮬레이터를 움직이는 말**이지 트윈을 고치는 말이 아니다. */ export interface ControlCommand { /** * · `stimulus` — 자극을 싣는다(`scenario`). 선언은 원본 설정에 살고 이 명령은 지금 태우는 것이다. * · `start`·`pause` — 구동을 켜고 멈춘다. * · `speed` — 시각의 배속(`speed`). * · `reset` — 씨앗부터 다시(원본의 상태를 되돌린다 — 트윈의 저널은 재기동 정책이 정한다). */ action: 'stimulus' | 'start' | 'pause' | 'speed' | 'reset' /** 자극 선언 — `action: 'stimulus'` 에만. 커널 규약(`validateScenario`)으로 검사된 값이어야 한다. */ scenario?: any /** 배속 — `action: 'speed'` 에만. */ speed?: number /** * 어느 트윈을 위한 구동인가 — 원본이 여러 트윈에 먹일 수 있으므로 대상을 함께 말한다. * * 인프로세스 시뮬레이터는 이 둘로 자기 런타임을 찾는다(테넌트까지 있어야 같은 id 의 두 트윈이 갈린다). * 원격 원본은 대개 무시한다(자기 세계가 하나다). */ instanceId?: string domainId?: string } /** 구동 결과 — **왜 안 됐는지**를 코드로 말한다(화면이 옮긴다). */ export interface ControlResult { ok: boolean /** 언어중립 사유 — `not-declared`(능력 없음) · `unsupported-action` · `invalid`(선언 거절) · `no-target`. */ code?: string detail?: string } /** * 한 흐름의 읽기 커서 — **어디까지 읽었나.** * * `since` 와 `seen` 이 **함께** 있어야 한다. 이어 읽기는 경계 시각을 겹쳐 읽고(`gte`), 그 겹침을 * `seen` 이 걸러 낸다. `gt` 로 좁히면 같은 밀리초의 다른 행이 사라진다 — 들어온 것이 없는 누락이다. * * `seen` 이 예상보다 커지면(한 시각에 몰린 행 수가 늘면) 그것은 원본 쪽 신호다. 어댑터가 그 크기를 * 말해야 한다 — 알리지 않고 커지게 두면 이어 읽기가 막히는 순간까지 아무도 모른다. */ export interface LiveFeedCursor { /** 그 흐름에서 마지막으로 본 시각(원본의 시각, ISO). */ since?: string /** 그 시각에 이미 소비한 행 id — 겹쳐 읽은 것을 걸러 낸다. 저장되는 모양이므로 배열이다. */ seen: string[] } /** * **트윈이 지금 들고 있는 물품 하나** — 붙는 커넥터가 견줄 근거(§`LiveFeedContinuity.observedItems`). * * 커널 어휘로 낸다. 원본의 낱말(그 시스템의 상태 코드)로 내지 않는 이유가 있다: 원본 낱말은 여러 개가 * 커널의 한 값으로 모이므로(예: 네 상태가 「진행 중」 하나가 된다) 되돌릴 수 없다. 그리고 되돌릴 * 필요도 없다 — 두 원본 상태가 커널에서 같은 값이면 **트윈이 든 사실은 바뀌지 않았다.** */ export interface ObservedItemFact { /** 트윈이 든 물품의 키 — 비직렬 재고는 `클래스@자리`. */ id: string /** 클래스 식별자(원문). */ epcClass?: string location?: string qty?: number uom?: string /** 표준 처분(CBV) — 원본 상태 코드가 아니다. */ disposition?: string /** 유통기한(밀리초). */ expiry?: number lot?: string } /** * 밀어 받은 본문 하나를 옮긴 결과 (2026-08-31). * * 레코드만 돌려주던 것을 넓혔다. 번호를 함께 받아야 프레임워크가 빠진 것을 알아챈다. * * **`scope` 가 없으면 `seq` 도 없는 것으로 다룬다.** 번호만 있고 그 번호가 어느 줄의 것인지 모르면 * 커서를 무엇으로 잡을지 짐작해야 하고, 짐작한 열쇠는 보내는 쪽이 줄을 하나 더 늘리는 날 어긋난다. * 실제로 operato-plant 가 `[domain, channel]` 마다 번호를 매기면서 봉투에 `channel` 을 싣지 않고 있었다. */ /** 번호 하나에 대해 원본이 아는 것. */ export interface SeqEntry { seq: number /** * `unknown` 은 결함이 아니다 — 채번을 따로 들지 않는 원본은 「행이 없다」에서 잃은 것과 아직 안 * 간 것을 가를 수 없다. 그것을 `lost` 로 단정하면 사람이 없는 사실을 찾으러 간다. */ state: 'present' | 'lost' | 'unissued' | 'unknown' /** 그 봉투의 발생 시각 — `present` 일 때만. 무엇이었는지 사람이 알아보는 데 쓴다. */ at?: string /** 그 봉투 자체 — `present` 이고 원본이 낼 수 있을 때만. 없어도 `state` 는 참이다. */ record?: unknown } /** 번호 구간에 대해 원본이 아는 것 — **원본이 아는 것만 낸다. 판정은 사람이 한다.** */ export interface SeqReport { scope: string entries: SeqEntry[] /** 상한에 닿아 구간을 다 못 냈다 — 알리지 않고 자르지 않는다. */ truncated?: boolean } export interface InboundBatch { /** * Receiver path for this batch. Omitted is FACTS, preserving every existing * connector; ATTENTIONS is a product-level analysis input and must not be * folded as a canonical fact. */ lane?: 'FACTS' | 'ATTENTIONS' /** * 옮긴 것들. 우리와 무관한 본문이면 빈 배열이다. * * **한 요청에 봉투가 여럿 온다.** 밀어 주는 쪽은 한 건씩 보내지 않는다 — 번호도 봉투마다 붙는다. * 배치를 대표하는 번호 하나로는 가운데가 빠진 것을 알아채지 못한다. */ items: InboundItem[] /** * 이 요청의 번호가 어느 줄의 것인가. 번호를 매기지 않는 원본은 주지 않는다. * * 요청 하나가 한 줄에만 속한다 — 한 요청에 두 줄을 섞으면 어느 줄의 어디까지 받았는지를 응답 * 하나로 말할 수 없다. */ scope?: string /** * (선택) **원본이 지금 가진 번호의 범위** — 우리 커서와 견주려면 이것이 있어야 한다. * * ── 왜 어댑터가 주나 (인티그레이션 레인 제안, 2026-09-06) ────────────────── * 「가장 낮은 번호」를 묻는 방법이 원본마다 다르다. operato-plant 는 * `mesOutboxEvents(since: 0, limit: 1)` 이고, ppms·chef 는 아웃박스가 없어 번호 범위 자체가 * 없다. 프레임워크가 이것을 알 방법이 없다. * * ── 모르면 주지 않는다 ──────────────────────────────────────────────────── * **0 이나 추측을 넣지 않는다.** 0 을 넣으면 「원본이 1번부터 다 갖고 있다」로 읽히고, 그것이 * 거짓이면 「우리가 다 받았다」는 잘못된 결론이 나온다. * * `last` 에는 조건이 하나 붙는다. 쪽 상한에서 멈췄으면 그때의 번호는 **원본의 끝이 아니라 * 「여기까지 읽었다」**이므로 주지 않는다 — 주면 「원본에 더 없다」로 읽히는데 실제로는 더 있다. */ sourceRange?: { first?: number; last?: number } } export interface InboundItem { /** 우리 어휘로 옮긴 레코드 하나. */ record: unknown /** * 원본이 이 봉투에 붙인 id — **떨어졌을 때 그쪽이 자기 행을 찾는 열쇠다.** * * ── 왜 필요한가 (2026-09-07 측정) ────────────────────────────────────────── * 훅 응답의 떨어진 목록이 `{ record, errors }` 였고, plant 은 그것을 **봉투 id 문자열 목록**으로 * 읽고 있었다. `Array.isArray` 는 통과하니 그쪽 guard 도 안 걸렸다 — id 를 맞추는데 객체라 하나도 * 안 맞고 **묶음 전체가 「보냈음」으로 찍혔다.** 422 와 `ok: false` 를 보냈는데도 그쪽 행은 재시도 * 0 · 오류 없음이었다. * * 이 값을 주면 응답이 `{ eventId, errors }` 로 나가고 그쪽이 그 행만 표시할 수 있다. 안 주면 * 레코드가 그대로 실려 나간다 — 무엇이 떨어졌는지 알 길이 그것뿐이고, 빈 id 를 실으면 보내는 쪽이 * 아무 행도 못 찾으면서 「알았다」고 여긴다. */ eventId?: string /** * 이 봉투의 번호. * * **한 배치 안에서 있거나 없거나 하나로 통일한다.** 섞이면 빠진 것이 있는지 판단할 근거가 없다. */ seq?: number /** * 이 봉투가 말하는 **발생 시각** — 레코드가 자기 시각을 안 말할 때 쓴다. * * ── 왜 필요한가 (2026-09-06 측정) ───────────────────────────────────────── * 시각을 안 싣는 레코드는 `defaultEventTime`(= ingest 시각)으로 떨어진다. 그 값이 **실행할 때마다 * 다르다.** 그런데 fact identity 가 시각을 포함하므로, 같은 사실을 다시 받으면 dedupe 가 안 되고 * 새 사실로 앉는다. * * 실제로 그렇게 됐다. `fillTwinScope(from: 0)` 을 두 번 돌렸더니 mes-line-a 저널이 * 40 → 69건이 되고 중복이 27가지 생겼다. 시각을 말하는 레코드 12건만 되풀이로 걸러졌다. * * ── 봉투는 그 시각을 알고 있었다 ────────────────────────────────────────── * plant 아웃박스가 `eventTime` 을 실어 보낸다. 그것이 갈 자리가 없어서 버려지고 있었다 — * connector 주석이 그 위험을 적어 두고 자리를 요청해 두었다(`operato-plant.ts`). * * **레코드가 자기 시각을 말하면 그것이 이긴다.** 봉투의 시각은 「그 사실을 언제 보냈나」에 가깝고, * 레코드의 시각은 「언제 일어났나」다. 둘이 다르면 뒤엣것이 맞다. */ at?: string } /** * 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`). * * 흐름 열쇠는 **어댑터가 정한다**(`Record`). 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다. */ export interface LiveFeedContinuity { /** * The name to take a task lease under, so only one instance polls this feed. * * ── Why this layer composes it ──────────────────────────────────────────── * `TaskLease` has no domain column on purpose: what is made exclusive is a * loop, not a tenant's slice of one, and a task that does need to be * per-tenant puts the tenant in the name so the unique index keeps meaning * what it says. * * An adapter cannot follow that rule. It is handed `cfg` and `site`, and * neither carries a domain — so a name built from `siteId` alone would let * two domains that happen to use the same plant code block each other's * channel. Silently, and looking exactly like "someone else holds it". * * So the name is composed here, where `domainId` and `instanceId` are known, * and the rule lives in one place. * * ── Always present, unlike `cursor` ─────────────────────────────────────── * A feed needs its lease on the very first attach, before there is any * cursor to carry. So this object is now handed over even when nothing has * been read yet; `cursor` being absent is what still says "first attach", * which is how every connector already reads it (`continuity?.cursor`). */ leaseName: string /** 지난번에 어디까지 읽었나 — 없으면 첫 붙음이다(그때만 되돌아볼 날수로 창을 만든다). */ cursor?: { streams?: Record; firstAttachedAt?: string } /** * **트윈이 지금 들고 있는 물품** — 붙는 커넥터가 「무엇이 바뀌었나」를 견줄 근거 (2026-08-28). * * ── 무엇을 고치나 ─────────────────────────────────────────────────────────── * 전량 절대값을 읽는 커넥터는 지난 주기와 견주어 **바뀐 것만** 낸다. 그 비교 표가 프로세스 안에만 * 있어서, 재기동하면 커넥터가 자기 기억을 잃고 **전량을 「바뀐 것」으로 판정**한다. 승화푸드에서 그 * 한 번이 2,665건이었고, 재기동마다 지난 기록을 덮었다. * * **트윈은 잃지 않는다** — 저장본에서 되세우고 뜬다(실측 로그: `items 2665 · orders 4794`). 잃는 * 쪽은 커넥터다. 그래서 트윈이 아는 것을 건네면 첫 주기부터 바뀐 것만 나간다. * * 없으면 `undefined` 다 — 그것이 「트윈이 아무것도 모른다」는 사실이고, 그때만 전량이 새 사실이다. * 빈 배열로 메우지 않는다(그러면 「모른다」와 「없다」가 같아진다). */ observedItems?: ObservedItemFact[] /** * 커서가 움직였다고 알린다 — 이 층이 저장한다. * * 어댑터가 **매 행마다 부르지 않는다**: 저장은 이 층의 몫이고, 잦으면 이 층이 눌린다. 흐름 하나가 * 한 묶음을 소비한 뒤 한 번이면 충분하다. */ onCursor?: (cursor: { streams: Record; firstAttachedAt?: string }) => void /** * **원본에 닿지 못했다** — 어댑터가 읽기 실패를 알린다(2026-08-23). * * ── 무엇이 아무 신호도 내지 않았나 ───────────────────────────────────────────────────────── * 실 원본이 끊겼을 때(접속 시간 초과) 트윈의 **조회 가능한 상태 어디에도** 그 사실이 없었다. * `onRecords` 가 불리지 않으면 유입 장부에 아무 일도 일어나지 않고, `TwinReference.lastError` 는 * 마스터 동기·접속 시험에만 적힌다. 그래서 화면이 볼 수 있는 것은 「새 사실이 없다」뿐이었고, * 그것은 **「연결된 시스템에서 들어온 것이 없다」와 구별되지 않는다.** * * 어댑터는 로그로 말하고 있었다(재시도 경고). 그러나 **로그는 사람이 볼 때만 값이 있다** — 화면이 * 말하려면 상태에 있어야 한다. 이 통로가 그 자리다. * * ── 무엇을 알리고 무엇을 알리지 않나 ──────────────────────────────────────── * **재시도를 다 쓰고 그 주기를 포기했을 때** 부른다 — 재시도마다 부르면 한 번의 흔들림이 단절로 보인다. * 흐름 여럿을 읽는 원본은 흐름 이름을 함께 준다(어느 표가 막혔나가 원인 찾기의 절반이다). * * **빈 읽기는 실패가 아니다** — 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로 `onRecords([])` * 로 알린다. 그 둘을 섞으면 들어온 것이 없는 연결이 끊긴 원본으로 보인다(고치려던 것의 반대 방향으로 틀린다). * * ── 무엇을 어댑터가 보내고 무엇을 이 층이 세나 (2026-08-27) ───────────────── * 어댑터는 **이유**만 보낸다 — 그리고 물러섰다면 다음에 다시 물을 때까지의 시간을 함께 보낸다. * * 연달아 몇 번 실패했는지와 언제부터 실패하고 있는지는 **이 층이 이미 센다**(`recordReadFailure`). * 그것을 어댑터가 함께 보내면 같은 수를 두 곳에서 세게 되고, 갈라지면 어느 쪽이 사실인지 알 수 없다. * * `reason` 에 **완성된 문장을 담지 않는다.** 이 값은 화면에 그려지고, 화면은 다섯 언어로 그린다. * 「3회 연달아 …부터 읽지 못했습니다」처럼 한 언어의 문장을 넣으면 그 문장은 번역되지 않고, 이 층이 * 세는 수와 어긋날 수도 있다. 이유 하나만 짧게 준다(접속 시간 초과 · 401 · 형식 오류). */ onReadFailure?: (info: { reason: string stream?: string /** 다음에 다시 물을 때까지 남은 시간(ms) — 물러섰을 때만 준다. */ nextRetryMs?: number }) => void /** * **끊겼다가 돌아왔다** — 어댑터가 회복을 알린다 (2026-08-27). * * ── 왜 따로 알려야 하나 ───────────────────────────────────────────────────── * 읽기가 다시 성공하면 실패 기록은 지워진다. 그것만 하면 「끊긴 적이 있었다」가 화면에서 사라지고, * 밤새 두 시간 끊겼던 연결과 한 번도 끊기지 않은 연결이 아침에 똑같이 보인다. * * 읽기 성공(`onRecords`)만으로 이 층이 회복을 유추하지 않는다 — 몇 번 실패한 뒤였는지와 얼마나 * 끊겨 있었는지는 재시도를 관리하는 어댑터가 안다. */ onRecovered?: (info: { afterFailures: number; downMs: number }) => void /** * **읽었는데 창을 넘길 수 없다** — 어댑터가 커서 정체를 알린다 (2026-08-24). * * ── 왜 `onReadFailure` 와 갈라야 하나 ─────────────────────────────────────── * 이 문을 만들기 전에는 두 사실이 **같은 이름으로** 나갔다. * * 원본에 닿지 못했다 접속 실패·시간 초과·형식 오류 → 기다리면 풀린다 * 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행이 몰려 있다 → **기다려도 안 풀린다** * * 둘째는 **읽기가 성공한 실패**다. 원본은 답했고, 그 답의 모양이 커서를 이긴 것이다. 그런데 화면이 * 「원본에 닿지 못한다」고 말하면 사람을 반대 방향으로 보낸다 — 원본을 의심하고 기다린다. 실제로 * 필요한 조치는 **페이지를 키우거나 같은 시각 안에서 순서를 정하는 것**이고, 기다림으로는 영원히 * 풀리지 않는다. * * 조치가 반대인 두 사실을 한 이름으로 부르면, 그 이름은 정보가 아니라 오해다. * * ── 무엇을 알리나 ─────────────────────────────────────────────────────────── * 그 주기를 포기했을 때 부른다(`onReadFailure` 와 같은 규율). 어느 흐름인지 알면 함께 준다 — * 밀도가 높은 표는 원본마다 다르므로 그 이름이 조치의 절반이다. * * **닿지 못한 것과 섞어 부르지 않는다.** 하나의 주기가 두 이유로 실패할 수는 없다(먼저 닿아야 읽는다). */ onCursorStall?: (info: { reason: string; stream?: string }) => void /** * **원본에 있는데 세우지 않은 것을 알린다** — 이유와 수. * * ── 왜 이 통로가 필요한가 (2026-08-24) ───────────────────────────────────── * 어댑터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**(입자가 안 맞는다 · 받으면 상태가 * 거짓이 된다 · 커널 어휘로 옮길 수 없다). 그때 어댑터는 경고를 냈는데, 그 경고가 **프로비저닝 * 화면에서 한 번 스쳐 지나갈 뿐**이었다 — 그 뒤로는 어디에서도 볼 수 없었다. * * 그래서 사용자가 「원본보다 이것이 적다」를 물으면 답이 어디에도 없었다. 그 사실은 한 번의 사건이 * 아니라 **상태의 성질**이다: 세우지 않은 것은 **지금도** 트윈에 없다. * * **주기마다 불러도 된다** — 장부가 이유로 묶어 세고 마지막 수로 덮는다(누적하지 않는다). 그래서 * 「지금 세우지 않은 것이 8건」이 8로 남고 800으로 부풀지 않는다. * * **풀리면 부르지 않는 것으로 끝나지 않는다** — 이유가 사라졌으면 `count: 0` 으로 부르는 것이 아니라 * 그 이유를 더 이상 보내지 않으면 된다… 가 **아니다.** 장부는 마지막 값을 들고 있으므로, 풀린 것을 * 알리려면 그 이유로 `count: 0` 을 한 번 보내라(그때 줄이 사라진다). 알리지 않고 그치면 낡은 수가 남는다. * * 이유는 **자유 문자열**이다 — 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 목록을 닫으면 새 원본을 * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율). 사람이 읽을 문장으로 적어라. */ onWithheld?: (info: { reason: string; count: number }) => void } /** * 저쪽이 조치를 받을 준비가 됐나 — **「모른다」를 「안 된다」로 답하지 않는다.** * * `ready` 가 거짓이면 `reason` 이 사람의 말로 무엇이 없는지 말하고, `code` 가 화면이 다음 할 일을 * 가를 값이다(문장을 파싱하지 않게 — 번역하면 문장이 바뀐다). */ export interface ActuationReadiness { /** 받을 수 있어 보이나. **모르면 `undefined`** — 거짓이 아니다. */ ready?: boolean /** 어디까지 봤나. `local` = 우리 설정만 봤다(망을 타지 않았다). `remote` = 저쪽에 물었다. */ checked: 'local' | 'remote' /** 무엇이 없나 — 사람의 말로. */ reason?: string /** 화면이 가를 값. 커넥터가 정한다(예: `no-endpoint` · `no-secret` · `unreachable` · `not-configured`). */ code?: string } import type { LiveCadence } from './live-cadence.js' /** * 조치를 넘긴 결과 — **어댑터가 돌려주는 것.** * * 이름을 붙인 이유는 두 가지다. 익명 타입이라 소비처가 그 모양을 말할 수 없었고(`actuate` 를 감싸는 * 쪽이 반환형을 다시 적어야 했다), 그리고 **거절 사유를 코드로 실을 자리가 없었다** — `error` 는 사람 * 문장이라 저장하면 번역과 판독이 함께 굳는다. * * `errorCode`·`errorParams` 는 `CommandAck` 과 **같은 낱말**이다(ADR-0037 ④ · ADR-0042 ③). 셋째 이름을 * 만들지 않는다 — 같은 것을 세 이름으로 부르면 매핑 표가 생기고, 그 표는 커넥터가 새 코드를 내는 날 * 「알 수 없는 사유」로 떨어진다. */ export interface ActuationResult { ok: boolean ref?: string /** * **왜 거절됐나 — 언어 중립 코드.** 저장되는 값은 이것이고 `error` 는 아니다. * * `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역도 못 하고, * 무엇이 났는지 세지도 못하고, 문장을 고치는 순간 옛 행과 새 행이 달라진다. */ errorCode?: RefusalCode /** 그 코드가 가리키는 원시값들 — 표현층이 문장에 끼운다. */ errorParams?: RefusalParams /** * **받아들였으나 남은 것이 있다** — 실패가 아니다. 사람이 저쪽에서 해야 할 일이 있으면 여기 적는다. * * `error` 에 적지 말 것 — 실패로 읽혀 커맨드가 `failed` 로 앉고, 다시 넘기게 된다. 실제로 MES 가 * 「지시서를 못 붙였다」를 답했을 때 커넥터가 적을 칸이 없어 로그로만 남겼고, 트윈 쪽에서는 성공한 * 조치와 구별되지 않았다. */ note?: string /** * 그 말 중 **다음에 할 일** 한 줄 — 원인은 위 `note` 다. * * 붙여 보내지 말 것. 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾고, 한 문장으로 오면 * **화면이 자르게 되며 자르는 규칙이 화면마다 생긴다.** 어댑터는 이미 둘로 알고 있다. */ noteNext?: string error?: string /** * **다시 해서 될 일인가** — 실패했을 때만. * * again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다 * after-fix 사람이 고친 뒤 그대로 나간다 설정 문제 * never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다 * * 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다. 설정이 틀린 것은 조치가 멀쩡하므로 * `after-fix`, 지시 내용이 틀린 것은 그 조치가 영원히 틀렸으므로 `never` 다. * * **문장에 담지 말 것** — 일꾼이 그것을 쓰려면 파싱해야 하고, 번역되면 깨진다. * * 말하지 않으면 일꾼이 **집지 않는다.** 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이 * 포기하고, `again` 으로 접으면 없는 자재를 끝없이 두드린다. */ retry?: 'again' | 'after-fix' | 'never' } export interface ReferenceAdapter { /** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */ type: string /** picker·연결 폼용 메타데이터(선택). 없으면 type 만 노출. */ meta?: AdapterMeta /** 연결 확인 — UI '연결 테스트'. */ testConnection(cfg: ConnectionConfig): Promise<{ ok: boolean; error?: string }> /** N 발견 — 사이트/공장 열거. 단일 소스는 길이 1 반환. */ discoverSites(cfg: ConnectionConfig): Promise /** 사이트별 마스터(구조) — 기존 masterToTwin 이 소비. */ /** * 이 어댑터가 마스터를 읽으며 **보낼 단계를 미리 선언한다** — 그것이 진행률의 분모다. * * 표준 낱말만 쓴다(`REFERENCE_STEPS`). 선언하지 않으면 화면은 퍼센트를 보이지 않고 단계 이름만 * 보인다 — **모르는 진행률을 지어내지 않는다.** * * 원본에 없는 자리는 선언하지 않는다. 그러면 「이 원본으로 채울 수 있는 만큼」이 곧 분모가 된다. */ masterSteps?: readonly ReferenceStep[] /** * 이 원천이 **축마다 언제·얼마나 자주 내놓나** — 유입 건강이 이 선언으로 견준다. * * 선언하지 않은 축은 **지금까지처럼** 창 하나(10분)로 견준다. 그러면 밤에 발전하지 않는 * 태양광과 업무시간만 도는 원천이 **정상인데 매일 밤 빨갛게** 난다 — 거짓 빨강이 쌓이면 진짜 * 빨강도 같이 안 읽힌다. * * `masterSteps` 와 같은 규율이다: **어댑터만 자기 시간의 결을 안다.** 호스트가 짐작하면 그것은 * 지어낸 판정이다. * * 자세한 것은 §`live-cadence.ts` — 특히 「해 있는 동안」을 **고정 시각으로 박지 말 것** * (오늘 잰 일몰은 두 달 뒤에 틀리다). */ liveCadence?: readonly LiveCadence[] /** * @param onStep 진행을 알리는 통로 — **선택이다.** 주지 않으면 예전과 같이 동작한다. * * 마스터 읽기는 원본에 여러 질의를 보내는 긴 작업이고(창고·자리·설비·공정·경로·품목·BOM), 화면은 그 * 사이에 아무것도 알 수 없었다. 어댑터만 자기 단계를 아므로 어댑터가 말해야 한다 — 호스트가 짐작하면 * 그것은 지어낸 진행률이다. * * `total` 은 **그 어댑터가 보낼 단계 수**다. 모르면 주지 않는다(화면이 퍼센트를 만들지 않는다). */ fetchMaster( cfg: ConnectionConfig, site: SiteDescriptor, onStep?: (step: { key: ReferenceStep; done: number; total?: number; detail?: string }) => void ): Promise /** * (선택) 사이트별 실 이벤트 스트림 — 없으면 sim. onRecords 로 레코드 push, unsubscribe 반환. * * ── 네 번째 인자: **읽기 커서** (2026-08-23) ──────────────────────────────── * * 왜 이 층이 커서를 드나: 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`) — 「원천이 **애초에 * 다시 말해 주지 않는 축**」은 재기동 연속성으로 이어받는다. **「우리가 어디까지 읽었나」가 정확히 그 * 성질이다.** 원본은 그것을 되풀어 주지 않는다. * * 커서가 없던 동안 어댑터는 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들었다. 그래서 * **재기동마다 미러의 과거가 잘렸다** — 실측으로 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는 * 것이 「원본의 사실」이 아니라 「창의 함수」였다. * * 그런데 미러의 상태는 **원천으로 서야 한다**(같은 절: 「미러의 진실은 원천이다 … 심으면 떠난 물건이 * 되살아난다」). 그러니 저널을 계산해 상태를 세우는 것으로 고칠 일이 아니다 — 그 선언의 **전제**가 * 「원천이 다시 말해 준다」이고, 창이 좁아지면 그 전제가 깨진다. 창을 고치는 것이 그 원칙을 지키는 길이다. * * ── 계약의 모양 ───────────────────────────────────────────────────────────── * · **흐름 열쇠는 어댑터가 정한다.** 원본마다 흐름 수와 뜻이 다르다(이 원본은 재고·오더·로트·투입 넷). * 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다 — 원본의 스키마가 계약을 끌고 가는 그 모양이다. * · **저장되는 모양으로 넘긴다**(`seen` 은 배열). 이 층은 저장·복원만 하고 안을 해석하지 않는다. * `Set` 을 받으면 이 층이 직렬화를 알게 되고, 커서 모양이 바뀔 때 두 곳을 고친다. * · `firstAttachedAt` — **첫 창을 만든 시각.** 이후 재기동에서 바뀌지 않는다. 커널은 아는 구간의 * 상한만 안다(`nowTime` = 마지막으로 들은 시각). 이 값이 하한이고, 둘이 「이 트윈이 아는 구간」이다. * 화면이 수를 보일 때 그 구간을 함께 말해야 한다 — 말하지 않으면 알리지 않고 자르는 것이 된다. * · **커서가 없을 때만** 되돌아볼 날수를 쓴다. 그 순간이 `firstAttachedAt` 이다. * * 주지 않는 호출자와도 함께 선다(선택 인자) — 커서를 모르는 배포는 예전처럼 동작한다. */ /** * (선택) **연결된 시스템이 밀어 준 것**을 우리 레코드로 옮긴다 — 웹훅. * * ── 왜 `openLiveFeed` 와 나누나 (2026-08-26) ─────────────────────────────── * `openLiveFeed` 는 「가져오기」와 「옮기기」를 한 함수에 담고 있다. 그래서 가져오는 방식이 바뀌면 * (그쪽이 밀어 주면) 옮기는 코드를 쓸 수 없었다 — 같은 파일 안에 있는데 밖에서 부를 수 없다. * * 이 함수는 **옮기기만** 한다. 밖에서 받은 본문을 받아 우리 레코드 목록을 돌려준다. 입구·인증·유입은 * 프레임워크가 맡는다(§`reference-hook`). * * ── 옮길 수 없으면 던진다 ───────────────────────────────────────────────── * 빈 목록과 「옮길 수 없다」는 다르다. 빈 목록은 「우리와 무관한 것이 왔다」이고, 던지는 것은 「그쪽 * 모양이 우리가 아는 것과 다르다」다. 그 둘을 같게 답하면 그쪽 모양이 바뀐 것을 아무도 모른다. * * ── 판단은 여기서 한다 ──────────────────────────────────────────────────── * 통신이 끊긴 설비의 값을 보내지 않는 것, 잰 시각이 나아가지 않은 줄을 거르는 것 — 폴링에서 하던 * 판단을 여기서도 한다. 그래야 두 길이 같은 답을 낸다. * * ── 번호는 프레임워크가 본다 (2026-08-31) ───────────────────────────────── * 빠진 번호를 알아채는 일은 밀어 주는 모든 연결에 같은 규율이라야 한다. 커넥터마다 만들면 한 곳이 * 빠지고, 빠진 그 연결에서만 사실이 없어진다. 그래서 커넥터는 **번호를 꺼내 주기만** 하고 판정은 * 프레임워크가 한다(§`reference-hook`). */ handleInbound?(cfg: ConnectionConfig, site: { siteId: string }, body: unknown, headers: Record): InboundBatch /** * **현장에 조치를 내린다** — 인바운드의 반대 면(§`command-routing.md` §8.1). * * 별개 어댑터를 두지 않는다. 같은 연결이 사실을 읽고 조치를 내리므로, 인증과 설정이 한 자리에 있어야 * 한다. 둘로 나누면 같은 시스템에 두 벌의 연결 설정이 생긴다. * * ── 승인은 이미 지났다 (2026-08-31) ─────────────────────────────────────── * 인자가 `ApprovedCommand` 다 — 그 표식은 승인을 지난 커맨드에만 붙고, 디스패처가 붙인다 * (§`command-dispatcher.ts`). **어댑터는 승인을 다시 확인하지 않는다.** 확인이 두 곳에 있으면 * 규칙이 두 벌이 되고 한쪽만 고쳐지는 날이 온다. * * 이 자리를 게이트보다 먼저 열지 않은 이유가 있다 — 자리가 있으면 누군가 부르고, 그때 사람 승인 없이 * 현장에 작업지시가 나간다. 작업지시는 취소해도 이미 만든 것이 남는다. * * ── 돌려줄 것 ───────────────────────────────────────────────────────────── * `ref` 를 **반드시** 돌려준다. 그것이 없으면 넘긴 것이 저쪽에서 무엇이 되었는지 되짚을 수 없고, * 되돌릴 때 무엇을 되돌릴지 말할 수 없다. */ actuate?(cfg: ConnectionConfig, site: SiteDescriptor, command: ApprovedCommand): Promise /** * **저쪽이 조치를 받을 준비가 됐나** — 보내기 전에 묻는다. 선택이다. * * ── 왜 필요한가 (2026-09-03 실측) ────────────────────────────────────────── * 승인된 조치를 넘겼더니 저쪽이 503 을 답했다 — **저쪽 프로세스에 비밀값이 실려 있지 않았다.** * 트윈 쪽 연결 설정에는 있었고, 주소도 맞았고, 서명도 맞았다. 저쪽이 재기동되면서 환경 변수에만 * 살던 값을 잃은 것이다. * * 그것을 보내 보기 전에는 알 수 없었다. 그래서 사람이 승인 화면까지 가서 누르고, 실패를 보고, * 다시 로그인했다. **승인이 사라지지는 않는다**(`failed → dispatch` 가 열려 있다) — 없어진 것은 * 사람의 시간이다. * * ── 문이 아니다. 알림이다 ────────────────────────────────────────────────── * **이 답으로 넘김을 막지 않는다.** 막으면 새 실패 방식이 생긴다 — 점검이 틀렸거나 잠깐 못 닿은 * 사이에, 성공할 수 있었던 조치가 못 나간다. 화면이 「지금 저쪽이 못 받는 것으로 보입니다」를 * 미리 말하는 데까지가 이 얼굴의 일이다. * * ── 두 겹을 구별한다 ────────────────────────────────────────────────────── * 우리 쪽에 무엇이 없는 것(주소·비밀값을 설정하지 않았다)은 **망을 타지 않고** 알 수 있다. * 저쪽이 받을 준비가 됐는지는 물어야 안다. 앞엣것만 답하고 뒤엣것을 안 물어도 되고, 그때 * `checked: 'local'` 로 그 사실을 말한다 — 「살아 있다」고 말한 적 없는 것과 「죽었다」는 다르다. * * 화면이 그릴 때마다 부를 수 있으므로 **망을 타는 구현은 짧게 끝내야 한다.** */ actuationReadiness?(cfg: ConnectionConfig, site: SiteDescriptor): Promise /** * **놓친 구간을 backfill 한다** — 밀어 주는 길의 짝. * * ── 왜 필요한가 (2026-08-31, 인티그레이션 레인 지적) ────────────────────── * 훅은 반드시 놓친다 — 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가 끊겼을 때. 받는 쪽이 * 번호로 그것을 **알아채게** 되었지만(§`takeInSequence`), 알아챈 뒤 **그 사이를 채우는 길이 없었다.** * 설계가 그 길을 적어 두었는데 만들지 않았다. * * ── 왜 `since` 가 번호인가 ──────────────────────────────────────────────── * 커서에 사는 것이 번호이고, 보내는 쪽이 되풀어 주는 단위도 번호다. 시각으로 두면 두 축이 섞이고, * 같은 밀리초의 두 행이 갈리지 않는다. * * ── 왜 `scope` 를 프레임워크가 주나 ─────────────────────────────────────── * 그것이 커서 열쇠의 절반이다. 커넥터가 만들면 열쇠를 만드는 자리가 둘이 되고, 어긋난 그곳은 조용하다. * 그래서 프레임워크가 커서에서 읽어 그대로 넘긴다. * * 답이 `InboundBatch` 인 이유도 하나다 — **backfill 한 것이 밀어 받은 것과 같은 번호 검사를 지난다.** * 다른 모양으로 돌려주면 그 검사를 다시 만들게 되고, 두 길이 다른 규칙으로 받는다. */ fetchSince?(cfg: ConnectionConfig, site: SiteDescriptor, scope: string, since: number): Promise /** * (선택) **원본에만 있는 관계를 조회한다** — 트윈으로 복제하지 않고 그때그때 물어본다. * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 트윈은 표준 어휘로 옮길 수 있는 것만 담는다. 원본에는 그 밖의 관계가 있다 — MES 의 생산 실적, * 설비 상태 구간, 정비 계획. 설비 상세 화면에서 사람이 실제로 묻는 것이 그런 것들이다. * * 이것을 트윈으로 복제하면 원본의 표를 전부 미러링하게 된다. 조회용 참조는 복제하지 않고 그때 * 물어보는 것이 맞다. * * ── 왜 어댑터가 답하나 ────────────────────────────────────────────────────── * 어느 테이블에 무엇이 있는지는 원본마다 다르다. 커널이 그것을 알면 원본 하나의 스키마에 묶이고, * 다음 원본이 다른 구조를 쓰면 커널을 또 고친다. * * 어댑터는 이미 그 원본에 붙는 방법과 인증을 갖고 있다. 연결 설정이 한 자리에 있어야 한다는 것은 * `actuate` 를 별개 어댑터로 두지 않은 것과 같은 이유다. * * ── 실패하면 그것을 알린다 ────────────────────────────────────────────────── * 예외를 던져도 됩니다. 호출하는 쪽이 잡아서 「조회 실패」로 표시하고, 나머지 관계는 그대로 * 표시합니다. 빈 배열은 「가리키는 것이 없다」는 뜻이고 실패와 다릅니다. * * @param axis `equipment` · `locations` 등 계약의 축 이름 * @param itemId 그 축에서의 식별자. 원본이 아는 이름이다(설비는 `ops_equipment.name`) */ fetchLinks?(cfg: ConnectionConfig, site: SiteDescriptor | undefined, axis: string, itemId: string): Promise /** * (선택) **그 번호가 무엇이었나** — 구멍을 만났을 때 묻는다. * * ── 왜 필요한가 (2026-09-06 실물) ────────────────────────────────────────── * 커넥터가 구멍을 만나면 「418 다음이 420」이라고만 말한다. **419 가 무엇이었는지 알 방법이 * 없다.** 그날 plant 레인이 자기 아웃박스를 손으로 뒤져서 그것이 부하 시험의 흔적임을 찾았다. * * 구멍은 채널을 세운다 — 그 뒤의 사실이 통째로 못 온다. 그래서 「이게 무엇이었나」가 급한 물음인데 * 물을 자리가 없었다. * * ── 세 가지를 가른다. 넷째는 「모른다」다 ────────────────────────────────── * ``` * present 그 행이 있다 — 봉투를 함께 낸다 * lost 번호는 나갔는데 행이 없다 — 쓰기 하나를 잃었다 * unissued 아직 그 번호까지 안 갔다 — 잃은 것이 아니다 * unknown 원본이 그 셋을 구별하지 못한다 * ``` * * **`unknown` 이 있어야 한다.** 채번을 따로 들지 않는 원본은 「행이 없다」에서 `lost` 와 * `unissued` 를 가를 수 없다. 그때 `lost` 로 단정하면 사람이 없는 사실을 찾으러 간다. * * ── 판정을 여기서 하지 않는다 ───────────────────────────────────────────── * 이 문은 **원본이 아는 것을 그대로 낸다.** 「그러니 이 번호는 포기해도 된다」는 판정은 사람이 * 한다 — 그 판단은 되돌릴 수 없어서(커서가 지나가면 끝이다) 코드가 대신할 자리가 아니다. * * @param from 이 번호부터(포함) * @param to 이 번호까지(포함). 원본이 상한을 두면 그만큼만 내고 `truncated` 로 말한다 */ seqReport?(cfg: ConnectionConfig, site: SiteDescriptor | undefined, scope: string, from: number, to: number): Promise openLiveFeed?( cfg: ConnectionConfig, site: SiteDescriptor, onRecords: (records: unknown[]) => void, continuity?: LiveFeedContinuity ): () => void /** * 이 원본이 **선언하는 능력** — 없으면 「구조만 준다」는 뜻이다(ADR-0029 §8). * * 선언과 구현이 어긋나지 않게 `capabilitiesOf` 가 둘을 함께 본다: `control` 을 선언했는데 `control` * 구현이 없으면 그 능력은 **없는 것으로 읽는다**(선언만 있는 능력은 사용자에게 거짓말이 된다). */ capabilities?: ReferenceCapability[] /** * 이 커넥터가 **무엇에 대고 확인됐나** — 능력과 다른 축이다(2026-08-23). * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * `capabilities` 는 「무엇을 할 수 있다고 선언하나」이고, 이 자리는 **「그 선언이 무엇으로 확인됐나」**다. * 둘이 갈리는 것이 지금 상태다: 등록된 커넥터 열넷 중 실 시스템에 붙어 본 것은 **하나**이고, 나머지는 * 목(mock)에 대고 만들었다. 그런데 화면의 커넥터 목록에 그 구분이 없어서 **목으로 확인한 것이 확인으로 * 보인다.** * * 그것은 이 저장소가 다른 자리에서 계속 거절하는 모양이다 — 「선언만 있는 능력은 사용자에게 * 거짓말이 된다」(§`capabilitiesOf`). 능력에 세운 그 규율을 근거에도 세운다. * * ── 왜 계약이고 배포 노트가 아닌가 ────────────────────────────────────────── * 노트는 다음 사람이 보지 않는다. 계약에 있으면 새 커넥터가 그 값을 **빠뜨릴 수 없고**, 빠뜨리면 * `groundingOf` 가 `unknown` 으로 답해 화면이 그 사실을 말한다. * * ── 자동으로 알 수 없는 값이다 ────────────────────────────────────────────── * 능력은 구현이 있는지 보면 알 수 있다. 「실 시스템에 붙여 봤나」는 **볼 수 없다** — 사람이 아는 사실이다. * 그래서 이 선언은 낡을 수 있고, 그것을 막는 것이 `verifiedAgainst` 다: **무엇에 대고 확인했는지 적지 * 않으면 그 주장은 주장이 아니다**(`groundingOf` 가 `unknown` 으로 내린다). */ grounding?: ReferenceGrounding /** * (선택) 구동 — 자극·속도·정지·초기화. **`capabilities` 에 `control` 을 선언한 원본만** 가진다. * * 실 시스템은 이것을 갖지 않는다(실 WMS 는 일시정지되지 않는다). 그 사실이 결손이 아니라 이 축의 요점이다. */ control?(cfg: ConnectionConfig, site: SiteDescriptor | undefined, command: ControlCommand): Promise } /** * 이 원본이 **실제로 할 수 있는 것** — 선언과 구현의 교집합. * * 선언만 있고 구현이 없으면 화면이 「할 수 있다」고 말한 뒤 아무 일도 일어나지 않는다. 반대로 구현이 * 있는데 선언이 없으면 그 능력은 **의도적으로 닫아 둔 것**이므로 열지 않는다 — 선언이 권위다. */ /** * 근거 수준 — **무엇에 대고 확인했나.** 넷이고 닫혀 있다. * * `synthetic` 실 시스템이 아니다 — 스스로 만들어 낸다(가상 원본·시뮬레이터). 한계가 아니라 성질이다. * `mock` 목·목 REST 에 대고만 확인했다. **실 시스템에 붙어 본 적이 없다.** * `instance` 실 시스템 **한 배포**에 붙여 확인했다. 그 배포의 성질과 제품의 성질을 아직 가르지 못했다. * `product` 실 시스템 **여러 배포**에 붙여 확인했다 — 그때 비로소 제품의 성질이라 말할 수 있다. * * `instance` 와 `product` 를 가르는 이유: 첫 실 연동에서 그 배포만의 성질이 넷 드러났고(관계 리솔버가 * 다른 테넌트의 행을 답한다 · 모르는 필터를 알리지 않고 무시한다 · 지워진 품목의 BOM 이 남아 있다 · * 사실상 무한을 뜻하는 표식) **그것이 그 제품의 성질인지 그 인스턴스의 성질인지 아무도 모른다.** * 두 번째 배포가 붙는 날 갈린다. 그 사실을 어휘가 담는다. */ export type ReferenceGroundingLevel = 'synthetic' | 'mock' | 'instance' | 'product' export interface ReferenceGrounding { level: ReferenceGroundingLevel /** * **무엇에 대고 확인했나** — 사람이 읽는 한 줄(원본의 버전·인스턴스·목의 이름). * * `instance`·`product` 를 주장하면서 이것이 비어 있으면 `groundingOf` 가 `unknown` 으로 내린다 — * 가리키는 대상이 없는 주장은 주장이 아니고, 그런 주장은 낡아도 아무도 알 수 없다. */ verifiedAgainst?: string } /** * 이 커넥터의 근거 — **주장을 그대로 믿지 않는다**(`capabilitiesOf` 와 같은 규율). * * 능력은 구현이 있는지 보아 선언을 검증할 수 있다. 근거는 **볼 수 없다** — 사람이 아는 사실이다. * 그래서 검증할 수 있는 것 하나만 검증한다: **실 시스템을 주장하면 무엇에 대고인지 말해야 한다.** * * 선언이 없으면 `unknown` 이다 — 「목이다」가 아니라 **「모른다」**다. 둘을 같게 두면 아직 선언하지 않은 * 새 커넥터가 목으로 낙인찍히고, 그것도 없는 사실이다. */ export function groundingOf(adapter: ReferenceAdapter | undefined): { level: ReferenceGroundingLevel | 'unknown' verifiedAgainst?: string /** 주장이 내려갔으면 그 이유 — 화면이 그대로 옮긴다. */ downgraded?: string } { const g = adapter?.grounding if (!g?.level) return { level: 'unknown' } const against = g.verifiedAgainst?.trim() if ((g.level === 'instance' || g.level === 'product') && !against) { return { level: 'unknown', downgraded: `claims "${g.level}" but does not say what it was verified against` } } return { level: g.level, ...(against ? { verifiedAgainst: against } : {}) } } /** * 능력마다 **그것을 증명하는 메서드들** — 하나라도 있으면 성립한다. * * ── `live` 의 증명이 왜 둘인가 (2026-09-16, ADR-0036 ③ 개정) ───────────────── * 예전에는 `openLiveFeed` 하나였다. 그런데 사실이 흘러 들어오는 방식은 **상대 시스템의 성질**이지 * 우리 능력의 종류가 아니다 — 우리가 당겨 오면 `openLiveFeed`, 상대가 웹훅으로 밀어 넣으면 * `handleInbound`(계약 일급 문)다. 둘 다 「이 원본에서 사실이 계속 온다」를 증명한다. * * 실측: `operato-plant` 은 outbox 가 밀어 넣는 쪽이라 `openLiveFeed` 가 없다. 그래서 **실 생산 * 사실을 나르는 유일한 커넥터가 판정에서 live 가 아니었다.** 등록 경고는 「구현했는데 선언 안 함」 * 한 방향만 봐서 아무 말도 하지 않았다. */ const PROVED_BY: Record = { live: ['openLiveFeed', 'handleInbound'], control: ['control'], actuate: ['actuate'], /* * **선언만으로 성립한다** — 부를 메서드가 없다. 「두 번 해도 같은가」는 어댑터가 하는 일이 아니고 * 저쪽 시스템의 성질이다. 그래서 구현 검사를 하지 않는다. */ 'idempotent-actuation': [] } const CAPABILITIES = Object.keys(PROVED_BY) as ReferenceCapability[] /** 그 능력을 증명하는 메서드가 실제로 붙어 있나. 증명이 필요 없는 능력은 언제나 참이다. */ function implemented(adapter: ReferenceAdapter, capability: ReferenceCapability): boolean { const methods = PROVED_BY[capability] if (methods.length === 0) return true return methods.some(name => typeof (adapter as any)[name] === 'function') } export function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] { if (!adapter) return [] const declared = new Set(adapter.capabilities ?? []) return CAPABILITIES.filter(capability => declared.has(capability) && implemented(adapter, capability)) } /* 어댑터 "타입" 레지스트리(전역·인메모리) — 레퍼런스 데이터는 DB(TwinReference), 어댑터 동작은 여기. */ const adapters = new Map() /** * 어댑터를 등록한다 — 그리고 **선언과 구현이 어긋나면 그 자리에서 말한다.** * * ── 왜 필요한가 (2026-08-26 실측) ────────────────────────────────────────── * 능력 판정(`capabilitiesOf`)은 **한 방향만** 본다: 선언했는데 구현이 없는 것은 걸러 내고, **구현했는데 * 선언이 없는 것은 아무 말도 하지 않는다.** 그래서 다음이 오류 없이 일어난다. * * 커넥터가 `openLiveFeed` 를 구현한다 → `capabilities: ['live']` 를 잊는다 * → 연결 화면이 「이 원본은 라이브를 못 낸다」로 읽는다 * → 재기동 정책을 정할 수 없어 트윈 생성이 거절된다 * → 사람이 보는 것은 「재기동 정책이 없습니다」뿐이다 * * 실제로 그렇게 막혔고(태양광 발전소), 원인을 찾는 데 코드를 뒤져야 했다. 지금 저장소에서 같은 상태인 * 커넥터가 일곱이다. * * ── 왜 막지 않고 알리기만 하나 ────────────────────────────────────────────── * 등록에서 던지면 커넥터 하나 때문에 호스트가 못 뜬다. 만들다 만 커넥터를 들고 있는 사람이 아무것도 * 못 하게 된다. 그리고 이 어긋남은 **고칠 수 있는 것**이지 위험한 것이 아니다 — 말해 주면 된다. */ export function registerAdapterType(adapter: ReferenceAdapter): void { const declared = new Set(adapter.capabilities ?? []) /* * **어긋남은 양쪽으로 본다** (2026-09-16, ADR-0036 ③ 개정). * * 이 경고는 「구현했는데 선언 안 함」 한 방향만 봤다. 그래서 반대쪽 — 선언했는데 증명이 없는 것 — * 은 `capabilitiesOf` 가 조용히 걸러 내기만 하고 아무도 그 사실을 듣지 못했다. 한 방향만 보면 * **있는 것을 없다고(이번 plant) 또는 없는 것을 있다고** 말하게 된다. 같은 무게로 말한다. */ const undeclared = CAPABILITIES.filter(c => PROVED_BY[c].length > 0 && implemented(adapter, c) && !declared.has(c)) const unproven = CAPABILITIES.filter(c => PROVED_BY[c].length > 0 && declared.has(c) && !implemented(adapter, c)) if (undeclared.length) { twinWarn( `[reference] connector "${adapter.type}" implements ${undeclared.join(' and ')} but does not declare it — ` + `add capabilities: [${undeclared.map(c => `'${c}'`).join(', ')}] or remove the implementation. ` + 'Until then the screens treat this connector as unable to do it, and a live twin cannot be created from it.' ) } if (unproven.length) { twinWarn( `[reference] connector "${adapter.type}" declares ${unproven.join(' and ')} but implements none of ` + `${unproven.map(c => PROVED_BY[c].map(m => `${m}()`).join(' or ')).join(', ')} — ` + 'the declaration is dropped, so the screens treat this connector as unable to do it. ' + 'Implement one of those methods or remove the capability.' ) } adapters.set(adapter.type, adapter) } export function getAdapter(type: string): ReferenceAdapter | undefined { return adapters.get(type) } export function listAdapterTypes(): string[] { return [...adapters.keys()] } /** 커넥터 메타데이터 목록(picker·연결 폼용). */ /** * 등록된 어댑터 목록 — **능력은 판정값이다. 선언 원본이 아니다.** * * 능력은 이 목록을 읽는 화면이 「이 원본에 무엇을 요구할 수 있나」를 판단하는 근거다. * * ── 왜 선언을 그대로 내지 않나 (2026-09-16, ADR-0036 ③ 개정) ──────────────── * 여기서 `a.capabilities` 를 그대로 냈다. 그래서 `capabilitiesOf` — 선언과 구현이 어긋나면 **없는 * 것으로 읽는다**는 그 문 — 의 판정값을 **읽는 자리가 하나도 없었다.** 화면은 선언을 보고 있었고, * 어느 커넥터든 구현 없이 능력을 적기만 하면 가능한 것으로 보였다. 선언을 믿지 않으려고 만든 문을 * 아무도 지나지 않은 셈이다. * * 커넥터 쪽에도 흔적이 남아 있었다 — `operato-plant` 주석이 「어댑터 목록은 선언한 값을 그대로 * 보여 주므로 남겨 두면 화면이 없는 능력을 있다고 말한다」며 선언을 손으로 줄여 두었다. 사람이 * 기억해서 맞추는 것은 다음 커넥터에서 끊긴다. */ export function listAdapters(): { type: string; meta?: AdapterMeta; capabilities?: ReferenceCapability[] }[] { return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: capabilitiesOf(a) })) }