@things-factory/headless-twin 10.1.206 → 10.1.209

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"connection-portability.js","sourceRoot":"","sources":["../../../server/service/reference/connection-portability.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * **연결을 내보내고 다시 들여옵니다** — 실제 셋업을 파일로 보관하고 되세웁니다.\n *\n * ── 이것은 데모가 아니라 제품 기능입니다 (2026-09-05) ───────────────────────\n * 데모 시드(`seed-demo-twins.ts`)와 **다른 것**입니다. 그쪽은 내장 가상 마스터로 보여 줄 트윈을\n * 만들고 `TWIN_PROVISION_DEMO` 뒤에 숨습니다. 이것은 **실제로 붙인 연결을 옮기는 길**이라\n * 데모 스위치 뒤에 두지 않습니다 — 백업·이관·복구가 데모일 수 없습니다.\n *\n * 그래서 특정 공장을 제품 코드에 적지 않습니다. 적으면 배포마다 남의 공장이 하나씩 따라가고,\n * 데모 시드 머리말이 적어 둔 「프로덕션 배포는 **트윈 0** 에서 시작한다」가 깨집니다. 무엇을 세울지는\n * **파일이 말하고 코드는 그 파일을 읽을 줄만 압니다.**\n *\n * ── 왜 필요했나 ────────────────────────────────────────────────────────────\n * 실 MES 두 공장을 트윈에 세웠는데(`mes-line-a` · `mes-miratek`) **그 셋업이 한 데이터베이스\n * 안에만 있었습니다.** 잃으면 다시 손으로 만들어야 하고, 다른 배포에 옮길 길도 없었습니다.\n *\n * ── 담는 것과 안 담는 것 ────────────────────────────────────────────────────\n *\n * 담는다 연결 선언 — 주소 · 도메인 · 사이트 · 치수 · 이름공간 · 받는 자리\n * 안 담는다 비밀값 — 토큰 · 웹훅 서명값 · 받는 쪽 비밀값\n * 안 담는다 사실(저널) — 원본이 다시 보내면 된다. 담으면 **가짜 과거가 배포마다 복제된다**\n * 안 담는다 마스터에서 온 트윈 모델 — 연결이 있으면 재동기화가 다시 만든다\n *\n * **비밀값은 이름만 적습니다.** 어느 칸이 채워져 있었는지는 알려 주되 값은 안 나갑니다 — 파일은\n * 주고받는 물건이고, 한 번 나간 비밀값은 되돌릴 수 없습니다. 들여올 때 「이 셋이 없다」고 말하고\n * 사람이 연결 화면에서 채웁니다.\n *\n * ── 왜 사실을 안 담나 ───────────────────────────────────────────────────────\n * 저널은 되풀이 읽히고 미러로 복제되고 구독으로 방송됩니다. 내보낸 사실을 다른 배포에 들여오면\n * **일어나지 않은 일이 그 공장의 지난 기록에 섭니다.** 커서만 옮겨 두면 원본이 그 뒤부터 다시\n * 보내 주므로, 사실은 원본에서 오는 것이 맞습니다.\n */\n\n/** 값이 아니라 **채워져 있었다는 사실**만 나가는 칸들. */\nexport const SECRET_KEYS = ['token', 'hookSecret', 'intakeSecret', 'password', 'apiKey'] as const\n\nexport interface PortableConnection {\n source: string\n system?: string\n siteName?: string\n adapterType: string\n /** 비밀값을 뺀 연결 설정. */\n config: Record<string, unknown>\n /** 내보낼 때 채워져 있던 비밀 칸 이름 — 값은 없습니다. */\n secretsRequired: string[]\n /** 이 연결이 무엇을 세우나. */\n scope?: unknown\n}\n\nexport interface ConnectionBundle {\n kind: 'operato-twin/connections'\n version: 1\n exportedAt: string\n /** 어디서 내보냈나 — 사람이 나중에 「이게 어느 배포 것인가」를 물을 때 답합니다. */\n exportedFrom?: { domain?: string; note?: string }\n connections: PortableConnection[]\n}\n\n/**\n * 무엇을 세우나 **선언만** 남깁니다 — 지난 동기화의 **측정값은 뺍니다.**\n *\n * `scopeSpec` 에는 `produced`(선언) 옆에 `assessments`(그때 몇 개가 섰나) · `lastFailures` 가 함께\n * 삽니다. 그것까지 내보내면 새 배포가 **다른 기계에서 잰 수**를 자기 것으로 보입니다 — 「설비 42」가\n * 아무것도 안 세운 배포의 화면에 서고, 그 수가 어디서 왔는지 아무도 모릅니다.\n *\n * 재는 것은 그 배포에서 다시 재야 합니다. 오늘 「설정을 고쳤다」와 「모델이 다시 섰다」를 가른 것과\n * 같은 구별입니다.\n */\nfunction producedOf(scopeSpec: any): { produced: unknown } | undefined {\n const produced = scopeSpec?.produced\n return Array.isArray(produced) && produced.length ? { produced } : undefined\n}\n\nconst isSecret = (key: string): boolean =>\n (SECRET_KEYS as readonly string[]).includes(key) || /secret|token|password|apikey/i.test(key)\n\n/**\n * 연결 행들을 옮길 수 있는 모양으로 — **비밀값을 빼고**.\n *\n * 빼는 판정을 이름으로 합니다. 목록으로만 하면 커넥터가 새 비밀 칸을 더할 때 그 값이 조용히\n * 파일로 나갑니다 — 이름 규칙을 함께 두어 **새 칸이 기본으로 안 나가게** 합니다.\n */\nexport function exportConnections(\n rows: readonly { source: string; system?: string; siteName?: string; adapterType: string; connectionConfig?: any; scopeSpec?: any }[],\n meta: { at: string; domain?: string; note?: string }\n): ConnectionBundle {\n return {\n kind: 'operato-twin/connections',\n version: 1,\n exportedAt: meta.at,\n ...(meta.domain || meta.note ? { exportedFrom: { ...(meta.domain ? { domain: meta.domain } : {}), ...(meta.note ? { note: meta.note } : {}) } } : {}),\n connections: rows.map(r => {\n const cfg = (r.connectionConfig ?? {}) as Record<string, unknown>\n const kept: Record<string, unknown> = {}\n const secrets: string[] = []\n for (const [k, v] of Object.entries(cfg)) {\n if (isSecret(k)) {\n /* 비어 있던 칸은 적지 않습니다 — 「채워야 한다」와 「이 커넥터에 그런 칸이 있다」는 다릅니다. */\n if (String(v ?? '').trim()) secrets.push(k)\n continue\n }\n kept[k] = v\n }\n return {\n source: r.source,\n ...(r.system ? { system: r.system } : {}),\n ...(r.siteName ? { siteName: r.siteName } : {}),\n adapterType: r.adapterType,\n config: kept,\n secretsRequired: secrets.sort(),\n ...(producedOf(r.scopeSpec) ? { scope: producedOf(r.scopeSpec) } : {})\n }\n })\n }\n}\n\nexport type ImportAction =\n | { action: 'create'; connection: PortableConnection; missingSecrets: string[] }\n | { action: 'update'; connection: PortableConnection; missingSecrets: string[]; keptSecrets: string[] }\n | { action: 'skip'; source: string; why: string }\n\nexport interface ImportPlan {\n actions: ImportAction[]\n /** 사람이 연결 화면에서 채워야 하는 것 — 이것이 비어야 「시드만으로 끝」입니다. */\n /**\n * 사람이 환경에 넣어야 하는 비밀값.\n *\n * `env` 를 함께 냅니다 — **그 이름을 사람이 손으로 적기 때문**입니다. 화면이 이름을 따로 만들면\n * 규칙이 두 곳에 살고, 갈리는 날 「넣었는데 안 읽힌다」가 됩니다. 실제로 났습니다(2026-09-06):\n * 화면이 `TWIN_SECRET__MES-MIRATEK__HOOKSECRET` 을 알려 줬는데 서버가 읽는 이름은\n * `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET` 이었습니다. 하이픈과 낙타 경계 둘 다 달랐습니다.\n *\n * `keys` 와 순서가 같습니다 — `keys[i]` 의 이름이 `env[i]` 입니다.\n */\n needsHuman: { source: string; keys: string[]; env: string[] }[]\n /** 들여온 **뒤에** 두 연결이 같은 곳에 조치를 내게 되나 — 있으면 들여오면 안 됩니다. */\n collisions: { intake: string; sources: string[] }[]\n errors: string[]\n}\n\n/**\n * 들여오기 **계획**을 먼저 냅니다 — 쓰기 전에 무엇이 일어날지 사람이 봅니다.\n *\n * ── 이미 있는 연결을 덮지 않습니다 ──────────────────────────────────────────\n * 같은 이름이 이미 있으면 **선언만 갱신하고 비밀값은 그대로 둡니다.** 사람이 화면에서 넣은 값을\n * 파일이 지우면, 파일 하나 들여왔다가 도는 연결이 멈춥니다. 데모 시드도 같은 규율을 적어 두었습니다\n * (「옆 설정을 지우지 않는다 — 시드는 자기가 아는 것만 채운다」).\n *\n * ── 없는 비밀값을 지어내지 않습니다 ─────────────────────────────────────────\n * 빈 값으로 채워 두면 연결이 서고 **동기화할 때 처음 실패합니다.** 그때는 무엇이 없어서인지 화면에서\n * 멉니다. 여기서 이름을 대고 멈추는 것이 낫습니다.\n */\nexport function planImport(\n bundle: unknown,\n existing: readonly { source: string; connectionConfig?: any }[],\n /** 지금 환경이 줄 수 있는 비밀값 이름들 — 값은 안 받습니다(이 함수는 값을 만지지 않습니다). */\n availableSecrets: (source: string, key: string) => boolean = () => false\n): ImportPlan {\n const errors: string[] = []\n const b = bundle as ConnectionBundle\n if (!b || typeof b !== 'object') return { actions: [], needsHuman: [], collisions: [], errors: ['들여올 것이 객체가 아닙니다'] }\n if (b.kind !== 'operato-twin/connections') {\n return { actions: [], needsHuman: [], collisions: [], errors: [`이 파일은 연결 꾸러미가 아닙니다(kind: ${JSON.stringify((b as any).kind ?? null)})`] }\n }\n if (b.version !== 1) errors.push(`모르는 판입니다(version: ${JSON.stringify((b as any).version)}) — 읽을 수 있는 것만 읽습니다`)\n if (!Array.isArray(b.connections)) return { actions: [], needsHuman: [], collisions: [], errors: [...errors, 'connections 가 목록이 아닙니다'] }\n\n const have = new Map(existing.map(e => [e.source, e]))\n const seen = new Set<string>()\n const actions: ImportAction[] = []\n const needsHuman: { source: string; keys: string[]; env: string[] }[] = []\n\n for (const c of b.connections) {\n const source = String(c?.source ?? '').trim()\n if (!source) {\n errors.push('이름 없는 연결이 있습니다 — 건너뜁니다')\n continue\n }\n if (seen.has(source)) {\n actions.push({ action: 'skip', source, why: '같은 이름이 파일에 두 번 있습니다' })\n continue\n }\n seen.add(source)\n if (!String(c?.adapterType ?? '').trim()) {\n actions.push({ action: 'skip', source, why: '커넥터 종류가 없습니다 — 무엇으로 붙일지 모릅니다' })\n continue\n }\n\n const cur = have.get(source)\n const curCfg = (cur?.connectionConfig ?? {}) as Record<string, unknown>\n const wanted = Array.isArray(c.secretsRequired) ? c.secretsRequired.map(String) : []\n const kept = wanted.filter(k => String(curCfg[k] ?? '').trim())\n const missing = wanted.filter(k => !kept.includes(k) && !availableSecrets(source, k))\n\n if (missing.length) needsHuman.push({ source, keys: missing, env: missing.map(k => secretEnvName(source, k)) })\n actions.push(\n cur\n ? { action: 'update', connection: c, missingSecrets: missing, keptSecrets: kept }\n : { action: 'create', connection: c, missingSecrets: missing }\n )\n }\n\n /*\n * **들여온 뒤의 모습으로 봅니다.** 파일 안에서만 보면 「이미 있는 연결과 부딪히는 것」을 못\n * 잡습니다 — 그게 실제로 더 쉽게 나는 쪽입니다(하나는 화면에서, 하나는 파일에서 생깁니다).\n */\n const after = [\n /* 행 모양 그대로 넘긴다 — 옮기는 자리는 `intakeCollisions` 안 한 곳이다(ADR-0053 ①). */\n ...existing.filter(e => !seen.has(e.source)),\n ...actions\n .filter((a): a is Extract<ImportAction, { connection: PortableConnection }> => a.action !== 'skip')\n .map(a => ({ source: a.connection.source, config: a.connection.config }))\n ]\n return { actions, needsHuman, collisions: intakeCollisions(after), errors }\n}\n\n/** 사람이 읽는 한 줄 — 화면과 로그가 같은 문장을 쓰게. */\nexport function sayPlan(plan: ImportPlan): string {\n const n = (a: ImportAction['action']) => plan.actions.filter(x => x.action === a).length\n const head = `연결 ${plan.actions.length}개 — 새로 ${n('create')} · 갱신 ${n('update')} · 건너뜀 ${n('skip')}`\n /* 부딪힘을 먼저 말합니다 — 비밀값이 없는 것은 안 도는 것이고, 부딪히는 것은 **잘못 도는 것**입니다. */\n if (plan.collisions.length) {\n const who = plan.collisions.map(c => `${c.sources.join(' · ')} → ${c.intake}`).join(', ')\n return `${head}. ⚠ **두 연결이 같은 곳에 조치를 냅니다** — ${who}. 다른 공장의 작업지시가 한 공장에 섭니다`\n }\n if (!plan.needsHuman.length) return `${head}. 비밀값은 다 있습니다`\n const who = plan.needsHuman.map(h => `${h.source}(${h.keys.join(' · ')})`).join(', ')\n return `${head}. **비밀값을 사람이 넣어야 합니다** — ${who}. 연결 화면에서 채우면 그때부터 돕니다`\n}\n\n/**\n * 비밀값이 사는 **환경 변수 이름** — `mes-miratek` · `hookSecret` → `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET`.\n *\n * 꾸러미에는 비밀 칸의 이름만 있고 값은 환경에서 옵니다. 그 이름을 **사람이 손으로 적으므로** 규칙이\n * 흔들리면 「넣었는데 안 읽힌다」가 됩니다 — 오늘 종일 잡은 「양쪽 다 초록인데 안 간다」의 설정 쪽\n * 판본입니다. 그래서 이 규칙을 한 곳에 두고 시험으로 못 박습니다.\n *\n * 낙타 이름의 경계를 밑줄로 가릅니다 — 안 그러면 `HOOKSECRET` 과 `HOOK_SECRET` 이 갈려 한쪽만 읽힙니다.\n */\nexport function secretEnvName(source: string, key: string): string {\n const up = (x: string) =>\n x\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .toUpperCase()\n return `TWIN_SECRET__${up(source)}__${up(key)}`\n}\n\n/**\n * **두 연결이 같은 곳에 조치를 내지 않나** — 같으면 다른 공장의 작업지시가 한 공장에 섭니다.\n *\n * ── 왜 이 검사가 생겼나 (2026-09-05) ────────────────────────────────────────\n * `mes-miratek` 이 서면서 한 커넥터가 처음으로 **공장 둘**을 달았습니다. 조치가 나가는 주소는\n * `intakeSiteId` 가 정하는데(`operato-plant.ts:389`), 그 값이 트윈의 사이트 이름을 **이깁니다** —\n * 트윈에서는 `mes-line-a` 인데 MES 의 도메인은 `system` 이라 그 override 가 필요합니다.\n *\n * 그래서 **연결을 베껴 만들고 그 칸만 안 고치면** 두 트윈의 조치가 같은 공장으로 갑니다. 오류가\n * 안 납니다 — 저쪽은 정상적인 작업지시로 받고 **실제로 만듭니다.**\n *\n * 게이트에 이미 「사이트를 모르면 보내지 않는다」가 있는데(`no-site`), 그 이유가 적혀 있는 문장이\n * **「잘못된 공장이 실제로 만든다」** 입니다. 이것은 그 둘째 길입니다 — 사이트를 아는데 **틀린\n * 사이트를 압니다.**\n *\n * 파일로 연결을 만들 수 있게 되면서 더 쉬워졌습니다: 꾸러미를 베껴 이름만 바꾸면 받는 자리가\n * 그대로 따라옵니다.\n */\nexport function intakeCollisions(\n connections: readonly {\n source: string\n adapterType?: string\n /** 꾸러미·계획이 쓰는 이름. */\n config?: Record<string, unknown> | null\n /** **DB 행이 쓰는 이름** — `TwinReference` 의 칸이다. 둘 다 받는다(ADR-0053 ①). */\n connectionConfig?: Record<string, unknown> | null\n }[]\n): { intake: string; sources: string[] }[] {\n const byIntake = new Map<string, string[]>()\n for (const c of connections) {\n /*\n * **모양이 둘인 것을 여기서 안다** (ADR-0053 ①, 2026-09-16).\n *\n * 꾸러미는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을\n * 그대로 넘기면 같은 충돌에도 `[]` 를 냈다 — 오류도 경고도 없이 **저장이 통과하고 가드는 선 채로\n * 아무 일도 안 한다.** 부르는 쪽마다 옮기게 두면 다음 소비처가 또 밟는다(이 함수는 화면이 쓰라고\n * 리졸버에서 다시 내보내진다).\n *\n * 「없다」가 편한 답인 자리라 더 위험하다 — 빨강이 아니라 **조용한 초록**으로 흐른다.\n */\n const cfg = c.config ?? c.connectionConfig ?? {}\n /* 받는 자리를 정하는 규칙은 커넥터의 것과 같아야 합니다 — 여기서 다시 지으면 두 벌이 됩니다. */\n const intake = String(cfg.intakeSiteId ?? cfg.siteId ?? '').trim()\n /* 조치를 낼 수 없는 연결은 부딪힐 것이 없습니다 — 주소가 없으면 아무 데도 안 갑니다. */\n if (!intake || !String(cfg.intakeEndpoint ?? '').trim()) continue\n const key = `${String(cfg.intakeEndpoint).trim().replace(/\\/$/, '')}::${intake}`\n byIntake.set(key, [...(byIntake.get(key) ?? []), c.source])\n }\n return [...byIntake.entries()]\n .filter(([, sources]) => sources.length > 1)\n .map(([intake, sources]) => ({ intake, sources: sources.sort() }))\n}\n\n/**\n * **화면 저장의 문** — 이 연결을 이 설정으로 저장하면 다른 연결과 받는 사이트가 겹치나 (ADR-0046 ②-1).\n *\n * 들여오기(`planImport`)는 `intakeCollisions` 로 거부하는데 화면 저장(`saveTwinReference`)은 부르지 않아서,\n * 연결을 베껴 이름만 바꾸면 경고 0 으로 두 트윈의 조치가 한 공장에 섰다. 판정은 같은 함수 하나로 한다.\n * 경고가 아니라 거부이고, 사람이 그 자리에서 고치도록 **이미 그 사이트를 쓰는 연결의 이름**을 싣는다.\n */\nexport function refuseIntakeCollision(\n source: string,\n config: Record<string, unknown> | undefined,\n others: readonly { source: string; siteName?: string | null; connectionConfig?: Record<string, unknown> | null }[]\n): { code: 'intake-site-taken'; message: string; params: { site: string; connection: string } } | null {\n const rest = others.filter(o => o.source !== source)\n /* `others` 는 DB 행이다 — 옮기지 않고 그대로 넘긴다(ADR-0053 ①). */\n const hit = intakeCollisions([...rest, { source, config: config ?? {} }]).find(c => c.sources.includes(source))\n if (!hit) return null\n const connection = rest\n .filter(o => hit.sources.includes(o.source))\n .map(o => o.siteName || o.source)\n .join(', ')\n const site = String(config?.intakeSiteId ?? config?.siteId ?? '').trim()\n return {\n code: 'intake-site-taken',\n message: `intake site ${site} is already used by ${connection}`,\n params: { site, connection }\n }\n}\n"]}
1
+ {"version":3,"file":"connection-portability.js","sourceRoot":"","sources":["../../../server/service/reference/connection-portability.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * **연결을 내보내고 다시 들여옵니다** — 실제 셋업을 파일로 보관하고 되세웁니다.\n *\n * ── 이것은 데모가 아니라 제품 기능입니다 (2026-09-05) ───────────────────────\n * 데모 시드(`seed-demo-twins.ts`)와 **다른 것**입니다. 그쪽은 내장 가상 마스터로 보여 줄 트윈을\n * 만들고 `TWIN_PROVISION_DEMO` 뒤에 숨습니다. 이것은 **실제로 붙인 연결을 옮기는 길**이라\n * 데모 스위치 뒤에 두지 않습니다 — 백업·이관·복구가 데모일 수 없습니다.\n *\n * 그래서 특정 공장을 제품 코드에 적지 않습니다. 적으면 배포마다 남의 공장이 하나씩 따라가고,\n * 데모 시드 머리말이 적어 둔 「프로덕션 배포는 **트윈 0** 에서 시작한다」가 깨집니다. 무엇을 세울지는\n * **파일이 말하고 코드는 그 파일을 읽을 줄만 압니다.**\n *\n * ── 왜 필요했나 ────────────────────────────────────────────────────────────\n * 실 MES 두 공장을 트윈에 세웠는데(`mes-line-a` · `mes-miratek`) **그 셋업이 한 데이터베이스\n * 안에만 있었습니다.** 잃으면 다시 손으로 만들어야 하고, 다른 배포에 옮길 길도 없었습니다.\n *\n * ── 담는 것과 안 담는 것 ────────────────────────────────────────────────────\n *\n * 담는다 연결 선언 — 주소 · 도메인 · 사이트 · 치수 · 이름공간 · 받는 자리\n * 안 담는다 비밀값 — 토큰 · 웹훅 서명값 · 받는 쪽 비밀값\n * 안 담는다 사실(저널) — 원본이 다시 보내면 된다. 담으면 **가짜 과거가 배포마다 복제된다**\n * 안 담는다 마스터에서 온 트윈 모델 — 연결이 있으면 재동기화가 다시 만든다\n *\n * **비밀값은 이름만 적습니다.** 어느 칸이 채워져 있었는지는 알려 주되 값은 안 나갑니다 — 파일은\n * 주고받는 물건이고, 한 번 나간 비밀값은 되돌릴 수 없습니다. 들여올 때 「이 셋이 없다」고 말하고\n * 사람이 연결 화면에서 채웁니다.\n *\n * ── 왜 사실을 안 담나 ───────────────────────────────────────────────────────\n * 저널은 되풀이 읽히고 미러로 복제되고 구독으로 방송됩니다. 내보낸 사실을 다른 배포에 들여오면\n * **일어나지 않은 일이 그 공장의 지난 기록에 섭니다.** 커서만 옮겨 두면 원본이 그 뒤부터 다시\n * 보내 주므로, 사실은 원본에서 오는 것이 맞습니다.\n */\n\n/** 값이 아니라 **채워져 있었다는 사실**만 나가는 칸들. */\nexport const SECRET_KEYS = ['token', 'hookSecret', 'intakeSecret', 'password', 'apiKey'] as const\n\nexport interface PortableConnection {\n source: string\n system?: string\n siteName?: string\n adapterType: string\n /** 비밀값을 뺀 연결 설정. */\n config: Record<string, unknown>\n /** 내보낼 때 채워져 있던 비밀 칸 이름 — 값은 없습니다. */\n secretsRequired: string[]\n /** 이 연결이 무엇을 세우나. */\n scope?: unknown\n}\n\nexport interface ConnectionBundle {\n kind: 'operato-twin/connections'\n version: 1\n exportedAt: string\n /** 어디서 내보냈나 — 사람이 나중에 「이게 어느 배포 것인가」를 물을 때 답합니다. */\n exportedFrom?: { domain?: string; note?: string }\n connections: PortableConnection[]\n}\n\n/**\n * 무엇을 세우나 **선언만** 남깁니다 — 지난 동기화의 **측정값은 뺍니다.**\n *\n * `scopeSpec` 에는 `produced`(선언) 옆에 `assessments`(그때 몇 개가 섰나) · `lastFailures` 가 함께\n * 삽니다. 그것까지 내보내면 새 배포가 **다른 기계에서 잰 수**를 자기 것으로 보입니다 — 「설비 42」가\n * 아무것도 안 세운 배포의 화면에 서고, 그 수가 어디서 왔는지 아무도 모릅니다.\n *\n * 재는 것은 그 배포에서 다시 재야 합니다. 오늘 「설정을 고쳤다」와 「모델이 다시 섰다」를 가른 것과\n * 같은 구별입니다.\n */\nfunction producedOf(scopeSpec: any): { produced: unknown } | undefined {\n const produced = scopeSpec?.produced\n return Array.isArray(produced) && produced.length ? { produced } : undefined\n}\n\nconst isSecret = (key: string): boolean =>\n (SECRET_KEYS as readonly string[]).includes(key) || /secret|token|password|apikey/i.test(key)\n\n/**\n * 연결 행들을 옮길 수 있는 모양으로 — **비밀값을 빼고**.\n *\n * 빼는 판정을 이름으로 합니다. 목록으로만 하면 커넥터가 새 비밀 칸을 더할 때 그 값이 조용히\n * 파일로 나갑니다 — 이름 규칙을 함께 두어 **새 칸이 기본으로 안 나가게** 합니다.\n */\nexport function exportConnections(\n rows: readonly { source: string; system?: string; siteName?: string; adapterType: string; connectionConfig?: any; scopeSpec?: any }[],\n meta: { at: string; domain?: string; note?: string }\n): ConnectionBundle {\n return {\n kind: 'operato-twin/connections',\n version: 1,\n exportedAt: meta.at,\n ...(meta.domain || meta.note ? { exportedFrom: { ...(meta.domain ? { domain: meta.domain } : {}), ...(meta.note ? { note: meta.note } : {}) } } : {}),\n connections: rows.map(r => {\n const cfg = (r.connectionConfig ?? {}) as Record<string, unknown>\n const kept: Record<string, unknown> = {}\n const secrets: string[] = []\n for (const [k, v] of Object.entries(cfg)) {\n if (isSecret(k)) {\n /* 비어 있던 칸은 적지 않습니다 — 「채워야 한다」와 「이 커넥터에 그런 칸이 있다」는 다릅니다. */\n if (String(v ?? '').trim()) secrets.push(k)\n continue\n }\n kept[k] = v\n }\n return {\n source: r.source,\n ...(r.system ? { system: r.system } : {}),\n ...(r.siteName ? { siteName: r.siteName } : {}),\n adapterType: r.adapterType,\n config: kept,\n secretsRequired: secrets.sort(),\n ...(producedOf(r.scopeSpec) ? { scope: producedOf(r.scopeSpec) } : {})\n }\n })\n }\n}\n\nexport type ImportAction =\n | { action: 'create'; connection: PortableConnection; missingSecrets: string[] }\n | { action: 'update'; connection: PortableConnection; missingSecrets: string[]; keptSecrets: string[] }\n | { action: 'skip'; source: string; why: string }\n\nexport interface ImportPlan {\n actions: ImportAction[]\n /** 사람이 연결 화면에서 채워야 하는 것 — 이것이 비어야 「시드만으로 끝」입니다. */\n /**\n * 사람이 환경에 넣어야 하는 비밀값.\n *\n * `env` 를 함께 냅니다 — **그 이름을 사람이 손으로 적기 때문**입니다. 화면이 이름을 따로 만들면\n * 규칙이 두 곳에 살고, 갈리는 날 「넣었는데 안 읽힌다」가 됩니다. 실제로 났습니다(2026-09-06):\n * 화면이 `TWIN_SECRET__MES-MIRATEK__HOOKSECRET` 을 알려 줬는데 서버가 읽는 이름은\n * `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET` 이었습니다. 하이픈과 낙타 경계 둘 다 달랐습니다.\n *\n * `keys` 와 순서가 같습니다 — `keys[i]` 의 이름이 `env[i]` 입니다.\n */\n needsHuman: { source: string; keys: string[]; env: string[] }[]\n /** 들여온 **뒤에** 두 연결이 같은 곳에 조치를 내게 되나 — 있으면 들여오면 안 됩니다. */\n collisions: { intake: string; sources: string[] }[]\n errors: string[]\n}\n\n/**\n * 들여오기 **계획**을 먼저 냅니다 — 쓰기 전에 무엇이 일어날지 사람이 봅니다.\n *\n * ── 이미 있는 연결을 덮지 않습니다 ──────────────────────────────────────────\n * 같은 이름이 이미 있으면 **선언만 갱신하고 비밀값은 그대로 둡니다.** 사람이 화면에서 넣은 값을\n * 파일이 지우면, 파일 하나 들여왔다가 도는 연결이 멈춥니다. 데모 시드도 같은 규율을 적어 두었습니다\n * (「옆 설정을 지우지 않는다 — 시드는 자기가 아는 것만 채운다」).\n *\n * ── 없는 비밀값을 지어내지 않습니다 ─────────────────────────────────────────\n * 빈 값으로 채워 두면 연결이 서고 **동기화할 때 처음 실패합니다.** 그때는 무엇이 없어서인지 화면에서\n * 멉니다. 여기서 이름을 대고 멈추는 것이 낫습니다.\n */\nexport function planImport(\n bundle: unknown,\n existing: readonly { source: string; connectionConfig?: any }[],\n /** 지금 환경이 줄 수 있는 비밀값 이름들 — 값은 안 받습니다(이 함수는 값을 만지지 않습니다). */\n availableSecrets: (source: string, key: string) => boolean = () => false\n): ImportPlan {\n const errors: string[] = []\n const b = bundle as ConnectionBundle\n if (!b || typeof b !== 'object') return { actions: [], needsHuman: [], collisions: [], errors: ['들여올 것이 객체가 아닙니다'] }\n if (b.kind !== 'operato-twin/connections') {\n return { actions: [], needsHuman: [], collisions: [], errors: [`이 파일은 연결 설정 파일가 아닙니다(kind: ${JSON.stringify((b as any).kind ?? null)})`] }\n }\n if (b.version !== 1) errors.push(`모르는 판입니다(version: ${JSON.stringify((b as any).version)}) — 읽을 수 있는 것만 읽습니다`)\n if (!Array.isArray(b.connections)) return { actions: [], needsHuman: [], collisions: [], errors: [...errors, 'connections 가 목록이 아닙니다'] }\n\n const have = new Map(existing.map(e => [e.source, e]))\n const seen = new Set<string>()\n const actions: ImportAction[] = []\n const needsHuman: { source: string; keys: string[]; env: string[] }[] = []\n\n for (const c of b.connections) {\n const source = String(c?.source ?? '').trim()\n if (!source) {\n errors.push('이름 없는 연결이 있습니다 — 건너뜁니다')\n continue\n }\n if (seen.has(source)) {\n actions.push({ action: 'skip', source, why: '같은 이름이 파일에 두 번 있습니다' })\n continue\n }\n seen.add(source)\n if (!String(c?.adapterType ?? '').trim()) {\n actions.push({ action: 'skip', source, why: '커넥터 종류가 없습니다 — 무엇으로 붙일지 모릅니다' })\n continue\n }\n\n const cur = have.get(source)\n const curCfg = (cur?.connectionConfig ?? {}) as Record<string, unknown>\n const wanted = Array.isArray(c.secretsRequired) ? c.secretsRequired.map(String) : []\n const kept = wanted.filter(k => String(curCfg[k] ?? '').trim())\n const missing = wanted.filter(k => !kept.includes(k) && !availableSecrets(source, k))\n\n if (missing.length) needsHuman.push({ source, keys: missing, env: missing.map(k => secretEnvName(source, k)) })\n actions.push(\n cur\n ? { action: 'update', connection: c, missingSecrets: missing, keptSecrets: kept }\n : { action: 'create', connection: c, missingSecrets: missing }\n )\n }\n\n /*\n * **들여온 뒤의 모습으로 봅니다.** 파일 안에서만 보면 「이미 있는 연결과 부딪히는 것」을 못\n * 잡습니다 — 그게 실제로 더 쉽게 나는 쪽입니다(하나는 화면에서, 하나는 파일에서 생깁니다).\n */\n const after = [\n /* 행 모양 그대로 넘긴다 — 옮기는 자리는 `intakeCollisions` 안 한 곳이다(ADR-0053 ①). */\n ...existing.filter(e => !seen.has(e.source)),\n ...actions\n .filter((a): a is Extract<ImportAction, { connection: PortableConnection }> => a.action !== 'skip')\n .map(a => ({ source: a.connection.source, config: a.connection.config }))\n ]\n return { actions, needsHuman, collisions: intakeCollisions(after), errors }\n}\n\n/** 사람이 읽는 한 줄 — 화면과 로그가 같은 문장을 쓰게. */\nexport function sayPlan(plan: ImportPlan): string {\n const n = (a: ImportAction['action']) => plan.actions.filter(x => x.action === a).length\n const head = `연결 ${plan.actions.length}개 — 새로 ${n('create')} · 갱신 ${n('update')} · 건너뜀 ${n('skip')}`\n /* 부딪힘을 먼저 말합니다 — 비밀값이 없는 것은 안 도는 것이고, 부딪히는 것은 **잘못 도는 것**입니다. */\n if (plan.collisions.length) {\n const who = plan.collisions.map(c => `${c.sources.join(' · ')} → ${c.intake}`).join(', ')\n return `${head}. ⚠ **두 연결이 같은 곳에 조치를 냅니다** — ${who}. 다른 공장의 작업지시가 한 공장에 섭니다`\n }\n if (!plan.needsHuman.length) return `${head}. 비밀값은 다 있습니다`\n const who = plan.needsHuman.map(h => `${h.source}(${h.keys.join(' · ')})`).join(', ')\n return `${head}. **비밀값을 사람이 넣어야 합니다** — ${who}. 연결 화면에서 채우면 그때부터 돕니다`\n}\n\n/**\n * 비밀값이 사는 **환경 변수 이름** — `mes-miratek` · `hookSecret` → `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET`.\n *\n * 패키지에는 비밀 칸의 이름만 있고 값은 환경에서 옵니다. 그 이름을 **사람이 손으로 적으므로** 규칙이\n * 흔들리면 「넣었는데 안 읽힌다」가 됩니다 — 오늘 종일 잡은 「양쪽 다 초록인데 안 간다」의 설정 쪽\n * 판본입니다. 그래서 이 규칙을 한 곳에 두고 시험으로 못 박습니다.\n *\n * 낙타 이름의 경계를 밑줄로 가릅니다 — 안 그러면 `HOOKSECRET` 과 `HOOK_SECRET` 이 갈려 한쪽만 읽힙니다.\n */\nexport function secretEnvName(source: string, key: string): string {\n const up = (x: string) =>\n x\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .toUpperCase()\n return `TWIN_SECRET__${up(source)}__${up(key)}`\n}\n\n/**\n * **두 연결이 같은 곳에 조치를 내지 않나** — 같으면 다른 공장의 작업지시가 한 공장에 섭니다.\n *\n * ── 왜 이 검사가 생겼나 (2026-09-05) ────────────────────────────────────────\n * `mes-miratek` 이 서면서 한 커넥터가 처음으로 **공장 둘**을 달았습니다. 조치가 나가는 주소는\n * `intakeSiteId` 가 정하는데(`operato-plant.ts:389`), 그 값이 트윈의 사이트 이름을 **이깁니다** —\n * 트윈에서는 `mes-line-a` 인데 MES 의 도메인은 `system` 이라 그 override 가 필요합니다.\n *\n * 그래서 **연결을 베껴 만들고 그 칸만 안 고치면** 두 트윈의 조치가 같은 공장으로 갑니다. 오류가\n * 안 납니다 — 저쪽은 정상적인 작업지시로 받고 **실제로 만듭니다.**\n *\n * 게이트에 이미 「사이트를 모르면 보내지 않는다」가 있는데(`no-site`), 그 이유가 적혀 있는 문장이\n * **「잘못된 공장이 실제로 만든다」** 입니다. 이것은 그 둘째 길입니다 — 사이트를 아는데 **틀린\n * 사이트를 압니다.**\n *\n * 파일로 연결을 만들 수 있게 되면서 더 쉬워졌습니다: 패키지를 베껴 이름만 바꾸면 받는 자리가\n * 그대로 따라옵니다.\n */\nexport function intakeCollisions(\n connections: readonly {\n source: string\n adapterType?: string\n /** 패키지·계획이 쓰는 이름. */\n config?: Record<string, unknown> | null\n /** **DB 행이 쓰는 이름** — `TwinReference` 의 칸이다. 둘 다 받는다(ADR-0053 ①). */\n connectionConfig?: Record<string, unknown> | null\n }[]\n): { intake: string; sources: string[] }[] {\n const byIntake = new Map<string, string[]>()\n for (const c of connections) {\n /*\n * **모양이 둘인 것을 여기서 안다** (ADR-0053 ①, 2026-09-16).\n *\n * 패키지는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을\n * 그대로 넘기면 같은 충돌에도 `[]` 를 냈다 — 오류도 경고도 없이 **저장이 통과하고 가드는 선 채로\n * 아무 일도 안 한다.** 부르는 쪽마다 옮기게 두면 다음 소비처가 또 밟는다(이 함수는 화면이 쓰라고\n * 리졸버에서 다시 내보내진다).\n *\n * 「없다」가 편한 답인 자리라 더 위험하다 — 빨강이 아니라 **조용한 초록**으로 흐른다.\n */\n const cfg = c.config ?? c.connectionConfig ?? {}\n /* 받는 자리를 정하는 규칙은 커넥터의 것과 같아야 합니다 — 여기서 다시 지으면 두 벌이 됩니다. */\n const intake = String(cfg.intakeSiteId ?? cfg.siteId ?? '').trim()\n /* 조치를 낼 수 없는 연결은 부딪힐 것이 없습니다 — 주소가 없으면 아무 데도 안 갑니다. */\n if (!intake || !String(cfg.intakeEndpoint ?? '').trim()) continue\n const key = `${String(cfg.intakeEndpoint).trim().replace(/\\/$/, '')}::${intake}`\n byIntake.set(key, [...(byIntake.get(key) ?? []), c.source])\n }\n return [...byIntake.entries()]\n .filter(([, sources]) => sources.length > 1)\n .map(([intake, sources]) => ({ intake, sources: sources.sort() }))\n}\n\n/**\n * **화면 저장의 문** — 이 연결을 이 설정으로 저장하면 다른 연결과 받는 사이트가 겹치나 (ADR-0046 ②-1).\n *\n * 들여오기(`planImport`)는 `intakeCollisions` 로 거부하는데 화면 저장(`saveTwinReference`)은 부르지 않아서,\n * 연결을 베껴 이름만 바꾸면 경고 0 으로 두 트윈의 조치가 한 공장에 섰다. 판정은 같은 함수 하나로 한다.\n * 경고가 아니라 거부이고, 사람이 그 자리에서 고치도록 **이미 그 사이트를 쓰는 연결의 이름**을 싣는다.\n */\nexport function refuseIntakeCollision(\n source: string,\n config: Record<string, unknown> | undefined,\n others: readonly { source: string; siteName?: string | null; connectionConfig?: Record<string, unknown> | null }[]\n): { code: 'intake-site-taken'; message: string; params: { site: string; connection: string } } | null {\n const rest = others.filter(o => o.source !== source)\n /* `others` 는 DB 행이다 — 옮기지 않고 그대로 넘긴다(ADR-0053 ①). */\n const hit = intakeCollisions([...rest, { source, config: config ?? {} }]).find(c => c.sources.includes(source))\n if (!hit) return null\n const connection = rest\n .filter(o => hit.sources.includes(o.source))\n .map(o => o.siteName || o.source)\n .join(', ')\n const site = String(config?.intakeSiteId ?? config?.siteId ?? '').trim()\n return {\n code: 'intake-site-taken',\n message: `intake site ${site} is already used by ${connection}`,\n params: { site, connection }\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.1.206",
3
+ "version": "10.1.209",
4
4
  "main": "dist-server/index.js",
5
5
  "exports": {
6
6
  ".": "./dist-server/index.js",
@@ -41,11 +41,11 @@
41
41
  "dependencies": {
42
42
  "@operato/ops-contract": "^0.9.40",
43
43
  "@operato/twin-kernel": "^0.11.29",
44
- "@things-factory/auth-base": "^10.1.206",
45
- "@things-factory/cache-service": "^10.1.206",
44
+ "@things-factory/auth-base": "^10.1.208",
45
+ "@things-factory/cache-service": "^10.1.208",
46
46
  "@things-factory/env": "^10.1.149",
47
- "@things-factory/ingest": "^10.1.206",
48
- "@things-factory/shell": "^10.1.206"
47
+ "@things-factory/ingest": "^10.1.208",
48
+ "@things-factory/shell": "^10.1.208"
49
49
  },
50
- "gitHead": "18588a058a4a6cb21c379b2da5517e077c6442ba"
50
+ "gitHead": "f0775da623f1d73007d41d676be9e1a8bed30325"
51
51
  }
@@ -243,12 +243,9 @@ export async function propose(
243
243
  )
244
244
  }
245
245
 
246
- /*
247
- * 사람이 낸 것이면 누구인지 있어야 한다. 없이 받으면 그 커맨드는 **아무나 승인할 수 있는 것**이 되고,
248
- * 자기 승인을 막는 규칙이 그 한 건만 비껴간다.
249
- */
246
+ /* 사람이 낸 것이면 누구인지 있어야 한다 — 되돌릴 수 없는 조치는 누가 냈는지가 기록에 남는다. */
250
247
  if (seed.origin === 'operator' && !seed.proposedBy?.trim()) {
251
- throw new Error('사람이 내는 조치는 낸 사람이 있어야 한다 — 없으면 자기 것을 자기가 승인하는 것을 막을 수 없다')
248
+ throw new Error('사람이 내는 조치는 낸 사람이 있어야 한다 — 누가 냈는지 남지 않는 조치는 받지 않는다')
252
249
  }
253
250
 
254
251
  /* 요구하는 칸이 없으면 계약이 던진다 — 빈 지시가 대기 줄에 쌓이면 승인하는 사람이 판단할 근거가 없다. */
@@ -292,7 +289,6 @@ export async function approve(
292
289
  ): Promise<StoredCommand> {
293
290
  const command = await mustLoad(store, domainId, id)
294
291
  assertDirectDoorOpen(command)
295
- assertNotSelfApproval(command, approval.by)
296
292
 
297
293
  const approved = approveCommand(command, approval)
298
294
  await store.save(domainId, { ...approved, ...carried(command) })
@@ -346,12 +342,6 @@ export async function settleByApproval(
346
342
 
347
343
  if (!decision.approved) return await settle(store, domainId, command, false, approval)
348
344
 
349
- /*
350
- * 결재선을 지나온 것도 이 규칙을 지난다. 결재선이 비어 있으면 업무 목록이 스레드를 곧바로 종료하고
351
- * (§`submit.ts`), 승인자를 `Myself` 로 넣으면 낸 사람이 승인자다 — 두 경우 다 여기서 막힌다.
352
- */
353
- assertNotSelfApproval(command, decision.by)
354
-
355
345
  const approved = approveCommand(command, approval)
356
346
  await store.save(domainId, { ...approved, ...carried(command) })
357
347
  return approved
@@ -372,54 +362,6 @@ function assertDirectDoorOpen(command: StoredCommand): void {
372
362
  )
373
363
  }
374
364
 
375
- /**
376
- * **낸 사람은 자기 것을 승인하지 못한다.**
377
- *
378
- * ── 왜 필요한가 (2026-08-31) ─────────────────────────────────────────────
379
- * 결재선에 올리는 길을 만들고 나서 업무 목록의 `submit.ts` 를 읽었다. **결재선이 비어 있으면 스레드가
380
- * 곧바로 종료되고**, 그러면 우리 콜백이 「승인됐다」로 받는다. 승인자를 지정하지 않은 도메인에서는 낸
381
- * 사람이 그 자리에서 자기 조치를 승인한 것이 된다. 오류 한 줄 없이.
382
- *
383
- * 결재선을 지정한 경우에도 열린다 — `Myself` 를 승인자로 넣으면 낸 사람이 승인자다.
384
- *
385
- * 그래서 규칙을 게이트 안쪽에 둔다. 올리는 자리에서만 막으면 그 자리를 지나지 않는 길에서 다시 열린다.
386
- * 되돌릴 수 없는 조치에 두 사람이 필요하다는 것이 이 표의 존재 이유다.
387
- *
388
- * 거절에는 걸지 않는다 — 낸 사람이 자기 것을 물리는 것은 막을 이유가 없다.
389
- */
390
- function assertNotSelfApproval(command: StoredCommand, by: string): void {
391
- const proposer = command.proposedBy?.trim()
392
- if (!proposer) return
393
-
394
- if (proposer.toLowerCase() === String(by ?? '').trim().toLowerCase()) {
395
- throw selfApprovalRefused(proposer)
396
- }
397
- }
398
-
399
- /**
400
- * 같은 사실에는 **같은 낱말** — `self-approval` (ADR-0052 ③, ADR-0042 ③).
401
- *
402
- * 워크리스트에도 같은 규칙이 선다(`worklist/server/controllers/activity-approval/own-request.ts`).
403
- * **두 벌이 아니라 두 문이다** — 커널은 업무 목록을 안 지나는 길(API · AI 발의)에서 자기 커맨드를
404
- * 지키고, 워크리스트는 결재 문을 지킨다. 커널↔호스트 경계를 넘어 합치지 않는다.
405
- *
406
- * 두 문이 위험한 것은 **서로 다른 답을 낼 때**다. 그래서 문장이 아니라 **코드를 맞춘다** — 문장은
407
- * 번역도 집계도 안 되고, 고치는 날 두 문이 다른 말을 한다. 붙잡는 쪽은 `code` 를 보고, 사람에게는
408
- * `message` 를 보인다.
409
- */
410
- function selfApprovalRefused(proposer: string): Error & { code: string } {
411
- const error = new Error(
412
- `낸 사람이 자기 조치를 승인할 수 없다 (${proposer}) — 되돌릴 수 없는 조치에는 다른 사람의 승인이 필요하다. ` +
413
- '승인자가 없다면 결재선에 승인자를 지정하거나, 다른 사람이 승인해야 한다'
414
- ) as Error & { code: string }
415
-
416
- error.code = SELF_APPROVAL
417
- return error
418
- }
419
-
420
- /** 낸 사람이 자기 것을 승인하려 했다 — 워크리스트가 쓰는 낱말과 **같은 값**이어야 한다. */
421
- export const SELF_APPROVAL = 'self-approval'
422
-
423
365
  /** 승인 기록만 바뀌고 나머지는 그대로 간다 — 하나라도 흘리면 그 사실이 사라진다. */
424
366
  function carried(command: StoredCommand): Partial<StoredCommand> {
425
367
  return {
@@ -117,8 +117,8 @@ async function runOneTwin(twin: OwnedTwin, deps: RuleRunnerDeps): Promise<Partia
117
117
  {
118
118
  ruleId: '(전체)',
119
119
  reason:
120
- '이 저장소는 이미 올라간 근거를 말해 주지 않는다 — 판정하면 같은 조건이 주기마다 다시 올라간다. ' +
121
- 'CommandStore.openCauses 가 필요하다'
120
+ '기존 조치 요청의 근거를 조회할 수 없어 규칙을 평가하지 않았습니다. 중복 요청을 방지하려면 ' +
121
+ 'CommandStore.openCauses를 구현해야 합니다.'
122
122
  }
123
123
  ]
124
124
  }
@@ -146,9 +146,9 @@ async function runOneTwin(twin: OwnedTwin, deps: RuleRunnerDeps): Promise<Partia
146
146
  ruleId: '(전체)',
147
147
  reason:
148
148
  mode === 'sim'
149
- ? '이 트윈은 시뮬레이션이다 — 시늉된 조건에서 현장 지시를 내지 않는다. ' +
150
- '예측을 근거로 미리 내려면 그것을 선언하는 자리가 필요하고, 아직 없다'
151
- : '이 트윈이 관측을 비추는지 알 수 없다 — 모름을 「관측이다」로 읽지 않는다'
149
+ ? '이 트윈은 시뮬레이션 모드이므로 현장 지시를 생성하지 않습니다. ' +
150
+ '시뮬레이션·예측을 근거로 한 현장 지시는 현재 지원하지 않습니다.'
151
+ : '트윈의 실행 모드를 확인할 수 없어 실제 관측에 근거한 현장 지시를 생성할 수 없습니다.'
152
152
  }
153
153
  ]
154
154
  }
@@ -161,7 +161,7 @@ async function runOneTwin(twin: OwnedTwin, deps: RuleRunnerDeps): Promise<Partia
161
161
  * 「조건이 하나도 없다」가 되고, 규칙이 도는 줄로 보인다.
162
162
  */
163
163
  return {
164
- skips: [{ ruleId: '(전체)', reason: '이 트윈의 상태를 읽을 수 없다 — 멈춰 있거나 아직 서지 않았다' }]
164
+ skips: [{ ruleId: '(전체)', reason: '트윈의 상태를 조회할 수 없습니다. 실행이 중지되었거나 아직 시작되지 않았을 수 있습니다.' }]
165
165
  }
166
166
  }
167
167
 
@@ -10,7 +10,7 @@
10
10
  * 안 옮긴다 잰 값 그 서버에서 다시 세면 된다
11
11
  * 안 옮긴다 비밀값 그 환경의 것이지 셋업이 아니다
12
12
  *
13
- * **꾸러미는 정본이 아니라 운반 수단이다.** 정본은 언제나 그 배포의 DB 이고, 꾸러미는 쓰고 버린다.
13
+ * **패키지는 정본이 아니라 운반 수단이다.** 정본은 언제나 그 배포의 DB 이고, 패키지는 쓰고 버린다.
14
14
  *
15
15
  * ── 주체는 받는 쪽이다 ─────────────────────────────────────────────────────
16
16
  * 운영이 개발에서 **당겨온다.** 개발이 운영으로 밀면 개발 환경에 운영 자격이 살고, 그 통로는 실수로도
@@ -38,13 +38,13 @@ function hasSecret(source: string, key: string): boolean {
38
38
  @Resolver()
39
39
  export class ConnectionPortabilityResolver {
40
40
  /**
41
- * 지금 연결을 꾸러미로 낸다 — **비밀값은 안 나간다.**
41
+ * 지금 연결을 패키지로 낸다 — **비밀값은 안 나간다.**
42
42
  *
43
43
  * 이름 규칙으로 걸러서 **새 비밀 칸도 기본으로 안 나간다**(`SECRET_KEYS`). 나가지 않은 것은
44
- * 꾸러미가 이름으로 말하고, 받는 쪽이 그 이름을 사람에게 보인다.
44
+ * 패키지가 이름으로 말하고, 받는 쪽이 그 이름을 사람에게 보인다.
45
45
  */
46
46
  /*
47
- * **읽기인데 쓰기와 같은 문을 쓴다.** 이 꾸러미는 연결 주소·도메인과 **비밀 칸의 이름**까지 낸다 —
47
+ * **읽기인데 쓰기와 같은 문을 쓴다.** 이 패키지는 연결 주소·도메인과 **비밀 칸의 이름**까지 낸다 —
48
48
  * 그 이름만으로도 그 배포가 무엇에 붙어 있는지 드러난다. 권한 가드가 「쓰기는 GATE 하나」로
49
49
  * 통일해 두었고, 이 문은 읽기지만 그 무게가 쓰기 쪽이다(ADR-0027).
50
50
  */
@@ -63,7 +63,7 @@ export class ConnectionPortabilityResolver {
63
63
  }
64
64
 
65
65
  /**
66
- * 꾸러미를 받아 적용한다 — **`dryRun` 이 기본이다.**
66
+ * 패키지를 받아 적용한다 — **`dryRun` 이 기본이다.**
67
67
  *
68
68
  * 쓰기가 기본이면 「무엇이 바뀌나」를 보기 위해 쓰게 된다. 계획을 먼저 보고 사람이 정하는 것이
69
69
  * 이 문의 요점이고, 그래서 적용은 명시해야 일어난다.
@@ -101,11 +101,11 @@ export class ConnectionPortabilityResolver {
101
101
 
102
102
  const c = a.connection
103
103
  /*
104
- * **비밀값을 빈 값으로 안 채운다.** 꾸러미에 비밀값이 없고, `planImport` 가 그 이름을
104
+ * **비밀값을 빈 값으로 안 채운다.** 패키지에 비밀값이 없고, `planImport` 가 그 이름을
105
105
  * `missingSecrets`·`needsHuman` 으로 이미 냈다. 여기서 그 칸을 `undefined` 로 쓰면 사람이
106
106
  * 화면에서 넣어 둔 값을 파일 하나가 지운다.
107
107
  *
108
- * 그래서 갱신은 **선언 칸만** 건드린다. 이미 있는 연결의 `connectionConfig` 는 꾸러미가 든
108
+ * 그래서 갱신은 **선언 칸만** 건드린다. 이미 있는 연결의 `connectionConfig` 는 패키지가 든
109
109
  * 것만 덮어쓰고 나머지 키는 그대로 남긴다.
110
110
  */
111
111
  const found = await repo.findOne({ where: { domain: { id: domainId }, source: c.source } })
@@ -160,7 +160,7 @@ export function planImport(
160
160
  const b = bundle as ConnectionBundle
161
161
  if (!b || typeof b !== 'object') return { actions: [], needsHuman: [], collisions: [], errors: ['들여올 것이 객체가 아닙니다'] }
162
162
  if (b.kind !== 'operato-twin/connections') {
163
- return { actions: [], needsHuman: [], collisions: [], errors: [`이 파일은 연결 꾸러미가 아닙니다(kind: ${JSON.stringify((b as any).kind ?? null)})`] }
163
+ return { actions: [], needsHuman: [], collisions: [], errors: [`이 파일은 연결 설정 파일가 아닙니다(kind: ${JSON.stringify((b as any).kind ?? null)})`] }
164
164
  }
165
165
  if (b.version !== 1) errors.push(`모르는 판입니다(version: ${JSON.stringify((b as any).version)}) — 읽을 수 있는 것만 읽습니다`)
166
166
  if (!Array.isArray(b.connections)) return { actions: [], needsHuman: [], collisions: [], errors: [...errors, 'connections 가 목록이 아닙니다'] }
@@ -231,7 +231,7 @@ export function sayPlan(plan: ImportPlan): string {
231
231
  /**
232
232
  * 비밀값이 사는 **환경 변수 이름** — `mes-miratek` · `hookSecret` → `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET`.
233
233
  *
234
- * 꾸러미에는 비밀 칸의 이름만 있고 값은 환경에서 옵니다. 그 이름을 **사람이 손으로 적으므로** 규칙이
234
+ * 패키지에는 비밀 칸의 이름만 있고 값은 환경에서 옵니다. 그 이름을 **사람이 손으로 적으므로** 규칙이
235
235
  * 흔들리면 「넣었는데 안 읽힌다」가 됩니다 — 오늘 종일 잡은 「양쪽 다 초록인데 안 간다」의 설정 쪽
236
236
  * 판본입니다. 그래서 이 규칙을 한 곳에 두고 시험으로 못 박습니다.
237
237
  *
@@ -262,14 +262,14 @@ export function secretEnvName(source: string, key: string): string {
262
262
  * **「잘못된 공장이 실제로 만든다」** 입니다. 이것은 그 둘째 길입니다 — 사이트를 아는데 **틀린
263
263
  * 사이트를 압니다.**
264
264
  *
265
- * 파일로 연결을 만들 수 있게 되면서 더 쉬워졌습니다: 꾸러미를 베껴 이름만 바꾸면 받는 자리가
265
+ * 파일로 연결을 만들 수 있게 되면서 더 쉬워졌습니다: 패키지를 베껴 이름만 바꾸면 받는 자리가
266
266
  * 그대로 따라옵니다.
267
267
  */
268
268
  export function intakeCollisions(
269
269
  connections: readonly {
270
270
  source: string
271
271
  adapterType?: string
272
- /** 꾸러미·계획이 쓰는 이름. */
272
+ /** 패키지·계획이 쓰는 이름. */
273
273
  config?: Record<string, unknown> | null
274
274
  /** **DB 행이 쓰는 이름** — `TwinReference` 의 칸이다. 둘 다 받는다(ADR-0053 ①). */
275
275
  connectionConfig?: Record<string, unknown> | null
@@ -280,7 +280,7 @@ export function intakeCollisions(
280
280
  /*
281
281
  * **모양이 둘인 것을 여기서 안다** (ADR-0053 ①, 2026-09-16).
282
282
  *
283
- * 꾸러미는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을
283
+ * 패키지는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을
284
284
  * 그대로 넘기면 같은 충돌에도 `[]` 를 냈다 — 오류도 경고도 없이 **저장이 통과하고 가드는 선 채로
285
285
  * 아무 일도 안 한다.** 부르는 쪽마다 옮기게 두면 다음 소비처가 또 밟는다(이 함수는 화면이 쓰라고
286
286
  * 리졸버에서 다시 내보내진다).
@@ -17,7 +17,6 @@ import assert from 'node:assert/strict'
17
17
  import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract'
18
18
 
19
19
  import {
20
- SELF_APPROVAL,
21
20
  approve,
22
21
  dispatch,
23
22
  linkApproval,
@@ -232,52 +231,28 @@ test('★ 두 어휘가 겹치지 않는다 — 겹치면 어느 쪽으로 읽
232
231
  )
233
232
  })
234
233
 
235
- /* ── 낸 사람은 자기 것을 승인하지 못한다 ─────────────────────────────────── */
234
+ /* ── 낸 사람도 자기 것을 승인한다 ───────────────────────────────────────── */
236
235
 
237
- test('★ 거절 낱말은 워크리스트와 **같은 글자**여야 한다 — 상수를 보면 이름을 바꿔도 안 걸린다', () => {
238
- /*
239
- * 이 줄만 글자를 적는다. 아래 시험들은 `SELF_APPROVAL` 을 읽으므로 **이름을 바꾸면 양쪽이 함께
240
- * 바뀌어 초록**이다(실제로 심어 보고 알았다). 두 문이 같은 말을 하는지는 **글자**로만 잴 수 있다.
241
- *
242
- * 반대쪽은 `worklist/server/controllers/activity-approval/own-request.ts` 다. 거기도 자기 쪽 글자를
243
- * 못 박는다 — 한쪽이 움직이면 그쪽이 빨개지고, 이 주석이 왜 그런지 말해 준다 (ADR-0052 ③).
244
- */
245
- assert.equal(SELF_APPROVAL, 'self-approval')
246
- })
236
+ /*
237
+ * 낸 사람은 자기 조치를 승인하지 못한다는 규칙은 2026-10-23 사용자가 거두었다: 「twin 액추에이션
238
+ * 커맨드의 자기 승인 금지도 걷어내라」(워크리스트의 상신자 규칙 ADR-0052 ② 와 함께). 결재선이 비어
239
+ * 아무도 승인하지 않은 채 끝나는 길은 올리는 자리(operato-twin `approvalLineGap`)가 막는다.
240
+ */
247
241
 
248
- test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가 시늉이 되는 자리', async () => {
249
- /*
250
- * 이 규칙이 왜 필요한지는 돌려 보고 알았다. 업무 목록은 결재선이 비어 있으면 스레드를 곧바로
251
- * 종료하고(§`submit.ts`), 그러면 콜백이 「승인됐다」로 받는다. 승인자를 지정하지 않은 도메인에서는
252
- * 낸 사람이 그 자리에서 자기 조치를 승인한 것이 된다 — 오류 한 줄 없이.
253
- */
242
+ test('★ 낸 사람이 자기 조치를 직접 승인한다', async () => {
254
243
  const store = makeStore()
255
244
  await propose(store, DOMAIN, SPEC, seed())
256
245
 
257
- /*
258
- * **문장이 아니라 코드로 잡는다** (ADR-0052 ③). 워크리스트에도 같은 규칙이 서므로 두 문이 같은
259
- * 낱말을 내야 하고, 문장을 못 박으면 문구를 고치는 날 규칙이 아니라 **문장이** 깨진다.
260
- */
261
- await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), (e: any) => e.code === SELF_APPROVAL)
262
- assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
246
+ assert.equal((await approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT })).state, 'approved')
263
247
  })
264
248
 
265
- test('★ 결재선을 지나온 판정에도 같은 규칙이 걸린다 — Myself 를 승인자로 넣으면 같은 사람이다', async () => {
249
+ test('★ 결재선을 지나온 판정도 낸 사람의 승인을 받는다', async () => {
266
250
  const store = makeStore()
267
251
  await propose(store, DOMAIN, SPEC, seed())
268
252
  await linkApproval(store, DOMAIN, 'cmd-1', REF)
269
253
 
270
- await assert.rejects(
271
- settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT }),
272
- (e: any) => e.code === SELF_APPROVAL
273
- )
274
- assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
275
- })
276
-
277
- test('대소문자가 달라도 같은 사람이다 — 로그인 이름의 표기로 규칙을 비껴가지 못한다', async () => {
278
- const store = makeStore()
279
- await propose(store, DOMAIN, SPEC, seed())
280
- await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER.toUpperCase(), at: AT }), /낸 사람이/)
254
+ await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT })
255
+ assert.equal(store.rows.get('cmd-1')!.state, 'approved')
281
256
  })
282
257
 
283
258
  test('★ 낸 사람은 자기 것을 물릴 수 있다 — 거절에는 걸지 않는다', async () => {
@@ -287,7 +262,7 @@ test('★ 낸 사람은 자기 것을 물릴 수 있다 — 거절에는 걸지
287
262
  assert.equal(rejected.state, 'rejected')
288
263
  })
289
264
 
290
- test('★ 사람이 내는데 낸 사람이 없으면 받지 않는다 — 그 한 건만 규칙을 비껴간다', async () => {
265
+ test('★ 사람이 내는데 낸 사람이 없으면 받지 않는다 — 누가 냈는지 남아야 한다', async () => {
291
266
  const store = makeStore()
292
267
  await assert.rejects(propose(store, DOMAIN, SPEC, seed({ proposedBy: undefined })), /낸 사람이 있어야 한다/)
293
268
  assert.equal(store.rows.size, 0)
@@ -301,14 +276,13 @@ test('규칙·AI 가 낸 것은 낸 사람이 없다 — 사람이 아니다', a
301
276
  assert.equal((await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })).state, 'approved')
302
277
  })
303
278
 
304
- test('★ 낸 사람이 재기동을 넘어 산다 — 사라지면 자기 승인이 열린다', async () => {
279
+ test('★ 낸 사람이 재기동을 넘어 산다 — 사고 뒤에 누가 냈는지 물을 자리다', async () => {
305
280
  const first = makeStore()
306
281
  await propose(first, DOMAIN, SPEC, seed())
307
282
  const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
308
283
 
309
284
  const second = makeStore(saved)
310
285
  assert.equal(second.rows.get('cmd-1')!.proposedBy, PROPOSER, '재기동에 낸 사람이 사라졌다')
311
- await assert.rejects(approve(second, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), /낸 사람이/)
312
286
  })
313
287
 
314
288
  test('★ 넘긴 뒤에도 낸 사람이 남는다 — 사고 뒤에 누가 냈는지 물을 자리다', async () => {
@@ -134,7 +134,7 @@ test('★ 시뮬 트윈에는 조치를 내지 않는다 — 지어낸 조건이
134
134
 
135
135
  assert.equal(out[0]!.proposed, 0)
136
136
  assert.equal(store.rows.size, 0)
137
- assert.match(out[0]!.skips[0]!.reason, /시뮬레이션이다/)
137
+ assert.match(out[0]!.skips[0]!.reason, /시뮬레이션 모드.*현장 지시를 생성하지 않습니다/)
138
138
  })
139
139
 
140
140
  test('★ 모드를 모르면 내지 않는다 — 모름을 「관측이다」로 읽지 않는다', async () => {
@@ -142,7 +142,7 @@ test('★ 모드를 모르면 내지 않는다 — 모름을 「관측이다」
142
142
  const out = await runRulesOnce(deps({ store, modeOf: () => undefined }))
143
143
 
144
144
  assert.equal(out[0]!.proposed, 0)
145
- assert.match(out[0]!.skips[0]!.reason, /알 수 없다/)
145
+ assert.match(out[0]!.skips[0]!.reason, /실행 모드를 확인할 수 없/)
146
146
  })
147
147
 
148
148
  /* ── 두 번 올리지 않는다 ─────────────────────────────────────────────────── */
@@ -200,7 +200,7 @@ test('★ 근거를 읽을 길이 없으면 아무것도 올리지 않고 말한
200
200
  test('★ 스냅샷이 없는 트윈은 「주의 0건」이 아니다 — 멈춘 트윈을 조건 없음으로 읽지 않는다', async () => {
201
201
  const out = await runRulesOnce(deps({ attentionsOf: () => undefined }))
202
202
  assert.equal(out[0]!.proposed, 0)
203
- assert.match(out[0]!.skips[0]!.reason, /멈춰 있거나 아직 서지 않았다/)
203
+ assert.match(out[0]!.skips[0]!.reason, /실행이 중지되었거나 아직 시작되지 않았을 수 있습니다/)
204
204
  })
205
205
 
206
206
  test('주의가 정말 0건이면 이유를 만들지 않는다 — 정상이다', async () => {
@@ -23,12 +23,12 @@ const row = (over: any = {}) => ({
23
23
  ...over
24
24
  })
25
25
 
26
- test('★ 비밀값이 꾸러미에 안 나간다 — 이름만 나간다', () => {
26
+ test('★ 비밀값이 패키지에 안 나간다 — 이름만 나간다', () => {
27
27
  const b = exportConnections([row()], { at: '2026-09-05T00:00:00.000Z' })
28
28
  const text = JSON.stringify(b)
29
29
 
30
- assert.ok(!text.includes('SECRET-VALUE'), '토큰이 꾸러미에 실렸다')
31
- assert.ok(!text.includes('ALSO-SECRET'), '훅 비밀값이 꾸러미에 실렸다')
30
+ assert.ok(!text.includes('SECRET-VALUE'), '토큰이 패키지에 실렸다')
31
+ assert.ok(!text.includes('ALSO-SECRET'), '훅 비밀값이 패키지에 실렸다')
32
32
  assert.deepEqual(b.connections[0].secretsRequired.sort(), ['hookSecret', 'token'], '무엇을 채워야 하는지 말하지 않는다')
33
33
  })
34
34
 
@@ -62,7 +62,7 @@ test('★ 없는 비밀값을 지어내지 않는다 — 이름을 대고 사람
62
62
  assert.deepEqual(needs!.keys.sort(), ['hookSecret', 'token'])
63
63
  })
64
64
 
65
- test('★ 이미 있는 연결의 비밀값을 꾸러미가 안 지운다', () => {
65
+ test('★ 이미 있는 연결의 비밀값을 패키지가 안 지운다', () => {
66
66
  const b = exportConnections([row()], { at: '2026-09-05T00:00:00.000Z' })
67
67
  const existing = [{ source: 'mes-miratek', connectionConfig: { token: 'LIVE-TOKEN' } }]
68
68
  const plan = planImport(b, existing, () => false)
@@ -72,7 +72,7 @@ test('★ 이미 있는 연결의 비밀값을 꾸러미가 안 지운다', () =
72
72
  assert.ok(a.keptSecrets?.includes('token'), '이미 있는 토큰을 지킨다고 말하지 않는다')
73
73
  })
74
74
 
75
- test('★ 꾸러미가 아닌 것을 받으면 아무것도 하지 않는다', () => {
75
+ test('★ 패키지가 아닌 것을 받으면 아무것도 하지 않는다', () => {
76
76
  for (const bad of [null, {}, { kind: 'something-else' }, 'text']) {
77
77
  const plan = planImport(bad, [], () => false)
78
78
  assert.equal(plan.actions.length, 0, `${JSON.stringify(bad)} 로 무언가를 하려 했다`)
@@ -82,6 +82,6 @@ test('★ 꾸러미가 아닌 것을 받으면 아무것도 하지 않는다', (
82
82
 
83
83
  test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
84
84
  const b = exportConnections([row()], { at: '2026-09-05T00:00:00.000Z' })
85
- assert.equal(b.connections.length, 1, '꾸러미가 비었다 — 이 검사가 아무것도 안 잰다')
85
+ assert.equal(b.connections.length, 1, '패키지가 비었다 — 이 검사가 아무것도 안 잰다')
86
86
  assert.ok(planImport(b, [], () => true).actions.length > 0, '계획이 비었다')
87
87
  })
@@ -54,7 +54,7 @@ test('★ 저장의 문이 그 판정을 부르고, 거부하면 쓰기 전에
54
54
  /*
55
55
  * ★ **같은 충돌을 두 모양으로 건다** (ADR-0053 ②, 2026-09-16).
56
56
  *
57
- * 꾸러미는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서 행을 그대로
57
+ * 패키지는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서 행을 그대로
58
58
  * 넘기면 `[]` 였다 — 오류도 경고도 없이 저장이 통과했다. 부르는 둘이 마침 옮겨서 부른 덕에 멀쩡했는데,
59
59
  * 그건 계약이 아니라 우연이다(이 함수는 화면이 쓰라고 리졸버에서 다시 내보내진다).
60
60
  *
@@ -80,7 +80,7 @@ test('★ 두 모양이 같은 답을 낸다 — 옮기기가 갈리면 여기
80
80
  assert.deepEqual(asRow, asConfig)
81
81
  })
82
82
 
83
- test('섞여 들어와도 한 충돌로 본다 — 들여오기는 실제로 행과 꾸러미를 함께 넘긴다', () => {
83
+ test('섞여 들어와도 한 충돌로 본다 — 들여오기는 실제로 행과 패키지를 함께 넘긴다', () => {
84
84
  const mixed = intakeCollisions([
85
85
  { source: 'mes-line-a', connectionConfig: { ...intake } },
86
86
  { source: 'mes-line-b', config: { ...intake } }