@things-factory/headless-twin 10.1.17 → 10.1.20

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.
Files changed (35) hide show
  1. package/dist-server/service/actuation/command-dispatcher.d.ts +2 -0
  2. package/dist-server/service/actuation/command-dispatcher.js +21 -2
  3. package/dist-server/service/actuation/command-dispatcher.js.map +1 -1
  4. package/dist-server/service/reference/actuation-routing.js +26 -1
  5. package/dist-server/service/reference/actuation-routing.js.map +1 -1
  6. package/dist-server/service/reference/actuation-verification.d.ts +22 -0
  7. package/dist-server/service/reference/actuation-verification.js +28 -0
  8. package/dist-server/service/reference/actuation-verification.js.map +1 -0
  9. package/dist-server/service/reference/connection-portability.d.ts +23 -1
  10. package/dist-server/service/reference/connection-portability.js +38 -4
  11. package/dist-server/service/reference/connection-portability.js.map +1 -1
  12. package/dist-server/service/reference/reference-adapter.d.ts +70 -41
  13. package/dist-server/service/reference/reference-adapter.js +59 -30
  14. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  15. package/dist-server/service/reference/reference-resolver.js +16 -4
  16. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  17. package/dist-server/service/reference/twin-reference.d.ts +35 -0
  18. package/dist-server/service/reference/twin-reference.js +15 -0
  19. package/dist-server/service/reference/twin-reference.js.map +1 -1
  20. package/package.json +7 -7
  21. package/server/service/actuation/command-dispatcher.ts +25 -4
  22. package/server/service/reference/actuation-routing.ts +29 -2
  23. package/server/service/reference/actuation-verification.ts +43 -0
  24. package/server/service/reference/connection-portability.ts +49 -5
  25. package/server/service/reference/reference-adapter.ts +124 -67
  26. package/server/service/reference/reference-resolver.ts +17 -4
  27. package/server/service/reference/twin-reference.ts +47 -0
  28. package/test/actuation-approval-door.test.ts +18 -2
  29. package/test/actuation-seam.test.ts +11 -5
  30. package/test/actuation-verification-state.test.ts +94 -0
  31. package/test/connector-capability-declaration.test.ts +60 -3
  32. package/test/rename-twin.test.ts +9 -1
  33. package/test/save-intake-collision.test.ts +91 -0
  34. package/test/twin-event-keys.test.ts +11 -1
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"twin-reference.js","sourceRoot":"","sources":["../../../server/service/reference/twin-reference.ts"],"names":[],"mappings":"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","sourcesContent":["import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'\nimport { ObjectType, Field, ID, Int } from 'type-graphql'\n\nimport { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'\n\n/*\n * TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).\n * 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.\n * inbound 전용(구조·상태). 아웃바운드 액추에이션은 command-routing(ActuationAdapter) 소유.\n * mappingSpec/scopeSpec 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통).\n * connectionConfig 는 자격 증명을 담으므로 암호화되는 텍스트 칸이다(칸 주석 참조).\n * 설계 SoT: operato-twin/design/plans/reference-management.md.\n * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)\n */\n@Entity()\n@Index('ix_twin_reference_0', (e: TwinReference) => [e.domain, e.source], { unique: true })\n@ObjectType({ description: 'A tenant-scoped connection to an external source system — the origin for ingesting twin instances (Face2).' })\nexport class TwinReference {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the reference record.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })\n domain?: Domain\n\n @RelationId((e: TwinReference) => e.domain)\n domainId?: string\n\n @Column()\n @Field({ description: 'Reference source id (unique within a domain).' })\n source: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Domain kernel system: wms | yms | mes.' })\n system?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Human-readable site/system name.' })\n siteName?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Optional description.' })\n description?: string\n\n @Column()\n @Field({ description: \"Adapter type registry key (e.g. 'virtual' | 'rest' | 'db' | 'epcis').\" })\n adapterType: string\n\n /**\n * 이 연결에 필요한 값 — **자격 증명이 여기 들어온다. 그래서 저장될 때 암호화된다.**\n *\n * ── 왜 표에 두나 (2026-09-09) ─────────────────────────────────────────────\n * 비밀값은 환경에 두고 이름으로 고르는 것이 원칙이다(§`webhookSecretCandidates`). 그 원칙은\n * **설치본마다 하나인 키**에 맞는다 — 배포할 때 넣으면 된다.\n *\n * 트윈의 연결은 그 모양이 아니다. **사람이 화면에서 연결을 만든다.** 새 공장을 붙이려고 재배포할\n * 수는 없으므로, 연결마다 다른 이 값은 표에 있어야 한다. 그 자리에서 원칙이 갈린다:\n *\n * 설치본마다 하나인 키 환경변수. 이름이 어느 상대 것인지 말한다\n * 연결마다 다른 값 표. 대신 저장될 때 암호화한다\n *\n * ── 무엇을 지키고 무엇을 못 지키나 ────────────────────────────────────────\n * 저장된 것을 지킨다 — DB 파일 · 백업 · 복제본. 2026-09-09 에 이 칸에서 `hookSecret` 과 접속\n * 토큰을 명령 두 개로 읽었다.\n *\n * **프로세스를 돌릴 수 있는 사람에게서는 못 지킨다** — 그 사람은 키를 갖고 있다. 그리고 값이\n * 화면으로 나가는 것도 이 칸이 막지 않는다: 내보내는 쪽이 어댑터 스키마의 `secret: true` 를 보고\n * 가린다(§`reference-resolver` 의 상세 조회). **두 가지가 갈려 있으므로 한쪽만 하면 반쪽이다.**\n *\n * 기존 평문 행은 안 깨진다 — 변환기가 암호문 모양이 아닌 값을 알아보고 파싱하며 경고를 남기고,\n * 다시 저장될 때 암호화된다.\n */\n @Column({ nullable: true, ...encryptedJsonColumn() })\n @Field(type => ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params). Encrypted at rest; secret-declared fields are masked on the way out.' })\n connectionConfig?: any\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Declarative legacy→canonical mapping rules (kernel face2-adapter AdapterRule[]).' })\n mappingSpec?: any\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: '1:N fan-out scope (discovered sites). Multiple sites → N instances.' })\n scopeSpec?: any\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Lifecycle status: draft | connected | error.' })\n status?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last successful master sync time (ISO 8601 string; portable across DB drivers).' })\n lastSyncedAt?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last connection/sync error message, if any.' })\n lastError?: string\n\n /**\n * 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다).\n *\n * 세지 않은 구조 인제스트는 도크를 랙으로 바꿔 놓고도 성공이라 답하면서 아무 일도 하지 않는다. 그 사실을 사후에 물을 수\n * 있어야 단계 판정이 「구조는 됐다」를 말할 수 있다 — 없는 동안 판정은 늘 `unmeasured` 였다.\n * 기본값을 두지 않는다: 0 을 기본으로 깔면 「경고 없었다」와 「세지 않았다」가 같아진다.\n */\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Number of values the last sync could not carry over (mapping fallbacks). Null means they were not counted at all, which is different from zero.' })\n lastWarningCount?: number\n\n /**\n * **어디까지 읽었나** — 라이브 피드의 읽기 커서(§`LiveFeedContinuity`).\n *\n * ── 왜 저장하나 (2026-08-23 실측) ──────────────────────────────────────────\n * 어댑터가 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들고 있었다. 그래서 **재기동마다\n * 미러의 과거가 잘렸다** — 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는 것이 「원본의 사실」이\n * 아니라 「창의 함수」였고, 그 사실을 아무도 말하지 않았다.\n *\n * 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`): 「원천이 애초에 다시 말해 주지 않는 축」은\n * 재기동 연속성으로 이어받는다. 「우리가 어디까지 읽었나」가 정확히 그 성질이므로 **어댑터의 사물함이\n * 아니라 이 층**에 있다 — 원본이 늘 때마다 저장 기제가 늘고 그중 하나가 알리지 않고 다르게 동작하지 않게.\n *\n * ── 왜 캐시가 아니라 표인가 ────────────────────────────────────────────────\n * 만료되면 창이 다시 미끄러지고, 그 손실은 오류 없이 들어온 것이 없다. 스냅샷 체크포인트와 성질이 다르다\n * (그쪽은 잃어도 원천이 정정해 준다 — 이 값은 **잃으면 원천에 묻지 않게 된다**).\n *\n * 모양은 `{ streams: { [흐름]: { since?, seen[] } }, firstAttachedAt? }` 다. **흐름 이름은 어댑터가\n * 정한다** — 원본마다 흐름 수와 뜻이 다르므로 이 층은 열쇠로만 다룬다. `simple-json` 이라 드라이버\n * 다섯을 그대로 지난다.\n *\n * `firstAttachedAt` 은 「언제부터 아는가」다. 커널은 상한만 안다(`nowTime` = 마지막으로 들은 시각).\n * 둘이 함께 「이 트윈이 아는 구간」이고, 화면이 수를 보일 때 그 구간을 말해야 한다.\n */\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Live feed read cursor carried across restarts, keyed by adapter-defined stream. Null means the feed has never attached, and the next attach decides its first window.' })\n liveCursor?: any\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Timestamp when the reference was first created.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Timestamp when the reference row was last updated.' })\n updatedAt?: Date\n}\n"]}
1
+ {"version":3,"file":"twin-reference.js","sourceRoot":"","sources":["../../../server/service/reference/twin-reference.ts"],"names":[],"mappings":"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","sourcesContent":["import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'\nimport { ObjectType, Field, ID, Int } from 'type-graphql'\n\nimport type { RefusalCode, RefusalParams } from '@operato/ops-contract'\n\nimport { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'\n\n/** 조치 검증 상태 — 「아직 안 해 봤다」는 실패가 아니라 그 자체로 하나의 사실이다. */\nexport type ActuationState = 'unverified' | 'verified' | 'failed'\n\n/** 마지막 거절 — 언어 중립 코드와 그 값들. 사람 문장은 담지 않는다. */\nexport interface ActuationFailure {\n code: RefusalCode\n /** 거절된 시각 (ISO 8601). */\n at: string\n params?: RefusalParams\n}\n\n/*\n * TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).\n * 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.\n * inbound 전용(구조·상태). 아웃바운드 액추에이션은 command-routing(ActuationAdapter) 소유.\n * mappingSpec/scopeSpec 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통).\n * connectionConfig 는 자격 증명을 담으므로 암호화되는 텍스트 칸이다(칸 주석 참조).\n * 설계 SoT: operato-twin/design/plans/reference-management.md.\n * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)\n */\n@Entity()\n@Index('ix_twin_reference_0', (e: TwinReference) => [e.domain, e.source], { unique: true })\n@ObjectType({ description: 'A tenant-scoped connection to an external source system — the origin for ingesting twin instances (Face2).' })\nexport class TwinReference {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the reference record.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })\n domain?: Domain\n\n @RelationId((e: TwinReference) => e.domain)\n domainId?: string\n\n @Column()\n @Field({ description: 'Reference source id (unique within a domain).' })\n source: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Domain kernel system: wms | yms | mes.' })\n system?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Human-readable site/system name.' })\n siteName?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Optional description.' })\n description?: string\n\n @Column()\n @Field({ description: \"Adapter type registry key (e.g. 'virtual' | 'rest' | 'db' | 'epcis').\" })\n adapterType: string\n\n /**\n * 이 연결에 필요한 값 — **자격 증명이 여기 들어온다. 그래서 저장될 때 암호화된다.**\n *\n * ── 왜 표에 두나 (2026-09-09) ─────────────────────────────────────────────\n * 비밀값은 환경에 두고 이름으로 고르는 것이 원칙이다(§`webhookSecretCandidates`). 그 원칙은\n * **설치본마다 하나인 키**에 맞는다 — 배포할 때 넣으면 된다.\n *\n * 트윈의 연결은 그 모양이 아니다. **사람이 화면에서 연결을 만든다.** 새 공장을 붙이려고 재배포할\n * 수는 없으므로, 연결마다 다른 이 값은 표에 있어야 한다. 그 자리에서 원칙이 갈린다:\n *\n * 설치본마다 하나인 키 환경변수. 이름이 어느 상대 것인지 말한다\n * 연결마다 다른 값 표. 대신 저장될 때 암호화한다\n *\n * ── 무엇을 지키고 무엇을 못 지키나 ────────────────────────────────────────\n * 저장된 것을 지킨다 — DB 파일 · 백업 · 복제본. 2026-09-09 에 이 칸에서 `hookSecret` 과 접속\n * 토큰을 명령 두 개로 읽었다.\n *\n * **프로세스를 돌릴 수 있는 사람에게서는 못 지킨다** — 그 사람은 키를 갖고 있다. 그리고 값이\n * 화면으로 나가는 것도 이 칸이 막지 않는다: 내보내는 쪽이 어댑터 스키마의 `secret: true` 를 보고\n * 가린다(§`reference-resolver` 의 상세 조회). **두 가지가 갈려 있으므로 한쪽만 하면 반쪽이다.**\n *\n * 기존 평문 행은 안 깨진다 — 변환기가 암호문 모양이 아닌 값을 알아보고 파싱하며 경고를 남기고,\n * 다시 저장될 때 암호화된다.\n */\n @Column({ nullable: true, ...encryptedJsonColumn() })\n @Field(type => ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params). Encrypted at rest; secret-declared fields are masked on the way out.' })\n connectionConfig?: any\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Declarative legacy→canonical mapping rules (kernel face2-adapter AdapterRule[]).' })\n mappingSpec?: any\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: '1:N fan-out scope (discovered sites). Multiple sites → N instances.' })\n scopeSpec?: any\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Lifecycle status: draft | connected | error.' })\n status?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last successful master sync time (ISO 8601 string; portable across DB drivers).' })\n lastSyncedAt?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last connection/sync error message, if any.' })\n lastError?: string\n\n /**\n * **조치를 이 주소로 실제로 넘겨 봤나** (ADR-0046 ②).\n *\n * ── 왜 세 값인가 ───────────────────────────────────────────────────────────\n * 설정이 맞는 것과 저쪽이 받는 것은 다르다. 주소도 비밀값도 다 적혀 있는데 저쪽 프로세스가 그 값을\n * 잃은 일이 실제로 있었다(§`actuationReadiness`). 그것은 **한 번 넘겨 봐야** 알 수 있고, 그래서\n * 「아직 안 해 봤다」와 「해 봤고 됐다」와 「해 봤고 안 됐다」가 다른 사실이다.\n *\n * unverified 아직 왕복이 없다 — 실패가 아니다. 새 연결과 설정이 바뀐 연결이 여기 선다\n * verified `actuate` 가 받아들여졌다. 그 시각이 `actuationVerifiedAt`\n * failed 거절됐다. 사유는 `actuationFailure` 에 **코드로**\n *\n * `unverified` 를 `failed` 로 접지 않는다 — 안 해 본 것을 실패로 그리면 사람이 고칠 것을 찾으러\n * 가고, 고칠 것이 없다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: \"Whether an actuation has actually reached this peer: 'unverified' | 'verified' | 'failed'. Null means the same as unverified; a connection that has never been tried is not a failed one.\" })\n actuationState?: ActuationState\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When an actuation was last accepted by the peer (ISO 8601 string; portable across DB drivers). Null while unverified.' })\n actuationVerifiedAt?: string\n\n /**\n * 마지막 거절 — **코드와 값만 저장한다. 문장은 저장하지 않는다.**\n *\n * `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역이 안 되고, 무엇이\n * 몇 번 났는지 세지도 못하고, 커넥터가 문장을 고치는 날 옛 행과 새 행이 서로 다른 말을 한다.\n * 코드는 `CommandAck.errorCode` 와 같은 낱말이다(ADR-0042 ③) — 표현층이 문장을 만든다.\n */\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Why the last actuation was refused: { code, at, params? }. Language-neutral codes only — the presentation layer builds the sentence. Null when the last attempt was accepted or none was made.' })\n actuationFailure?: ActuationFailure\n\n /**\n * 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다).\n *\n * 세지 않은 구조 인제스트는 도크를 랙으로 바꿔 놓고도 성공이라 답하면서 아무 일도 하지 않는다. 그 사실을 사후에 물을 수\n * 있어야 단계 판정이 「구조는 됐다」를 말할 수 있다 — 없는 동안 판정은 늘 `unmeasured` 였다.\n * 기본값을 두지 않는다: 0 을 기본으로 깔면 「경고 없었다」와 「세지 않았다」가 같아진다.\n */\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Number of values the last sync could not carry over (mapping fallbacks). Null means they were not counted at all, which is different from zero.' })\n lastWarningCount?: number\n\n /**\n * **어디까지 읽었나** — 라이브 피드의 읽기 커서(§`LiveFeedContinuity`).\n *\n * ── 왜 저장하나 (2026-08-23 실측) ──────────────────────────────────────────\n * 어댑터가 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들고 있었다. 그래서 **재기동마다\n * 미러의 과거가 잘렸다** — 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는 것이 「원본의 사실」이\n * 아니라 「창의 함수」였고, 그 사실을 아무도 말하지 않았다.\n *\n * 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`): 「원천이 애초에 다시 말해 주지 않는 축」은\n * 재기동 연속성으로 이어받는다. 「우리가 어디까지 읽었나」가 정확히 그 성질이므로 **어댑터의 사물함이\n * 아니라 이 층**에 있다 — 원본이 늘 때마다 저장 기제가 늘고 그중 하나가 알리지 않고 다르게 동작하지 않게.\n *\n * ── 왜 캐시가 아니라 표인가 ────────────────────────────────────────────────\n * 만료되면 창이 다시 미끄러지고, 그 손실은 오류 없이 들어온 것이 없다. 스냅샷 체크포인트와 성질이 다르다\n * (그쪽은 잃어도 원천이 정정해 준다 — 이 값은 **잃으면 원천에 묻지 않게 된다**).\n *\n * 모양은 `{ streams: { [흐름]: { since?, seen[] } }, firstAttachedAt? }` 다. **흐름 이름은 어댑터가\n * 정한다** — 원본마다 흐름 수와 뜻이 다르므로 이 층은 열쇠로만 다룬다. `simple-json` 이라 드라이버\n * 다섯을 그대로 지난다.\n *\n * `firstAttachedAt` 은 「언제부터 아는가」다. 커널은 상한만 안다(`nowTime` = 마지막으로 들은 시각).\n * 둘이 함께 「이 트윈이 아는 구간」이고, 화면이 수를 보일 때 그 구간을 말해야 한다.\n */\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Live feed read cursor carried across restarts, keyed by adapter-defined stream. Null means the feed has never attached, and the next attach decides its first window.' })\n liveCursor?: any\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Timestamp when the reference was first created.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Timestamp when the reference row was last updated.' })\n updatedAt?: Date\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.1.17",
3
+ "version": "10.1.20",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -29,11 +29,11 @@
29
29
  "dependencies": {
30
30
  "@operato/ops-contract": "^0.9.20",
31
31
  "@operato/twin-kernel": "^0.11.12",
32
- "@things-factory/auth-base": "^10.1.17",
33
- "@things-factory/cache-service": "^10.1.17",
34
- "@things-factory/env": "^10.1.3",
35
- "@things-factory/ingest": "^10.1.17",
36
- "@things-factory/shell": "^10.1.16"
32
+ "@things-factory/auth-base": "^10.1.20",
33
+ "@things-factory/cache-service": "^10.1.20",
34
+ "@things-factory/env": "^10.1.20",
35
+ "@things-factory/ingest": "^10.1.20",
36
+ "@things-factory/shell": "^10.1.20"
37
37
  },
38
- "gitHead": "0c8214182702162ceb5398bdcf42ef14ea672394"
38
+ "gitHead": "6bd01660a4b28554bbe6ae4ee58f3ff844c5af69"
39
39
  }
@@ -392,13 +392,34 @@ function assertNotSelfApproval(command: StoredCommand, by: string): void {
392
392
  if (!proposer) return
393
393
 
394
394
  if (proposer.toLowerCase() === String(by ?? '').trim().toLowerCase()) {
395
- throw new Error(
396
- `낸 사람이 자기 조치를 승인할 수 없다 (${proposer}) — 되돌릴 수 없는 조치에는 다른 사람의 승인이 필요하다. ` +
397
- '승인자가 없다면 결재선에 승인자를 지정하거나, 다른 사람이 승인해야 한다'
398
- )
395
+ throw selfApprovalRefused(proposer)
399
396
  }
400
397
  }
401
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
+
402
423
  /** 승인 기록만 바뀌고 나머지는 그대로 간다 — 하나라도 흘리면 그 사실이 사라진다. */
403
424
  function carried(command: StoredCommand): Partial<StoredCommand> {
404
425
  return {
@@ -22,7 +22,9 @@ import type { ApprovedCommand } from '@operato/ops-contract'
22
22
  import { TwinInstance } from '../twin-instance/twin-instance.js'
23
23
 
24
24
  import { TwinReference } from './twin-reference.js'
25
- import { capabilitiesOf, getAdapter, type ActuationReadiness, type SiteDescriptor } from './reference-adapter.js'
25
+ import { twinError } from '../../engine/log.js'
26
+ import { actuationOutcomePatch } from './actuation-verification.js'
27
+ import { capabilitiesOf, getAdapter, type ActuationReadiness, type ActuationResult, type SiteDescriptor } from './reference-adapter.js'
26
28
  import { APPROVAL_STANDS, actuationTarget, isActuationAllowed, type ActuationTarget } from './actuation-target.js'
27
29
 
28
30
  /** 어댑터가 답하는 모양 그대로 — 디스패처가 이 값을 커맨드 행에 적는다. */
@@ -104,7 +106,32 @@ export async function routeActuation(
104
106
  }
105
107
 
106
108
  const site: SiteDescriptor = { siteId: target.siteId }
107
- return adapter.actuate(ref?.connectionConfig ?? {}, site, command)
109
+ const result = await adapter.actuate(ref?.connectionConfig ?? {}, site, command)
110
+
111
+ /*
112
+ * **왕복이 일어났다는 사실을 남긴다** (ADR-0046 ②).
113
+ *
114
+ * 설정이 맞는 것과 저쪽이 받는 것은 다르고, 그 차이는 한 번 넘겨 봐야만 안다. 승인 화면이 그것을
115
+ * 미리 말하려면 지난 왕복의 결과가 어딘가에 남아 있어야 한다 — 없으면 화면은 매번 「해 봐야 안다」만
116
+ * 말한다.
117
+ *
118
+ * 두 경로(`dispatchAfterApproval` · `sweepPendingDispatch`)가 다 이 함수를 지나므로 여기 한 곳이면
119
+ * 된다. 실패해도 조치 결과는 그대로 돌려준다 — 기록이 본업을 막으면 안 되고, 삼키지도 않는다.
120
+ */
121
+ if (ref) await recordActuationOutcome(ref, result).catch(err => twinError('[reference] actuation state not recorded', err))
122
+
123
+ return result
124
+ }
125
+
126
+ /**
127
+ * 조치 결과를 레퍼런스에 적는다 — **코드만, 문장은 아니다.**
128
+ *
129
+ * 거절 사유에 코드가 없으면 `actuationFailure` 를 비워 둔다. 자리를 채우려고 `error` 문장을 옮기면
130
+ * `lastError` 가 이미 겪은 것을 반복한다 — 저장된 문장은 번역도 집계도 안 되고, 커넥터가 그 문장을
131
+ * 고치는 날 옛 행과 새 행이 다른 말을 한다. **모르는 것은 안 적는다.**
132
+ */
133
+ async function recordActuationOutcome(ref: TwinReference, result: ActuationResult): Promise<void> {
134
+ await getRepository(TwinReference).update({ id: ref.id }, actuationOutcomePatch(result, new Date().toISOString()))
108
135
  }
109
136
 
110
137
  /**
@@ -0,0 +1,43 @@
1
+ /*
2
+ * 조치 왕복의 결과를 레퍼런스에 어떻게 적을 것인가 — **순수하게.**
3
+ *
4
+ * `actuation-routing.ts` 에 두면 시험이 그 파일을 들여야 하고, 그 파일은 엔티티를 값으로 들인다
5
+ * (데코레이터가 붙은 클래스). 가벼운 모듈로 떼어 두면 판단만 따로 확인할 수 있다.
6
+ *
7
+ * 여기서 들이는 것은 **타입뿐**이다 — 런타임에 남지 않는다.
8
+ */
9
+ import type { ActuationFailure, ActuationState } from './twin-reference.js'
10
+ import type { ActuationResult } from './reference-adapter.js'
11
+
12
+ /** 레퍼런스에 적을 칸들. `actuationVerifiedAt` 이 없는 것은 「건드리지 않는다」는 뜻이다. */
13
+ export interface ActuationOutcomePatch {
14
+ actuationState: ActuationState
15
+ actuationVerifiedAt?: string
16
+ actuationFailure: ActuationFailure | null
17
+ }
18
+
19
+ /**
20
+ * 결과 → 적을 것.
21
+ *
22
+ * ── 세 가지를 지킨다 ───────────────────────────────────────────────────────
23
+ * **받아들여지면 지난 거절을 지운다.** 남겨 두면 화면이 「됐는데 실패 사유가 있다」를 그린다.
24
+ *
25
+ * **거절은 성공 시각을 건드리지 않는다.** 「전에 한 번 닿았다」와 「이번에 안 됐다」는 둘 다 사실이고,
26
+ * 지우면 앞엣것이 사라진다 — 사람이 「한 번이라도 닿은 적 있나」를 물을 자리가 없어진다.
27
+ *
28
+ * **코드가 없으면 사유를 비운다.** 자리를 채우려고 `error` 문장을 옮기지 않는다. `lastError` 가 이미
29
+ * 겪었다 — 저장된 사람 문장은 번역도 집계도 안 되고, 커넥터가 그 문장을 고치는 날 옛 행과 새 행이
30
+ * 서로 다른 말을 한다. 모르는 것은 안 적는다.
31
+ */
32
+ export function actuationOutcomePatch(result: ActuationResult, at: string): ActuationOutcomePatch {
33
+ if (result.ok) {
34
+ return { actuationState: 'verified', actuationVerifiedAt: at, actuationFailure: null }
35
+ }
36
+
37
+ return {
38
+ actuationState: 'failed',
39
+ actuationFailure: result.errorCode
40
+ ? { code: result.errorCode, at, ...(result.errorParams ? { params: result.errorParams } : {}) }
41
+ : null
42
+ }
43
+ }
@@ -205,9 +205,8 @@ export function planImport(
205
205
  * 잡습니다 — 그게 실제로 더 쉽게 나는 쪽입니다(하나는 화면에서, 하나는 파일에서 생깁니다).
206
206
  */
207
207
  const after = [
208
- ...existing
209
- .filter(e => !seen.has(e.source))
210
- .map(e => ({ source: e.source, config: (e.connectionConfig ?? {}) as Record<string, unknown> })),
208
+ /* 행 모양 그대로 넘긴다 — 옮기는 자리는 `intakeCollisions` 안 한 곳이다(ADR-0053 ①). */
209
+ ...existing.filter(e => !seen.has(e.source)),
211
210
  ...actions
212
211
  .filter((a): a is Extract<ImportAction, { connection: PortableConnection }> => a.action !== 'skip')
213
212
  .map(a => ({ source: a.connection.source, config: a.connection.config }))
@@ -267,11 +266,28 @@ export function secretEnvName(source: string, key: string): string {
267
266
  * 그대로 따라옵니다.
268
267
  */
269
268
  export function intakeCollisions(
270
- connections: readonly { source: string; adapterType?: string; config?: Record<string, unknown> }[]
269
+ connections: readonly {
270
+ source: string
271
+ adapterType?: string
272
+ /** 꾸러미·계획이 쓰는 이름. */
273
+ config?: Record<string, unknown> | null
274
+ /** **DB 행이 쓰는 이름** — `TwinReference` 의 칸이다. 둘 다 받는다(ADR-0053 ①). */
275
+ connectionConfig?: Record<string, unknown> | null
276
+ }[]
271
277
  ): { intake: string; sources: string[] }[] {
272
278
  const byIntake = new Map<string, string[]>()
273
279
  for (const c of connections) {
274
- const cfg = c.config ?? {}
280
+ /*
281
+ * **모양이 둘인 것을 여기서 안다** (ADR-0053 ①, 2026-09-16).
282
+ *
283
+ * 꾸러미는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을
284
+ * 그대로 넘기면 같은 충돌에도 `[]` 를 냈다 — 오류도 경고도 없이 **저장이 통과하고 가드는 선 채로
285
+ * 아무 일도 안 한다.** 부르는 쪽마다 옮기게 두면 다음 소비처가 또 밟는다(이 함수는 화면이 쓰라고
286
+ * 리졸버에서 다시 내보내진다).
287
+ *
288
+ * 「없다」가 편한 답인 자리라 더 위험하다 — 빨강이 아니라 **조용한 초록**으로 흐른다.
289
+ */
290
+ const cfg = c.config ?? c.connectionConfig ?? {}
275
291
  /* 받는 자리를 정하는 규칙은 커넥터의 것과 같아야 합니다 — 여기서 다시 지으면 두 벌이 됩니다. */
276
292
  const intake = String(cfg.intakeSiteId ?? cfg.siteId ?? '').trim()
277
293
  /* 조치를 낼 수 없는 연결은 부딪힐 것이 없습니다 — 주소가 없으면 아무 데도 안 갑니다. */
@@ -283,3 +299,31 @@ export function intakeCollisions(
283
299
  .filter(([, sources]) => sources.length > 1)
284
300
  .map(([intake, sources]) => ({ intake, sources: sources.sort() }))
285
301
  }
302
+
303
+ /**
304
+ * **화면 저장의 문** — 이 연결을 이 설정으로 저장하면 다른 연결과 받는 사이트가 겹치나 (ADR-0046 ②-1).
305
+ *
306
+ * 들여오기(`planImport`)는 `intakeCollisions` 로 거부하는데 화면 저장(`saveTwinReference`)은 부르지 않아서,
307
+ * 연결을 베껴 이름만 바꾸면 경고 0 으로 두 트윈의 조치가 한 공장에 섰다. 판정은 같은 함수 하나로 한다.
308
+ * 경고가 아니라 거부이고, 사람이 그 자리에서 고치도록 **이미 그 사이트를 쓰는 연결의 이름**을 싣는다.
309
+ */
310
+ export function refuseIntakeCollision(
311
+ source: string,
312
+ config: Record<string, unknown> | undefined,
313
+ others: readonly { source: string; siteName?: string | null; connectionConfig?: Record<string, unknown> | null }[]
314
+ ): { code: 'intake-site-taken'; message: string; params: { site: string; connection: string } } | null {
315
+ const rest = others.filter(o => o.source !== source)
316
+ /* `others` 는 DB 행이다 — 옮기지 않고 그대로 넘긴다(ADR-0053 ①). */
317
+ const hit = intakeCollisions([...rest, { source, config: config ?? {} }]).find(c => c.sources.includes(source))
318
+ if (!hit) return null
319
+ const connection = rest
320
+ .filter(o => hit.sources.includes(o.source))
321
+ .map(o => o.siteName || o.source)
322
+ .join(', ')
323
+ const site = String(config?.intakeSiteId ?? config?.siteId ?? '').trim()
324
+ return {
325
+ code: 'intake-site-taken',
326
+ message: `intake site ${site} is already used by ${connection}`,
327
+ params: { site, connection }
328
+ }
329
+ }
@@ -1,4 +1,4 @@
1
- import type { ApprovedCommand } from '@operato/ops-contract'
1
+ import type { ApprovedCommand, RefusalCode, RefusalParams } from '@operato/ops-contract'
2
2
 
3
3
  import type { ReferenceStep } from './reference-progress.js'
4
4
  import type { ReferenceMaster } from './reference-master.js'
@@ -482,6 +482,63 @@ export interface ActuationReadiness {
482
482
 
483
483
  import type { LiveCadence } from './live-cadence.js'
484
484
 
485
+ /**
486
+ * 조치를 넘긴 결과 — **어댑터가 돌려주는 것.**
487
+ *
488
+ * 이름을 붙인 이유는 두 가지다. 익명 타입이라 소비처가 그 모양을 말할 수 없었고(`actuate` 를 감싸는
489
+ * 쪽이 반환형을 다시 적어야 했다), 그리고 **거절 사유를 코드로 실을 자리가 없었다** — `error` 는 사람
490
+ * 문장이라 저장하면 번역과 판독이 함께 굳는다.
491
+ *
492
+ * `errorCode`·`errorParams` 는 `CommandAck` 과 **같은 낱말**이다(ADR-0037 ④ · ADR-0042 ③). 셋째 이름을
493
+ * 만들지 않는다 — 같은 것을 세 이름으로 부르면 매핑 표가 생기고, 그 표는 커넥터가 새 코드를 내는 날
494
+ * 「알 수 없는 사유」로 떨어진다.
495
+ */
496
+ export interface ActuationResult {
497
+ ok: boolean
498
+ ref?: string
499
+ /**
500
+ * **왜 거절됐나 — 언어 중립 코드.** 저장되는 값은 이것이고 `error` 는 아니다.
501
+ *
502
+ * `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역도 못 하고,
503
+ * 무엇이 났는지 세지도 못하고, 문장을 고치는 순간 옛 행과 새 행이 달라진다.
504
+ */
505
+ errorCode?: RefusalCode
506
+ /** 그 코드가 가리키는 원시값들 — 표현층이 문장에 끼운다. */
507
+ errorParams?: RefusalParams
508
+ /**
509
+ * **받아들였으나 남은 것이 있다** — 실패가 아니다. 사람이 저쪽에서 해야 할 일이 있으면 여기 적는다.
510
+ *
511
+ * `error` 에 적지 말 것 — 실패로 읽혀 커맨드가 `failed` 로 앉고, 다시 넘기게 된다. 실제로 MES 가
512
+ * 「지시서를 못 붙였다」를 답했을 때 커넥터가 적을 칸이 없어 로그로만 남겼고, 트윈 쪽에서는 성공한
513
+ * 조치와 구별되지 않았다.
514
+ */
515
+ note?: string
516
+ /**
517
+ * 그 말 중 **다음에 할 일** 한 줄 — 원인은 위 `note` 다.
518
+ *
519
+ * 붙여 보내지 말 것. 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾고, 한 문장으로 오면
520
+ * **화면이 자르게 되며 자르는 규칙이 화면마다 생긴다.** 어댑터는 이미 둘로 알고 있다.
521
+ */
522
+ noteNext?: string
523
+ error?: string
524
+ /**
525
+ * **다시 해서 될 일인가** — 실패했을 때만.
526
+ *
527
+ * again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다
528
+ * after-fix 사람이 고친 뒤 그대로 나간다 설정 문제
529
+ * never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다
530
+ *
531
+ * 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다. 설정이 틀린 것은 조치가 멀쩡하므로
532
+ * `after-fix`, 지시 내용이 틀린 것은 그 조치가 영원히 틀렸으므로 `never` 다.
533
+ *
534
+ * **문장에 담지 말 것** — 일꾼이 그것을 쓰려면 파싱해야 하고, 번역되면 깨진다.
535
+ *
536
+ * 말하지 않으면 일꾼이 **집지 않는다.** 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이
537
+ * 포기하고, `again` 으로 접으면 없는 자재를 끝없이 두드린다.
538
+ */
539
+ retry?: 'again' | 'after-fix' | 'never'
540
+ }
541
+
485
542
  export interface ReferenceAdapter {
486
543
  /** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */
487
544
  type: string
@@ -603,46 +660,7 @@ export interface ReferenceAdapter {
603
660
  * `ref` 를 **반드시** 돌려준다. 그것이 없으면 넘긴 것이 저쪽에서 무엇이 되었는지 되짚을 수 없고,
604
661
  * 되돌릴 때 무엇을 되돌릴지 말할 수 없다.
605
662
  */
606
- actuate?(
607
- cfg: ConnectionConfig,
608
- site: SiteDescriptor,
609
- command: ApprovedCommand
610
- ): Promise<{
611
- ok: boolean
612
- ref?: string
613
- /**
614
- * **받아들였으나 남은 것이 있다** — 실패가 아니다. 사람이 저쪽에서 해야 할 일이 있으면 여기 적는다.
615
- *
616
- * `error` 에 적지 말 것 — 실패로 읽혀 커맨드가 `failed` 로 앉고, 다시 넘기게 된다. 실제로 MES 가
617
- * 「지시서를 못 붙였다」를 답했을 때 커넥터가 적을 칸이 없어 로그로만 남겼고, 트윈 쪽에서는 성공한
618
- * 조치와 구별되지 않았다.
619
- */
620
- note?: string
621
- /**
622
- * 그 말 중 **다음에 할 일** 한 줄 — 원인은 위 `note` 다.
623
- *
624
- * 붙여 보내지 말 것. 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾고, 한 문장으로 오면
625
- * **화면이 자르게 되며 자르는 규칙이 화면마다 생긴다.** 어댑터는 이미 둘로 알고 있다.
626
- */
627
- noteNext?: string
628
- error?: string
629
- /**
630
- * **다시 해서 될 일인가** — 실패했을 때만.
631
- *
632
- * again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다
633
- * after-fix 사람이 고친 뒤 그대로 나간다 설정 문제
634
- * never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다
635
- *
636
- * 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다. 설정이 틀린 것은 조치가 멀쩡하므로
637
- * `after-fix`, 지시 내용이 틀린 것은 그 조치가 영원히 틀렸으므로 `never` 다.
638
- *
639
- * **문장에 담지 말 것** — 일꾼이 그것을 쓰려면 파싱해야 하고, 번역되면 깨진다.
640
- *
641
- * 말하지 않으면 일꾼이 **집지 않는다.** 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이
642
- * 포기하고, `again` 으로 접으면 없는 자재를 끝없이 두드린다.
643
- */
644
- retry?: 'again' | 'after-fix' | 'never'
645
- }>
663
+ actuate?(cfg: ConnectionConfig, site: SiteDescriptor, command: ApprovedCommand): Promise<ActuationResult>
646
664
 
647
665
  /**
648
666
  * **저쪽이 조치를 받을 준비가 됐나** — 보내기 전에 묻는다. 선택이다.
@@ -849,19 +867,42 @@ export function groundingOf(adapter: ReferenceAdapter | undefined): {
849
867
  return { level: g.level, ...(against ? { verifiedAgainst: against } : {}) }
850
868
  }
851
869
 
852
- export function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] {
853
- if (!adapter) return []
854
- const declared = new Set(adapter.capabilities ?? [])
855
- const out: ReferenceCapability[] = []
856
- if (declared.has('live') && typeof adapter.openLiveFeed === 'function') out.push('live')
857
- if (declared.has('control') && typeof adapter.control === 'function') out.push('control')
858
- if (declared.has('actuate') && typeof adapter.actuate === 'function') out.push('actuate')
870
+ /**
871
+ * 능력마다 **그것을 증명하는 메서드들** — 하나라도 있으면 성립한다.
872
+ *
873
+ * ── `live` 의 증명이 왜 둘인가 (2026-09-16, ADR-0036 ③ 개정) ─────────────────
874
+ * 예전에는 `openLiveFeed` 하나였다. 그런데 사실이 흘러 들어오는 방식은 **상대 시스템의 성질**이지
875
+ * 우리 능력의 종류가 아니다 — 우리가 당겨 오면 `openLiveFeed`, 상대가 웹훅으로 밀어 넣으면
876
+ * `handleInbound`(계약 일급 문)다. 둘 다 「이 원본에서 사실이 계속 온다」를 증명한다.
877
+ *
878
+ * 실측: `operato-plant` 은 outbox 가 밀어 넣는 쪽이라 `openLiveFeed` 가 없다. 그래서 **실 생산
879
+ * 사실을 나르는 유일한 커넥터가 판정에서 live 가 아니었다.** 등록 경고는 「구현했는데 선언 안 함」
880
+ * 한 방향만 봐서 아무 말도 하지 않았다.
881
+ */
882
+ const PROVED_BY: Record<ReferenceCapability, readonly string[]> = {
883
+ live: ['openLiveFeed', 'handleInbound'],
884
+ control: ['control'],
885
+ actuate: ['actuate'],
859
886
  /*
860
887
  * **선언만으로 성립한다** — 부를 메서드가 없다. 「두 번 해도 같은가」는 어댑터가 하는 일이 아니고
861
888
  * 저쪽 시스템의 성질이다. 그래서 구현 검사를 하지 않는다.
862
889
  */
863
- if (declared.has('idempotent-actuation')) out.push('idempotent-actuation')
864
- return out
890
+ 'idempotent-actuation': []
891
+ }
892
+
893
+ const CAPABILITIES = Object.keys(PROVED_BY) as ReferenceCapability[]
894
+
895
+ /** 그 능력을 증명하는 메서드가 실제로 붙어 있나. 증명이 필요 없는 능력은 언제나 참이다. */
896
+ function implemented(adapter: ReferenceAdapter, capability: ReferenceCapability): boolean {
897
+ const methods = PROVED_BY[capability]
898
+ if (methods.length === 0) return true
899
+ return methods.some(name => typeof (adapter as any)[name] === 'function')
900
+ }
901
+
902
+ export function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] {
903
+ if (!adapter) return []
904
+ const declared = new Set(adapter.capabilities ?? [])
905
+ return CAPABILITIES.filter(capability => declared.has(capability) && implemented(adapter, capability))
865
906
  }
866
907
 
867
908
  /* 어댑터 "타입" 레지스트리(전역·인메모리) — 레퍼런스 데이터는 DB(TwinReference), 어댑터 동작은 여기. */
@@ -888,18 +929,17 @@ const adapters = new Map<string, ReferenceAdapter>()
888
929
  */
889
930
  export function registerAdapterType(adapter: ReferenceAdapter): void {
890
931
  const declared = new Set(adapter.capabilities ?? [])
891
- /* 능력 이름과 그것을 증명하는 메서드 — **한 자리에 적는다.** 두 벌이면 새 능력이 한쪽에만 늘어난다. */
892
- const METHOD_OF: Record<ReferenceCapability, string> = {
893
- live: 'openLiveFeed',
894
- control: 'control',
895
- actuate: 'actuate',
896
- /* 성질 선언이라 부를 메서드가 없다 — 어댑터가 `capabilities` 에 적는 것으로 끝난다. */
897
- 'idempotent-actuation': ''
898
- }
899
- const undeclared = (Object.keys(METHOD_OF) as ReferenceCapability[]).filter(
900
- /* 메서드 이름이 없는 능력(선언만으로 성립하는 것)은 이 경고의 대상이 아니다. */
901
- c => METHOD_OF[c] !== '' && typeof (adapter as any)[METHOD_OF[c]] === 'function' && !declared.has(c)
902
- )
932
+
933
+ /*
934
+ * **어긋남은 양쪽으로 본다** (2026-09-16, ADR-0036 ③ 개정).
935
+ *
936
+ * 이 경고는 「구현했는데 선언 안 함」 한 방향만 봤다. 그래서 반대쪽 — 선언했는데 증명이 없는 것 —
937
+ * 은 `capabilitiesOf` 가 조용히 걸러 내기만 하고 아무도 그 사실을 듣지 못했다. 한 방향만 보면
938
+ * **있는 것을 없다고(이번 plant) 또는 없는 것을 있다고** 말하게 된다. 같은 무게로 말한다.
939
+ */
940
+ const undeclared = CAPABILITIES.filter(c => PROVED_BY[c].length > 0 && implemented(adapter, c) && !declared.has(c))
941
+ const unproven = CAPABILITIES.filter(c => PROVED_BY[c].length > 0 && declared.has(c) && !implemented(adapter, c))
942
+
903
943
  if (undeclared.length) {
904
944
  twinWarn(
905
945
  `[reference] connector "${adapter.type}" implements ${undeclared.join(' and ')} but does not declare it — ` +
@@ -907,6 +947,15 @@ export function registerAdapterType(adapter: ReferenceAdapter): void {
907
947
  'Until then the screens treat this connector as unable to do it, and a live twin cannot be created from it.'
908
948
  )
909
949
  }
950
+ if (unproven.length) {
951
+ twinWarn(
952
+ `[reference] connector "${adapter.type}" declares ${unproven.join(' and ')} but implements none of ` +
953
+ `${unproven.map(c => PROVED_BY[c].map(m => `${m}()`).join(' or ')).join(', ')} — ` +
954
+ 'the declaration is dropped, so the screens treat this connector as unable to do it. ' +
955
+ 'Implement one of those methods or remove the capability.'
956
+ )
957
+ }
958
+
910
959
  adapters.set(adapter.type, adapter)
911
960
  }
912
961
  export function getAdapter(type: string): ReferenceAdapter | undefined {
@@ -917,12 +966,20 @@ export function listAdapterTypes(): string[] {
917
966
  }
918
967
  /** 커넥터 메타데이터 목록(picker·연결 폼용). */
919
968
  /**
920
- * 등록된 어댑터 목록 — **선언한 능력도 함께 낸다.**
969
+ * 등록된 어댑터 목록 — **능력은 판정값이다. 선언 원본이 아니다.**
970
+ *
971
+ * 능력은 이 목록을 읽는 화면이 「이 원본에 무엇을 요구할 수 있나」를 판단하는 근거다.
972
+ *
973
+ * ── 왜 선언을 그대로 내지 않나 (2026-09-16, ADR-0036 ③ 개정) ────────────────
974
+ * 여기서 `a.capabilities` 를 그대로 냈다. 그래서 `capabilitiesOf` — 선언과 구현이 어긋나면 **없는
975
+ * 것으로 읽는다**는 그 문 — 의 판정값을 **읽는 자리가 하나도 없었다.** 화면은 선언을 보고 있었고,
976
+ * 어느 커넥터든 구현 없이 능력을 적기만 하면 가능한 것으로 보였다. 선언을 믿지 않으려고 만든 문을
977
+ * 아무도 지나지 않은 셈이다.
921
978
  *
922
- * 예전에는 `{ type, meta }` 만 냈습니다. 그런데 소비처(어댑터 목록 질의)가 `capabilities` 를 함께
923
- * 내보내고 있어 타입검사가 막혔고, 그 상태로는 개발 서버 빌드가 실패합니다. 능력은 이 목록을 읽는
924
- * 화면이 「이 원본에 무엇을 요구할 수 있나」를 판단하는 근거이므로, 빼지 않고 여기서 함께 냅니다.
979
+ * 커넥터 쪽에도 흔적이 남아 있었다 — `operato-plant` 주석이 「어댑터 목록은 선언한 값을 그대로
980
+ * 보여 주므로 남겨 두면 화면이 없는 능력을 있다고 말한다」며 선언을 손으로 줄여 두었다. 사람이
981
+ * 기억해서 맞추는 것은 다음 커넥터에서 끊긴다.
925
982
  */
926
983
  export function listAdapters(): { type: string; meta?: AdapterMeta; capabilities?: ReferenceCapability[] }[] {
927
- return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: a.capabilities }))
984
+ return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: capabilitiesOf(a) }))
928
985
  }
@@ -22,6 +22,7 @@ import {
22
22
  referenceNotFound, adapterUnknown, discoveryFailed, discoveryTimedOut
23
23
  } from './discovery-result.js'
24
24
  import { refuseImportSpace } from './ingest-space.js'
25
+ import { refuseIntakeCollision } from './connection-portability.js'
25
26
  import { startReferenceLiveFeed } from './reference-live.js'
26
27
  import { listTemplates, getTemplate } from './template-registry.js'
27
28
 
@@ -643,6 +644,21 @@ export class TwinReferenceResolver {
643
644
  const domainId = context.state.domain.id
644
645
  const repo = getRepository(TwinReference)
645
646
  const existing = await repo.findOne({ where: { domain: { id: domainId }, source } })
647
+ // 필드 단위 병합 — 편집 시 생략된 키(예: 마스킹된 secret)는 기존 값 유지.
648
+ const connectionConfig = patch.connectionConfig
649
+ ? { ...((existing?.connectionConfig as any) ?? {}), ...patch.connectionConfig }
650
+ : existing?.connectionConfig
651
+
652
+ /*
653
+ * 같은 받는 사이트를 가리키는 연결 둘은 **저장하는 순간 거부한다** (ADR-0046 ②-1).
654
+ * 들여오기(planImport)에는 있던 검사가 화면 저장에는 없어서, 연결을 베껴 이름만 바꾸면 두 트윈의
655
+ * 조치가 한 공장으로 갔다 — 저쪽은 200 으로 받고 실제로 만든다. 짝(intakeEndpoint·intakeSiteId)은
656
+ * 암호화된 칸 안에 있어 DB 유니크로 못 잡으니 여기서 잡는다(판정: connection-portability.ts).
657
+ */
658
+ const others = await repo.find({ where: { domain: { id: domainId } } })
659
+ const taken = refuseIntakeCollision(source, connectionConfig as any, others as any)
660
+ if (taken) return { ok: false, source, error: taken.message, errorCode: taken.code, errorParams: taken.params }
661
+
646
662
  const saved = await repo.save(
647
663
  repo.create({
648
664
  ...(existing ?? {}),
@@ -652,10 +668,7 @@ export class TwinReferenceResolver {
652
668
  siteName: patch.siteName ?? existing?.siteName,
653
669
  description: patch.description ?? existing?.description,
654
670
  adapterType: patch.adapterType ?? existing?.adapterType,
655
- // 필드 단위 병합 — 편집 시 생략된 키(예: 마스킹된 secret)는 기존 값 유지.
656
- connectionConfig: patch.connectionConfig
657
- ? { ...((existing?.connectionConfig as any) ?? {}), ...patch.connectionConfig }
658
- : existing?.connectionConfig,
671
+ connectionConfig,
659
672
  mappingSpec: patch.mappingSpec ?? existing?.mappingSpec,
660
673
  scopeSpec: patch.scopeSpec ?? existing?.scopeSpec,
661
674
  status: patch.status ?? existing?.status ?? 'draft'