# Provider Adapter Contract

| 항목 | 값 |
|---|---|
| Status | Active provider contract |
| Owner | APP/provider: 주우철 · backend counterpart: 정정일 |
| Last verified | 2026-07-16 KST |
| Supersedes | 초기 provider 미결정·provider-pending 계획 |

이 문서는 APP의 file upload, OCR, PDF/document signed URL, phone, map, customer support handoff 경계를 정리한다. OCR 방향은 Google Cloud Vision, 지도 방향은 네이버지도로 고정되어 있다. 실제 credential·bucket·runtime opener·SDK evidence는 각 production gate에서 검증하며, 구현 PR은 이 adapter shape를 따른다.

## Current Decision

| 영역 | 결정 |
|---|---|
| Upload storage | S3-compatible presigned upload를 기본 adapter shape로 둔다. local inner loop는 floci S3(`localhost:4566`)를 사용한다. Backend adapter는 unversioned private bucket, exact size/type PUT, immutable final object를 요구한다. 완료·중단 뒤에도 아직 유효한 upload capability가 staging object를 다시 만들 수 있으므로 backend는 capability 만료 뒤 terminal session을 재-sweep하고, production bucket은 `staging/` prefix lifecycle expiration을 defense-in-depth로 둔다 |
| APP file picker | Expo SDK 56 foundation dependency(`expo-image-picker`, `expo-document-picker`, `expo-file-system`)를 사용한다 |
| OCR | APP은 file/capture metadata만 보낸다. OCR job 생성, vendor 호출, confidence/manual-review 판단은 backend-owned다. local inner loop는 deterministic fake OCR, real provider는 Google Cloud Vision `DOCUMENT_TEXT_DETECTION` + `ko` language hint를 사용한다 |
| PDF/document viewer | backend가 short-lived signed URL capability를 발급하고 APP adapter가 viewer/browser/share handoff를 수행한다 |
| Phone | APP은 adapter boundary 뒤에서만 `tel:` deep link를 사용한다. number source/masking/audit은 backend/provider-owned다 |
| Map | 지도 provider 방향은 네이버지도로 고정한다. APP은 internal route guide를 기본값으로 유지하고, external map handoff는 좌표/주소 contract와 adapter가 있을 때만 연다 |
| Customer support | OS browser 또는 `expo-web-browser` handoff를 기본값으로 둔다. URL/auth handoff는 backend/provider config로 받는다 |
| Offline/retry | OS background task를 사용한 silent retry는 하지 않는다. 운전자 proof는 app-start·foreground recovery에서 durable queue를 drain하고, 일반 provider action은 사용자 retry를 사용한다. queue 정책은 [Mobile Provider Policy](../40-mobile/policies/PROVIDER_POLICY.md#dg-offline-01-durable-proof-queue-target)에 둔다 |

Provider-ready 구현 전 APP 화면은 기존 provider-pending, provider-error, provider-offline, disabled reason surface를 유지한다. 화면 컴포넌트는 provider SDK, raw signed URL, credential, bucket/object key 조립을 직접 알지 않는다.

## Owner-approved issue #1010 contract and implementation state

아래 값은 [#1009 owner decision](https://github.com/uitiorg/zerro/issues/1009#issuecomment-4967609073)으로 승인된 계약·정책 기준이다. #1010 backend slice는 upload/read/RBAC/deletion/retention/legal-hold enforcement를 local/Testcontainers와 S3-compatible adapter 경계까지 구현했다. Production provider 연결을 의미하지 않으며 production bucket/credential 생성·변경은 별도 `VERIFY`/write gate, 운영·컴플라이언스 승인 surface는 wiring gate다.

### DG-FILE-01 file access and retention target

| 항목 | owner-approved target |
|---|---|
| 저장·read 권한 | private object, handover 참여 회사 기준 RBAC, read capability TTL 10분 |
| 삭제 lifecycle | soft delete 후 retention 만료 시 purge. 최소 legal-hold state/metadata를 보존하고 held file은 purge에서 제외하며, hold 상태를 판별할 수 없으면 purge를 fail-closed 한다 |
| 삭제 책임 | `company_admin` 요청 → ZERRO 운영·컴플라이언스 승인 → backend retention job 실행 |
| legal hold boundary | hold set/release는 내부 운영·컴플라이언스 application boundary와 테스트로만 다룬다. legal-hold HTTP 관리 endpoint나 새 platform role은 이 계약에 추가하지 않는다 |
| retention anchor | `chat_attachment`: 신청에 속한 모든 handover의 terminal history 확인 후 가장 늦은 종료 시점부터 1년. handover가 없는 계약/ad-hoc room은 자동 purge를 fail-closed; `operational_evidence`: handover 완료 후 3년; `regulatory_document`: 최종 발급 후 5년 |
| 사용자·회사 lifecycle | 사용자 탈퇴는 접근을 즉시 revoke하되 transaction evidence는 retention까지 유지. 회사 계약 종료는 30일 read-only export 후 접근을 revoke하고 retention은 유지 |
| download/share | download 허용. chat/evidence의 OS share는 차단하고, regulatory document share는 future explicit `company_admin` capability와 audit가 있을 때만 허용 |

`shareAllowed`는 v1에서 chat/evidence/regulatory document 모두 기본 `false`로 해석한다. regulatory document의 share 허용은 후속 capability와 audit wiring이 실제로 제공된 경우에만 바뀔 수 있다. 정확한 API path/response shape와 production enforcement는 구현 parent가 정하며, 이 문서는 policy value를 임의의 구현 사실로 승격하지 않는다. legal-hold metadata와 fail-closed purge는 production enforcement test의 필수 대상이다.

### DG-DOC-01 document lifecycle and version target

| 문서 | 생성 기준 event |
|---|---|
| 반입의뢰서 | `dispatch_confirmed` |
| 반입확인서 | weighing complete로 `intake_pending`에 진입할 때 |
| 처리확인서 | 최종 `treatment_recorded` |

문서는 backend immutable snapshot과 append-only version을 사용한다. 정정은 기존 version을 덮어쓰지 않고 reason을 가진 새 version을 만든다. 일반 사용자는 latest version만 보고, handover 참여 `company_admin`/운영자는 history에 접근할 수 있다. v1은 signature/seal을 요구하지 않으며 company, actor, time, source event, hash를 보존한다. 운전자 safety signature는 문서 signature/seal이 아니라 별도 operational evidence다.

G010은 `zerro-pdf-v1` renderer와 private generated object, immutable snapshot/hash/version persistence, local/dev read capability를 구현했다. 허용된 계근·처리 보정은 기존 version을 덮어쓰지 않고 `weighing_amend` 또는 `treatment_amend` source event와 사유를 가진 v2+ 정정본으로 발급하며, 참여 회사 snapshot도 함께 보존한다. production PDF template/font, 영구 production object storage, history authorization enforcement, transaction 이후 발급은 여전히 구현·검증 gate이며 현재 문서만으로 완료됐다고 주장하지 않는다.

### DG-OCR-01 acceptance and confirmation target

- benchmark set은 최소 50 samples, 5개 이상 form, form당 10개 이상, challenging sample 20% 이상으로 구성한다.
- required field는 `grossWeightKg`, `tareWeightKg`, `vehicleNo`, `measuredAt`이며 `netWeightKg`는 backend가 계산하고 `deductionKg`는 사용자가 입력한다.
- 목표는 gross/tare numeric exact-match 97% 이상, vehicle/time 95% 이상, required-field recall 95% 이상이다.
- required field confidence가 0.90 미만이거나 값이 invalid/missing/mismatch이면 `needs_review`다. 낮은 confidence만으로 영구 차단하지 않고 사용자가 수정할 수 있다.
- driver와 processor가 각각 자기 surface에서 최종 확인하며 OCR은 domain mutation을 자동 실행하지 않는다.

acceptance threshold와 `needs_review` 분류는 `OcrAcceptancePolicy`가 backend 구조화 결과에 적용하도록 코드로 고정했다. 다만 benchmark와 대표 production sample 결과는 아직 `VERIFY`다. 현재 deterministic fake OCR 또는 dev smoke는 Google Cloud Vision 품질 기준을 통과했다는 근거가 아니다.

## Local and Real Provider Gates

Provider-ready 작업은 local 반복과 real provider smoke를 분리한다.

| Gate | 목적 | 기준 |
|---|---|---|
| Local S3 | 비용과 credential 없이 upload session, presigned PUT/GET, metadata flow를 반복 검증 | floci S3 endpoint `http://localhost:4566`, throwaway credential, `pnpm provider:s3:smoke:floci` |
| Local OCR | OCR UI/adapter/job flow와 backend acceptance 경계를 deterministic하게 검증 | fake OCR provider와 `OcrAcceptancePolicy`. 필수 field 누락·형식 오류·gross/tare 관계 오류·confidence 미달을 `needs_review`로 고정하되 vendor accuracy를 주장하지 않음 |
| Real S3 smoke | AWS S3 presigned upload와 bucket policy/credential shape가 production path와 맞는지 최소 검증 | #910에서 생성한 `ap-northeast-2` dev smoke bucket 기준 `pnpm provider:doctor -- --run-real-s3 --s3-bucket=<dev-bucket> --strict`, 1KB 이하 object, short TTL signed URL, object cleanup 필수 |
| Real OCR smoke | Google Cloud Vision이 한국어 계근표/증빙 sample에서 쓸 수 있는지 benchmark | #908에서 `btalk-dev` Cloud Vision API enable 후 `pnpm provider:doctor -- --run-gcv --gcv-image=<sample>` smoke 통과. `DOCUMENT_TEXT_DETECTION`, `languageHints: ["ko"]`, raw response redaction |

floci는 S3-compatible upload 개발용 local cloud다. Google Cloud Vision OCR 자체는 floci로 대체하지 않는다. Vision 품질 판단은 sample image set으로 `btalk-dev` real smoke에서만 수행한다. #908 smoke는 provider 사용 가능성을 확인한 증거이며, production OCR adapter나 benchmark 통과를 주장하지 않는다. acceptance threshold와 `needs_review` 분류 경계는 backend policy로 구현됐지만, real smoke command는 일반 CI/local loop의 필수 gate가 아니며 dev credential과 throwaway bucket/sample이 준비된 환경에서만 실행한다.

Provider loop 시작 전에는 `pnpm provider:doctor`를 먼저 실행한다. 이 doctor는 local floci S3, real AWS S3, Google Cloud Vision gate의 prerequisites를 한 번에 보고하되, 실제 real provider 호출은 `--run-real-s3` 또는 `--run-gcv`가 있을 때만 수행한다. local inner loop 검증은 `pnpm provider:doctor -- --run-local-s3`로 실행한다. `btalk-dev` GCV smoke는 #908에서 통과했고, ZERRO real AWS S3 smoke는 #910에서 생성한 dev smoke bucket 기준으로 통과했다. doctor output은 signed URL, credential, raw OCR text, local file path를 출력하지 않는다.

Doctor report의 `ok`는 provider tool/report 자체가 실패하지 않았다는 뜻이고, `strictOk`는 실제 gate가 모두 통과하거나 실행 가능한 상태라는 뜻이다. ZERRO 전용 AWS dev smoke bucket은 #910에서 생성됐으므로 real S3 promotion gate는 `pnpm provider:doctor -- --run-real-s3 --s3-bucket=<dev-bucket> --strict`로 재검증한다. GCV smoke는 sample image가 명시되지 않은 기본 실행에서는 `skipped`로 남기고, `--run-gcv`를 명시했을 때만 sample image 미지정을 blocker로 취급한다.

## Current Backend Slice

Local Spring backend now owns the first upload session slice for APP/backend wiring:

| Endpoint | Method | Purpose |
|---|---|---|
| `/files/upload-sessions` | POST | create short-lived upload capability and stable pending `fileId` |
| `/files/upload-sessions/{uploadSessionId}/object` | PUT | local/dev upload target used instead of a production signed URL; `uploadSessionId` is treated as the short-lived capability token |
| `/files/upload-sessions/{uploadSessionId}/complete` | POST | commit uploaded object metadata and expose stable file metadata |
| `/files/upload-sessions/{uploadSessionId}/abort` | POST | reject a pending draft session |
| `/files/{fileId}/signed-url` | POST | create short-lived read/download capability for an uploaded or linked file owned by the caller |
| `/files/access/{accessToken}` | GET | local/dev bearerless preview token target used instead of production S3 signed read URL |
| `/files/{fileId}/deletion-requests` | POST | owner company admin deletion request. Approval, legal hold, and purge stay internal |

This slice keeps production S3 credential/bucket, malware scan, operations approval UI/API, and production environment smoke out of scope. The backend enforces upload claims, exact size/type, MIME magic bytes, source ETag 조건부 copy와 final object 재해시를 통한 immutable finalization, capability-expiry staging cleanup, owner/participating-company read access, 10-minute capability TTL, soft-delete approval, legal hold, and retention purge. Remote pending PUT에는 capability 만료 후 15분 grace를 적용하고, stale `verifying`과 terminal session은 만료 후 final/staging object를 반복 정리한다. 정리는 provider/DB 사이 비원자적 실패를 복구할 수 있도록 48시간 동안 idempotent하게 재시도한 뒤 완료 처리한다. Purge는 승인 시점과 retention anchor가 모두 존재하고 retention이 만료된 경우에만 가능하며, 15분 claim lease와 provider idempotent delete로 재실행을 수렴시킨다. Chat attachment handover provenance is derived from the request-scoped room on the server; its retention anchor waits for every handover in that request to reach a terminal history. Contract/ad-hoc rooms may attach files without a handover, but automatic purge remains fail-closed without an anchor. Driver cannot create chat attachments. APP의 운전자 증빙은 `expo-sqlite` queue metadata와 app-private file을 owner scope로 격리해 보관하며, app-start·foreground recovery와 SQLite 재오픈 composition을 연결했다. backend 성공 뒤 로컬 파일 삭제가 실패하면 `cleanup_pending`으로 남겨 재전송 없이 정리만 재시도한다. 상차와 하차 사진 2장, 계근표 1장은 Android camera-only capture에서 surface-specific app-private artifact로 들어오며, 하차만 owner-scoped saved draft recovery를 제공한다. Weighing direct/queue enqueue는 assignment-bound capture root/filename을 검증하고 queue replay는 exact queue root만 별도로 허용한다. loading 서명은 실제 canvas PNG를 검증해 app-private file URI로 전달한다. Driver weighing Android emulator positive/denied source-bound receipts는 완료됐다. `needs_review` 수동 보정, network recovery listener, process-death/replay/conflict 실기기 evidence, logout/account switch 동작과 production credentials는 별도 gate다.

### Driver proof upload surfaces

Driver loading and unloading proof is backend-mode provider wiring, not a production capture claim. For every camera-provenance local URI, the APP requests a backend upload session, performs the capability `PUT`, calls `complete`, and passes only the returned stable `fileId` to the lifecycle mutation. Explicit development fixture test injection is separate from operational upload eligibility: injection itself does not issue an upload session, capability `PUT`, `complete`, or lifecycle mutation. Fixture preparation may still re-encode or copy a local development input into app-private storage. The loading UI does not synthesize fixture or `mock://` photo URIs; it requires two camera artifacts. Unloading `origin=fixture` is rejected before operational upload file or network I/O regardless of runtime or API base. `driver-unloading-camera-denied` is a separate `mock-direct` permission-edge flow. Its exact single-valued DEV-only `driverAssignmentFixture=driver-z5488156120-loading-started` seam projects only that mock assignment to `loading_started`; it creates no photo artifact, is ignored outside development, and does not affect backend mode.

| Surface | Required files | Lifecycle request |
|---|---|---|
| `driver_loading_photo` | exactly 2 photos | `loading-start.photoFileIds` |
| `driver_loading_signature` | exactly 1 signature | `loading-start.signatureFileId` |
| `driver_unloading_photo` | exactly 2 photos | `unloading-complete.photoFileIds` |

Before linking, backend locks the assigned handover and validates the assigned driver, file owner user, `ownerRole=driver`, linked handover, requested surface, and `status=uploaded`. The claim and lifecycle write run in one transaction: the immutable object keys are preserved in `Photo` and `SafetyChecklist` only after all files pass validation.

The loading package additionally requires the server-owned canonical nine-item safety checklist in exact ID, label, order, and checked state. APP duplicate submit shares one in-flight package, completed files are cached for same-runtime foreground retry with a monotonic runtime clock and a maximum 23-hour TTL from completion, and incomplete sessions are best-effort aborted. This leaves a margin after the 15-minute upload-session window before uploaded driver proof files that never reach a lifecycle claim become eligible for the backend's 24-hour `createdAt`-based orphan job; a clock rollback evicts the cache rather than extending it, and successful lifecycle mutation clears it. Provider delete failure is retried after 15 minutes, while linked or already purging files fail closed.

Driver proof upload session creation is serialized by the handover row lock and requires the currently assigned driver, not only a participating company. Active files are capped at two complete proof packages per assigned driver, handover, and surface: loading photo 4, loading signature 2, unloading photo 4. This leaves one process-loss retry package while bounding object, hash, and provider cost before any upload capability is issued. After `e2c41743`, `mock-direct` drafts are instance-local and non-durable: a later `mock-direct` capture session cannot restore or view the prior session's camera artifact. Authenticated `login-entry` drafts remain app-private and persisted, but recovery is gated by authenticated manual re-entry, a ready current board, and actionability. #1049 is two distinct P0 gates, shared auth/runtime API-base allowlist plus active-role verification; strict driver runtime base enforcement is a separate driver-only boundary, not a system-wide claim.

The unloading APP surface now obtains two photos through Android camera-only `expo-image-picker`, re-encodes an accepted local result as JPEG before app-private persistence, and stores stable capture metadata in an owner-scoped saved draft. Every unloading request carries exactly two `photoOrigins` aligned with its two artifact slots; missing provenance is invalid rather than defaulting to `camera`. Each artifact preserves `camera | fixture` provenance through the durable queue; `pending_result` and `saved_draft` describe recovery timing and do not replace capture provenance. A bundled photo fixture is only an explicit development app-link test injection, is separate from operational upload eligibility, and is never real-camera evidence. Unloading `origin=fixture` is rejected before file read, upload session, capability `PUT`/`complete`, or lifecycle mutation. Pending camera-result cancellation or unavailability clears the pending state and returns a saved capture only when one remains. Recovery is attempted only after a driver in `login-entry` authenticated mode has re-entered or opened the schedule/unloading-proof surface; this mode does not yet verify the active driver role through `/session/me?role=driver`; #1049 covers the shared auth/runtime API-base allowlist and active-role verification production gates. Relaunch does not auto-route to that surface. It resolves the local owner scope derived from the login-entry access token, never falls through to `mock-direct`, never redirects from the APP root or role route, and does not supersede an active driver-assignment notification/deep-link target. It waits for the current assignment board and resumes a saved draft only while the same assignment can still complete unloading, otherwise it clears the stale local draft. Driver backend mode accepts credential-free HTTPS bases without raw query/hash markers or malformed raw syntax, plus only the Android emulator HTTP origin `http://10.0.2.2:8080`; malformed, credentialed, or external HTTP values cannot select the backend transport. Any `2026-07-16` Cycle 1 manual Android emulator observation is historical/local-only: a reviewer-readable tracked artifact receipt is unavailable, so it is not current completion evidence. The implemented boundary remains that relaunch does not auto-route to the unloading surface and recovery is attempted only after a driver in `login-entry` authenticated mode manually re-enters or opens the schedule/unloading-proof surface. The status remains `Partial` and physical-device verification remains `VERIFY`.

The loading APP surface uses the same Android camera-only JPEG re-encode boundary with a distinct `loading-{assignmentId}-photo-{slot}` storage namespace. It accepts only two distinct `file://` artifacts whose provenance is `camera`; incomplete, fixture, mock, or duplicate input keeps `상차 시작` disabled. This draft is intentionally session-local: close, backdrop, pan-dismiss, or unmount discards it, a replacement removes the prior slot artifact, and no relaunch recovery is claimed. A failed backend/queue handoff retains both photos for retry. After a direct adapter has consumed the artifacts, or after the durable queue has copied them into its owned store, the source artifacts are discarded; cleanup failure after a successful handoff is reported without resubmitting the successful domain action. Loading signature is implemented as validated canvas PNG to app-private file. The Figma note at `893:2757` requires two loading photos after signature and before enabling `상차 시작`; because no exact photo-frame node exists, this photo section is a derived-actual surface rather than a pixel-parity claim. Network-triggered recovery, process-death/replay/conflict device evidence, and production provider credentials remain pending. Local/dev proof transport must not be described as production capture readiness.

`origin=camera`는 standard client camera flow invariant이지 attested provenance가 아니다. 수정된 client나 future internal caller가 읽을 수 있는 다른 local file을 넘기지 않았음을 server가 증명하는 값으로 사용하지 않는다. Loading source는 relaunch UI recovery 대신 role-shell startup에서 capture root의 `loading-*.jpg`만 process-wide 1회 reconciliation하며 unloading draft, signature, durable queue namespace는 제외한다. 같은 process의 후속 manager는 완료 또는 진행 중 promise를 공유하고 실패한 sweep만 재시도하므로 이전 manager의 in-flight handoff source를 다시 sweep하지 않는다. Reconciliation이 끝나기 전에는 새 camera capture를 열지 않는다. Submit 중 unmount는 photo 2장과 signature source 삭제를 handoff 완료까지 보류한다. 실패가 surface mounted 상태에서 끝나면 retry를 위해 모두 유지하고, 이미 unmount된 뒤 실패가 확정되면 source를 best-effort 정리한다. Direct adapter consume 또는 durable queue copy 성공 뒤에도 parent lifecycle이 source를 정리한다.

Driver weighing capture는 Figma note `1011:2112`에 대응하는 derived-actual surface이며 pixel-parity를 주장하지 않는다. Android camera에서 받은 한 장을 `weighing-{assignmentIdLength}-{assignmentId}-photo-1` app-private JPEG로 저장하고 preview·재촬영·권한 거부를 노출한다. Capture/UI handoff, direct backend entry, durable queue enqueue는 모두 exact `driver-proof-capture` root와 length-prefixed assignment-bound filename을 같은 validator로 확인하고, queue replay는 capture root와 분리된 exact `driver-proof-queue` immediate-root JPEG만 허용한다. Backend UUID를 포함해 파일명에 정확히 보존 가능한 assignment ID는 `[A-Za-z0-9_-]{1,64}`로 허용하고 그 밖의 값은 손실 치환·truncate하지 않고 capture와 validator가 fail closed 한다. Length prefix는 `driver`와 `driver-photo-1-other`처럼 delimiter가 겹치는 ID도 서로의 artifact를 승인하지 않게 한다. Process startup은 weighing namespace의 orphan만 1회 정리하고 새 capture는 sweep을 기다린다. Replacement/close는 source를 정리하며, submit 중 unmount는 source를 보호한다. Direct adapter consume 또는 durable queue copy 성공 뒤 source를 정리하고, mounted failure는 retry를 위해 유지하며 unmounted failure는 best-effort 정리한다. 성공 뒤 첫 cleanup 실패는 domain submit을 반복하지 않고 unmount에서 cleanup만 재시도한다. 이 경계는 process-death recovery, production storage/OCR, OCR `needs_review` correction UI, physical-device/OEM readiness를 증명하지 않는다.

Android emulator의 `driver-weighing-ticket-camera`와 denied flow는 동일 staged code identity에서 source-bound `status=ok` receipt를 기록했다. Positive flow는 OCR 결과 등록과 실중량 `7,000kg` 완료를, denied flow는 명시적 camera permission 안내·`0/1`·disabled CTA를 확인했다. 이는 physical-device/OEM, production bucket/credential/provider, Google Cloud Vision runtime/benchmark, active driver auth, network listener, process-death/replay/conflict 또는 `needs_review` 수동 보정을 증명하지 않는다.

Exact Android emulator mode exercises the loading camera surface with real camera-provenance artifacts; it does not make an unloading fixture-origin artifact operational, and that artifact is rejected before file or network access. An explicit development app link cannot enable `mock://` proof input against a production-like backend URL. If the authoritative today-board read fails, the saved unloading draft remains local but does not force the schedule surface. The driver can keep normal navigation, retry the board read, or defer the reminder; only a ready board with `canCompleteUnloading` opens the unfinished unloading proof surface.

APP의 운전자 증빙은 backend 재시도 가능 응답을 받으면 `expo-sqlite` metadata와 app-private file을 durable queue에 owner scope로 보관하고, app start·foreground에서 같은 proof adapter를 통해 재전송한다. SQLite enqueue는 owner·idempotency·quota 확인과 insert를 하나의 write transaction으로 묶고, 동일 connection의 enqueue를 직렬화하며, 파일은 실제 크기를 측정한 뒤 quota를 검사한다. owner scope를 붙이기 전의 legacy row는 현재 로그인 사용자에게 재귀속하지 않고 검토 대기 격리 scope로 보낸다. SQLite schema rebuild도 exclusive transaction과 legacy table resume 경계를 사용한다. queue 초기화 때 DB manifest에 없는 app-private queue 파일은 orphan으로 정리하고, retention이 지난 격리 row도 cleanup 경로로 수렴시킨다. 제출 직전 현재 owner scope가 바뀌었으면 proof를 네트워크로 보내지 않고 수동 검토로 보낸다. backend 성공은 `cleanup_pending`으로 먼저 기록한 뒤 로컬 파일을 정리하고, 정리 실패는 다음 recovery에서 파일 정리만 재시도한다. 따라서 성공한 domain action을 파일 정리 실패 때문에 재전송하지 않는다. queue가 보존하는 payload는 assignment/action에 필요한 최소 필드, app-private file URI, capture provenance이며 token, signed URL, object key, OCR 원문을 저장하지 않는다. 상·하차·계근 camera capture와 loading canvas 서명은 이 app-private URI의 source가 된다. Network recovery listener, process-death/replay/conflict 실기기 evidence, logout/account switch 동작과 production credentials는 별도 gate다.

Non-local S3 profile은 private bucket의 public access block과 `staging/` lifecycle expiration을 운영자가 검증한 뒤에만 `zerro.storage.private.production-safety-verified=true`를 설정해 기동한다. 이 플래그는 검증을 대신하지 않으며, production bucket write/smoke evidence가 없는 환경에서는 설정하지 않는다.

Local Spring backend also owns the first deterministic OCR job/result slice:

| Endpoint | Method | Purpose |
|---|---|---|
| `/ocr/jobs` | POST | create a local fake OCR job from an uploaded `fileId` and domain surface |
| `/ocr/jobs/{ocrJobId}` | GET | read persisted OCR job status/result |

The OCR slice requires a `StoredFile` in `uploaded` state, checks owner, surface match, and role/surface pairing, persists only structured fields/confidence/status, and keeps raw OCR text/provider payload out of storage. Current accepted pairs are `driver` + `driver_weighing_ticket` and `processor` + `processor_weighing_ticket`. The local fake provider has deterministic success, `needs_review`, and retryable `failed` branches so APP upload/OCR adapters can exercise user-visible states without a production OCR credential. Real Google Cloud Vision credential/project smoke remains a separate provider gate.

Local Spring backend also owns the first document access capability slice:

| Endpoint | Method | Purpose |
|---|---|---|
| `/documents/{documentId}/signed-url` | POST | create a short-lived `DocumentAccessCapability` from an existing append-only `document` record |
| `/documents/access/{accessToken}` | GET | local/dev bearerless preview token target used instead of production S3 signed read URL |

This slice uses `DocumentRecord` as source of truth, checks that the requesting user's company participates in the handover as emitter, transporter, or processor, and returns only `documentId`, stable `fileId`, URL capability, disposition, mime type, file name, and non-secret retention metadata. It does not expose storage `objectKey`, render the real PDF template, or enforce final share permission. Production S3 signed read URL generation can replace the local access token target without changing APP screen semantics.

## Adapter Ownership

| Layer | 책임 |
|---|---|
| Backend | upload session 발급, object key 생성, signed URL 발급, file metadata persistence, OCR job/result persistence, auth/RBAC, audit log |
| Mobile APP adapter | picker/capture URI 수집, upload session 요청, signed URL upload/download 실행, foreground retry, provider state 표시 |
| Screen component | adapter result에 따른 UI state만 렌더링. raw provider URL/credential/SDK detail을 직접 다루지 않음 |
| Provider | S3-compatible object storage, OCR vendor, map/phone/support external app or web handoff |

## Upload Session Contract

APP provider-ready 구현은 아래 3단계 flow를 따른다.

1. APP requests an upload session from backend.
2. APP uploads the local file to the short-lived signed URL.
3. APP confirms completion to backend so domain workflow can reference a stable `fileId`.

### Candidate Endpoints

| Endpoint | Method | Purpose |
|---|---|---|
| `/files/upload-sessions` | POST | create short-lived upload capability |
| `/files/upload-sessions/{uploadSessionId}/object` | PUT | local/dev upload target for backend-mode proof |
| `/files/upload-sessions/{uploadSessionId}/complete` | POST | commit uploaded object metadata |
| `/files/upload-sessions/{uploadSessionId}/abort` | POST | abandon a draft session |
| `/files/{fileId}/signed-url` | POST | create short-lived read/download capability |
| `/files/access/{accessToken}` | GET | local/dev preview target for a short-lived read capability |

These are backend/provider endpoints, not APP mock paths. APP mock paths may keep local draft behavior until provider-ready.

### Upload Session Shape

```ts
type FileUploadSession = {
  readonly uploadSessionId: string;
  readonly fileId: string;
  readonly method: "PUT";
  readonly uploadUrl: string;
  readonly headers: Readonly<Record<string, string>>;
  readonly expiresAt: string;
  readonly maxBytes: number;
  readonly allowedMimeTypes: readonly string[];
  readonly objectKeyHint?: string;
};
```

Rules:

- `uploadUrl`, headers, and object key hints are sensitive. Do not log them to Sentry, PR evidence, or console output.
- Backend creates `fileId` before upload and keeps the object in `pending_upload` until complete.
- APP must not generate object keys.
- `linkedHandoverId` is optional and backend-shaped. APP adapters must send it only when they hold a backend UUID. Figma/mock/display ids such as `processor-z...` must be omitted so backend rejects malformed domain linkage instead of silently storing fake ids.
- 현재 upload contract는 single-part다. Multipart upload는 later scale decision이다.
- Upload completion uses backend-read metadata as source of truth, not client-provided file name alone.
- Local/dev upload does not require a bearer token on the PUT target because production S3 signed upload URLs are capability URLs. Create, complete, and abort remain authenticated backend calls.
- Local/dev upload streams bytes, returns checksum proof from the backend, and never returns the storage object key. Production S3 can replace the local upload target without changing APP screen state semantics.

## File Metadata Contract

Backend persists stable metadata separate from temporary signed URL capability.

```ts
type StoredFile = {
  readonly fileId: string;
  readonly ownerRole: "emitter" | "dispatcher" | "driver" | "processor";
  readonly surface:
    | "chat_attachment"
    | "driver_loading_photo"
    | "driver_loading_signature"
    | "driver_unloading_photo"
    | "driver_weighing_ticket"
    | "processor_weighing_ticket"
    | "receipt_document"
    | "report_document";
  readonly status: "pending_upload" | "uploaded" | "linked" | "rejected" | "deleted";
  readonly fileName: string;
  readonly mimeType: string;
  readonly sizeBytes: number;
  readonly checksumSha256?: string;
  readonly createdAt: string;
  readonly linkedHandoverId?: string;
  readonly linkedChatMessageId?: string;
  readonly linkedDocumentId?: string;
};
```

APP display contracts can keep friendly `sizeLabel`, thumbnail, local URI, and provider state, but backend persistence must use stable file metadata.

## File Read Capability Contract

Uploaded or linked files are opened through a short-lived capability. APP components must not persist the URL or infer provider storage keys.

| Endpoint | Method | Purpose |
|---|---|---|
| `/files/{fileId}/signed-url` | POST | create read/download capability for a caller-owned uploaded/linked file |
| `/files/access/{accessToken}` | GET | local/dev preview target for the capability token |

```ts
type FileReadCapability = {
  readonly fileId: string;
  readonly url: string;
  readonly expiresAt: string;
  readonly disposition: "inline" | "download";
  readonly mimeType: string;
  readonly fileName: string;
  readonly retentionPolicy?: {
    readonly retentionCategory: "chat_attachment" | "operational_evidence" | "regulatory_document";
    readonly accessTtlSeconds: number;
    readonly downloadAllowed: boolean;
    readonly shareAllowed: boolean;
  };
};
```

Rules:

- Signed read capability TTL is 10 minutes for the owner-approved target. The current local/dev capability is not production storage evidence.
- Backend-issued read capabilities include non-secret `retentionPolicy` metadata so APP can decide download/share CTA state without parsing provider URLs or object keys.
- Backend retention metadata is produced from the common `ReadRetentionPolicies` boundary. Production S3/CDN provider code may change URL generation, but must not let individual services invent ad-hoc retention categories, TTL, download permission, or share permission.
- `StoredFile.status` must be `uploaded` or `linked`. Pending, rejected, deleted, or mismatched linked files are not readable.
- Chat room projection may include `readCapability` only after `stored_file.linked_chat_message_id` matches the chat message being rendered.
- APP opens capability URLs through approved viewer/browser handoff and keeps preview placeholders when no capability is present.
- Production S3/CDN credential/bucket smoke, thumbnail generation, malware scan, company termination export, and operations approval wiring remain provider-owned follow-up slices. Retention/deletion enforcement itself is implemented in the backend boundary.

## OCR Job Contract

OCR is backend-owned. APP captures or picks a file, obtains `fileId`, then requests OCR for a domain surface.

| Endpoint | Method | Purpose |
|---|---|---|
| `/ocr/jobs` | POST | create OCR job for an uploaded file |
| `/ocr/jobs/{ocrJobId}` | GET | read job status/result |

```ts
type OcrJob = {
  readonly ocrJobId: string;
  readonly fileId: string;
  readonly surface: "driver_weighing_ticket" | "processor_weighing_ticket";
  readonly status: "queued" | "processing" | "succeeded" | "needs_review" | "failed";
  readonly confidence: number;
  readonly fields: Readonly<Record<string, string>>;
  readonly fieldConfidences: Readonly<Record<string, number>>;
  readonly message?: string;
  readonly retryable: boolean;
};
```

Rules:

- `confidence` and each `fieldConfidences` value use a normalized `0.0` to `1.0` score.
- Required field confidence below `0.90`, or an invalid, missing, or mismatched required value, is `needs_review`, not success.
- APP may show extracted fields for manual correction, but the final domain mutation must still pass through backend validation.
- Low confidence alone does not permanently block submission. Driver or processor must confirm the corrected result on its respective surface, and OCR never performs domain mutation automatically.
- OCR raw text, signed URLs, and provider response payloads are not user-visible and not sent to Sentry by default.
- OCR job creation validates that the uploaded file role matches the requested OCR surface. A driver-owned file cannot be processed as a processor weighing ticket, and vice versa.
- Driver and processor OCR screens keep foreground retry and manual-review UI while real provider credentials are pending. Driver proof의 retryable backend failure는 승인된 durable queue에 보관되며, APP은 사용자에게 연결 후 자동 전송될 수 있음을 안내한다. Local backend mode may use deterministic fake OCR to exercise success/retry/failure branches.
- Current local backend implements deterministic fake OCR for `driver_weighing_ticket` and `processor_weighing_ticket`, then applies the backend `OcrAcceptancePolicy` before persistence. It is only a contract/evidence harness; it does not claim Google Cloud Vision accuracy. Local fixture names containing `low-confidence` return `needs_review`; names containing `ocr-failure` return retryable `failed`. The policy requires `grossWeightKg`, `tareWeightKg`, `vehicleNo`, and `measuredAt`, rejects invalid gross/tare relations, and uses `0.90` as the required-field confidence threshold.

## Document Signed URL Contract

PDF/receipt/report documents use metadata + short-lived read capability.

| Endpoint | Method | Purpose |
|---|---|---|
| `/documents/{documentId}/signed-url` | POST | create read/download/share capability |

```ts
type DocumentAccessCapability = {
  readonly documentId: string;
  readonly fileId: string;
  readonly url: string;
  readonly expiresAt: string;
  readonly disposition: "inline" | "download";
  readonly mimeType: string;
  readonly fileName: string;
  readonly retentionPolicy?: {
    readonly retentionCategory: "regulatory_document";
    readonly accessTtlSeconds: number;
    readonly downloadAllowed: boolean;
    readonly shareAllowed: boolean;
  };
};
```

Rules:

- Signed URL TTL is 10 minutes for the owner-approved target. The local/dev document capability does not prove production object storage or production signed-read enforcement. Backend document transitions currently generate a `zerro-pdf-v1` artifact and persist its immutable snapshot, checksum, and renderer version through the same private-object boundary.
- `mimeType` is a backend-validated IANA media type. Receipt/report PRs should normally return `application/pdf`, while image evidence can return image media types.
- APP does not persist signed URLs.
- APP may request an `inline` capability from the document access adapter and open it through the approved browser/viewer handoff. Screen components must not parse provider storage keys or persist the capability URL.
- APP must treat a capability whose `expiresAt` has passed as expired, never reuse its URL, and request a new capability through the adapter before an inline open or download handoff. Expiry is a user-facing retry state, not a successful preview.
- Download/share permission is separate from read permission and is exposed through `retentionPolicy.downloadAllowed` / `retentionPolicy.shareAllowed`. `downloadAllowed` describes whether the caller may request/download this resource, not whether the current URL was issued with `disposition=download`.
- If `downloadAllowed=true`, APP may request a fresh `download` capability and use the Android `StorageAccessFramework` save adapter to let the user choose a device directory. Because SDK 56 legacy `downloadAsync` writes only to an app-private `file://` URI, the adapter downloads to a temporary app file, accepts only an HTTP 2xx result, reads the bytes as Base64, and writes them to the selected SAF `content://` URI. It removes temporary and partially created files after failure and never treats permission cancellation as success. This is local/dev APP storage evidence, not production object-storage readiness.
- If `shareAllowed=false`, APP must keep the share CTA disabled with user-facing copy. In v1, OS share is blocked for chat/evidence and regulatory-document share requires a future explicit `company_admin` capability plus audit.
- If a document is not generated yet, APP shows provider-pending viewer, not a fake success.
- Current backend generates versioned intake/treatment PDF artifacts on the owned lifecycle transitions and stores `snapshotJson`, `contentSha256`, and `rendererVersion` in append-only `DocumentRecord` rows. It can issue a short-lived local/dev `DocumentAccessCapability` for those rows, including `regulatory_document` retention metadata. The emitter detail receipt viewer can request/open inline capability and request a fresh download capability when the policy allows it. Android APP local/dev save uses the existing `expo-file-system` SAF boundary with an app-private temporary file and Base64 write; current share policy remains disabled. This proves the document renderer, private-object boundary, and APP save adapter, not production credentials, final Korean typography/template, legal signature/seal, transaction-outbox delivery, or real-device SAF evidence.
- Document retention metadata also comes from `ReadRetentionPolicies.regulatoryDocument()`. Receipt/report service code should not hard-code its own TTL or share/download booleans.

## External Handoff Contract

Phone, map, and support are not file providers, but the same adapter boundary applies.

| Surface | Adapter | Provider-ready input |
|---|---|---|
| Customer support | `openCustomerSupport` | URL, auth handoff policy, fallback label |
| Phone | `openPhoneDialer` | masked display number, dialable number, audit policy |
| Map | `openRouteMap` | provider kind=`naver_map`, coordinates or normalized address, Naver Map URL |
| Document | `openReceiptDocument` / `openReportDocument` | `DocumentAccessCapability` |

APP components must not call `Linking.openURL` directly for these surfaces. Provider-ready PRs add adapter tests for success, unavailable provider, user cancellation, and retryable error.

Before the real opener adapters are enabled, APP uses `resolveCustomerSupportHandoff`, `resolvePhoneDialerHandoff`, and `resolveRouteMapHandoff` in `apps/mobile/src/shared/api/mobile-external-handoff.ts` to centralize user-facing pending/ready labels, preview values, and disabled CTA state. `resolveRouteMapHandoff` is fixed to Naver Map user-facing labels and only treats `https://map.naver.com`, `https://m.map.naver.com`, and `https://naver.me` URLs as openable. These helpers do not open external apps and do not decide phone number source, coordinate/address runtime source, support auth handoff, or audit logging policy.

## Provider State Mapping

| Adapter result | UI state | Error envelope / telemetry |
|---|---|---|
| `ready` | normal CTA or preview | no Sentry |
| `pending` | provider-pending message or disabled CTA | no Sentry |
| `offline` | offline copy + explicit retry | no Sentry by default |
| `retryable_error` | retry CTA | Sentry only if schema/envelope mismatch |
| `permission_denied` | disabled reason or permission sheet | no Sentry |
| `contract_error` | fallback + internal diagnostics | Sentry, redacted |

The APP must not mutate domain state after upload/OCR/provider failure. Domain mutation happens only after backend returns a stable `fileId`, `ocrJobId`, or document capability.

## Redaction Rules

Never log these values:

- signed upload/download URL
- provider credential or request header
- object key if it contains company, handover, or user-identifying text
- raw OCR text
- phone number source payload
- local file URI from device storage

Allowed evidence:

- adapter state
- surface
- file mime category
- file size bucket
- redacted `fileId`/`documentId`/`ocrJobId`
- backend requestId

Real provider smoke output도 같은 규칙을 따른다. S3 smoke는 bucket을 redacted label로만 출력하고 full object key, signed URL, credential을 출력하지 않는다. Google Vision smoke는 file name, byte size, text length, page/block/paragraph count만 출력하고 raw OCR text/provider payload를 출력하지 않는다.

## Implementation Gates

Provider-ready PRs must include:

- adapter interface and tests before screen wiring
- backend endpoint or explicit backend stub owner
- schema validation at the boundary
- provider success, provider unavailable, offline, retry, and permission-denied branches
- Sentry redaction proof or statement
- Expo compatibility evidence for any native dependency
- at least one Maestro or agent-device evidence for user-visible surface changes

Out of scope for this decision:

- production bucket/credential provisioning and the separate production write gate
- OCR vendor benchmark beyond Google Cloud Vision dev smoke
- network-triggered, process-death, and conflict-device evidence for the durable offline upload queue
- production credential/bucket smoke, malware scan, company termination export, and operations approval wiring; local/Testcontainers retention/deletion enforcement is implemented
- PDF renderer implementation
- Naver Map native SDK adoption and store/build evidence
