# VD-24: Runtime Schema와 boundary Mapper - 상태: Accepted — reference typed codec/mapper baseline composed, artifact governance pending - 결정일: 2026-07-28 - reference REST Schema/Mapper vertical: `COMPOSED` - semantic compatibility/codegen governance delta: `DESIGNED_NOT_IMPLEMENTED` - generated API product selection: `NOT_SELECTED` - 관련 결정: VD-13, VD-23, VD-25, VD-26, VD-27, VD-29, VD-30 - 상세 설계: [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) ## 배경 현재 reference vertical은 다음 trust path를 실제 실행한다. ```text HTTP response -> common envelope Zod -> feature payload Zod -> feature mapper -> domain factory -> application view -> TanStack Query ``` reference baseline은 schema contribution collision을 boot 전에 거절하고, schema version/direction/unknown-field policy를 기록한다. request는 strict reject, ordinary response DTO는 strip projection을 사용하며 list item 수와 response/cache byte admission을 제한한다. mapper는 no-throw `MappingResult`를 반환하고 예상 가능한 drift는 `MAPPING_CONTRACT_VIOLATION`으로 분류한다. runtime schema codec과 mapper contribution은 collision-aware composer로 설치되고, 각 REST operation의 path/request/response schema와 mapper input schema reference를 boot 전에 exact resolve한다. operation별 cast-free result guard도 raw executor의 성공 값을 fail-closed로 재검증한다. 남은 delta는 actual codec fingerprint/source provenance/generated artifact join과 전체 scalar policy set이다. 이 결정은 runtime validation을 특정 library 이름으로 축소하지 않는다. 현재 owned schema는 Zod를 사용하지만 GraphQL generated types와 protobuf messages에도 동일한 trust transition을 적용한다. ## 결정 ### 1. 다섯 validation 경계를 분리한다 | 경계 | owner | 목적 | | --- | --- | --- | | route/form input | presentation/feature | 사용자 입력 정규화와 UX issue | | application command/query input | application/feature | use-case precondition과 canonical semantic input | | transport request wire | adapter/contract | exact outbound representation | | transport response wire | adapter/contract | untrusted server bytes/message 검증 | | domain invariant | domain | 업무상 유효한 entity/value 생성 | 하나의 Zod schema를 form, API request, response와 domain에 재사용하지 않는다. field 이름이 같아도 trust source와 failure semantics가 다르다. ### 2. TypeScript와 generated type은 proof가 아니다 ```text unknown bytes/message -> bounded decoder -> RuntimeCodec -> ValidatedDto -> BoundaryMapper -> MappingResult ``` `as Dto`, generic `execute()`, generated TypeScript interface와 protobuf class instance는 runtime proof를 만들지 않는다. 목표 API: ```text RuntimeCodec schemaId parse(input, budget) -> ValidationResult BoundaryMapper mapperId inputSchemaId map(dto) -> MappingResult BoundOperation requestCodec responseCodec mapper ``` operation definition 생성 시 codec output과 mapper input type을 compiler가 연결한다. runtime registry도 same IDs/fingerprints를 검증한다. ### 3. Schema registry v2 ```text SchemaDefinitionV2 schemaId wireVersion boundary protocol sourceKind = OWNED | GENERATED sourceArtifactId sourceArtifactDigest codecId codecFingerprint unknownFieldPolicy numericPolicyId temporalPolicyId providerMaxEncodedBytes maxDecodedBytes maxDepth maxNodes maxObjectKeys maxStringBytes maxCollectionItems compatibilityPolicy dataClassification owner ``` 정적 metadata는 실행 codec definition에서 결정적으로 투영한다. 실제 codec resolver가 없는 schema ID, fingerprint가 다른 resolver와 duplicate ID는 contribution composition에서 실패한다. `codecFingerprint`는 library 내부 AST serialization을 무조건 신뢰하지 않는다. 프로젝트가 소유한 canonical schema manifest를 사용한다. ```text canonical schema manifest field/path required/nullability scalar/format/range enum/discriminant collection/item ceiling unknown-field policy transform identifier/version ``` Zod upgrade로 내부 representation이 바뀌어도 canonical meaning diff가 안정적이어야 한다. ### 4. Decode budget Content-Length나 protobuf frame length만으로 충분하지 않다. ```text ValidationBudget decodedBytesRemaining nodesRemaining depthRemaining objectKeysRemaining stringBytesRemaining collectionItemsRemaining deadlineRemaining ``` - BFF/proxy/provider가 actual wire/encoded transfer와 decompression-ratio cap을 집행한다. - browser Fetch adapter는 present/valid Content-Length를 advisory preflight로만 사용하고 browser-visible decoded stream bytes를 hard cap으로 센다. - codec은 depth/node/key/string/item cap을 적용한다. - collection nested item도 global budget을 함께 소모한다. - transform/refine도 남은 logical deadline 안에서 동기적이고 bounded해야 한다. - async network/storage refinement를 runtime wire schema에 넣지 않는다. - budget 초과는 validation issue list를 무한 생성하지 않고 첫 bounded summary로 닫는다. 표준 `JSON.parse` profile에서 decoded-byte cap은 pre-parse guard지만 depth/node/key/string/item cap은 materialization 뒤 admission guard다. parse 중 구조 cap이 필요한 payload는 bounded tokenizing parser를 별도 profile로 선택하며, 그 구현 전에는 큰 byte ceiling을 승인하지 않는다. current request `limit <= 100`은 response item ceiling이 아니다. response schema가 items maximum과 total byte budget을 별도로 검증한다. ### 5. Unknown-field 정책 ```text UnknownFieldPolicy REJECT_UNKNOWN STRIP_UNKNOWN ``` `PRESERVE_UNKNOWN`은 application boundary에서 허용하지 않는다. | schema class | 기본 정책 | | --- | --- | | request, config, command, capability | `REJECT_UNKNOWN` | | auth/authorization/control envelope | `REJECT_UNKNOWN` | | ordinary additive REST response DTO | `STRIP_UNKNOWN` | | GraphQL selected data object | requested field shape만 투영 | | sealed discriminated union | unknown discriminator 거절 | | protobuf generated message | codec/library unknown-field behavior 뒤 mapper는 known projection만 사용 | current response `.strict()`를 모두 `.passthrough()`로 바꾸지 않는다. unknown field를 제거한 typed projection만 mapper로 보낸다. unknown field 이름/value를 log에 남기지 않는다. 필요한 경우 low-cardinality `unknown-field-detected` observation만 sampling한다. ### 6. Request와 response 방향성 request: - strict field set - trim/coerce/default/normalization 정책이 명시됨 - parsed output만 transport가 serialize - input 원본을 query key나 request에 따로 사용하지 않음 - route/search/application command 변환이 같은 canonical semantic input을 공유 response: - untrusted value를 coerce하지 않음 - required/nullability/discriminant/range를 검증 - additive unknown은 profile에 따라 strip - default value를 서버가 보낸 값처럼 조용히 생성하지 않음 - missing/null/empty를 mapper가 명시적으로 소진 `z.coerce`는 URL/form 같은 string input 경계에서만 허용한다. JSON/protobuf response에 적용하지 않는다. ### 7. Scalar 의미 #### ID - opaque bounded string - empty/control/overlong 거절 - 업무 계약이 없는 case folding, Unicode normalization과 numeric parse 금지 - account/resource ID를 diagnostics label이나 physical cache key에 직접 넣지 않음 #### Integer와 decimal - JSON integer는 finite safe integer 범위를 증명 - `int64`/`uint64`는 JavaScript number로 mapping하지 않음 - protobuf bigint/string representation은 adapter-private - money/decimal/high precision은 canonical decimal string + currency/scale policy - `NaN`, Infinity와 negative zero가 의미상 허용되는지 explicit - string-to-number response coercion 금지 #### Time - exact RFC 3339 profile과 offset/precision을 검증 - date-only, instant, local date-time과 duration을 다른 type으로 둠 - leap/invalid date를 JavaScript `Date` normalization에 맡기지 않음 - protobuf Timestamp/Duration range/nanos를 검증 - mapper가 application temporal value로 변환 - locale/timezone formatting은 presentation에서만 수행 #### Null과 absent ```text ABSENT NULL EMPTY VALUE ``` 네 의미를 schema/mapper contract에 명시한다. current mapper처럼 “string이 아니면 모두 null”로 합치지 않는다. optional server field의 default가 필요하면 application policy가 이름 있는 결정으로 적용한다. #### Enum/union/oneof - unknown discriminator는 sealed control union에서 fail-closed - evolvable business enum은 domain이 explicit `UNKNOWN` case와 UX를 소유한 경우에만 mapping - raw unknown string/number를 domain에 전달하지 않음 - protobuf enum zero value, unknown numeric enum과 oneof absence를 명시적으로 처리 #### Binary - REST base64는 decoded byte cap과 canonical encoding profile 필요 - GraphQL upload/binary는 이 JSON schema 경계의 기본 기능이 아님 - protobuf `bytes`는 bounded copy/stream policy 뒤에만 application으로 projection - large binary는 File/transfer capability를 사용 ### 8. Transport-specific schema #### REST - exact status/media/envelope profile 뒤 operation DTO codec 실행 - error body도 별도 bounded codec - Problem Details의 type/title/detail/instance를 raw UI copy로 사용하지 않음 - response envelope와 payload unknown policy를 따로 설정 #### GraphQL - variables와 selected `data` shape에 separate codec - top-level `data`, `errors`, `extensions`를 GraphQL response codec이 검증 - errors path/message/extensions는 safe failure mapper 전 untrusted - partial policy가 허용한 missing/null만 operation DTO type에 표현 - persisted operation manifest의 schema digest와 codec fingerprint 일치 #### gRPC-Web - frame/trailer 검증 뒤 generated protobuf decoder 실행 - generated decode success 뒤에도 semantic validator가 range/presence/enum/oneof를 검증 - descriptor digest/message full name과 codec binding 일치 - `google.rpc.Status` details는 allowlisted type만 decode #### Connect-Web/Connect - Connect unary HTTP/error 또는 stream EndStream proof 뒤 generated message decode - JSON/binary encoding과 descriptor/message binding을 operation profile에 고정 - generated decode와 `ConnectError` code는 semantic domain proof가 아니므로 같은 validator/mapper와 safe failure vocabulary를 통과 #### Protobuf REST Gateway - HttpRule/ProtoJSON/status/error profile 뒤 ordinary REST DTO codec 실행 - generated OpenAPI type이나 ProtoJSON message를 application model로 사용하지 않음 - direct gateway와 curated BFF의 envelope/schema를 같은 codec으로 추측하지 않음 ### 9. Boundary Mapper v2 ```text MapperDefinitionV2 mapperId mapperVersion inputSchemaId outputContractId scalarPolicySetId maxOutputItems maxEstimatedOutputBytes owner ``` mapper는: - pure - deterministic - synchronous - side-effect-free - locale/timezone-independent - input mutation 없음 - immutable output - exhaustive - bounded mapper가 호출하면 안 되는 것: - fetch, generated client, QueryClient - clock/random - storage/cache - telemetry/logger - DOM/browser API - authorization/feature flag ### 10. Mapping result ```text MappingResult { ok: true, value: T } { ok: false, error: MAPPING_INVARIANT_REJECTED | UNSUPPORTED_WIRE_VALUE | OUTPUT_LIMIT_EXCEEDED } ``` 예상 가능한 domain invariant/unknown enum/temporal conversion 실패는 throw하지 않는다. programming defect가 throw되더라도 adapter boundary가 `MAPPING_CONTRACT_VIOLATION`으로 정규화한다. raw DTO/value/path/message를 failure에 복사하지 않는다. `UNKNOWN_FAILURE`는 mapper drift의 정상 분류가 아니다. operation ID, schema/mapper profile/version과 safe outcome만 관측한다. ### 11. Domain, application projection과 view ```text ValidatedDto -> domain value/entity factory -> ApplicationReadModel / command result -> presentation-only ViewModel ``` - DTO는 adapter/contracts 내부 - domain은 transport nullability/error/envelope를 모름 - application read model은 query cache에 넣을 수 있는 immutable plain value - presentation view는 locale/formatted copy와 UI-only optimistic marker를 소유 - domain class/service, function, native object와 generated message를 Query cache에 넣지 않음 current reference가 domain을 거쳐 view를 만드는 구조는 유지한다. 단 collection과 return object를 immutable/bounded하게 만들고 mapping type proof를 연결한다. ### 12. Collection mapping - input array/page count는 codec에서 먼저 제한 - mapper는 output count와 estimated bytes를 다시 제한 - item 하나 실패 시 partial collection을 success/cache하지 않음 - stable identity, ordering, duplicate 의미는 feature contract가 결정 - duplicate ID를 임의로 마지막 값으로 덮지 않음 - mapper가 sort/filter/deduplicate를 한다면 이름 있는 policy와 fixture 필요 - pagination page/snapshot binding을 보존 estimated output bytes는 quota/serialization exact value가 아니라 cache admission ceiling용 보수적 측정이다. 측정 실패는 unlimited로 간주하지 않고 cache admission을 거절한다. ### 13. Generated와 owned source ```text backend-authoritative contract -> authenticated immutable source artifact -> source digest + provenance -> pinned codegen -> adapter-private DTO/client/codec -> owned semantic validator where required -> handwritten boundary mapper ``` | protocol | generated source 후보 | 반드시 owned인 것 | | --- | --- | --- | | REST | OpenAPI DTO/client/codec | gateway, mapper, application model, query policy | | GraphQL | schema types, operation types | persisted manifest policy, runtime result/error codec, mapper | | gRPC-Web | protobuf messages/client | semantic validation, failure mapping, mapper, stream reducer | normal build가 네트워크에서 최신 schema를 암묵적으로 내려받지 않는다. source fetch/update는 authenticated explicit workflow이며 reviewable diff를 만든다. generator: - exact package/plugin/runtime/Node version pin - reproducible output - generated directory 수동 수정 금지 - clean regenerate diff 0 - license/SBOM/secret scan - vendor import boundary - generated artifact removal gate 현재 `generated-api` recipe의 generic `execute(unknown)`와 caller-selected cast는 production schema proof가 아니다. ### 14. Compatibility change classification: | 변경 | 기본 판정 | | --- | --- | | optional ordinary response field 추가 + strip policy | additive | | request required field 추가 | breaking | | response required field 제거/rename/type/nullability 축소 | breaking | | enum value 추가 | domain unknown policy에 따라 additive 또는 breaking | | numeric range/precision/temporal profile 변경 | semantic breaking | | mapper output meaning/identity/order 변경 | application breaking | | unknown-field policy 변경 | compatibility review | | codec transform/default 변경 | semantic diff 필수 | `apiContractVersion` 하나만 올리지 않는다. ```text ContractSetManifest globalCompatibilityVersion REST/OpenAPI artifact digest GraphQL schema + persisted operation digest protobuf descriptor digest runtime schema registry digest mapper registry digest query policy digest ``` - N과 N-1 fixture를 보존 - breaking deployment는 old frontend window와 backend compatibility를 고려 - future major는 fail-closed - rollback은 frontend, generated artifacts, config, BFF/router/proxy와 backend compatibility를 coherent set으로 복구 - mapper-only semantic change도 cache/release epoch invalidation을 검토 ### 15. Failure와 cache admission 다음 상태에서는 cache write가 0회다. - body/frame limit - envelope/status/media mismatch - operation schema mismatch - mapper failure - scope/runtime generation mismatch - output item/byte ceiling 초과 - incompatible contract/source digest stale data를 유지할지는 VD-25 query profile이 결정한다. schema/mapper incompatibility를 ordinary transient network failure와 동일하게 retry하지 않는다. ### 16. Security와 privacy - validation failure에 raw value를 포함하지 않음 - issue path/code를 allowlist와 count/byte cap으로 projection - schema/mapper error가 PII field/value를 diagnostics에 넣지 않음 - prototype pollution key와 accessor/class/native object 거절 - `structuredClone` 성공을 safe plain-data proof로 사용하지 않음 - generated code가 arbitrary URL/header/logger를 application에 노출하지 않음 - source artifact와 generator provenance 검증 - schema가 frontend authorization boundary라는 주장 금지 관측 허용: - operation/schema/mapper ID와 version - source/compatibility outcome - validation/mapping failure kind - encoded/decoded/output size와 item bucket - unknown-field detected bucket source digest 실제 값, field path/value, DTO, GraphQL error path와 protobuf payload는 high-cardinality/sensitive이므로 telemetry label에 넣지 않는다. ### 17. Testing schema: - missing/null/empty/unknown field - numeric safe bounds, decimal, negative zero, NaN/Infinity - RFC 3339/Timestamp/Duration edge - enum/union/oneof future value - depth/node/key/string/array/byte cap - invalid UTF-8, base64와 binary cap - N/N-1/future-major registry: - duplicate ID before spread - missing/mismatched codec/mapper resolver - codec fingerprint/source digest drift - operation schema/mapper output type binding - orphan/owner/version mismatch mapper: - typed DTO only - deterministic/pure/input unmodified - immutable output - no throw for expected semantic rejection - collection partial failure - item/output byte ceiling - missing/null/date/numeric/unknown enum matrix - no raw value leakage codegen: - source provenance/digest - lint/breaking - clean reproducible generation - generated import boundary - runtime codec fixture parity - dependency/removal inventory integration: - bytes → decoder → schema → mapper → application/query - schema/mapper drift에서 cache write 0 - old runtime generation result 폐기 - actual REST/GraphQL/gRPC provider fixture ### 18. Rollout 1. current string-dispatch schema/mapper를 그대로 두고 typed definition builder를 추가한다. 2. reference operation에 shadow validation/mapping을 실행하되 secondary result를 UI/cache에 쓰지 않는다. 3. collision-aware contribution composer와 codec fingerprint를 먼저 blocking한다. 4. bounded decoder/collection ceiling을 query read부터 canary한다. 5. typed mapping result와 failure taxonomy를 적용한다. 6. current unchecked binder/cast를 제거한다. 7. OpenAPI/GraphQL/proto generation은 제품 contract source가 선택된 것만 별도 canary한다. 8. schema/mapper version을 release/cache epoch와 연결한다. rollback은 codec schema number를 낮추거나 cache의 incompatible value를 억지로 decode하지 않는다. old adapter/backend path와 coherent artifact로 돌리고 current scope의 incompatible mapped cache를 폐기한다. ## 완료 기준 - runtime codec output과 mapper input이 compiler/runtime registry 양쪽에서 연결된다. - schema registry가 actual meaning fingerprint, provenance와 budget을 가진다. - duplicate contribution이 overwrite 전에 실패한다. - request strict/response additive 방향 정책이 test로 증명된다. - scalar/null/enum/collection 의미가 mapper policy로 닫힌다. - mapper가 typed DTO만 받고 expected failure를 `Result`로 반환한다. - generated DTO/message가 domain/application/presentation/query public type에 없다. - schema/mapper failure에서 cache write와 raw-data observation이 0회다. - N/N-1, breaking diff, provider fixture와 rollback/removal drill이 통과한다.