Files
tech-log-frontend/docs/architecture/server-file-capability-infrastructure.md

1883 lines
72 KiB
Markdown

# Server file capability infrastructure handoff
- 상태: 설계 완료, 서버 구현 대기
- 목표 상태: `AVAILABLE_NOT_COMPOSED`
- 대상: 이후 서버 template 또는 제품 backend에서 구현할 공통 file capability
- 비대상: 제품 domain, 제품 use case, HTTP route/controller, provider 선택
- 기준일: 2026-07-28
이 문서는 frontend에 준비된 File/Blob/picker/download capability와 이후 연결할
서버측 기반을 정의한다. 서버는 object storage, multipart, integrity,
signed capability, idempotency와 quarantine을 실제 provider adapter로 제공하되,
제품이 파일 기능을 선택하기 전에는 endpoint, background job, database migration과
runtime composition을 설치하지 않는다.
브라우저 쪽 capability envelope, bounded fetch, resume checkpoint와 Image CDN
descriptor 계약은
[`presigned-transfer-and-image-cdn.md`](./presigned-transfer-and-image-cdn.md)와
VD-12가 소유한다. Range resume/background 경계는 VD-14, top-level transfer
composition과 Image descriptor provider는 VD-16이 소유한다. 이 문서의 server
port를 그 frontend DTO에 직접 노출하지 않고, 제품 BFF/controller가 두 경계
사이를 매핑한다.
`S3`, `MinIO`, `GCS`, `Azure Blob`, PostgreSQL, Redis, scanner vendor는 이 문서의
port 구현 후보일 뿐이다. provider SDK type, bucket/key, multipart upload ID,
scanner 원문 결과는 port 밖으로 노출하지 않는다.
이 설계의 최상위 불변조건은 다음과 같다.
> object storage에 byte가 존재하는 것, multipart complete가 성공한 것,
> scan이 clean으로 끝난 것, 사용자가 접근 가능한 `AVAILABLE` 상태는 서로 다른
> commit point다. 어느 단계도 다음 단계를 암묵적으로 보장하지 않는다.
## 1. 설계 경계
### 1.1 이 문서가 제공하는 것
- streaming object read/write의 provider-neutral port와 불변조건
- multipart staging/complete/abort 상태 머신
- 실제 byte length와 checksum의 authoritative 검증 규칙
- 짧은 수명의 제한된 signed capability
- 동시 replay를 견디는 idempotency store
- immutable quarantined object를 검사하는 scan port
- provider locator를 숨기는 opaque object reference
- deadline, retry, cancellation과 resource cleanup 규칙
- provider exception을 닫는 공통 failure model
- 모든 provider가 통과해야 하는 동일 contract test
- 미선택 상태, 선택적 composition과 완전 제거 계약
### 1.2 이 문서가 만들지 않는 것
- `AttachContractDocument`, `UploadProfileImage` 같은 제품 use case
- `ContractAttachment`, `EvidenceDocument` 같은 domain model
- 업무별 MIME, 용량, retention, 소유권과 승인 규칙
- 실제 REST/GraphQL endpoint와 request/response DTO
- account/tenant authorization 구현
- 실제 bucket, region, database, queue, scanner 선택
- frontend offline sync 전체 protocol
- CDN/Service Worker/Cache Storage release protocol
제품 선택 이후에는 이 capability 위에 feature application port와 use case를
추가한다. 공통 capability가 domain 이름이나 aggregate ID를 알게 해서는 안 된다.
이 문서의 `create`, `putPart`, `complete`, `scan`, `promote`는 provider와 technical
lifecycle을 닫기 위한 platform protocol operation이지, 그대로 외부에 노출할 제품
use case가 아니다. 제품 use case가 생기기 전에는 caller authorization, aggregate
연결, 업무 정책 선택과 endpoint가 없으므로 실행 경로도 composition하지 않는다.
단, orphan reconciliation과 resource cleanup 규칙은 adapter가 만든 기술 자원의
정합성을 위한 infrastructure 책임으로 정의해 둘 수 있다.
### 1.3 계층과 방향
```text
future product feature
domain/use case
-> product-owned file port
-> server file capability facade
-> ObjectByteStorePort
-> MultipartStagingPort
-> ObjectRegistryPort
-> IdempotencyStorePort
-> ContentScannerPort
-> CleanPromotionPort
-> SignedTransferCapabilityPort
-> provider adapters
S3 / MinIO / GCS / Azure Blob
PostgreSQL / Redis
scanner / CDR
```
HTTP controller는 미래의 inbound adapter다. object store, metadata database,
scanner와 signer 구현은 outbound adapter다. 이 문서의 port와 protocol은
application-neutral server platform 경계다.
### 1.4 control plane과 data plane
두 plane을 하나의 범용 `FileService` method로 뭉개지 않는다.
| plane | 책임 | 일반적인 경로 |
| --- | --- | --- |
| control | authorization, session, 정책 snapshot, idempotency, 상태 전이, capability 발급 | Browser → Web API/BFF |
| data | bounded byte upload/download, range, checksum, backpressure | Browser → BFF proxy 또는 제한된 signed URL → object store |
| inspection | quarantine read, parser/scanner/CDR, verdict | worker → private object store/scanner |
브라우저는 provider 관리자 credential, internal bucket/key 또는 database
credential을 받지 않는다. direct transfer가 필요하면 control plane이 exact
operation에 한정된 short-lived capability만 발급한다.
### 1.5 권장 서버 모듈 경계
언어와 framework가 달라도 dependency 방향은 다음과 같이 유지한다.
```text
file-capability/
protocol/
identifiers, lifecycle, policy, failure, receipts
ports/
object-store, multipart, registry, idempotency, signer, scanner, promotion
runtime/
streaming, integrity, deadline, retry, resource-lease, reconciliation
adapters/
object-store/<provider>
registry/<provider>
idempotency/<provider>
signer/<provider>
scanner/<provider>
contract-tests/
reusable suites, fixtures, conformance evidence
composition/
optional provider profile, probes, workers, kill switches
```
- `protocol``ports`는 HTTP/framework/provider SDK에 의존하지 않는다.
- `runtime`은 제품 domain을 모르며 port와 protocol만 사용한다.
- concrete SDK type과 exception은 각 adapter package 안에서 끝난다.
- concrete adapter를 동시에 import할 수 있는 곳은 composition root와 해당
provider contract fixture뿐이다.
- 제품 module은 capability facade 또는 product-owned port에만 의존한다.
- provider adapter는 별도 package/dependency로 격리해 미선택 build와 제거
profile에서 아예 포함되지 않게 한다.
- 미래 inbound controller와 제품 use case는 이 tree 밖의 제품 feature가
소유한다.
## 2. 공통 식별자와 영속 모델
### 2.1 외부에 노출 가능한 opaque reference
다음 reference는 URL-safe cryptographic random 값이거나 같은 수준의
registry-issued opaque 값이어야 한다.
```text
ObjectRef
UploadSessionRef
UploadPartRef
TransferCapabilityReceipt
IdempotencyKey
ScanJobRef
```
최소 요구사항:
- 최소 128-bit의 예측 불가능성
- account ID, email, 업무 key, filename, bucket, region을 인코딩하지 않음
- 대소문자와 Unicode normalization이 개입하지 않는 제한된 alphabet
- log/metric/trace label에 원문을 기록하지 않음
- reference만으로 authorization을 얻지 못함
- 서로 다른 종류의 reference를 type/namespace로 혼용하지 않음
- authorization scope와 object generation/version은 reference 문자열에 넣지 않고
server registry와 authorization decision에서 별도로 검증
- caller가 볼 권한이 없는 reference와 존재하지 않는 reference는 enumeration이
가능한 API에서 같은 외부 failure 표현 사용
`ObjectRef`는 provider object key가 아니다. server-owned registry가 아래 binding을
소유한다.
```text
ObjectBinding
objectRef
generation
providerProfileId
encryptedProviderLocator
state
byteLength
mediaType
wholeObjectDigest
createdAt
policySnapshotId
```
`encryptedProviderLocator`에는 provider가 요구하는 bucket/key/version locator만
보관한다. controller, domain과 frontend DTO에 반환하지 않는다. provider migration
시에도 `ObjectRef`는 유지하고 binding만 versioned CAS로 교체할 수 있어야 한다.
### 2.2 technical lifecycle
제품 domain 상태와 분리된 server file capability의 기술 상태는 다음과 같다.
```text
UploadSession
OPEN
-> COMPLETE_REQUESTED
-> PROVIDER_COMMITTED
-> QUARANTINE_RECORDED
-> QUARANTINED
-> ABORT_REQUESTED -> ABORTED
-> EXPIRED -> CLEANUP_PENDING -> CLEANED
StoredObject
STAGING
-> QUARANTINED
-> AVAILABLE
-> REJECTED
-> DELETING -> DELETED
```
불변조건:
- `AVAILABLE` 전에는 일반 download capability를 발급하지 않는다.
- `QUARANTINED` object는 private/non-executable 위치와 response policy를 사용한다.
- 상태 전이는 `(objectRef, generation, expectedState)` CAS로 수행한다.
- scan verdict는 exact generation과 digest에 binding한다.
- complete/abort/expiry race는 하나의 durable 상태만 승리한다.
- `PROVIDER_COMMITTED`는 provider complete 성공과 registry 응답 유실 사이를
복구하는 필수 fence다.
- `QUARANTINE_RECORDED`는 exact provider generation/length/digest와 scan outbox가
같은 database transaction 또는 동등한 atomic outbox로 durable해진 상태다.
- terminal state를 되돌리려면 새 generation과 새 audit record를 만든다.
- provider object 존재만으로 `AVAILABLE`이라고 판단하지 않는다.
### 2.3 정책 snapshot
미래 use case가 선택한 정책은 session 생성 시 immutable snapshot으로 binding한다.
```text
TransferPolicySnapshot
policyId
policyVersion
maxObjectBytes
minPartBytes
maxPartBytes
maxPartCount
maxConcurrency
allowedChecksumAlgorithms
requiredScanProfile
sessionExpiresAt
capabilityMaxTtl
orphanRetention
```
caller는 이 한도를 낮출 수만 있다. 진행 중인 session이 mutable configuration을
다시 읽어 한도가 상승하거나 의미가 바뀌어서는 안 된다. 긴급 차단은 별도
deny/kill-switch registry로 fail-closed하게 적용한다.
## 3. Server platform ports
아래 signature는 언어 중립적인 의미 계약이다. 실제 서버 언어의 native stream,
SDK response와 exception이 이 경계를 통과해서는 안 된다.
### 3.0 공통 실행 문맥과 숫자 표현
모든 I/O port 호출은 같은 bounded execution context를 받는다.
```text
OperationContext
registeredOperation
opaqueRequestId
opaqueTraceId?
monotonicDeadline
cancellation
```
- ingress timeout은 server-owned hard cap으로 clamp한다.
- 다른 process로 전달할 때 client의 absolute timestamp를 신뢰하지 않고 남은
timeout을 상한 안에서 전달한 뒤 각 process에서 monotonic deadline으로 바꾼다.
- child operation은 `min(parent remaining, operation cap)`만 사용할 수 있다.
- queue 대기, retry backoff와 cleanup 전의 본 작업도 전체 deadline에 포함한다.
- request/trace ID와 idempotency key는 서로 다른 식별자다.
- byte length, offset과 합계는 signed 64-bit 범위를 안전하게 표현하는
`bigint`/decimal value object를 사용한다. JSON number나 provider SDK의 좁은
정수로 암묵 변환하지 않고 모든 덧셈·곱셈 overflow를 검사한다.
### 3.1 `ObjectByteStorePort`
```text
ObjectByteStorePort
capabilities() -> Result<ObjectStoreCapabilities>
inspect(locator, context)
-> Result<ProviderObjectMetadata | NOT_FOUND>
openRead(
locator,
expectedVersion?,
range?,
context
) -> Result<OpenedObjectRead>
putAtomic(
newLocator,
expectedLength,
expectedDigest?,
mediaType,
byteStream,
context
) -> Result<ProviderWriteReceipt>
deleteExact(
locator,
expectedVersion,
context
) -> Result<DeleteReceipt>
```
`OpenedObjectRead`는 다음을 제공한다.
```text
OpenedObjectRead
providerVersion
declaredByteLength
mediaType
contentRange?
stream: bounded backpressure byte stream
close()
```
필수 규칙:
- read stream은 첫 실패 이후 byte를 더 전달하지 않는다.
- EOF 전에 declared length를 초과하거나 미달하면 `INTEGRITY_FAILED`다.
- range는 normalized inclusive/exclusive 의미를 port에서 하나로 고정한다.
- empty object와 empty range를 구분한다.
- conditional version mismatch는 `CONFLICT`이지 임의의 `NOT_FOUND`가 아니다.
- `putAtomic` 성공은 provider가 새 object/version을 durable하게 확정한 뒤만 반환한다.
- overwrite를 기본 허용하지 않는다. 새 locator 또는 exact version CAS만 허용한다.
- `deleteExact`의 late response loss는 inspect/reconcile로 결정하며 blind retry하지
않는다.
### 3.2 `MultipartStagingPort`
```text
MultipartStagingPort
create(
newLocator,
mediaType,
providerConstraints,
context
) -> Result<ProviderMultipartRef>
putPart(
multipartRef,
partNumber,
exactOffset,
expectedLength,
expectedDigest,
byteStream,
context
) -> Result<ProviderPartReceipt>
inspectParts(
multipartRef,
context
) -> Result<ProviderPartSet>
complete(
multipartRef,
orderedExactPartReceipts,
context
) -> Result<ProviderWriteReceipt>
abort(
multipartRef,
context
) -> Result<AbortReceipt>
```
`ProviderMultipartRef`, provider part ETag와 raw upload ID는 adapter-private
branded/opaque 값이다. frontend가 받는 `UploadSessionRef`와 동일하지 않다.
필수 규칙:
- part number는 연속성·범위·중복을 server ledger가 검증한다.
- part receipt는 multipartRef, part number, length, digest와 provider version에
binding한다.
- provider ETag를 SHA-256으로 해석하지 않는다.
- `complete`는 server ledger가 승인한 ordered receipt만 사용한다.
- `complete` 성공 후에도 object는 `QUARANTINED`다.
- `abort`는 idempotent하며 already-aborted를 성공으로 재생할 수 있다.
- complete가 먼저 commit됐다면 abort는 완료 object를 삭제하지 않고 `CONFLICT`
반환한다.
- 실패한 create/put/complete는 provider multipart orphan을 남길 수 있으므로
bounded reconciliation 대상이 된다.
### 3.3 `ObjectRegistryPort`
```text
ObjectRegistryPort
reserveObject(bindingDraft, expectedAbsent, context) -> Result<ObjectReservation>
getObject(objectRef, context) -> Result<ObjectBinding | NOT_FOUND>
transitionObject(objectRef, generation, expectedState, nextState, context)
-> Result<ObjectBinding>
createSession(sessionSnapshot, context) -> Result<UploadSessionRecord>
getSession(sessionRef, context) -> Result<UploadSessionRecord | NOT_FOUND>
appendPart(sessionRef, expectedRevision, partRecord, context)
-> Result<UploadSessionRecord>
transitionSession(sessionRef, expectedRevision, expectedState, nextState, context)
-> Result<UploadSessionRecord>
listExpiredOpenSessions(cursor, maxRows, context) -> Result<SessionPage>
listReconciliationCandidates(cursor, maxRows, context) -> Result<ObjectPage>
```
registry transaction이 object provider transaction과 원자적이라고 가정하지 않는다.
모든 cross-system mutation은 durable phase와 reconciliation을 갖는 saga다.
### 3.4 `SignedTransferCapabilityPort`
```text
SignedTransferCapabilityPort
issueUploadPart(
subjectScope,
sessionRef,
providerLocator,
partNumber,
exactLength,
requiredDigest,
requiredHeaders,
expiresAt,
context
) -> Result<SignedTransferCapability>
issueDownload(
subjectScope,
objectRef,
generation,
providerLocator,
allowedRange?,
responseMetadata,
expiresAt,
context
) -> Result<SignedTransferCapability>
```
```text
SignedTransferCapability
receipt
href
method
requiredHeaders
objectRef
generation/sessionRef
exactLengthOrMaximum
expectedDigest?
mediaType
safeExtension?
expiresAt
```
capability는 이미 authorization된 control-plane 결과다. signer port가 업무 권한을
판단하지 않는다.
### 3.5 `IdempotencyStorePort`
```text
IdempotencyStorePort
begin(scope, operation, key, requestFingerprint, leaseUntil, context)
-> Result<ACQUIRED<LeaseAndFence> | IN_PROGRESS | REPLAY<CompletionReceipt>>
renew(leaseAndFence, extendUntil, context)
-> Result<LeaseAndFence>
complete(leaseAndFence, requestFingerprint, completionReceipt, expiresAt, context)
-> Result<CompletionReceipt>
releaseRetryable(
leaseAndFence,
requestFingerprint,
proofSideEffectNotStarted,
context
)
-> Result<void>
pruneExpired(cursor, maxRows, context)
-> Result<PrunePage>
```
`scope + operation + key`가 unique key다. 같은 key를 다른 request fingerprint로
재사용하면 `CONFLICT`다. idempotency record는 provider upload ID, signed URL,
credential, 원본 filename 또는 request body를 저장하지 않는다.
lease expiry 뒤 takeover는 fence를 증가시키고 이전 owner의 renew/complete를
거절한다. 외부 side effect가 시작됐는지 불명확하면 record를 지우거나
`releaseRetryable`하지 않고 `RECOVERY_REQUIRED`로 남겨 reconciler가 provider와
registry를 확인한다.
### 3.6 `ContentScannerPort`
```text
ContentScannerPort
capabilities() -> Result<ScannerCapabilities>
submit(
scanJobRef,
objectRef,
generation,
immutableDigest,
scanProfileId,
sourceFactory,
context
) -> Result<ScanSubmissionReceipt>
inspect(jobRef, context) -> Result<ScanStatus>
cancel(jobRef, context) -> Result<CancelReceipt>
```
```text
ScanStatus
PENDING | RUNNING
CLEAN {
objectRef, generation, immutableDigest,
engineProfile, definitionVersion, completedAt
}
REJECTED {
objectRef, generation, immutableDigest,
allowlistedReasonCodes, completedAt
}
FAILED_RETRYABLE { safeReasonCode }
FAILED_TERMINAL { safeReasonCode }
```
scanner의 raw stdout, path, vendor exception, signature name과 원본 filename은
application response나 telemetry로 전달하지 않는다.
### 3.7 `CleanPromotionPort`
검사와 availability commit 권한을 한 adapter에 함께 주지 않는다.
```text
CleanPromotionPort
promote(
objectRef,
expectedGeneration,
expectedQuarantineDigest,
sealedCleanScanReceipt,
idempotency,
context
) -> Result<AvailableObjectReceipt>
reject(
objectRef,
expectedGeneration,
expectedQuarantineDigest,
sealedRejectedScanReceipt,
idempotency,
context
) -> Result<RejectedObjectReceipt>
```
- scan receipt는 exact object/generation/digest/scan-policy version에 binding된
server-only sealed value다.
- scanner는 registry를 `AVAILABLE`로 전환할 권한을 갖지 않는다.
- promotion adapter는 raw scanner 결과를 해석하지 않고 sealed receipt만 검증한다.
- CDR/sanitized output은 원본과 다른 object generation/digest이므로 새 object로
length/digest를 다시 검증한다.
## 4. Streaming object read/write
### 4.1 공통 stream envelope
stream은 다음을 명시적으로 소유한다.
- backpressure와 최대 queued bytes
- immutable declared length 또는 `unknown`
- operation deadline과 cancellation
- 첫 실패에서 terminal close
- byte counter와 digest accumulator
- completion/close truth
- resource lease
전체 object를 하나의 byte array, string, base64 또는 temporary in-memory Blob으로
합치는 API를 공통 경로로 제공하지 않는다. small-object 편의 API가 필요하면
composition-owned hard cap 아래 별도 wrapper로만 제공한다.
### 4.2 proxy upload
```text
request body
-> transport maximum-body guard
-> decoded stream
-> authoritative byte counter
-> digest accumulator
-> provider write stream
-> provider commit
-> registry phase transition
```
- `Content-Length`는 preflight hint일 뿐 실제 count를 대체하지 않는다.
- chunked transfer나 HTTP/2/3에서도 실제 decoded payload를 센다.
- max bytes를 넘는 즉시 upstream read와 provider write를 모두 cancel한다.
- provider commit 전에 client disconnect가 발생하면 staging을 abort/cleanup한다.
- provider commit 뒤 client response가 유실되면 idempotency replay가 같은 receipt를
반환해야 한다.
### 4.3 proxy download
```text
authorized immutable ObjectBinding
-> exact provider version open
-> byte/digest/range verifier
-> response stream
-> close/cancel
```
- authorization은 open 전에 완료한다.
- `AVAILABLE`과 exact generation을 다시 확인한다.
- metadata preflight와 stream open이 분리된 provider에서는 둘 다 같은 immutable
provider version에 pin하고 `If-Match`와 동등한 조건을 강제한다. version pin을
지원하지 않으면 HEAD 뒤 GET 같은 TOCTOU 경로를 사용하지 않는다.
- response header를 확정하기 전에 media type, length, range와 filename을
normalize한다.
- range response는 exact provider version, normalized range와 실제 반환 byte
count를 검증한다. whole-object SHA-256만으로 부분 range의 무결성을 검증했다고
주장하지 않는다.
- 검증 가능한 range가 필요하면 immutable chunk digest/Merkle manifest를 별도로
설계하고 exact chunk proof를 검증한다. 그렇지 않은 range는 transport integrity와
version/range binding만 보장하며 `digestVerified=false`로 명시한다.
- `Content-Disposition` filename은 advisory metadata로 sanitize한다.
- sensitive object는 기본적으로 attachment, `nosniff`, private/no-store 정책을
사용한다.
- downstream disconnect는 provider read를 cancel한다.
- download 완료 metric은 server stream 종료이지 사용자 disk 저장 완료가 아니다.
### 4.4 direct provider transfer
signed URL을 사용하는 direct transfer에서는 server가 byte stream을 직접 보지
못한다. 그러므로 다음을 모두 만족해야 한다.
- capability가 method, exact provider locator, part/range, length, checksum header,
expiry와 subject/session scope에 binding
- provider가 해당 조건을 실제 request에서 강제
- complete 전에 server가 provider part/object metadata를 authoritative하게 재조회
- provider metadata가 충분하지 않으면 quarantined object를 server-side stream으로
다시 읽어 length와 whole-object digest 검증
- verification 완료 전 registry 상태를 `AVAILABLE`로 전환하지 않음
provider가 검증하지 않는 client-declared metadata를 signed request에 포함했다는
이유만으로 integrity를 주장하지 않는다.
## 5. Multipart protocol
### 5.1 control-plane 상태 흐름
frontend reference runtime과 연결하는 ordered multipart wire protocol literal은
`PRESIGNED_MULTIPART_V1`이다. create/status/part capability/complete/abort의
application contract, session response와 durable checkpoint 전체에서 이 protocol
값을 exact하게 검증한다. `UPLOAD_PART` capability binding 자체에도 protocol을
포함하고, part capability endpoint는 `sessionId`로 조회한 server-side session과
이 값이 일치하는지 확인해야 한다.
```text
create
authorize + policy snapshot + idempotency begin
reserve ObjectRef/generation
create provider multipart staging
persist OPEN session
return session constraints
put part
reauthorize session scope
validate OPEN + expiry + part plan
claim part idempotency
stream or issue exact signed part capability
persist verified ProviderPartReceipt by session revision CAS
complete
reauthorize
idempotency begin
CAS OPEN -> COMPLETE_REQUESTED
validate ordered complete part set
provider complete
persist PROVIDER_COMMITTED with exact provider generation
verify final length/digest
atomically persist object QUARANTINED + scan outbox
persist QUARANTINE_RECORDED -> QUARANTINED
return stable quarantined receipt
abort
reauthorize
idempotency begin
CAS OPEN -> ABORT_REQUESTED
provider abort/cleanup
persist ABORTED
```
### 5.2 create
server가 결정하고 snapshot해야 하는 값:
- object/session opaque reference
- max object bytes
- min/max part bytes와 max part count
- max client concurrency
- required checksum algorithm
- exact session expiry
- scan profile
- direct/proxy transfer mode
client가 bucket/key, provider upload ID, concurrency ceiling, checksum algorithm이나
expiry를 선택하지 않는다.
### 5.3 part
- client가 보낸 `requestBindingSha256`, `uploadBindingSha256`와 part digest를
authorization proof로 취급하지 않는다. `sessionId`로 server-owned session을
조회하고 subject/purpose/state/expiry를 재검증한 다음 canonical binding과 part
plan을 직접 재계산한다.
- `partNumber`, exact offset, expected length를 session plan과 대조한다.
- 같은 idempotency key와 같은 fingerprint 재전송은 같은 receipt를 반환한다.
- 같은 part number의 다른 length/digest는 `CONFLICT`다.
- concurrent upload의 aggregate in-flight bytes와 provider connections에 hard cap을
둔다.
- 마지막 part를 제외한 최소 part 크기는 provider와 platform ceiling을 모두
만족해야 한다.
- part 성공 response가 유실되면 provider inspect와 ledger reconcile로 결정한다.
### 5.4 complete
- client가 제출한 receipt 목록을 그대로 provider에 전달하지 않는다.
- server ledger의 exact verified part set과 ordered fingerprint를 다시 계산한다.
- part count, offset 연속성, byte sum과 중복/누락을 검증한다.
- provider complete의 ETag를 whole-object SHA-256으로 해석하지 않는다.
- provider complete 직후 exact provider locator/version을
`PROVIDER_COMMITTED`로 먼저 기록한다. 이 checkpoint 전후 crash와 응답 유실은
provider inspect + journal reconcile로 결정한다.
- object metadata와 scan outbox가 durable해진 뒤
`QUARANTINE_RECORDED`를 기록한다.
- complete 성공은 `QUARANTINED` object가 durable하다는 의미이며 clean/available을
의미하지 않는다.
### 5.5 abort, expiry와 orphan cleanup
abort와 expiry job은 같은 state CAS를 사용한다.
| 현재 상태 | complete | abort/expiry |
| --- | --- | --- |
| `OPEN` | `COMPLETE_REQUESTED` claim 가능 | `ABORT_REQUESTED` claim 가능 |
| `COMPLETE_REQUESTED` | resume/reconcile | object 삭제 금지, conflict |
| `PROVIDER_COMMITTED` | verify/resume | object 삭제 금지, conflict |
| `QUARANTINE_RECORDED` | outbox/session resume | 일반 abort 금지 |
| `QUARANTINED` | stable replay | 일반 abort 금지 |
| `ABORT_REQUESTED/ABORTED/EXPIRED` | conflict/expired | stable replay |
orphan janitor는 다음을 bounded page로 처리한다.
- registry OPEN이지만 session TTL이 지난 행
- registry에 provider multipart ref가 있으나 terminal state가 아닌 행
- provider staging은 있으나 registry binding이 없는 owned orphan
- provider complete 가능성이 있으나 response가 유실된 `COMPLETE_REQUESTED`
- provider object는 durable하지만 registry/outbox가 미완료인
`PROVIDER_COMMITTED`/`QUARANTINE_RECORDED`
- `DELETING`에서 provider delete response가 유실된 object
전체 bucket list/delete를 자동 실행하지 않는다. owned prefix/tag와 registry
binding이 동시에 확인된 대상만 정리한다.
## 6. Byte length와 checksum
### 6.1 authoritative source
| 값 | 신뢰 수준 |
| --- | --- |
| client `Content-Length` | preflight hint |
| client checksum | expected value, 단독 authority 아님 |
| provider ETag | opaque provider version |
| provider checksum field | contract test를 통과한 알고리즘/representation에서만 사용 |
| server streaming counter | proxy path의 authoritative length |
| server digest accumulator | proxy path의 authoritative digest |
| server post-complete readback | direct path의 authoritative fallback |
### 6.2 알고리즘
- 기본 whole-object algorithm은 SHA-256으로 제한한다.
- algorithm confusion을 막기 위해 digest에 algorithm tag를 항상 포함한다.
- lowercase/uppercase나 base64/hex encoding을 port 하나로 canonicalize한다.
- multipart part digest와 whole-object digest를 구분한다.
- OPFS의 `SHA-256-TREE-V1`, multipart ETag와 whole-object SHA-256은 서로 다른
digest다.
- digest가 맞아도 MIME/content safety가 증명되는 것은 아니다.
### 6.3 mismatch
다음 경우 provider commit 또는 availability promotion을 금지한다.
- 실제 byte length가 expected length와 다름
- object가 composition hard cap을 초과
- part byte sum이 whole-object length와 다름
- expected digest와 actual digest가 다름
- provider metadata와 server ledger가 다름
- digest algorithm 또는 encoding이 policy와 다름
이미 direct upload가 provider에 commit된 뒤 mismatch가 발견되면 object를
`REJECTED` 또는 cleanup-pending quarantine으로 유지하고 일반 read capability를
발급하지 않는다.
## 7. Short-lived signed capability
### 7.1 capability binding
capability 또는 그 server-side receipt는 최소 다음을 binding한다.
- issuer와 audience
- subject/session scope
- operation: upload part, object download 또는 authorized range
- `ObjectRef + generation` 또는 `UploadSessionRef + partNumber`
- exact HTTP method
- exact provider locator
- exact length 또는 server-enforced maximum
- required checksum와 signed headers
- upload 성공의 exact status, receipt response header와 expected response bytes
- media type과 safe extension
- issued-at/not-before/expiry
- capability policy version
- random receipt/nonce
값 하나라도 caller request, object registry와 다르면 발급 또는 handoff를
fail-closed한다.
### 7.2 lifetime
- session TTL과 per-request capability TTL을 분리한다.
- capability TTL은 composition이 정하고 implementation hard ceiling보다 낮출 수만
있다.
- 대용량 transfer 시간과 재발급 UX를 측정해 값을 선택한다.
- 만료 capability를 연장하지 않고 authorization 후 새 capability를 발급한다.
- proxy 경로는 stream admission 시 expiry를 검증하고, admission 뒤에는 별도의
bounded operation deadline과 maximum transfer duration을 적용한다. expiry가
지났다는 이유만으로 이미 허용된 stream을 임의의 시점에 자를지는 정책으로
명시하며 기본값은 새 요청/재시도만 거절하는 것이다.
- direct provider 경로는 provider가 “시작 시 유효”와 “전송 내내 유효” 중 어떤
의미를 실제로 강제하는지 contract test로 고정한다. 중간 만료의 강한 회수가
필요하면 provider URL이 아니라 proxy/relay 경로를 사용한다.
- signing key rotation 시 current/previous verification window와 강제 폐기 절차를
정의한다.
문서 출발점으로는 per-request capability를 수분 단위로 유지하되, 실제 값은
provider와 최대 part/object 크기의 production-like transfer evidence로 승인한다.
장기 URL을 session 전체와 동일하게 발급하지 않는다.
### 7.3 single-use의 한계
object store signed URL은 일반적으로 URL 자체만으로 single-use를 보장하지 않는다.
정확한 single-use가 요구되면 다음 중 하나를 선택한다.
- BFF proxy가 nonce를 원자 consume한 뒤 stream
- control plane receipt를 원자 consume하고 아주 짧은 provider capability 발급
- provider가 지원하는 조건부 write/version 정책과 server ledger를 결합
single-use를 구현하지 않았으면 문서나 API 이름으로 주장하지 않는다.
### 7.4 URL과 logging
- HTTPS 외 protocol은 local test 외 금지
- allowlisted provider/origin만 허용
- redirect는 기본 금지
- query의 signature/token을 log, analytics, trace attribute에 기록하지 않음
- `Referer`와 browser history 노출을 고려한 delivery 정책
- response header와 CORS expose 목록을 explicit하게 고정
- signed URL을 database의 장기 object locator로 저장하지 않음
## 8. Idempotency store
### 8.1 scope와 fingerprint
idempotency는 다음 연산에 기본 적용한다.
- upload session create
- part registration/stream upload
- multipart complete
- abort
- scan submission
- availability promotion
- delete
`requestFingerprint`는 operation별 canonical request의 SHA-256이다. 원문 payload,
filename, signed URL, token과 PII를 fingerprint input/record에 넣지 않는다.
업무 payload가 필요한 미래 use case는 feature-owned canonicalization을 추가한다.
fingerprint는 registered canonicalizer만 만들며 operation/version, exact
object/session generation, length, digest와 relevant precondition을 포함한다.
deadline, trace/request ID 같은 volatile 값은 제외한다.
### 8.2 concurrency
`begin`은 하나의 atomic operation이어야 한다.
```text
first caller -> ACQUIRED + lease/fence
same fingerprint -> IN_PROGRESS 또는 completed REPLAY
different fingerprint -> CONFLICT
```
- process-local lock만으로 correctness를 주장하지 않는다.
- lease owner가 죽으면 expiry 이후 같은 fingerprint만 reclaim할 수 있다.
- reclaim은 fence를 증가시키며 old owner의 complete를 거절한다.
- complete는 expected lease/fence와 request fingerprint를 다시 확인한다.
- durable side effect와 idempotency complete 사이 crash는 reconciliation 가능한
operation receipt로 해결한다.
- “exactly once”를 주장하지 않고 at-least-once delivery + idempotent effect로
설계한다.
- exactly-once가 실제로 필요하면 업무 mutation과 receipt를 같은 transaction에
넣거나 transactional inbox/outbox로 묶어야 한다. 별도 Redis `SETNX`
database/object mutation을 실행하는 구조는 duplicate suppression일 뿐이다.
- idempotency store가 unavailable한 keyed mutation은 fail-closed한다.
### 8.3 receipt와 retention
completion receipt에는 다음처럼 재생에 필요한 최소 정보만 둔다.
```text
CompletionReceipt
operation
stableResultCode
objectRef/sessionRef
generation/revision
safeResponseFingerprint
completedAt
```
- replay response는 최초 성공과 의미가 같아야 한다.
- transient provider error 전체를 영구 replay하지 않는다.
- retention은 최대 client retry/session window를 포함하되 무제한이 아니다.
- row/byte hard cap, expiry index와 bounded prune를 필수로 둔다.
- 아직 replay 가능한 receipt를 storage pressure만으로 삭제하지 않는다.
## 9. Quarantine과 scan
### 9.1 격리
- staging/quarantine object는 public ACL과 CDN 배포를 금지한다.
- 일반 download capability issuer가 `QUARANTINED`를 읽지 못하게 한다.
- scanner principal은 exact quarantine read와 verdict write만 가진다.
- clean destination writer와 destructive delete 권한을 최소화한다.
- 원본 filename으로 provider path를 만들지 않는다.
### 9.2 검사 pipeline
```text
QUARANTINED object + exact generation/digest
-> durable scan outbox
-> scanner/validator
malware
MIME sniff + allowlisted parser
archive traversal/symlink/nesting/expanded-size ratio
image dimension/pixel budget
PDF/active content
optional CDR
-> bound verdict
-> CAS promotion or rejection
```
scanner가 clean이라고 반환해도 submit 당시와 object generation/digest가 다르면
stale verdict로 폐기한다.
### 9.3 promotion
promotion은 provider별로 다음 중 하나다.
- immutable quarantine object를 그대로 유지하고 registry access state만
`AVAILABLE`로 CAS
- clean bucket/key로 server-side copy 후 length/digest/version을 재검증하고 binding
CAS
copy+delete를 atomic rename으로 주장하지 않는다. crash 단계마다 source/destination
binding을 재검증하는 saga와 cleanup phase가 필요하다. availability가 commit되기
전 source를 삭제하지 않는다.
### 9.4 scanner 장애
- required scanner unavailable은 clean으로 degrade하지 않는다.
- retryable failure는 bounded backoff와 retry count/age ceiling을 가진다.
- terminal failure는 quarantine을 유지하고 operator/user recovery를 요구한다.
- scan process/container에는 wall-clock, CPU, memory, file count, recursion depth,
expanded bytes와 output bytes hard limit를 둔다.
- scan definition/profile freshness가 composition의 maximum age를 넘으면 이전
`CLEAN` verdict로 promotion하지 않고 새 job/generation fence로 재검사한다.
- scan backlog가 SLO를 초과하면 신규 session 발급을 제한하거나 kill switch를
사용한다.
- scan timeout 이후에도 late verdict가 state를 바꾸지 못하게 generation/job
fence를 검증한다.
## 10. Deadline, retry와 resource cleanup
### 10.1 deadline budget
각 public capability invocation은 절대 deadline 또는 남은 budget을 받는다.
```text
request deadline
- authorization
- registry transaction
- provider call
- stream transfer
- verification
- response margin
```
하위 adapter가 각자 전체 timeout을 새로 시작해 총 시간이 무한히 늘어나서는 안
된다. 남은 budget이 최소 provider timeout보다 작으면 side effect 전에
`DEADLINE_EXCEEDED`로 종료한다. caller cancellation은 `CANCELLED`이고 server
deadline 소진은 `DEADLINE_EXCEEDED`다. 둘은 metric과 retry 판단에서도 합치지
않는다.
### 10.2 retry matrix
공통 retry directive는 세 종류뿐이다.
```text
NEVER
SAFE { afterMs? }
SAME_IDEMPOTENCY_KEY { afterMs? }
```
| operation | directive | 조건 |
| --- | --- | --- |
| immutable metadata/read open | `SAFE` | 같은 exact version과 남은 deadline |
| range read | `SAFE` | 같은 exact version/range와 아직 전달되지 않은 경계 |
| session create | `SAME_IDEMPOTENCY_KEY` | 같은 canonical fingerprint |
| put part | `SAME_IDEMPOTENCY_KEY` | 같은 key/part/length/digest |
| multipart complete | `SAME_IDEMPOTENCY_KEY` | stable receipt + inspect/reconcile |
| abort/delete | `SAME_IDEMPOTENCY_KEY` | exact state/version + inspect/reconcile |
| signed capability issue | `NEVER` | authorization/expiry 확인 후 새 operation으로 발급 |
| scan submit | `SAME_IDEMPOTENCY_KEY` | stable job ref + generation/digest binding |
retry는 exponential backoff, full jitter, max attempts와 전체 deadline을 가진다.
rate limit/provider overload에서는 `Retry-After` 또는 provider-safe backoff hint를
상한 안에서 반영한다.
- retry owner는 ingress/application orchestration, adapter wrapper 또는 provider
SDK 중 정확히 한 계층이다. service mesh와 SDK의 숨은 retry는 끄거나 동일한
total-attempt budget에 포함해 retry amplification을 막는다.
- mutation의 적용 여부가 `UNKNOWN`이면 새 idempotency key로 재시도하지 않는다.
같은 key로 receipt를 조회하거나 provider/registry reconciliation을 먼저 한다.
- 이미 response byte를 client에 전달한 stream read는 처음부터 자동 재시작하지
않는다. resumable protocol이 명시된 경우에만 검증된 다음 range에서 재개한다.
### 10.3 cleanup
모든 adapter는 다음 자원을 명시적으로 종료한다.
- input/output stream과 provider response body
- multipart writer/upload handle
- temporary file와 bounded buffer
- digest/scanner process stream
- database cursor/transaction/connection lease
- scheduled timeout/retry task
- lock/semaphore permit
- tracing span
resource owner는 다음과 같은 idempotent lease 계약을 구현한다.
```text
ResourceLease
state: OPEN | CLOSING | CLOSED
transferOwnership(newOwner)
close(reason, cleanupDeadline) -> CleanupReport
```
- ownership transfer는 명시적이며 transfer 뒤 이전 owner는 close하지 않는다.
- `close`는 여러 번 호출돼도 안전하고 첫 close reason과 cleanup 결과를 보존한다.
- request deadline과 별도로 짧고 bounded한 cleanup budget을 예약한다. 이 budget은
client response deadline을 연장하지 않으며, 즉시 끝낼 수 없는 provider cleanup은
durable reconciliation record로 넘긴다.
- finalizer/garbage collector는 correctness 경로가 아니라 마지막 누수 경보다.
success, failure, cancellation, timeout, downstream disconnect와 exception 모든 경로를
contract test한다. cleanup 자체의 실패가 최초 failure를 덮지 않으며 safe secondary
observation만 남긴다.
### 10.4 shutdown
- 신규 session/capability 발급 중지
- in-flight admission 중지
- bounded grace 동안 active stream drain
- 남은 stream cancel
- leased idempotency operation을 reclaim 가능 상태로 둠
- multipart/scanner reconciliation checkpoint 저장
- provider clients/executors close
무기한 graceful shutdown을 허용하지 않는다.
## 11. 공통 failure model
### 11.1 closed failure
```text
FileCapabilityFailure
code
operation
retry: NEVER
| SAFE { afterMs? }
| SAME_IDEMPOTENCY_KEY { afterMs? }
effect: NOT_APPLIED | APPLIED | UNKNOWN
recovery
safeReasonCode?
correlationId
```
권장 closed code:
```text
INVALID_INPUT
UNAUTHENTICATED
FORBIDDEN
NOT_FOUND
CONFLICT
POLICY_REJECTED
LIMIT_EXCEEDED
PAYLOAD_TOO_LARGE
UNSUPPORTED_MEDIA_TYPE
INTEGRITY_FAILED
EXPIRED_RESOURCE
QUARANTINED
REJECTED
RATE_LIMITED
IDEMPOTENCY_KEY_REUSED
OPERATION_IN_PROGRESS
CANCELLED
DEADLINE_EXCEEDED
DEPENDENCY_UNAVAILABLE
CONTRACT_MISMATCH
RECOVERY_REQUIRED
CORRUPT_DATA
INTERNAL
```
권장 operation:
```text
OBJECT_INSPECT
OBJECT_READ
OBJECT_WRITE
OBJECT_DELETE
MULTIPART_CREATE
MULTIPART_PART
MULTIPART_COMPLETE
MULTIPART_ABORT
CAPABILITY_ISSUE
IDEMPOTENCY
SCAN_SUBMIT
SCAN_INSPECT
OBJECT_PROMOTE
RECONCILE
```
`recovery``RETRY`, `REAUTHORIZE`, `RESTART_SESSION`, `REOPEN`,
`READ_ONLY`, `SUPPORT`, `NONE` 같은 allowlist다.
- `NOT_APPLIED`는 side effect가 시작되지 않았음이 증명된 경우만 사용한다.
- `APPLIED`는 durable receipt로 effect를 증명할 수 있는 경우다.
- response loss, provider timeout 또는 process crash로 확정할 수 없으면
`UNKNOWN`이며, caller에게 성공이나 안전한 신규 요청을 암시하지 않는다.
- `SAFE`는 read 또는 side effect 전 실패에만 사용한다.
`SAME_IDEMPOTENCY_KEY`는 key/fingerprint/receipt 계약이 갖춰진 mutation에만
사용한다. 단순 `retryable: true`는 허용하지 않는다.
### 11.2 mapping
- provider status/exception class를 application failure로 한 곳에서 mapping한다.
- raw SDK exception, request ID, bucket/key, endpoint와 provider body를 port 밖으로
throw하지 않는다.
- unknown provider failure는 `INTERNAL` 또는 `DEPENDENCY_UNAVAILABLE` 중 사전에
정한 fail-closed mapping을 사용하고 effect는 보수적으로 `UNKNOWN`으로 둔다.
- authorization과 object existence를 노출하면 안 되는 API는 `FORBIDDEN`
`NOT_FOUND` 외부 표현을 동일하게 만들 수 있다.
- HTTP status는 inbound adapter가 failure code에서 mapping하며 domain/application이
HTTP status를 반환하지 않는다.
- retry directive와 effect certainty는 provider message 문자열이 아니라 typed
mapping, operation semantics, idempotency receipt와 reconciliation evidence로
결정한다.
### 11.3 safe observability
다음을 기록하지 않는다.
- object/session/idempotency reference 원문
- filename과 user/account ID
- provider locator, bucket/key/version
- signed URL/query/header
- checksum 원문
- scanner raw result
- request/response body와 exception message/stack
허용 가능한 metric dimension:
- provider profile ID
- operation
- stable failure code
- byte/part/latency bucket
- transfer mode proxy/direct
- state transition
- scan profile와 safe verdict class
- retry count bucket
- retry directive와 effect certainty
high-cardinality identifier를 metric label로 사용하지 않는다.
## 12. Provider contract test
### 12.1 한 suite, 여러 adapter
각 port는 provider-neutral contract suite factory를 제공한다.
```text
objectByteStoreContract(createProviderFixture)
multipartStagingContract(createProviderFixture)
signedCapabilityContract(createProviderFixture)
rangeDownloadContract(createProviderFixture)
idempotencyStoreContract(createProviderFixture)
contentScannerContract(createProviderFixture)
objectRegistryContract(createProviderFixture)
cleanPromotionContract(createProviderFixture)
imageDescriptorContract(createProviderFixture)
```
동일 suite를 in-memory fake, emulator와 실제 provider adapter에 실행하되 결과
등급을 섞지 않는다.
- in-memory fake: orchestration 개발과 빠른 invariant 회귀
- emulator/container: SDK wiring과 local integration
- actual provider conformance: 선택한 provider product/API/version의 격리된
production-like account/region에서 실행한 promotion evidence
fake나 emulator 통과는 actual provider conformance를 대체하지 않는다. actual
provider에서 destructive/fault test를 실행할 수 없다면 누락 항목, 보완 통제,
승인 owner와 expiry가 있는 명시적 waiver가 필요하며 자동으로 “동등”하다고
간주하지 않는다.
### 12.2 object store matrix
- zero-byte와 boundary-size object
- exact bytes/media/version round trip
- range beginning/middle/end/invalid/empty
- declared length 미달·초과
- checksum mismatch
- provider metadata/ETag와 digest 구분
- mid-stream read/write failure
- cancellation/downstream disconnect
- deadline before call/during stream/after provider commit
- concurrent exact-version write/delete
- response loss 뒤 inspect/reconcile
- resource close exactly once
- object locator escaping/path traversal 거절
### 12.3 multipart matrix
- create/put/inspect/complete 정상 흐름
- minimum/maximum part size와 count
- duplicate part same fingerprint replay
- duplicate part different fingerprint conflict
- missing/duplicate/out-of-order receipt
- complete/abort/expiry races
- complete response loss와 recovery
- part success response loss와 inspect
- orphan multipart cleanup
- provider complete ETag가 whole SHA-256이 아님을 검증
- direct signed PUT의 required length/checksum header 강제
### 12.4 idempotency matrix
- 동시 100개 begin에서 정확히 하나만 `STARTED`
- 같은 fingerprint의 `IN_PROGRESS`와 stable replay
- 다른 fingerprint conflict
- lease expiry/reclaim
- crash between durable effect and complete
- retryable release
- receipt expiry와 bounded prune
- count/byte cap
- tenant/scope/operation key isolation
- clock skew/invalid expiry fail-closed
### 12.5 capability matrix
- wrong method/object/part/range/header 거절
- expiry/not-before
- TTL hard ceiling
- modified query/path/host 거절
- wrong audience/subject/session
- key rotation current/previous/expired
- redirect/CORS/header exposure policy
- token/query redaction
- direct provider가 signed constraint를 실제로 강제하는지 확인
### 12.6 scanner matrix
- clean/rejected/timeout/unavailable
- corrupt/truncated input
- stale generation/digest verdict 폐기
- duplicate submission replay
- archive path traversal/nesting/expanded-size limit
- scanner crash와 process/resource cleanup
- late verdict after cancellation/timeout
- scan backlog admission control
- clean verdict 전 download/promotion 불가
### 12.7 Range download matrix
- exact `RANGE_RESUMABLE_DOWNLOAD_V1`과 unknown/missing version 거절
- immutable generation/strong validator/total length/media/full digest binding
- beginning/middle/final segment의 exact `206 Content-Range`
- `If-Range` match와 mismatch의 `206`/full `200`
- 별도 generation precondition의 `412`
- before-start/at-end/beyond-end `416`과 authoritative total
- capability expiry/reissue의 same-binding 유지
- generation replacement 뒤 old partial 이어 쓰기 거절
- encoded/transform response와 redirect 거절
- direct provider와 BFF relay가 같은 constraint를 실제로 강제
### 12.8 Image descriptor/CDN matrix
- exact `IMAGE_CDN_DESCRIPTOR_V1`과 unknown/missing field/version 거절
- authorization/existence hiding/quarantine 상태
- immutable asset revision과 preset binding exact recomputation
- arbitrary source/transform/query 거절
- old/new key overlap, signer cutover와 old key drain
- expiry/reissue/emergency revoke
- public immutable/private no-store cache header
- cross-origin CORS/CSP와 ambient credential 비의존
- malformed/animated/oversize rendition 거절
- asset/preset mismatch와 provider response loss
### 12.9 fault injection과 evidence
- provider latency, throttle, connection reset, partial response
- database deadlock/serialization retry
- registry commit 전후 process termination
- provider commit 뒤 response loss
- queue duplicate/out-of-order
- scanner unavailable와 slow verdict
- clock movement은 wall clock/monotonic clock 책임에 맞춰 주입
CI가 만드는 immutable conformance evidence에는 최소 다음을 포함한다.
```text
ProviderConformanceEvidence
adapterName/version/artifactDigest
providerProduct/apiVersion/runtimeVersion
environmentClass/region
capabilityProfileDigest
contractSuiteName/version/artifactDigest
startedAt/completedAt
passed/failed/skipped counts
faultSuiteResult
waiverIds[]
evidenceExpiresAt
ciRunIdentity/signature
```
실제 provider evidence와 fake/emulator result는 별도 artifact로 보관한다. 필수
test skip, expired evidence, adapter/provider/config 변경 또는 contract suite
version 불일치는 promotion을 막는다. 최소 정기 schedule과 provider/SDK upgrade,
capability/config 변경 시 actual provider conformance를 다시 실행한다.
## 13. 선택적 composition
### 13.1 서로 독립적인 상태 축
source catalog 상태, runtime 설치, 현재 health와 traffic admission을 하나의
`ENABLED` boolean으로 합치지 않는다.
```text
InstallationState
NOT_SELECTED | INSTALLED | REMOVING
RuntimeState
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
TrafficAdmission
DISABLED | SHADOW | CANARY | ENABLED
```
이 문서의 목표인 `AVAILABLE_NOT_COMPOSED`는 source catalog에 port, adapter,
contract suite와 문서가 있지만 runtime은 `NOT_SELECTED`, traffic은 `DISABLED`
상태다. provider client/route/job/migration을 만들지 않으므로 `RuntimeState`
평가하지 않는다.
`INSTALLED`는 dependency와 immutable policy가 composition되었다는 뜻일 뿐,
provider가 현재 `AVAILABLE`하거나 traffic이 `ENABLED`라는 뜻이 아니다.
`INCOMPATIBLE`은 contract/API/capability 불일치이며 fail-closed한다.
### 13.2 composition root
선택 시 composition root만 concrete provider를 안다.
```text
ServerFileCapabilityComposition
objectByteStore
multipartStaging
objectRegistry
idempotencyStore
signedTransferCapability
contentScanner
cleanPromotion
clock
secureRandom
deadlinePolicy
retryPolicy
transferPolicyRegistry
safeObserver
```
composition 시 다음을 검증하고 immutable snapshot으로 고정한다.
- provider capability와 요구 기능 일치
- hard byte/part/TTL/retry/deadline ceiling
- registry와 provider profile binding
- signing key/audience
- scanner profile
- cleanup owner와 schedule
- idempotency retention/capacity
- metric/trace redaction policy
선택된 필수 dependency가 없거나 capability가 부족하면 startup 또는 feature
installation을 fail-closed한다. unavailable provider를 fake로 바꾸어 production을
계속하지 않는다.
### 13.3 capability probe와 readiness
probe는 다음 판단을 분리해 기록한다.
| probe | 답하는 질문 |
| --- | --- |
| static config | 필수 값, hard ceiling과 profile schema가 유효한가 |
| connectivity | DNS/TLS/network endpoint에 bounded하게 도달하는가 |
| credential/entitlement | 최소 권한 principal이 필요한 operation을 허용받는가 |
| compatibility | provider API/version/capability가 승인 contract와 일치하는가 |
| operational health | 현재 latency/error/throttle가 admission SLO 안인가 |
- probe는 짧은 timeout, cancellation과 bounded retry를 사용하고 동시 요청은
single-flight로 합친다.
- cached result에는 TTL와 jitter를 두며
`state, observedAt, validUntil, adapterVersion, contractVersion, safeReasonCode`
외 token, endpoint body, locator와 credential을 포함하지 않는다.
- probe를 매 product request의 authorization 또는 correctness check로 사용하지
않는다. request는 여전히 exact registry state, generation과 capability를
검증한다.
- last-known-good는 짧은 grace의 운영 신호일 뿐 expiry 뒤 authority가 아니다.
stale result는 `UNKNOWN`으로 낮춘다.
- read/write probe가 side effect를 만들면 별도의 owned canary namespace와 exact
cleanup receipt를 사용한다. 일반 customer object를 probe하지 않는다.
- `DISABLED/SHADOW`인 optional capability 장애는 base server readiness를 깨지
않는다. `ENABLED`이고 해당 제품 경로의 필수 dependency이면 feature readiness와
admission을 fail-closed한다. boot-blocking 여부는 composition policy로 명시한다.
`INSTALLED -> CANARY` promotion에는 유효한 actual-provider conformance evidence,
owner, policy snapshot, SLO/alert, runbook, cleanup drill과 rollback/kill switch가
필요하다.
### 13.4 미선택 상태
`AVAILABLE_NOT_COMPOSED`에서는 다음을 금지한다.
- public HTTP route/controller registration
- provider SDK client 초기화와 credential 요구
- database table/migration 자동 생성
- bucket/container 생성
- cleanup/scan worker와 scheduler 시작
- health/readiness 필수 dependency 등록
- product module이 concrete adapter를 직접 import
- provider endpoint로의 network/DNS 호출과 background capability probe
server template core는 file capability 디렉터리를 완전히 제거해도 build, base test,
application startup과 artifact 생성이 통과해야 한다.
### 13.5 rollout
1. 제품 use case와 owner 결정
2. 분류, size, retention, scan, fallback 정책 승인
3. provider와 deployment profile 선택
4. actual provider contract/fault test와 evidence 생성
5. `INSTALLED + UNKNOWN + DISABLED`로 composition
6. bounded operator probe 후 `SHADOW`
7. 내부 cohort proxy transfer를 `CANARY`
8. direct signed transfer가 필요하면 별도 canary
9. quarantine/scan backlog, reconciliation과 cleanup drill
10. SLO/alert/kill switch/rollback 확인 후 `ENABLED`로 점진 확대
rollout 중 evidence expiry, `INCOMPATIBLE`, integrity failure 또는 cleanup 불능은
자동 promotion을 중단한다. rollback은 code rollback뿐 아니라
`TrafficAdmission=DISABLED`, credential revoke와 worker drain 절차를 포함한다.
### 13.6 kill switch
- 신규 upload session 발급 off
- direct signed upload off → bounded proxy 또는 전체 off
- multipart off → 승인된 small simple upload
- 신규 download capability off
- scanner promotion off, quarantine 유지
- provider write off, read-only
- background reconciliation batch 축소/정지
kill switch가 기존 object를 자동 삭제하거나 quarantine을 available로 만들면 안 된다.
## 14. 제거 가능성
### 14.1 제거 순서
1. `InstallationState=REMOVING`, `TrafficAdmission=DISABLED`로 전환하고 신규
session/capability와 product write를 중지한다.
2. in-flight operation을 bounded drain/cancel한다.
3. `OPEN`, `COMPLETE_REQUESTED`, `PROVIDER_COMMITTED`,
`QUARANTINE_RECORDED`, `ABORT_REQUESTED` session을 reconcile한다.
4. quarantine/scan/reconciliation backlog와 orphan inventory를 0 또는 승인된
handoff 상태로 만든다.
5. `AVAILABLE` object의 새 provider/use case 이전과 read cutover를 완료한다.
6. route/controller, consumer와 product composition을 제거한다.
7. cleanup/scanner workers, queue subscription, webhook, scheduler와 provider
lifecycle rule을 중지·제거한다.
8. provider-owned IAM principal, signing key, credential와 secret을 revoke하고
config/SDK dependency를 제거한다.
9. alert/dashboard/SLO, runbook, on-call ownership과 비용 budget을 제거하거나 새
owner에게 명시적으로 이관한다.
10. owned registry/idempotency rows와 provider objects는 별도 승인된 data
migration으로 제거한다.
11. architecture, contract, startup, network와 dependency inventory gate를
재실행한다.
data 삭제를 code removal과 같은 단계에서 암묵적으로 실행하지 않는다.
일반 object 삭제도 logical tombstone/CAS와 exact provider generation purge를
분리하고 durable purge receipt를 남긴다. retention/legal hold가 있으면 physical
purge보다 우선하며 provider `NOT_FOUND`만으로 삭제 완료를 추정하지 않는다.
### 14.2 제거 gate
server 구현 저장소에는 격리 copy 또는 build profile에서 다음을 자동 검증하는
removal test를 둔다.
- file capability source와 provider dependency 제거
- file 전용 route/job/config/migration 제거
- 남은 source import 0개
- base type/compile/lint/unit/integration 통과
- application startup 통과
- dependency/SBOM에 provider SDK 부재
- generated API/schema에 file endpoint 부재
- 기본 health/readiness가 file provider를 요구하지 않음
- startup과 idle 기간 provider DNS/network 호출 0건
- secret/IAM/queue/webhook/scheduler/IaC inventory에 orphan 0건
- provider object와 database data는 삭제 완료 또는 새 owner에게 이관됐다는
별도 signed inventory 존재
## 15. Frontend와의 향후 계약
현재 frontend runtime과 연결할 때 control-plane API는 최소 다음 의미를 제공해야
한다. 실제 endpoint/DTO는 제품 feature가 소유한다.
whole-object presigned control envelope의 목표 protocol은
`PRESIGNED_TRANSFER_V1`, Image descriptor envelope은
`IMAGE_CDN_DESCRIPTOR_V1`, Range resume는
`RANGE_RESUMABLE_DOWNLOAD_V1`이다. 현재 frontend/server template에 이 세
control plane이 모두 구현됐다는 뜻은 아니다. BFF는 unknown/missing version을
fail-closed하고 N-1 client drain/rollback window를 명시해야 한다.
### 15.1 browser-managed download
server가 발급하는 capability는 frontend
`BrowserManagedDownloadCapability`와 다음 의미가 일치해야 한다.
```text
receipt
href
resourceId/ObjectRef
mediaType
safeExtension
maxBytes
expectedSha256?
expiresAt
```
endpoint는 capability가 주장한 max bytes, object generation, digest와 expiry를
실제 response에 enforce한다. frontend의 `BROWSER_HANDOFF`는 disk save 완료가
아니다.
### 15.2 authorized stream download
제품 연결 전 frontend의 `AUTHORIZED_STREAM_RESOURCE`는 단순 `resourceId`보다
좁은 server-issued capability를 받도록 확장해야 한다.
```text
AuthorizedStreamCapability
receipt
objectRef
generation
exactLength
expectedDigest
mediaType
expiresAt
```
stream opener는 이 capability를 검증해 exact provider/server response를
`FileByteSource`로 변환한다.
### 15.3 multipart upload
frontend feature가 필요한 최소 의미:
```text
protocol = PRESIGNED_MULTIPART_V1
create -> protocol, sessionRef, request binding, part constraints, expiry,
SHA-256-PARTS-V1 fingerprint
put/sign part -> protocol/session, request + upload binding, part number,
offset, exact length, digest, idempotency key
complete -> protocol/session + ordered verified receipts -> QUARANTINED
abort -> protocol/session -> stable terminal result
status -> protocol/session -> ACTIVE | QUARANTINED | ABORTED | EXPIRED | NOT_FOUND
```
browser control adapter는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`
closed operation set을 composition-owned fixed HTTPS endpoint map으로 실행한다.
part/download capability 발급 adapter도 composition 시 fixed BFF endpoint를 한
번만 받는다. server route가 어떤 path를 선택하든 caller가 endpoint를 URL로
전달하거나 operation을 추가할 수 없는 closed contract를 유지한다.
canonical binding은 UTF-8 newline-separated field sequence의 SHA-256 lowercase
hex다.
```text
fingerprint digest fields:
SHA-256-PARTS-V1
fingerprint.byteLength
fingerprint.partSizeBytes
fingerprint.partCount
{partNumber}:{offset}:{byteLength}:{checksumSha256} for each ordered part
request binding fields:
RESUMABLE-UPLOAD-BINDING-V1
uploadKey, purpose, mediaType
fingerprint.algorithm, fingerprint.digestHex
fingerprint.byteLength, fingerprint.partSizeBytes, fingerprint.partCount
upload session binding fields:
RESUMABLE-UPLOAD-SESSION-BINDING-V1
requestBindingSha256, sessionId
fingerprint.algorithm, fingerprint.digestHex
fingerprint.byteLength, fingerprint.partSizeBytes, fingerprint.partCount
```
각 field/part line은 UTF-8 newline 하나로 연결하고 trailing newline을 붙이지
않는다. server는 complete 때 ledger의 ordered part set으로 fingerprint digest를
재계산한다.
BFF는 part capability 요청의 `sessionId`로 registry row와 immutable session
snapshot을 조회해 위 값을 다시 만든다. client-provided digest는 비교 입력이지
authorization, ownership, current state 또는 part eligibility를 증명하지 않는다.
`UPLOAD_PART` capability binding에도 exact
`protocol: PRESIGNED_MULTIPART_V1`을 포함하고 subject scope, expiry, protocol,
exact part plan과 idempotency를 별도로 검증한다.
PUT capability는 expected success status, receipt response header와
`expectedResponseByteLength`를 binding한다. object store/proxy는 exact
`Content-Length`를 보내야 한다. 단 204는 response bytes가 0이어야 하고 header
부재를 0으로 정규화한다. frontend는 hard cap과 transfer deadline 안에서 response
body를 EOF까지 drain한 뒤 receipt를 채택하므로 CORS는 custom receipt header를
명시적으로 expose해야 한다.
status의 HTTP 404/410은 각각 `NOT_FOUND`/`EXPIRED_RESOURCE` terminal 의미다.
frontend는 stale checkpoint를 CAS 제거하고 restart한다. network/429/모든 5xx는
server가 명시한 bounded `Retry-After`와 frontend attempt/deadline ceiling 안에서만
재시도된다. complete 내부 DTO는 session/fingerprint binding을 검증하지만
application-facing `QUARANTINED` 결과에는 state, opaque resource reference,
byte length와 replay 여부만 노출한다.
`AVAILABLE` 전에는 public URL, normal download capability나 active-content preview를
발급하지 않는다.
### 15.4 Range resumable download
Range capability는 whole-object download URL을 재사용하는 편의 header가 아니라
별도 `RANGE_RESUMABLE_DOWNLOAD_V1` control contract다.
server registry는 logical resource를 다음 immutable representation에 binding한다.
```text
representationBinding
objectRef
immutable generation
strong validator
exact total byte length
media type
whole-object SHA-256
```
- weak validator, multipart ETag의 digest 추정, last-modified나 file name을
representation identity로 사용하지 않는다.
- object provider의 version ID가 안정적이면 registry generation과 exact
provider locator를 server 내부에서 binding한다. 그렇지 않으면 BFF proxy가
immutable generation을 enforce한다.
- control response가 data-plane URL과 `If-Range` validator를 전달하더라도 raw
값은 adapter-owned in-memory vault에만 있고 application/checkpoint에는
representation binding digest만 남는다.
- capability는 exact `Range: bytes=start-end`,
`preconditionMode=STRONG_IF_RANGE | IMMUTABLE_GENERATION_PRECONDITION`, mode가
정한 exact header/value, `allowWholeObjectFallback`, policy-derived allowed
status subset, expected total/segment bytes, expiry와 single-use receipt를 묶는다.
- BFF/object provider가 이 제약을 실제로 강제하지 못하면 direct signed URL을
사용하지 않고 BFF relay를 사용한다.
data-plane 의미:
| 응답 | server 의미 | frontend 처리 계약 |
| --- | --- | --- |
| `206` | 같은 representation의 exact requested segment | exact `Content-Range`, length와 validator 검증 뒤 write |
| `200` | byte 0 full representation 또는 `If-Range` mismatch | 기존 partial에 append 금지, 새 generation/restart reconcile |
| `412` | 별도 `If-Match`/generation precondition 실패 | representation replacement로 checkpoint 격리 |
| `416` | requested range가 current representation에 유효하지 않음 | 자동 success 금지, total/destination/full digest 재검증 |
RFC 9110의 일반 `If-Range` mismatch는 `200` full response다. `412`를 받으려면
제품 계약이 별도 strong precondition을 명시해야 한다.
capability expiry 시 BFF는 current authorization과 registry를 다시 확인하고
같은 representation binding에 대해서만 새 segment capability를 발급한다.
object generation/length/media/digest가 달라지면 기존 partial resume를 거절한다.
final 성공은 frontend destination 전체 SHA-256 검증 뒤에만 가능하지만, server도
download capability가 주장한 digest/length가 registry의 authoritative object와
일치하도록 보장한다.
Range checkpoint retention과 seek/truncate 또는 OPFS staging은 browser local
계약이다. server는 raw local path/offset을 authorization proof로 받지 않는다.
### 15.5 Upload pause와 checkpoint lifecycle
pause는 기본적으로 browser work를 중지하는 local control이며 server abort가
아니다. 제품이 explicit server pause를 만들지 않더라도 status/reconcile은
paused client가 안전하게 돌아올 수 있도록 다음을 보장한다.
- session expiry와 terminal status의 안정적인 의미
- completed part의 ordered checksum과 bounded non-authorizing receipt
- list/status pagination 또는 절대 part-count ceiling
- 같은 idempotency key의 replay 결과
- abort/complete response loss 뒤 authoritative reconcile
- abandoned session TTL과 orphan janitor
frontend checkpoint inventory/retention API에는 presigned URL, server capability,
raw provider upload ID와 object key가 나타나지 않는다. checkpoint sweep가 local
row를 지웠다고 server session이 즉시 abort됐다고 가정하지 않으며 janitor SLO로
ambiguous orphan을 정리한다.
### 15.6 Image CDN delivery
backend asset registry는 scan/promotion을 통과한 immutable object generation만
opaque asset ID/revision에 binding한다. descriptor에는 registry-owned named
preset, static raster media type, exact natural/rendition dimensions, delivery
class와 private capability expiry를 포함한다. arbitrary external source URL 또는
caller transform query를 signer/CDN에 전달하지 않는다.
private capability가 서명하는 allowed preset binding ID는 versioned
server-owned immutable preset registry의 key다. CDN/BFF는 각 요청마다 이 ID를
조회하고 width/height/DPR/fit/format/quality query를 registry의 exact candidate와
재계산해 불일치를 거절한다. client query, binding digest 또는 signature의 단순
존재는 transform authorization이 아니다.
public rendition은 revisioned URL과 `public, max-age=..., immutable`, private
rendition은 short-lived capability와 실제 `Cache-Control: no-store`를 제공한다.
private response도 CORS에서 browser probe가 읽을 `Content-Type`,
`Content-Length`, `Cache-Control`을 허용해야 하며 credential cookie에 의존하지
않는다. CDN은 application과 다른 HTTPS origin에서 제공한다. 이는
`<img crossorigin="anonymous">`의 same-origin credential mode가 application
cookie를 보내는 경로를 차단하기 위한 배포 계약이다. frontend는
PNG/JPEG/WebP/AVIF static header metadata와
pixel/decoded-byte budget을 native decode 전에 검증하고, private delivery는
mandatory probe와 fetch/body/decode 전체 timeout을 적용한다.
private descriptor BFF는 fixed endpoint에서
`IMAGE_CDN_DESCRIPTOR_V1` strict bounded response를 반환한다. caller는 opaque
asset/preset reference만 제출하고 URL, origin, width, DPR, format, quality, fit,
cache policy와 key ID를 선택하지 않는다.
descriptor 발급과 refresh는 매번 current authorization, promotion state, immutable
asset revision, preset registry와 signing key registry를 다시 확인한다. expiry
뒤 stale descriptor 사용을 허용하지 않는다. key rotation은 bounded old/new
verification overlap, signer 전환, maximum descriptor lifetime + clock skew +
client rollout drain 이후 old key 제거 순서다. emergency revoke는 client
signature/expiry만 기다리지 않고 registry/CDN/BFF에서 capability를 거절한다.
generic Query cache나 durable client persistence가 private descriptor URL의
lifetime owner가 아니다. account/logout 뒤 늦은 refresh가 성공하더라도 frontend
runtime generation fence가 이를 채택하지 않으며, backend authorization도 old
session을 거절해야 한다.
### 15.7 failure mapping
server의 closed failure를 frontend의 allowlisted failure로 한 곳에서 mapping한다.
HTTP status/message를 application에 그대로 전달하지 않는다.
| server meaning | frontend meaning 예 |
| --- | --- |
| invalid request | `INVALID_INPUT` |
| size/part ceiling | `LIMIT_EXCEEDED` |
| revision/idempotency conflict | `CONFLICT` |
| checksum/length mismatch | `INTEGRITY_FAILED` |
| expired session/capability | `EXPIRED_RESOURCE` |
| denied policy/authorization | `POLICY_REJECTED` 또는 auth failure |
| provider/scanner temporary failure | `DEPENDENCY_UNAVAILABLE` + server retry directive |
| caller cancellation | `CANCELLED` |
| server deadline exhausted | `DEADLINE_EXCEEDED` |
| mutation outcome unknown | 같은 idempotency key로 status/reconcile, 신규 요청 금지 |
## 16. 제품 선택 시 결정할 항목
다음 표가 채워지기 전에는 endpoint와 composition을 만들지 않는다.
| 결정 | owner |
| --- | --- |
| 파일의 업무 목적과 aggregate 관계 | product/domain |
| authorization와 existence-hiding 정책 | security/domain |
| provider/region/data residency | platform/security |
| proxy/direct transfer 선택 | platform/product |
| object/part 최대 크기와 concurrency | performance/platform |
| session/capability TTL | security/product |
| checksum algorithm과 encoding | platform |
| MIME/parser/archive/CDR/scan profile | security/product |
| quarantine/available/rejected UX | product |
| retention/legal hold/delete | domain/legal |
| idempotency replay window/capacity | platform/product |
| provider/scanner SLO와 fallback | operations/product |
| logging, audit와 privacy retention | security/operations |
| rollout, kill switch와 removal owner | operations |
## 17. Server 구현 순서
1. 공통 identifier, `Result`와 closed failure model
2. in-memory deterministic fakes와 provider contract suite
3. `ObjectRegistryPort``IdempotencyStorePort`
4. 첫 database adapter 및 concurrency/fault evidence
5. `ObjectByteStorePort`와 첫 object provider adapter
6. streaming counter/digest/deadline/resource wrappers
7. `MultipartStagingPort`와 orphan reconciliation
8. `SignedTransferCapabilityPort`와 expiry/key-rotation tests
9. optional Range capability와 immutable-generation/provider contract
10. `ContentScannerPort`, quarantine와 promotion saga
11. optional Image descriptor/preset/signing control plane
12. optional composition/removal gate
13. server template에서는 `AVAILABLE_NOT_COMPOSED`로 종료
14. 실제 제품에서만 domain/use case/controller와 provider composition 추가
## 18. 완료 기준
- 모든 요청 항목에 port, 불변조건, failure와 lifecycle이 정의됨
- native provider SDK type/exception이 port 밖으로 유출되지 않음
- streaming 경로에 whole-buffer 기본 구현이 없음
- byte length와 digest authority가 proxy/direct 경로별로 명확함
- multipart complete/abort/expiry/response-loss race가 결정적임
- signed capability가 exact operation/resource/limit/expiry에 binding됨
- Range를 선택한 경우 immutable representation, exact
`200/206/412/416`, reissue와 provider constraint가 같은 contract suite를 통과
- idempotency가 concurrent replay와 fingerprint mismatch를 처리함
- scan verdict가 exact generation/digest에 binding됨
- Image CDN을 선택한 경우 `IMAGE_CDN_DESCRIPTOR_V1`, preset exact recomputation,
key rotation/revocation과 private no-store가 실제 BFF/CDN에서 검증됨
- deadline/retry/cancel 모든 경로에서 resource cleanup이 검증됨
- 동일 provider contract suite가 fake와 실제 선택 provider에 실행됨
- 미선택 상태에 route/job/migration/provider client가 없음
- capability 전체 제거 후 server template core가 정상 동작함
- 실제 제품 domain/use case와 provider 선택이 이 문서에 하드코딩되지 않음
## 19. 관련 frontend 설계
- `docs/architecture/browser-data-capability-completion-ledger.md`
- `docs/architecture/browser-file-and-origin-storage.md`
- `docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md`
- `docs/architecture/presigned-transfer-and-image-cdn.md`
- `docs/architecture/decisions/VD-12-presigned-transfer-and-image-cdn.md`
- `docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md`
- `docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md`
- `docs/architecture/frontend-ports-adapters-and-boundaries.md`
- `docs/architecture/optional-adapter-recipes.md`
- `docs/operations/browser-file-storage-recovery.md`
- `docs/operations/browser-transfer-recovery.md`
## 20. 참고 표준과 보안 가이드
- RFC 9110, HTTP Semantics: <https://www.rfc-editor.org/rfc/rfc9110>
- RFC 9530, Digest Fields: <https://www.rfc-editor.org/rfc/rfc9530>
- OWASP File Upload Cheat Sheet:
<https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html>