import { ClassStereotype, MultiLangText, EmbeddableCatalog, LogicalModel, ModelId } from '../core/types'; import { OpShape } from '../command/op'; import { AssocHandle, GroupHandle, Handle, SymbolicOp } from './symbolicOp'; /** 해소 실패 — 어느 심볼릭 op(`opIndex`)의 어떤 핸들이 0개/복수 매칭인지. */ /** * 모호 엔티티 후보 — **판별 맥락을 함께 싣는다**. * * modelId 만 돌려주면 사람이 고를 근거가 없다(UUID 둘 사이에 우열이 없다). 실제로 stale/live 를 가르는 데 * 쓰인 축을 그대로 싣는다 — 패키지 소속·테이블·속성 수·참조 수, 그리고 스테레오타입(한 이름 아래 * `@Entity` 와 `@Embeddable` 이 섞인 형상이 실 데이터에 있다). 거부에 사람이 행동할 맥락을 동봉하는 것은 * 이 리포의 기존 패턴이다(`attribute-fk-managed` 가 소유 관계의 양 끝 이름을 싣는다). * * 고른 뒤 지목하는 수단은 `Handle.by:'modelId'`다 — 이 둘이 짝이라야 고리가 닫힌다. */ export interface EntityCandidate { modelId: ModelId; stereotype: ClassStereotype; /** 실효 패키지(명시 packageName 또는 소속 그룹의 packageName). 없으면 null — 코드젠과 같은 판별 단계. */ packageName: string | null; /** 테이블 물리명 **원문**. 미설정이면 null — 폴백하지 않는다(미설정 자체가 미완성 신호라서). */ table: string | null; attributes: number; /** 이 엔티티를 양 끝 중 하나로 갖는 관계 수 — 0이면 고립(버려진 쪽일 가능성). */ references: number; /** * 참조하는 상대 엔티티 식별자(이름 → 테이블 → modelId 순 폴백, 중복 제거). * * ★수치만으로는 갈리지 않는 실 형상이 있다 — 프로덕션 `module-catalog` 의 `Gift` 두 벌은 패키지·테이블· * 참조 수가 **전부 같고** 속성 수(6 vs 8)만 다르다. *누가 쓰는가*가 실제 판별 축이라 함께 싣는다 * (이름이 빈 엔티티가 실재해 테이블·modelId 로 폴백한다 — 없는 이름을 지어내지 않는다). */ referencedBy: string[]; } /** * 모호 그룹 후보 — 엔티티 축과 같은 이유로 판별 맥락을 싣는다(→ `EntityCandidate`). * 그룹은 테이블·속성이 없으므로 축이 다르다: 이름(다국어 원문)·패키지·멤버 수. */ export interface GroupCandidate { modelId: ModelId; /** 다국어 원문 그대로 — 표시 로케일은 소비처가 정한다(resolver는 로케일을 모른다). */ name: MultiLangText | null; packageName: string | null; members: number; } export type ResolveError = { code: 'entity-not-found'; opIndex: number; handle: Handle; } | { code: 'entity-ambiguous'; opIndex: number; handle: Handle; matches: EntityCandidate[]; } | { code: 'attribute-not-found'; opIndex: number; entity: Handle; handle: Handle; } | { code: 'attribute-ambiguous'; opIndex: number; entity: Handle; handle: Handle; matches: ModelId[]; } | { code: 'association-not-found'; opIndex: number; handle: AssocHandle; } | { code: 'association-ambiguous'; opIndex: number; handle: AssocHandle; matches: ModelId[]; } | { code: 'self-identifying'; opIndex: number; handle: AssocHandle; } /** * `association.add` 의 end 중 하나가 `JPA_EMBEDDABLE` — 임베더블은 독립 참조 대상이 아니라 owner 테이블로 * 평탄화되는 값이므로 그 관계는 **EMBED 여야** 한다(owner 의 `EMBED_OWN` 표시속성 + `end.composition` + * `end.attributeRef` 셋이 EMBED 판정의 유일한 근거다 — `core/sharedColumn.sharedColumnScopeEntities`· * `ddl.ts` 평탄화·코드젠 `@Embedded` 가 전부 그것을 본다). * * ★★**2026-09-13 — 범위가 «to 가 아니라 from» 으로 좁았다**(AA-5 S3 구현). 종전엔 end 중 하나라도 * 임베더블이면 전부 거부했는데, 그때의 근거는 *「resolver 에 EMBED 관계 생성 어휘가 없다」* 였고 그래서 * 통과시키면 *항상* 「스테레오타입은 임베더블인데 관계는 일반」 무신호 형상이 됐다(공유 통화 스코프가 * 자기 자신으로 좁아져 `SHARED_REF` 후보 0 · DDL 평탄화 없음 · 코드젠 `@Embedded` 없음 · * 실측 2026-08-25 프로덕션 4건 중 1건 증상·3건 잠복). **그 어휘가 생겼으므로 거부가 방출로 바뀌었다** — * 종전 주석이 *「어휘가 생기면 거부 대신 EMBED 방출로 바뀔 자리」* 라 예고한 그 자리다. * * ⇒ 남는 거부는 **`from` 만 임베더블**인 경우뿐이다. 그것은 임베더블을 **FK 부모**로 세우는 것이고 * 임베더블엔 테이블도 PK 도 없어 전파할 식별자가 없다 ⇒ 만들 관계가 존재하지 않는다. * (`to` 가 임베더블이면 — `from` 이 임베더블이어도 — EMBED 이고, 그 연쇄 임베드는 정당하다.) */ | { code: 'embeddable-relation-unsupported'; opIndex: number; handle: AssocHandle; } /** * EMBED 로 분기된 `association.add` 인데 `AssocSpec` 에 **임베드에 성립하지 않는 필드**가 실려 있다. * * ★**조용히 무시하지 않는 이유** — 이 리포가 반복해 대가를 치른 「조용한 반쪽 적용」이다. `identifying` * (식별 관계 = 부모 PK 를 자식 PK 로 전파)과 `composition`·`fromMultiplicity` 는 **FK 관계의 축**이고 * 임베드엔 대응물이 없다(임베드의 소유 관계는 `end2.composition=true` 고정이고, 카디널리티 권위는 * **임베더블을 가리키는 end** 하나다 — `editor/association.setEmbedCardinality` 의 결정 3). * 보내 놓고 안 먹히면 호출자는 *「먹었다」* 고 믿는다 ⇒ 거부해서 그 자리를 지목한다. * * ⚠️**`toMultiplicity` 는 여기 들어오지 않는다** — 그쪽이 곧 카디널리티 축이다(단일 `@Embedded` ↔ * 컬렉션 `@ElementCollection`). 부적합이 아니라 **유일한 적용 필드**다. */ | { code: 'embed-spec-inapplicable'; opIndex: number; handle: AssocHandle; fields: string[]; } /** * 임베더블이 **자기 자신을 임베드**하려 했다 — 구조적으로 성립하지 않는다 * (`class Addr { @Embedded Addr addr; }` = 무한 재귀). * * ★**연쇄 임베드(임베더블 → «다른» 임베더블)와 갈라야 한다** — 그쪽은 **정당하고 실재한다** * (프로덕션 v2 43문서 실측: 연쇄 **3** · 자기 임베드 **0** — `ApplyPriceDcPromo.dcRule → PromoDcRule` 등). * * ★★**판정은 이제 `core/embedGraph.findEmbedCycle` 이 한다** — GUI 와 «같은 술어»다. 종전엔 이 자리가 * `from.modelId === to.modelId` 단순 비교였고 GUI 는 임베더블↔임베더블을 통째로 무시해서, **두 경로가 * 서로 다른 것을 막고 둘 다 간접 순환(A→B→A)을 못 봤다**(자기 비교는 1단만 본다). 그 GUI 쪽 차단은 * 해제됐고(연쇄는 정당), 남은 거부가 이 코드와 아래 `embed-cycle-unsupported` 둘이다. */ | { code: 'embed-self-unsupported'; opIndex: number; handle: AssocHandle; } /** * 임베드가 **순환**을 이룬다 — 자기 임베드의 «간접» 형태(A 가 B 를 품고 B 가 A 를 품는다). * * ★**코드를 자기 임베드와 가르는 이유**는 호출자가 **고칠 자리가 다르기** 때문이다: 자기 임베드는 * 보낸 op 자체가 잘못이지만, 순환은 **이미 저장된 반대 방향 임베드**가 상대편이라 그쪽을 먼저 정리해야 * 한다. 그래서 `path` 에 순환을 이루는 엔터티 **이름**을 순서대로 담는다(모호할 때 사람이 고를 수 있는 * 지목 수단 — modelId 나열만으론 어느 자리를 지울지 못 고른다). * * ⚠️**같은 배치 안에서 나눠 보내는 우회도 막는다** — `A→B` 와 `B→A` 를 두 op 으로 보내면 각 op 은 * 저장 전 모델만 보고 무해해 보인다. 선행 op 이 만든 간선을 `pending` 으로 함께 넘겨 판정한다. */ | { code: 'embed-cycle-unsupported'; opIndex: number; handle: AssocHandle; path: string[]; } /** * `association.add` 의 end 중 하나가 `SERIALIZED_TYPE` — **거부 사유가 임베더블과 다르다**. * * 임베더블은 *「EMBED 여야 하는데 이 경로에 EMBED 어휘가 없다」* 는 **한시적** 미지원이라 어휘가 생기면 * (AA-5 S3) 방출로 바뀔 자리다. 직렬화 타입은 그렇지 않다 — 직렬화 컬렉션의 요소 타입은 **관계로 연결되지 * 않고** 소비 속성의 `type` 참조로 지목된다(2단계 결정 2-ⓐ·3-①). 즉 만들어야 할 관계가 *존재하지 * 않으므로* 어휘가 생겨도 통과시킬 것이 없다 ⇒ **항구적** 거부다. * * ⇒ 같은 코드로 묶지 않는다. 코드가 다르면 AI 가 **고칠 방향**을 구분할 수 있다: * 임베더블 = 「EMBED 로 표현할 것(현재 GUI 소관)」 / 직렬화 타입 = 「관계 말고 소비 속성 `type` 을 쓸 것」. */ | { code: 'serialized-relation-invalid'; opIndex: number; handle: AssocHandle; } /** * `entity.update` patch 의 `stereotype` 이 **`JPA_EMBEDDABLE` 경계를 넘음** — 이 전환은 단일 필드 쓰기가 * 아니라 **동반 명령을 가진 composite** 다(`editor/entity.ts` becomingEmbeddable/leavingEmbeddable): * - 들어갈 때: 자기 PK 해제(임베더블은 `@Id` 불가 = `CHK-JPA-2`) + 보유 FK 제거·자식 cascade reconcile + * 이 엔티티를 가리키는 일반 관계를 EMBED 로 복원(`enteringEmbeddableCommands`). * - 나올 때: PK 자동 부여 + EMBED 관계를 일반 관계로 정규화(`leavingEmbeddableCommands`). * 맨 patch 만 흘리면 PK·FK 잔재(`CHK-JPA-2` error)와 「임베더블인데 일반 연관」 형상이 동시에 남는다. * ★`navigable-toggle-unsupported` 와 같은 근거의 구조 거부다 — *동반 작업이 GUI 소유*라 op 경로엔 표현이 없다. * 경계를 넘지 않는 전환(예: `JPA_ENTITY`↔`JPA_MULTI_LANGUAGE_ENTITY`)은 그대로 통과한다. */ | { code: 'stereotype-embeddable-transition-unsupported'; opIndex: number; entity: Handle; from: string; to: string; } /** * `entity.update` patch 의 `stereotype` 이 **`SERIALIZED_TYPE` 경계를 넘음** — 위 임베더블 전환과 **같은 사유**다. * 직렬화 타입도 값 타입이라 진입 시 자기 PK 해제 + 보유 FK 제거·자식 cascade reconcile 이, 이탈 시 PK 자동 * 부여가 동반된다(`editor/entity.updateEntity` 의 `enteringValueType`/`leavingValueType`). 맨 patch 만 * 흘리면 PK·FK 잔재(`CHK-JPA-2` error)가 남는다. * * ★코드를 **분리**한 것은 사유가 달라서가 아니라 `stereotype-embeddable-transition-unsupported` 가 * 이미 게시된 공개 계약(`ResolveError`)이고 호스트가 그 문자열을 소비하기 때문이다 — 조건을 넓히면서 * 이름이 거짓말을 하게 두는 대신, **추가**로 정직한 이름을 낸다(개명은 breaking). */ | { code: 'stereotype-serialized-transition-unsupported'; opIndex: number; entity: Handle; from: string; to: string; } /** `type:'GroupCodeEnum'` 인데 `groupCode` 미동반 — groupCode 바인딩 없는 깨진 파생 타입 방지(B3). */ | { code: 'groupcode-required'; opIndex: number; entity: Handle; handle: Handle; } /** * `groupCode` 는 있는데 `type` 이 `GroupCodeEnum` 이 아니다 — 위 `groupcode-required` 의 **반대 방향**. * * ★커플링은 XOR 이 아니라 **동치**다(`core/javaTypes.isGroupCodeCouplingBroken`). 종전엔 한 방향만 * 막았고, 그래서 «`groupCode` 는 남긴 채 `type` 만 `String` 으로» 가 add·update 전부 통과했다 — * 코드 enum 에 묶인 속성이 평범한 문자열을 방출하는 **깨진 파생**이다. GUI 는 `setAttributeGroupCode` * 가 둘을 항상 함께 바꿔 이 상태를 만들 수 없으므로, 막지 않으면 AI 경로에만 생기는 형상이 된다. * ⇒ 고칠 자리는 둘 중 하나다: `type` 을 `GroupCodeEnum` 으로 두거나, `groupCode` 를 함께 비우거나 * (같은 patch 에 둘을 함께 주면 통과한다 — 판정이 **결과 상태** 기준이다). */ | { code: 'groupcode-type-mismatch'; opIndex: number; entity: Handle; handle: Handle; type: string; groupCode: string; } /** 물리 컬럼을 만들면서 dataType 미지정 — 빈 타입 컬럼은 DDL에서 드롭된다(발명 대신 요구). */ | { code: 'datatype-required'; opIndex: number; entity: Handle; handle: Handle; } /** index.add 컬럼 핸들이 엔티티 내 dbAttr와 0개 매칭. */ | { code: 'column-not-found'; opIndex: number; entity: Handle; handle: Handle; } /** index.add 컬럼 핸들이 복수 dbAttr와 매칭(예: 속성명 지정인데 다중 컬럼 임베드/Money). */ | { code: 'column-ambiguous'; opIndex: number; entity: Handle; handle: Handle; matches: ModelId[]; } /** index.update/remove 핸들이 엔티티 내 인덱스와 0개/복수 매칭. */ | { code: 'index-not-found'; opIndex: number; entity: Handle; handle: Handle; } | { code: 'index-ambiguous'; opIndex: number; entity: Handle; handle: Handle; matches: ModelId[]; } /** operation.update/remove 핸들이 엔티티 내 operation과 0개/복수 매칭. */ | { code: 'operation-not-found'; opIndex: number; entity: Handle; handle: Handle; } | { code: 'operation-ambiguous'; opIndex: number; entity: Handle; handle: Handle; matches: ModelId[]; } /** reorder order 목록의 핸들이 엔티티 내 속성/메서드와 0개 매칭(D7 — 부분 재배치 안 함, 전체 거부). */ | { code: 'reorder-handle-not-found'; opIndex: number; entity: Handle; handle: Handle; } /** reorder order 목록의 핸들이 복수 매칭(모호). */ | { code: 'reorder-handle-ambiguous'; opIndex: number; entity: Handle; handle: Handle; matches: ModelId[]; } /** reorder order 목록에 같은 요소가 두 번(중복 핸들) — 배열 재배치 모호. */ | { code: 'reorder-handle-duplicate'; opIndex: number; entity: Handle; handle: Handle; } /** * reorder 목록이 비었다 — 재배치 의도가 없는 요청. * * ★종전엔 **스키마 `minItems:1`** 이 막던 자리인데, 그 제약은 Anthropic structured-outputs 엄격 * 서브셋 밖이라(수치·문자열·복합 배열 제약 미지원 · SDK 가 떼어내고 클라이언트에서 검증한다) * 스키마 헤더가 스스로 *「미사용」* 이라 선언해 놓고 두 자리만 쓰고 있었다 ⇒ 집행을 이리로 옮겼다. * 규율: **스키마는 형(shape)만 선언하고 카디널리티·내용 제약은 resolver 가 집행한다.** * * ⚠️막지 않으면 조용히 쓰기가 난다 — 빈 목록은 `picked=[]` 로 흘러 «지목되지 않은» 전 요소를 * `order` 0..n−1 로 **재번호**하는 op 을 방출한다(기존 order 에 구멍이 있으면 실제 쓰기). */ | { code: 'reorder-empty'; opIndex: number; entity: Handle; } /** * index.update patch에 `columns` 키 — columnRef(dbAttr modelId)는 사람 핸들로 표현 불가라 패스스루 시 * 깨진 인덱스가 된다. 컬럼 변경은 index.remove + index.add(컬럼 핸들 해소 구현)로 유도(거부 권고안). */ | { code: 'index-columns-patch-unsupported'; opIndex: number; entity: Handle; handle: Handle; } /** * 같은 배치에서 `entity.add`로 추가되는 엔티티를 자식/관계 op가 핸들로 참조(A 백스톱 가드). * mongo arrayFilter는 update 선이미지에 평가되므로 갓 push된 엔티티에 닿는 child push가 silent no-op이 된다. * 신규 엔티티의 자식은 `attribute.add`/`index.add`/`operation.add` 분리가 아니라 `entity.add` spec에 inline fold할 것 * (관계는 inline 불가 → 엔티티 배치 확정 후 별도 배치). entity-not-found 대신 의도를 드러내는 명시 에러. */ | { code: 'pending-entity-ref'; opIndex: number; handle: Handle; } /** * embeddableOverrides가 있으나 `type`이 주입된 임베더블 카탈로그의 어떤 엔트리와도 매칭되지 않음 * (카탈로그 미주입이거나 embeddable 아닌 type). 서브컬럼 오버라이드 silent drop 방지. */ | { code: 'embeddable-type-unresolved'; opIndex: number; entity: Handle; handle: Handle; } /** * `@MapsId` 파생 식별이 **방출 시점에 합성하는 스칼라 `@Id` 필드명**과 같은 이름으로 속성을 만들려 함. * * ★그 필드는 **모델 속성이 아니다** — 모델은 FK 속성 «하나»만 갖고 코드젠이 두 필드를 낸다 * (`core/scaffoldJava.derivedIdScalarNameOf`). 그대로 통과시키면 관용구⑤(FK-as-PK) 형상이 되어 * 같은 물리 컬럼을 두 속성이 쓰고 `CHK-NAME-2`(컬럼 중복) error + DDL 중복으로 착지한다. * * ⚠️★**유입 경로는 「오독」이 아니라 「규칙 준수」다** — sync 의 diff 규칙이 「코드에 있고 모델에 * 없음 → `attribute.add`」이고 코드에는 그 스칼라가 실재한다. 그래서 방어가 **세 겹**이다: * `/text` 의 `derivedIdField=` 표시(읽는 쪽이 존재를 안다) · playbook 예외(규칙이 스스로 면제한다) · * **이 거부**(둘 다 안 읽혔을 때의 구조적 차단). 앞 둘은 준수에 기대고 이것만 기대지 않는다. * * ★★**거부는 «갈 곳»을 함께 말한다** — `fk` 가 그 스칼라를 내는 FK 속성명이다. 필드명을 바꾸려는 * 의도였다면 답은 새 속성이 아니라 그 FK 에 대한 `attribute.update {derivedIdFieldName}` 이다. * * ⚠️★**`attribute.update {name}` 의 rename 은 «의도적으로» 막지 않는다** — 이 축의 유입원인 sync 는 * 속성을 **이름으로 매칭**하므로 rename op 자체를 낼 수 없다(이름이 다르면 add/remove 로 갈린다 — * playbook §4 의 「수정」 비교 필드에 `name` 이 없는 이유가 그것이다) ⇒ 남는 경로는 **사람이 지시한 * AI 조작**뿐이고 그때는 의도가 있다. 발동 근거 0 인 가드를 세우지 않는 것이 이 리포 규율이다. * ⚠️단 그 경로의 착지점은 **무신호**다(물리명은 안 겹쳐 `CHK-NAME-2` 가 안 울리고, 스칼라는 모델에 * 없어 `CHK-NAME-1` 도 못 본다 — 코드젠 산출에서 필드명이 겹쳐 컴파일이 깨진다). * 트리거 = **사람 지시 rename 으로 그 형상이 실제로 관측될 때**. */ | { code: 'derived-id-scalar-conflict'; opIndex: number; entity: Handle; handle: Handle; fk: string; } /** EMBED_PREDEF SHARED_REF 서브컬럼의 공유 대상 물리명이 소속 엔터티 내 컬럼과 0개 매칭. */ | { code: 'shared-column-target-not-found'; opIndex: number; entity: Handle; handle: Handle; } /** EMBED_PREDEF SHARED_REF 공유 대상 물리명이 복수 컬럼과 매칭(모호). */ | { code: 'shared-column-target-ambiguous'; opIndex: number; entity: Handle; handle: Handle; matches: ModelId[]; } /** * attribute.update patch의 평탄 dbAttr 키를 스칼라 단일 컬럼으로 매핑할 수 없음. 스칼라(NORMAL·단일 dbAttr)면 * `dbAttrs[0]`로 자동 매핑되지만, 모호/불가한 대상은 거부한다: EMBED_PREDEF/다중 dbAttr(어느 컬럼인지 모호), * dbAttrs 0개/transient(머지할 컬럼 없음), `sharedColumnRef` 평탄(opaque modelId), * flat + dotted-dbAttrs 혼용($set 충돌). 통과 시 논리 노드 blind-write로 orphan 오염이라 거부. * * ★★**거부는 «갈 곳»을 함께 말해야 한다**(2026-09-02 정정 — 「모르는 값을 발명하지 말 것」의 짝): * - **다중 dbAttr** → *바인딩* 축(물리명·정밀도·공유)은 `embeddableOverrides`, **개별 컬럼 필드**(논리명 등)는 * **dotted `dbAttrs..`**. ⚠️종전엔 이 분기가 `embeddableOverrides` «만» 지목했는데 그 배열엔 * 논리명 축이 없어 **열린 문(dotted) 옆에서 막힌 문을 가리키고** 있었다(실사용에서 raw `/ops` 우회를 * 유발했다 — 불필요했다). dotted 는 아래 `attribute-patch-unknown-key` 가 이미 허용하는 접두다. * - **dbAttrs 0개** → `fieldLogicalName`(아래 주석 — 이쪽은 처음부터 대안을 지목하고 있었다). * - ★**`attrType !== 'NORMAL'`**(FK 파생 `RELATION_*`·임베드·직렬화 타입) → **dotted**. ⚠️이 갈래는 * 종전 서술에 «없었다** — 위 두 줄이 사유를 「다중 컬럼」·「0컬럼」으로만 적어서 **단일 컬럼 FK** 가 * 걸릴 때 안내가 EMBED 축을 가리켰다(2026-09-08 · FK 가드 회차에서 드러났다). 매핑을 열지 않는 이유는 * 모호성이 아니라 **경로를 하나로 두는 것**이다 — dotted 가 이미 그 자리를 정확히 지목한다. */ | { code: 'attribute-patch-dbattr-flat-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } /** * attribute.update patch에 Attribute 논리 노드에 실재하지 않는 미지 키 — 통과 시 orphan blind-write. * 오탈자/스키마 밖 키를 조용히 흡수하지 않고 명시 거부(자기증식 오염 원천 차단). * 허용 dotted 접두는 `dbAttrs.` 하나뿐이다(형제 op의 `jpaAttrs.`·`jpa.` 제한과 같은 근거 — 아래 분류 주석). */ | { code: 'attribute-patch-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } /** * entity.update patch에 Entity 논리 노드에 실재하지 않는 미지 키 — `attribute-patch-unknown-key`의 형제. * * ★이 op은 **스키마가 의도적으로 open**이다(dotted-key `jpaAttrs.*`가 필요해 `additionalProperties:false`로 * 닫지 못한다 — schema.ts §5.2 폐색 주석). 즉 LLM 가이드가 없는 경로이고 **resolver가 유일한 게이트**다. * 특히 `attributes`/`indexes`/`operations`(자식 컬렉션)가 통과하면 호스트 인터프리터가 논리 노드에 통째 * `$set`해 **전 배열 blind-write**가 된다(모든 modelId 재발급 = 인덱스 columnRef·derivedFrom·end.attributeRef * 전량 dangling). 자식 변경은 전용 op 소관. */ | { code: 'entity-patch-unknown-key'; opIndex: number; entity: Handle; keys: string[]; } /** * ── `*-spec-unknown-key` 계열 — add op 의 `spec` 에 그 spec 이 갖지 않는 키 ── * * ★**patch 계열과 실패 모드가 다르다.** patch 는 통과하면 호스트가 **blind-write** 해 오염을 «만든다». * spec 은 반대로 resolver 가 명명된 필드만 골라 담으므로 미지 키가 **조용히 사라진다** — 호출자는 * `ok` 를 받고 그 값이 반영됐다고 믿는다(무신호 소실). 그래서 둘 다 거부하지만 근거가 갈린다. * * ★**스키마는 이미 `additionalProperties: false` 로 닫아 두었다** — 이 거부는 새 제약이 아니라 * **선언된 계약의 집행**이다. 스키마는 LLM 에게 주는 tool 정의일 뿐 게이트가 아니고(호스트 * symbolic-ops 라우트는 body 를 스키마 검증 없이 resolver 로 넘긴다), 그래서 resolver 가 유일한 게이트다. * * ★발견 경위(2026-08-29): `entity.add` 에 `serialization`(직렬화 컨테이너)을 주면 `ok:true` 인데 값이 * 사라졌다. 같은 필드를 `entity.update` 로 주면 `entity-patch-unknown-key` 로 **거부**됐다 — 즉 * **add 는 삼키고 update 는 거부**하는 비대칭이었다. 어휘를 연 뒤에도(위 `serialization`) 오탈자· * 미래 필드가 같은 자리로 사라지므로 계열 자체를 신설한다. */ | { code: 'entity-spec-unknown-key'; opIndex: number; handle: Handle; keys: string[]; } | { code: 'attribute-spec-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } | { code: 'index-spec-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } | { code: 'operation-spec-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } | { code: 'association-spec-unknown-key'; opIndex: number; handle: AssocHandle; keys: string[]; } | { code: 'group-spec-unknown-key'; opIndex: number; keys: string[]; } /** * ★**계열의 «중첩» 사본** — 위 여섯은 spec 의 **1단 키**만 보는데 `embeddableOverrides` 는 spec 안의 * **배열**이고 그 items 의 키는 add·update 양쪽에서 아무도 안 봤다(`ATTR_SPEC_KEYS` 에 배열 «이름»만 * 있다). resolver 가 `field` 로 매칭해 명명된 필드만 읽으므로 미지 키는 **조용히 사라진다** — * 실측 `{field:'amount', dataType:'NUMERIC'}` 이 `ok:true` 인데 `dataType` 이 소실됐다. * ⇒ 계열의 근거(*「무신호 소실」* + *「스키마가 이미 `additionalProperties:false` 로 닫아 둔 계약의 * 집행」*)가 **그대로** 적용된다 · 스키마 자리는 `EMBEDDABLE_OVERRIDES.items`. */ | { code: 'embeddable-override-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } /** * entity.add의 논리명이 모델에 이미 있는 엔티티와 충돌. 통과시키면 **도구가 스스로 `CHK-NAME-3`(error) * 상태를 만든다**(CC-5 교훈: 엔진이 검증 error 상태를 생성하지 않는다). 더 나쁜 건 같은 배치의 형제 * op이다 — pending 가드는 `not-found`일 때만 격상하므로(`entityErr`), 이름이 겹치면 자식 op의 핸들이 * **기존 엔티티로 조용히 해소돼** 사용자가 의도한 신규 엔티티가 아니라 남의 엔티티에 붙는다. * 이름은 사용자·AI가 정해야 할 값이라 도구가 유일화(`Order2`)로 발명하지 않는다([[dont-invent-unknown-values]]). */ | { code: 'entity-name-conflict'; opIndex: number; handle: Handle; matches: ModelId[]; } /** * attribute.update patch의 `attrType` — 미지 값이거나 **전환**(현재 값과 다름)이라 거부. * * ★`attrType`은 patch로 자유 설정할 값이 아니라 **동반 구조에서 파생되는 성격**이다: * `RELATION_*`은 관계가 소유(`derivedFrom`·`end.attributeRef`), `EMBED_*`는 `embedded` 또는 카탈로그 * 임베더블 `type`, `DIVIDER`는 `dbAttrs=[]`·`type=''`가 동반돼야 성립한다. patch는 그 동반 구조를 * 표현할 수 없으므로 전환을 통과시키면 **아무도 관리하지 않는데 사용자도 고칠 수 없는 잠긴 컬럼**이 * 된다(CC-10 ①-b에서 mermaid 파서가 만들던 바로 그 상태 — `fkDerived`가 GUI 편집을 잠그는데 전파· * 코드젠은 그 속성을 관리하지 않는다). 각 축의 정상 경로는 따로 있다: 관계 실체화=관계 op, * 임베드 서브컬럼=`embeddableOverrides`, inverse nav 토글=`navigable-toggle-unsupported`가 안내. * ⇒ 라운드트립 에코(같은 값 재전송)만 통과시킨다. `reason`으로 사용자가 할 일이 갈린다. */ | { code: 'attribute-attrtype-unsupported'; opIndex: number; entity: Handle; handle: Handle; /** `unknown-value`=오탈자·존재하지 않는 값 / `transition`=값은 유효하나 patch로 바꿀 수 없는 축. */ reason: 'unknown-value' | 'transition'; current: string; requested: string; } /** * attribute.update patch의 `type` — **동반 구조가 함께 가야 성립**하는 전환이라 거부. * * ★위 `attrType` 게이트의 **짝**이다(2026-09-02 신설). 그쪽은 `attrType` 을 직접 준 경우를 막는데, * `type` 만 주는 경로가 열려 있었다: `{type:'Money'}` 를 NORMAL 속성에 주면 `type` 만 갈리고 * `attrType`·`dbAttrs` 는 그대로라 **컬럼 1개짜리 EMBED 아닌 Money** 라는 모순이 남는다(반대로 * EMBED_PREDEF 에 `{type:'String'}` 를 주면 컬럼 2개짜리 String 이 된다). 통과시키면 그 자리는 * 코드젠·DDL·검증이 각자 다르게 읽는 **조용한 반쪽 적용**이고, `attrType` 게이트가 막으려던 *「아무도 * 관리하지 않는데 사용자도 고칠 수 없는」* 상태의 같은 부류다. ⚠️GUI 는 같은 조작을 **지원**하므로 * (`editor/attribute.convertAttributeType` — 삭제+추가 합성) 막지 않으면 *「GUI 로는 되는데 AI 로는 * 조용히 깨진다」* 가 된다. 판정선은 `core/javaTypes.isStructuralTypeChange` 로 **공유**한다. * ⇒ 대안이 실재하므로 표현력 부족이 아니다 — `reason` 이 그 경로를 지목한다. */ | { code: 'attribute-type-conversion-unsupported'; opIndex: number; entity: Handle; handle: Handle; /** * `core/javaTypes.typeOwner` 의 부류를 그대로 전달한다 — **사용자가 갈 곳이 갈린다**: * - `column-count` — 컬럼 개수 축이 갈린다(`EMBED_PREDEF` 출발 또는 ② 임베더블 목적지) * ⇒ **`attribute.remove` + `attribute.add`**(+ 자리 복원이 필요하면 `attribute.reorder`). * - `association-owned` — `EMBED_OWN`/`EMBED_REF` 라 컬럼이 association source 소유 ⇒ 관계 op 소관. * - `derived` — `RELATION_*` 이라 `type` 이 파생값이다(부모 PK 의 Java 타입 / 상대 엔티티) * ⇒ 고칠 자리는 **부모 쪽 또는 관계**이지 이 속성이 아니다(GUI 도 같은 이유로 잠근다). */ reason: 'column-count' | 'association-owned' | 'derived'; current: string; requested: string; } /** association.update patch 미지 키(스키마는 하드 폐색 — REST 직접 호출 대비 심층 방어). */ | { code: 'association-patch-unknown-key'; opIndex: number; handle: AssocHandle; keys: string[]; } /** * associationEnd.update patch 미지 키. entity.update와 같은 이유로 스키마가 open(dotted `jpa.*`)이라 * resolver가 유일한 게이트다. `entityRef`·`attributeRef`는 구조 참조라 patch로 바꾸면 관계가 끊긴다. */ | { code: 'association-end-patch-unknown-key'; opIndex: number; handle: AssocHandle; keys: string[]; } /** * index/operation 핸들에 `by:'physicalName'` — 두 대상엔 물리명 차원이 없어 고정할 키가 없다. * 조용히 name으로 매칭하면 *지정하지 않은 키로 해소된 결과*가 성공으로 돌아온다(엔티티 핸들의 `by`는 * 실제로 키를 고정하므로 같은 필드가 대상에 따라 다르게 동작하는 무신호 비대칭). `by`를 빼거나 `'name'`으로. */ /** * 핸들의 **형상**이 계약과 다르다 — 값이 아니라 «모양»이 틀렸다(`ref` 없음 · 객체 아님 · * 관계 핸들에 `from`/`to` 없음). * * ★★**왜 별 코드인가**: 종전엔 이 입력이 `matchEntities` 말단까지 흘러가 **`TypeError`(500)** 로 터졌다. * 라우트가 그것을 잡지 못해 호출자는 «무엇이 틀렸는지» 없이 `Server Error` 만 받는다 — 이 파일의 다른 * 거부들이 전부 *고칠 방향*을 동봉하는 것과 정반대이고, **AI 무인 경로에 직접 해가 된다**(2026-09-03 * 실측: 그 500 을 「호스트 op 어휘가 죽었다」로 오진해 raw `/ops` 우회까지 했다). * ⚠️말단에서 조용히 빈 배열을 돌려주는 방어는 **답이 아니다** — 그러면 `*-not-found` 로 둔갑해 * *「대상이 없다」* 로 읽힌다(실제로는 «찾아보지도 못했다»). * * `expected` 는 기대 형상을 문자열로 동봉한다 — 관계 핸들은 **`{from:{ref}, to:{ref}}`**(양 끝 엔티티로 * 지목)이고 `{ref, by}` 가 아니다. 그 혼동이 실제 발생한 형태다. */ | { code: 'handle-shape-invalid'; opIndex: number; where: string; expected: string; got: string; } /** * 다국어(MultiLangText) 자리에 **수용할 수 없는 형상**이 왔다 — 특히 평문 문자열. * * ★★**조용한 오염을 막는 자리다**(2026-09-05 실사용 결함): `attribute.update {description:"설명"}` 이 * 어느 층에서도 안 막혀 그대로 저장됐고, 객체로 취급되는 지점에서 **문자 단위 맵** * (`{"0":"회","1":"원",…}`)으로 굳었다. 스키마(`schema.ts` `MULTI_LANG`)는 형상을 규정하는데 * 실행 경로가 그걸 집행하지 않아 «선언과 집행이 갈려» 있었다. * ⚠️`{}`·`null` 은 **정당하다**(부재·비움 동치 — `null ≡ {} ≡ 부재` 규약). */ | { code: 'multilang-shape-invalid'; opIndex: number; where: string; expected: string; got: string; } | { code: 'handle-by-unsupported'; opIndex: number; entity: Handle; handle: Handle; target: 'index' | 'operation'; } /** index.update patch 미지 키(스키마 하드 폐색 — 심층 방어). `columns`는 전용 에러가 먼저 잡는다. */ | { code: 'index-patch-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } /** operation.update patch 미지 키(스키마 하드 폐색 — 심층 방어). */ | { code: 'operation-patch-unknown-key'; opIndex: number; entity: Handle; handle: Handle; keys: string[]; } /** * associationEnd.update patch의 `navigable`(양 end) — end1(from): inverse nav 실체화/철거는 * RELATION_REF 속성 생성·삭제가 동반되는 GUI 토글 소유(통과 시 "navigable=true ⟺ RELATION_REF 존재" * 불변식이 깨져 로드 자가치유가 비결정 실체화를 반복). end2(to): JPA 소유측 참조는 관계 존재와 * 동치라 고정 true — 참조 없는 FK는 관계가 아니라 일반 컬럼으로 모델링. */ | { code: 'navigable-toggle-unsupported'; opIndex: number; handle: AssocHandle; } /** * FK 파생 속성(RELATION* + 물리 컬럼) 직접 삭제 — 관계가 소유하는 파생물이라 직접 지우면 관계 * end.attributeRef가 끊긴다(GUI removeAttribute 가드 동형, Option B). `association`(양 끝 엔티티명)의 * 관계를 association.remove로 삭제하도록 유도. */ | { code: 'group-not-found'; opIndex: number; handle: GroupHandle; } | { code: 'group-ambiguous'; opIndex: number; handle: GroupHandle; matches: GroupCandidate[]; } /** * group.update patch에 `LogicalGroup` 논리 노드에 실재하지 않는 미지 키 — 형제 op의 화이트리스트와 같은 근거 * (호스트가 patch 를 논리 노드에 통째 `$set` 하므로 통과하면 스키마 밖 orphan 키가 그대로 영속된다). * * ★`memberEntityRefs` 는 **일부러 화이트리스트 밖**이다. patch 로 통과시키면 배열을 **통째 교체**하게 되어 * 동시 편집이 서로를 덮는다 — 호스트는 멤버십을 `$push`/`$pull` 원소 단위로 처리하는 전용 verb 를 * 갖고 있으므로(`group.addMember`/`removeMember`) 그쪽이 정상 경로다. */ | { code: 'group-patch-unknown-key'; opIndex: number; handle: GroupHandle; keys: string[]; } | { code: 'attribute-fk-managed'; opIndex: number; entity: Handle; handle: Handle; association: { from: string; to: string; }; } /** * FK 유래 컬럼의 **파생 형상 필드**를 patch 로 덮으려 했다 — 목록·근거는 `core/fkDerived` * (`FK_RELATION_OWNED_FIELDS`)가 소유하고 GUI 잠금과 **같은 목록**이다. * * ★`attribute-fk-managed`(삭제 차단)의 **필드 축 형제**다. 코드를 가른 이유 = 처방이 다르다(그쪽은 * *「관계를 지워라」*, 이쪽은 *「그 필드를 patch 에서 빼고 파생원을 고쳐라」*). * ★`association` 은 **선택**이다 — 게이트가 `isFkDerivedColumn` 이라 소유 관계를 못 찾는 고아 * `RELATION_OWN` 도 대상이고(뷰도 그 자리를 잠근다) 그때 지목할 관계가 없다. */ | { code: 'attribute-fk-derived-field-locked'; opIndex: number; entity: Handle; handle: Handle; fields: string[]; association?: { from: string; to: string; }; } /** * `attribute.update` 가 **`NORMAL` 속성의 컬럼을 둘 이상으로** 만들려 했다 — 컬럼 «개수» 축 가드(AA-5 S5). * * ★★**형제 게이트 둘이 막던 것과 «같은 상태»인데 문이 달랐다**: `attribute-attrtype-unsupported` 는 * `attrType` 을 직접 준 경우를, `attribute-type-conversion-unsupported` 는 `type` 만 준 경우를 막는다. * 그런데 **`dbAttrs` 만 늘리면** `attrType`·`type` 이 그대로라 **둘 다 비켜간다** — 실증(2026-09-13): * ⓒ`patch.dbAttrs` 배열 통째 교체와 ⓑdotted `dbAttrs.<없는 i>.` 가 **둘 다 `ok:true`** 였다. * 그 결과 상태를 하류가 각자 다르게 읽는다: **DDL 은 2컬럼을 내고** 코드젠은 **1필드**만 내며 * (두 번째 컬럼이 산출에서 사라진다) 검증은 **신고 0**이고, GUI 는 `hasColumnSlots` 가 false 라 * **화면에 보이지도 않는다**. 게다가 사용자가 그 속성을 편집하면 `useAttributeEditing.setDb` 가 * `dbAttrs:[next]` 로 **통째 교체** = 두 번째 컬럼이 **조용히 삭제**된다 ⇒ 진입만 있고 출구가 파괴적인 * **단방향 트랩**이다(메모리 `affordance-must-be-reversible`). * * ⇒ **GUI 에 없는 조작이므로 막는 것이 패리티다**(GUI 는 NORMAL 을 단일 컬럼 블록으로만 편집한다). * ★**그리고 이제 대안이 실재한다** — 다중 컬럼이 필요하면 **임베더블 + EMBED 관계**(AA-5 S3)가 그 자리다. * 이 가드를 S3 «뒤»에 세운 이유가 그것이다: 먼저 세웠다면 표현력 부족을 가드로 덮는 것이었다. * ⚠️판정은 **patch 적용 «후»** 의 `attrType` 으로 한다(계획 diff 규율 — 메모리 * `plan-diff-judgment-uses-post-plan-state`). ★★**다만 그 읽기는 «현재» 불활성이다** — 민감도로 * 실증했다(2026-09-13: 적용 «전» 으로 되돌려도 전량 green). `patch.attrType` 을 세우는 경로가 둘뿐이고 * **둘 다 여기 안 닿는다**: ⓐ사용자 직접 지정은 `attribute-attrtype-unsupported` 가 **먼저** 거부하고 * ⓑresolver 내부 in-place 값 타입 전환은 **컬럼 개수를 보존**한다(`columnPreservingValueTypeEntry`). * ⇒ 선례(`fe47316`)대로 처방은 *「가드를 단순화」* 가 아니라 **「불활성의 전제를 테스트로 고정」** 이고, * 그 앵커가 `resolver.test.ts` 의 «in-place 값 타입 전환은 컬럼 개수를 보존한다» 다. ⓑ가 컬럼을 * 늘리게 되면 그 테스트가 red 가 되고 이 post-patch 읽기가 **살아난다**. */ | { code: 'attribute-column-count-unsupported'; opIndex: number; entity: Handle; handle: Handle; /** patch 적용 후 attrType(판정 기준). */ attrType: string; current: number; requested: number; }; export type ResolveNotice = { code: 'inherited-slot-override'; opIndex: number; entity: Handle; attribute: Handle; /** 오버라이드가 생기는 컬럼 슬롯 인덱스. */ columnIndex: number; /** 그 슬롯에서 **지금 상속 중**인데 이 patch 가 덮어쓰는 필드들. */ fields: string[]; } /** * FK 유래 컬럼에 **부적합한** 필드에 값을 썼다 — 파생값이 아니라 «그 자리에 의미가 없는» 축이라 * 거부하지 않고 알린다(목록·근거 = `core/fkDerived.FK_INAPPLICABLE_FIELDS`). * * ★안내인 근거: 쓰기 자체가 구조 위반은 아니지만 **되돌리는 재주장이 없어 영구 잔존**하는데 뷰가 그 * 입력을 FK 자리에서 렌더하지 않아 사람이 되짚을 수 없다 ⇒ 「막을 일은 아니지만 모르고 지나갈 일도 * 아니다」(`inherited-slot-override` 와 같은 판단). */ | { code: 'fk-derived-inapplicable-field'; opIndex: number; entity: Handle; attribute: Handle; /** 호출자가 보낸 그 키(평탄 `unique`·dotted `dbAttrs.0.unique` 등). */ fields: string[]; }; export type ResolveResult = { ok: true; ops: OpShape[]; notices?: ResolveNotice[]; } | { ok: false; errors: ResolveError[]; }; export interface ResolverOptions { /** modelId 발급기(주입 시 테스트 결정성). 기본 newId. */ mkId?: () => ModelId; /** * predefined 임베더블 카탈로그(호스트 주입). `attribute.add`/inline fold의 `spec.type`이 엔트리 `type`과 * 매칭되면 EMBED_PREDEF(다중 dbAttr)로 확장한다. 미주입(기본 [])이면 확장 없이 NORMAL — 카탈로그 없이는 * type이 embeddable인지 판정 불가하므로 graceful degrade(현행 동작 유지). 서버 resolve 경로가 주입 책임. */ embeddableCatalog?: EmbeddableCatalog; } export declare const ENTITY_SPEC_KEYS: ReadonlySet; export declare const ATTR_SPEC_KEYS: ReadonlySet; /** * `embeddableOverrides[]` 항목의 허용 키 — **바인딩 축만** 덮는다(컬럼 논리명은 여기 없고 patch 의 * dotted `dbAttrs..logicalName` 이 받는다 · `dataType` 도 없다 = 카탈로그가 타입 정본이다). * ★`schema.test.ts` 가 이 집합과 `EMBEDDABLE_OVERRIDES.items.properties` 를 대조한다. */ export declare const EMBEDDABLE_OVERRIDE_KEYS: ReadonlySet; export declare const INDEX_SPEC_KEYS: ReadonlySet; export declare const OPERATION_SPEC_KEYS: ReadonlySet; export declare const ASSOC_SPEC_KEYS: ReadonlySet; export declare const GROUP_SPEC_KEYS: ReadonlySet; export declare function resolveSymbolicOps(model: LogicalModel, rawOps: SymbolicOp[], options?: ResolverOptions): ResolveResult;