fix: enforce installed HTTP auth profiles

Install the REST auth profile registry once at composition and make it the
single transport authority for V3. Contract composition now rejects an
unregistered authProfileId, so the executor never resolves a profile at
runtime.

The credential collaborator contributes proof headers only: Fetch credentials
come from the resolved profile, transport-owned and forbidden headers are
rejected, headers outside the profile's allowed set are rejected, and a missing
required header fails closed as AUTH_INTEGRATION_FAILURE with zero fetch calls.
The final invariant re-proves credentials mode and the exact header sets.

Demo mode satisfies the strict bearer profile with a fixed non-secret marker
instead of weakening REFERENCE_EXTERNAL_BEARER. Credential owners now receive
the operation lifetime through AuthOperationContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 22:56:44 +09:00
co-authored by Claude Opus 5
parent 67cc5b6d2c
commit 4e87bacdf3
21 changed files with 5045 additions and 41 deletions
@@ -262,6 +262,39 @@ auth-required operation은 session state가 `authenticated`가 아니면 fetch
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
request로 downgrade하지 않는다.
#### 5-1. Installed auth profile registry (V3 집행)
`installRestAuthProfileRegistry()`가 composition 시점에 profile을 한 번 설치하고
`INSTALLED_REST_AUTH_PROFILES`가 유일한 authority다. contract composition
(`assertExecutionPolicy`)은 등록되지 않은 `authProfileId`를 거절하므로 executor는
runtime에 profile을 발명하지 않는다. profile은 다음을 exact하게 소유한다.
- Fetch `credentials` (credential collaborator가 바꿀 수 없다)
- `allowedCredentialHeaders`: 이 operation이 허용하는 정확한 proof header 집합
- `requiredCredentialHeaders`: dispatch 전에 반드시 관찰되어야 하는 집합
`CredentialPatchOutcome.READY`는 proof header만 담는다. `credentials` field는
제거되었다. credential owner가 transport-owned header(`accept`, `content-type`,
`idempotency-key`)나 forbidden header를 넣거나, profile이 허용하지 않는 header를
넣거나, required header를 빠뜨리면 `AUTH_INTEGRATION_FAILURE`이고 fetch 0회이며
command effect는 `NOT_STARTED`다. `idempotency-key`는 contract-owned이므로 더
구체적인 `UNEXPECTED_IDEMPOTENCY_KEY` request violation으로 남는다.
`UNAUTHENTICATED`는 user/session state이지 integration failure가 아니다.
transport-owned header는 credential header 뒤에 기록되어 key ordering으로도
shadow될 수 없고, final invariant가 `init.credentials`와 profile을 다시 대조하며
allowed/required credential header 집합을 독립적으로 재검증한다.
`AUTH_MODE=demo`는 profile을 약화시키지 않는다. `createDemoSessionAdapter`
고정된 비밀 아닌 `DEMO_AUTHORIZATION_MARKER` proof header를 제공하여 strict
`REFERENCE_EXTERNAL_BEARER`를 그대로 만족시킨다. 진짜 anonymous backend는 별도
anonymous contract/profile을 composition에서 선택해야 한다.
credential collaborator는 `AuthOperationContext { signal, deadlineAtMonotonicMs }`
받는다. cooperative owner는 스스로 중단하고, non-cooperative owner도 executor가
같은 lifetime signal과 race하므로 operation 수명을 넘기지 못하며 late completion은
관찰되지 않는다.
### 6. Cookie auth, CSRF와 CORS
same-origin BFF cookie session을 기본 권장한다.
@@ -73,7 +73,7 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
| --- | --- | --- | --- | --- | --- | --- |
| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | `fix: restore V3 HTTP observability` | `FIXED_NOT_RELEASED` | diagnostics/telemetry producer gate regression | Red 5/5 failed → green 5/5; `check:diagnostics` PASS (8 diagnostics, 5 telemetry producers); `check:types` PASS; `check:architecture` PASS |
| N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | — | `NOT_STARTED` | authenticated request 4xx spike after profile enforcement | |
| N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | `fix: enforce installed HTTP auth profiles` | `FIXED_NOT_RELEASED` | authenticated request 4xx spike after profile enforcement | Red suite failed to load (`installRestAuthProfileRegistry` absent) → green 7/7; `check:types` PASS; `check:architecture` PASS; `lint` PASS; unit+integration+features 1560 passed with only the pre-existing environmental `ci-artifact-contract` failures |
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts` | — | `NOT_STARTED` | command effect verdict regression | — |
| N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts` | — | `NOT_STARTED` | telemetry delivery loss after teardown change | — |
| N-05 | Rollout blocker (sidecar not composed) | `corepack pnpm exec vitest run tests/unit/conditional-validator-store.test.ts` | — | `NOT_STARTED` | persisted validator key incompatibility | — |
@@ -0,0 +1,990 @@
# Network and state adapters implementation review
- Review date: 2026-08-13 (Asia/Seoul)
- Reviewed revision: 4dc033cf33a5b6173bbf960d5eb464a406dc4c92
- Mode: code review only; no implementation source was changed
- Primary scope: src/adapters/http, auth, query-cache, cross-context-invalidation, platform, diagnostics, telemetry
- Traced boundaries: corresponding contracts, application ports, bootstrap composition, reference feature adapters, tests, architecture decisions, and operating documentation
## 1. Outcome and priority
No Critical issue was found. Five High findings are implementation blockers or near-term correctness/security work:
1. N-01: the production V3 HTTP observation is always rejected by the diagnostics projector, and the V3 path never emits terminal-failure telemetry.
2. N-02: V3 declares authProfileId but neither composes nor enforces a profile; a credential collaborator can change transport-owned Accept and credentials, while a declared bearer profile can send no Authorization header.
3. N-03: after a command attempt has been dispatched, a retry-time final-invariant fence can downgrade MAYBE_APPLIED to NOT_STARTED.
4. N-04: telemetry dispose only removes pagehide; queued callbacks, future emit calls, and in-flight delivery survive runtime teardown.
5. N-05: the conditional-validator key codec is delimiter-ambiguous and lets two valid bindings overwrite each other. It is not composed into HTTP yet, so this is a rollout blocker rather than a current request-path incident.
The current reference feature uses V3. The older createHttpClient path remains exported and is still the documented rollback/compatibility seam, so its replay and cancellation defects cannot be dismissed as dead code.
## 2. Method, severity, and confidence
Severity:
| Level | Meaning |
| --- | --- |
| Critical | immediate broad confidentiality/integrity loss, arbitrary execution, or unrecoverable state corruption |
| High | security boundary bypass, wrong command-effect verdict, silent loss of required production evidence, or unsafe replay/rollout blocker |
| Medium | bounded correctness, cancellation, cleanup, or cross-context isolation defect with a narrower activation condition |
| Low | hardening or contract/documentation mismatch without a demonstrated material product failure |
Confidence:
| Level | Meaning |
| --- | --- |
| Very high | direct control/data-flow proof and a minimal failing reproduction |
| High | direct code proof and aligned contract/documentation evidence |
| Medium | implementation evidence exists but product requirement or browser/provider behavior must be selected |
| Low | hypothesis requiring characterization before acceptance |
Validation performed without retaining test changes:
- A temporary five-case Vitest characterization was added, run, and removed. All five expected-correct assertions failed: V3 diagnostic projection, telemetry-after-dispose, conditional-validator collision, credential transport ownership, and retry-time effect preservation.
- Related baseline: 21 test files, 144 tests passed.
- Static producer check: corepack pnpm check:diagnostics passed with “8 diagnostics and 5 telemetry producers”.
- The temporary test was deleted and the implementation worktree was clean before this report was written. Other agents later created unrelated docs/reviews entries; this review does not modify them.
This distinction matters: the green suite proves current intended behaviors, while the five failures identify missing assertions rather than contradicting existing passing tests.
## 3. Complete primary-scope inventory
All 24 files below were read in full.
| File | Responsibility | Direct dependencies / consumers | Review disposition |
| --- | --- | --- | --- |
| src/adapters/auth/external-session-adapter.ts | Adapts the external session owner to AuthSessionPort; validates credential header names and values; supplies demo, anonymous, and unavailable variants | application/ports/auth-session-port.ts; bootstrap/runtime-adapters.ts; HTTP V2/V3 | Preserve token opacity and allowlist. Modify for cooperative cancellation and required-profile header enforcement. |
| src/adapters/cross-context-invalidation/browser-cross-context-host.ts | Safely captures browser BroadcastChannel, localStorage, storage events, and secure random capabilities | contracts/cache-invalidation.ts; browser-cross-context-invalidation.ts; runtime-adapters.ts | Preserve fail-closed capability capture. Modify to capture one localStorage identity and validate native StorageEvent.storageArea. |
| src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts | Versioned invalidate-only wire protocol, BroadcastChannel/storage fallback, TTL, dedupe, per-source sequence/gap detection, bounded tracking, subscription and close | contracts/cache-invalidation.ts; host adapter; TanStack coordinator | Preserve closed envelope, bounded maps, and idempotent close. Modify exact storage-area admission; dual-transport fan-out is a separate product choice. |
| src/adapters/cross-context-invalidation/index.ts | Public barrel for host/runtime types and constructors | bootstrap and query-cache coordinator | Modify exports only if the storage event facade type changes. |
| src/adapters/diagnostics/bounded-diagnostics.ts | Bounded in-memory diagnostics projection/sink and safe pre-mount boot evidence | DiagnosticsPort; contracts/diagnostics.ts and telemetry.ts; bootstrap | Preserve fail-isolated projection and cloning. Harden non-finite capacity. V3 producer fix belongs primarily in bootstrap. |
| src/adapters/http/bounded-body-reader.ts | Declared-length and streamed byte ceilings, stream cancellation/release isolation, forbidden-body probing, strict UTF-8/JSON decode | V3 executor; bounded-body-reader tests | Keep as the single bounded response primitive. It already contains the cleanup behavior missing from bounded-json.ts. |
| src/adapters/http/bounded-json.ts | Legacy response stream reader and JSON decoder | legacy client.ts | Replace internals with bounded-body-reader delegation, then remove with V2 retirement. Current cancel/release failures can reject. |
| src/adapters/http/client.ts | Legacy/V2 operation lookup, profiles, auth recovery, retries, total deadline, response validation/mapping, diagnostics and telemetry | legacy contracts and ports; runtime-adapters createRuntimeHttpClient | Compatibility-only but exported. Fix empty keyed replay, cooperative auth cancellation, and common body-reader use before relying on it for rollback. Deprecate after callers are migrated. |
| src/adapters/http/http-contract-bridge.ts | V3 request projection, URL/body bounds, credential patch type, final request invariant | external-contract-runtime.ts; V3 executor | Preserve descriptor-owned request projection. Change credential authority: patch cannot own credentials or transport headers; final invariant must compare exact resolved profile. |
| src/adapters/http/http-effect-certainty.ts | Converts physical-attempt state and problem descriptors into mutation certainty/UI projection | V3 executor; contracts | Preserve explicit certainty vocabulary. Add a monotonic logical-execution certainty join used across retries. |
| src/adapters/http/http-execution-v3.ts | Descriptor-driven V3 lifetime: validation, projection, credentials, deadline, retry, fetch, response admission, effect verdict and observation | external contracts, scope, mutation intent, bridge, bounded reader, certainty, retry policy | Main correction site for N-01, N-02, N-03 and cooperative cancellation. Keep one retry authority and closed result union. |
| src/adapters/http/request-builder.ts | Legacy path/query construction and origin/base-prefix checks | legacy client; ApiOperation | Keep while V2 remains. Do not reuse it to weaken V3 descriptor projection. |
| src/adapters/http/resource-mapper.ts | Thin legacy operation-payload mapper delegation | boundary-mapper; legacy client | No standalone defect. Remove only with V2, not as part of the correctness patch. |
| src/adapters/http/retry-policy.ts | Legacy retry decision/backoff plus parseRetryAfter reused by V3 | legacy client and V3 executor | Keep deterministic parse/backoff seam. V2 must additionally prove a valid key before keyed replay. |
| src/adapters/http/schema-registry.ts | Legacy Zod envelope/request/payload validation and clone | legacy client/tests | No standalone defect. Remains V2-only and should not be merged with installed external validators. |
| src/adapters/platform/browser-lifecycle.ts | Single owner of visibility/network/focus/page/beforeunload listeners and lifecycle snapshots | optional-runtime-host.ts | Preserve centralized listener ownership and idempotent dispose. Clarify or redesign dirty-source attachment semantics; selection item O-02. |
| src/adapters/platform/browser-mutation-intent-factory.ts | Secure intent/idempotency UUIDs plus monotonic creation time, normalized by defineMutationIntent | MutationIntentFactory port; bootstrap | Keep. Share one idempotency-key validator so external inputs and generated values have identical bounds. |
| src/adapters/platform/system-clock.ts | Wall clock and abortable sleep with listener/timer cleanup | ClockPort; legacy HTTP | Keep. Existing unit test covers abort cleanup behavior. |
| src/adapters/query-cache/conditional-validator-store.ts | In-memory ETag CAS sidecar keyed by scope, definition, identity, representation and cache revision | bootstrap scope reset; future conditional HTTP/query join | Fix tuple codec before composition. Preserve validator grammar, generation/revision checks and bounded capacity. |
| src/adapters/query-cache/cursor-pagination-runtime.ts | Bounded cursor chain, page/snapshot/loop/item/byte validation | cursor pagination contract; currently available but not composed | Add post-await abort admission or an abort race. Current pre-await-only check can admit a late page. |
| src/adapters/query-cache/server-state-scope-runtime.ts | Synchronous session-generation fence, ordered reset participants, cache reset, identity replacement and lifecycle notifications | AuthSessionPort, query invalidation, scope contract; bootstrap | Keep synchronous FENCED-before-await design. Consider async shutdown only as O-03; no current stale-generation admission was found. |
| src/adapters/query-cache/tanstack-cache-coordinator.ts | Maps registry topics to query namespace invalidation, coalesces remote hints, defers under mutation leases, resets/disposes | TanStack Query, invalidation contracts, cross-context runtime | Keep invalidate-only remote authority, generation guard, reset serialization and bounded registry validation. Add teardown characterization if dispose becomes async. |
| src/adapters/query-cache/tanstack-query-cache.ts | Creates QueryClient defaults and QueryCachePort read/write/invalidate adapter with diagnostics | TanStack Query, QueryCachePort, errors/diagnostics | Keep retry disabled and clone-on-write. Clone-on-read is an optional port-semantics decision, not a confirmed production defect. |
| src/adapters/telemetry/best-effort-telemetry.ts | Allowlisted bounded oldest-drop telemetry queue, scheduled/pagehide flush, sink isolation and evidence | TelemetryPort, telemetry/diagnostic contracts, bootstrap | Add terminal lifecycle state, joined flush promise and in-flight abort; ensure runtime composition disposes it. |
## 4. Traced boundary inventory
### Application ports
| File | Relevant contract |
| --- | --- |
| src/application/ports/auth-session-port.ts | Session state, credential patch, recovery; currently has no AbortSignal/deadline context. |
| src/application/ports/query-cache-port.ts | Closed read/write/invalidate result; value is unknown and read mutability is unspecified. |
| src/application/ports/clock-port.ts | Time and abortable sleep. |
| src/application/ports/diagnostics-port.ts | Non-throwing logical diagnostics producer boundary. |
| src/application/ports/telemetry-port.ts | Fire-and-forget semantic event emission. |
| src/application/ports/mutation-intent-factory.ts | Intent identity and keyed-command creation. |
| src/application/result.ts | Closed application result used by pagination and feature projection. |
### Contracts
Reviewed: server-state-scope.ts, cursor-pagination.ts, diagnostic-buckets.ts, mutation-intent.ts, boundary-mapper.ts, rest-profiles.ts, api-operations.ts, errors.ts, diagnostics.ts, telemetry.ts, query-invalidation.ts, query-keys.ts, cache-invalidation.ts, and external-contract-runtime.ts.
Key joins:
- external-contract-runtime.ts:117-148 declares authProfileId but validates only non-empty text at 300-344.
- rest-profiles.ts:11-37 already provides the profile/strategy shape and exact credentials mode used by V2.
- diagnostics.ts:22-42 is a closed context allowlist and 83-99 rejects the whole record on an unknown key.
- telemetry.ts defines api.request.failed required attributes: error_kind, http_status_group, attempt_count_bucket, and route_id.
- server-state-scope.ts makes synchronous signal abortion/isCurrent the generation admission boundary.
- cache-invalidation.ts supplies the closed wire grammar used by the cross-context runtime.
### Bootstrap and feature path
Reviewed: runtime-adapters.ts, server-state-generation-store.ts, create-runtime-composition.ts, composition-root.ts, runtime-application.tsx, main.tsx, optional-runtime-host.ts, installed-contract-contributions.ts, installed-feature-adapters.ts, reference create-reference-feature-input.ts, reference-http-gateway.ts, reference feature contract contribution, reference feature API, application-query.ts, and server-state-generation-provider.tsx.
Production request flow:
reference-http-gateway (has routeId)
-> createReferenceFeatureInstalledInput (drops routeId)
-> runtime-adapters contractOperations
-> createContractHttpExecutor (V3)
-> runtime-adapters observe
-> bounded diagnostics projector
The legacy createRuntimeHttpClient is still exported at runtime-adapters.ts:142-163 but is not the installed reference feature request path.
## 5. Confirmed defects
### N-01 — V3 HTTP diagnostics are silently dropped and terminal telemetry is absent
- Severity: High
- Confidence: Very high
- Activation: current production reference-feature V3 path
Evidence:
- http-execution-v3.ts:150-155 defines an observation with diagnosticsOperation, outcome, attempts and certainty.
- http-execution-v3.ts:354-365 emits that shape exactly once.
- runtime-adapters.ts:331-343 maps attempts and certainty as literal context keys.
- diagnostics.ts:22-42 allows attempt_count_bucket but not attempts or certainty.
- diagnostics.ts:83-99 rejects the complete diagnostic on the first unknown context key.
- reference-http-gateway.ts:14-42 and 69-103 constructs a low-cardinality routeId.
- create-reference-feature-input.ts:41-54 forwards signal and intent but discards routeId.
- runtime-adapters.ts:331-347 has no V3 telemetry emit at all.
- VD-07 lines 36-39 requires exactly one logical HTTP diagnostic and exactly one terminal non-abort failure telemetry event.
Minimal reproduction:
Input was the exact runtime-adapters V3 record context:
{
operation_id: "reference.list",
outcome: "TRANSPORT_FAILURE",
attempts: 2,
certainty: "TIMEOUT"
}
Expected projectDiagnosticRecord(...).success true; actual false.
Impact:
- Success, retry recovery, failure and cancellation on the installed V3 feature leave no HTTP diagnostic record.
- V3 terminal failures leave no api.request.failed event even when telemetry is enabled.
- check:diagnostics remains green because it checks producer presence/source policy, not whether the concrete producer output passes the projector.
Required decision:
- Observation is a safe typed internal record, not an arbitrary context map.
- routeId is required at the installed operation-executor boundary.
- Raw attempt count/duration/status stay internal; only buckets reach diagnostics/telemetry.
- Cancellation and scope-fence outcomes produce diagnostics once but never api.request.failed.
- Diagnostics/telemetry failures remain unable to affect the HTTP outcome.
Proposed signature:
export type HttpExecutionObservation = Readonly<{
routeId: string;
operationId: string;
diagnosticsOperation: string;
outcome: HttpExecutionOutcome<unknown, unknown>["kind"];
errorKind: string;
status?: number;
attemptCount: number;
durationMs: number;
effect: HttpEffectCertainty;
cancellationOwner?: CancellationOwner;
}>;
export interface HttpExecutionContext {
readonly routeId: string;
readonly signal?: AbortSignal;
readonly scope: CacheScopeSnapshot;
readonly intent?: MutationIntent;
}
Projection in runtime-adapters:
diagnostics.record({
eventId: "http.request.completed",
context: {
route_id: observation.routeId,
operation_id: observation.operationId,
operation: observation.diagnosticsOperation,
outcome: observation.outcome,
error_kind: observation.errorKind,
http_status_group: statusGroup(observation.status),
attempt_count_bucket: attemptBucket(observation.attemptCount),
duration_bucket: durationBucket(observation.durationMs)
}
});
For terminal non-abort failures, emit api.request.failed using the same safe route/operation/status/attempt/duration fields. Do not add attempts or certainty as unregistered context. If product operators need effect certainty, add the explicit effect_certainty key and a closed value policy to both contracts and ADR; do not pass the current free string.
### N-02 — V3 auth profile is declarative only; credential code can alter transport policy
- Severity: High
- Confidence: Very high for generic V3 authority violation; High for current missing-bearer behavior
- Activation: current bootstrap permits a bearer-declared demo request with an empty patch; arbitrary Accept/credentials requires a custom/defective credential collaborator
Evidence:
- external-contract-runtime.ts:117-125 contains authProfileId.
- external-contract-runtime.ts:300-344 checks only non-empty identity, not registry existence or coherence.
- runtime-adapters.ts:302-326 receives operation.authProfileId but ignores it, always returns credentials: omit, and accepts an authenticated empty patch.
- http-contract-bridge.ts:14-22 lets the credential patch select any RequestCredentials.
- http-execution-v3.ts:475-483 rejects only idempotency-key.
- http-execution-v3.ts:486-489 spreads credential headers after transport-owned Accept, so patch Accept wins.
- http-contract-bridge.ts:253-273 only checks that credentials is one of three valid Fetch values and generally allows Accept/Content-Type; it does not prove profile equality or header ownership.
- reference contribution lines 133-141, 177-185, 224-236 declares REFERENCE_EXTERNAL_BEARER for all operations.
- demo-session credentialPatch is empty at external-session-adapter.ts:109, yet authenticated demo requests are sent.
- VD-23 lines 211-259 says transport owns Accept/Content-Type and an auth profile exact-fixes Fetch credentials.
- The existing V2 profile registry at rest-profiles.ts:11-37 and 77-106 is a working local pattern.
Minimal reproduction:
A credential collaborator returned:
{
kind: "READY",
headers: { Accept: "text/plain" },
credentials: "include"
}
The request completed successfully and fetch observed Accept text/plain and credentials include. Expected fetch count was zero or transport-owned application/json/omit.
Current mitigation and residual issue:
- external-session-adapter.ts:18-46 currently restricts the real owner to authorization and x-csrf-token, so the Accept injection is blocked in this bootstrap path.
- That does not restore generic executor authority, and required Authorization is not checked. Empty bearer remains possible in the current demo path.
- Therefore do not characterize this as arbitrary external-owner header injection in the current bootstrap; characterize it as an executor contract violation plus a current profile-completeness failure.
Required decision:
- Reuse the existing Profile/Strategy registry; do not introduce a general interceptor chain.
- The resolved profile, not the credential patch, owns credentials.
- Credential patch contains only a typed, runtime-validated subset of credential headers.
- A bearer profile requires authorization; an anonymous profile permits none.
- Unknown/incoherent profiles fail during composition. Missing required headers or extra headers return AUTH_INTEGRATION_FAILURE with effect NOT_STARTED and fetch count zero.
- UNAUTHENTICATED remains a user/session state, not an integration/configuration error.
Proposed signatures:
export type CredentialHeaderName =
| "authorization"
| "x-csrf-token"
| "x-tenant-context";
export type RestAuthProfile = Readonly<{
authProfileId: string;
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
credentials: "omit" | "same-origin" | "include";
allowedCredentialHeaders: readonly CredentialHeaderName[];
requiredCredentialHeaders: readonly CredentialHeaderName[];
}>;
export type CredentialPatchOutcome =
| Readonly<{
kind: "READY";
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
}>
| Readonly<{ kind: "UNAUTHENTICATED" }>
| Readonly<{ kind: "UNAVAILABLE" }>
| Readonly<{ kind: "SCOPE_FENCED" }>;
attachCredentials(
operation: Readonly<{
operationId: string;
authProfileId: string;
method: string;
}>,
context: Readonly<{ signal: AbortSignal }>
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
The executor dependency receives a validated ReadonlyMap<string, RestAuthProfile>. Final invariant compares init.credentials to the selected profile and rejects missing/extra credential headers.
Demo migration must be explicit. Recommended repository choice: allow createDemoSessionAdapter to receive a demo credential patch from bootstrap and supply a fixed non-secret Authorization marker only in AUTH_MODE=demo; keep REFERENCE_EXTERNAL_BEARER strict. Do not silently weaken the bearer profile to make tests pass. If a product backend wants anonymous demo calls, it needs a distinct anonymous contract/profile selected at composition.
### N-03 — retry-time scope fence downgrades a previously dispatched command to NOT_STARTED
- Severity: High
- Confidence: Very high
- Activation: latent for a future IDEMPOTENT command with retryBudget greater than zero; the current reference create is KEYED with retryBudget zero
Evidence:
- After response admission, attemptState becomes SETTLED at http-execution-v3.ts:648-656.
- A retry continues at 657-692.
- The next iteration checks scope at 511-516 and uses current attemptState, which still yields MAYBE_APPLIED.
- There is a second scope check inside final invariants at 554-563.
- If the scope changes between those two checks, lines 568-575 call preDispatchEffect(isCommand), returning NOT_STARTED and forgetting the prior attempt.
- Deep design lines 1683-1690 says dispatch followed by timeout/network/abort/body loss is MAYBE_APPLIED.
Minimal reproduction:
- Contract: commandEffect non-null, retrySemantics IDEMPOTENT, retryBudget 1.
- Attempt 1: fetch returns 429.
- Sleep resolves.
- Scope is current at retry-loop entry and false at final invariant.
- Expected SCOPE_FENCED with MAYBE_APPLIED.
- Actual SCOPE_FENCED with NOT_STARTED.
Root cause:
PhysicalAttemptState is being used as both current-attempt state and logical-execution history. Reset/final-invariant code reasons only about “this retry has not sent” and loses “a previous physical attempt was sent”.
Required decision:
Maintain a monotonic logical certainty accumulator for the whole execution. A new unsent retry cannot lower prior MAYBE_APPLIED. Final-invariant failures use the joined logical certainty; the first attempt can still return NOT_STARTED.
Proposed helper:
export function joinMutationEffectCertainty(
current: MutationEffectCertainty,
observed: MutationEffectCertainty
): MutationEffectCertainty;
Join rules:
- MAYBE_APPLIED dominates NOT_STARTED and NOT_APPLIED.
- APPLIED_CONFIRMED is terminal and cannot enter an automatic retry.
- NOT_APPLIED dominates NOT_STARTED for internal history.
- Query operations remain NOT_APPLICABLE and do not use the mutation lattice.
Also check caller/scope/deadline ownership after every awaited admission and before returning success. A separately named characterization should decide the response-completed-versus-caller-abort race; do not fold an unverified race rule into this patch without a test.
### N-04 — telemetry continues work after dispose and composition never disposes it
- Severity: High
- Confidence: Very high
- Activation: current when telemetry is enabled; no-op telemetry is unaffected
Evidence:
- best-effort-telemetry.ts:95-101 schedules a callback that always calls flush.
- emit at 104-125 has no disposed check.
- flush at 127-147 has no disposed check or in-flight AbortController.
- dispose at 154-156 removes only pagehide.
- runtime-adapters.ts:412-416 clears validators/scope/generation but omits telemetry.dispose.
- create-runtime-composition.ts:60-66 calls infrastructure.dispose after optional shutdown, so the omission reaches application teardown.
- flush at 127-130 returns an already-resolved promise when another flush is active, so await adapter.flush does not mean “the active delivery has settled”.
Minimal reproduction:
- Queue one valid api.request.failed with a captured scheduler callback.
- Call dispose.
- Run the captured callback and call emit again.
- Expected no fetch and pendingCount zero.
- Actual one fetch; later emit is also accepted.
Impact:
- HMR/test/runtime teardown can send queued or future events after the owning composition is gone.
- In-flight work has no cancellation owner.
- This is a lifecycle/privacy contract defect even though delivery is best-effort.
Required decision:
Use a small terminal lifecycle state, not a durable queue:
type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
flush(): Promise<void>;
dispose(): void;
Semantics:
- emit after dispose is a no-op.
- dispose removes pagehide, clears queued events, invalidates scheduled callbacks, and aborts the current sink request.
- flush joins and returns the active flush promise.
- completion of a sink that ignored abort cannot reschedule or update post-dispose delivery state.
- disposal drops data silently; it must not recursively emit a drop event while shutting down.
- runtime infrastructure.dispose calls telemetry.dispose before destroying diagnostics/state dependencies.
A separate async shutdown or persistent retry queue is unnecessary for current best-effort policy.
### N-05 — conditional-validator composite key is collision-prone
- Severity: High when composed; current effective risk Medium / activation blocker
- Confidence: Very high
- Activation: docs classify sidecar AVAILABLE_NOT_COMPOSED; bootstrap creates/clears it but HTTP does not use it
Evidence:
- conditional-validator-store.ts:43-58 joins four unescaped components with colon.
- identityToken permits colon at 47.
- scope fingerprint permits colon in server-state-scope-runtime.ts:196-200.
- definitionId is only checked for truthiness at line 46.
- architecture status at api-contract-schema-mapper-and-server-state.md:108 explicitly says AVAILABLE_NOT_COMPOSED.
Collision using valid values and the same scope/version:
A: definitionId = "resource:detail"
identityToken = "identity-token-00000001"
B: definitionId = "resource"
identityToken = "detail:identity-token-00000001"
Both encode to the same string. Installing B overwrites A; prepare(A) returns Bs ETag.
Impact after composition:
A validator from one definition could be sent for another and a 304 could admit the wrong cached representation/revision relationship.
Required decision:
Use an injective deterministic tuple codec, not a repository abstraction. JSON.stringify of a validated fixed tuple is sufficient:
type ConditionalValidatorKeyTuple = readonly [
scopeFingerprint: string,
definitionId: string,
identityToken: string,
representationVersion: number
];
Validate and byte-bound definitionId and fingerprint at this trust boundary. No public store API change or persisted-data migration is needed because the store is in-memory and not composed into HTTP yet.
### N-06 — legacy keyed commands can automatically retry with no Idempotency-Key
- Severity: High on the compatibility/rollback path
- Confidence: High
- Activation: exported createRuntimeHttpClient/createHttpClient; not the current installed reference path
Evidence:
- client.ts:241-246 uses nullish coalescing, so caller value "" is retained rather than replaced.
- client.ts:534 sets Idempotency-Key only if the value is truthy.
- retry-policy.ts:45-68 allows both safe and keyed retries.
- Therefore a keyed command with explicit empty key can replay after a retryable response while sending no key.
- The existing keyed integration test uses a non-empty logical-command value; there is no invalid-key case.
- VD-23 lines 691-703 permits V1 fallback only if hardening remains.
Required decision:
Export one defineIdempotencyKey validator from mutation-intent.ts and use it in both V2 and V3. Reject empty, whitespace-only, control-character, and over-byte-budget keys before credentials, timers, or fetch. Do not trim or silently regenerate a caller-supplied invalid value. Return VALIDATION_REJECTED / IDEMPOTENCY_KEY_INVALID, attempt count zero.
### N-07 — legacy total deadline does not bound or cancel credential attachment
- Severity: High on the compatibility/rollback path
- Confidence: High
- Activation: auth-required V2 operation with a non-cooperative external owner
Evidence:
- client.ts:517-526 creates the attempt controller/timer.
- client.ts:570-586 awaits authSession.credentialPatch directly.
- auth-session-port.ts:15-27 exposes no signal/deadline to credentialPatch.
- If the owner never settles, aborting the attempt controller does not settle the await, so execute can exceed its total deadline indefinitely.
- Recovery is raced at client.ts:297-324 and 666-697, but authSession.recover itself receives no signal; late owner work can continue.
- V3 has the better local waiting pattern at http-execution-v3.ts:427-466, although its underlying credential work is not cooperatively signaled either.
- VD-23 lines 339-349 explicitly includes credential/recovery in total deadline and requires auth waiter cleanup.
Proposed compatible port extension:
export type AuthOperationContext = Readonly<{
signal: AbortSignal;
deadlineAtMonotonicMs: number;
}>;
credentialPatch(
binding: CredentialRequestBinding,
context?: AuthOperationContext
): Promise<CredentialPatch>;
recover(context?: AuthOperationContext):
Promise<"restored" | "no-session">;
Make context optional for one release to preserve existing owner implementations, but both clients must race owner promises against the lifetime signal immediately. Extend ExternalSessionOwner attachCredential/recoverSession the same way, pass the context through, and ignore all late completions. In the following breaking release, require the context from external owners.
Error semantics:
- deadline owner: REQUEST_TIMEOUT / TIMEOUT.
- caller owner: REQUEST_ABORTED / CANCELLED.
- scope owner in V3: ABORTED_BY_SCOPE or SCOPE_FENCED, preserving logical effect.
- ordinary owner rejection: AUTH_INTEGRATION_FAILURE.
- none of these paths may fetch.
### N-08 — legacy bounded JSON can reject and leave response cleanup inconsistent
- Severity: Medium
- Confidence: High
- Activation: V2 response path
Evidence:
- bounded-json.ts:9-12 awaits response.body.cancel outside a catch.
- lines 24-26 awaits reader.cancel; a rejection escapes the closed result.
- lines 30-33 does not cancel after reader failure and releaseLock can throw.
- client.ts:714-723 returns immediately on content-type mismatch without cancelling the response body.
- bounded-body-reader.ts:31-76 and 148-153 already isolates cancellation/release errors and is well tested.
Required decision:
Make bounded-body-reader the common primitive. Keep readBoundedJsons public return codes temporarily by delegating and mapping:
- RESPONSE_TOO_LARGE -> RESPONSE_BODY_LIMIT.
- UTF8_INVALID / JSON_INVALID / RESPONSE_STREAM_FAILURE -> MALFORMED_JSON for legacy compatibility.
Cancel on V2 content-type mismatch. Do not maintain two stream-reader strategies.
### N-09 — localStorage fallback cannot prove the event came from localStorage
- Severity: Medium
- Confidence: Very high
- Activation: storage fallback; invalidation is hint-only, so effect is stale/refetch pressure rather than data/authorization corruption
Evidence:
- StoragePulseEvent at browser-cross-context-invalidation.ts:98-101 contains only key and newValue.
- receiveStorage at 179-193 checks exact key/value but cannot check area.
- browser-cross-context-host.ts:206-248 discards native storageArea.
- client-cache-and-storage.md:82-83, 939-948 and checklist 1638 explicitly documents this missing check.
- Existing native browser tests cover homogeneous BroadcastChannel and homogeneous storage fallback, not a foreign storage area.
Required decision:
Capture localStorage once and derive both the write facade and event validator from the same object identity. Do not call a hostile getter twice.
Proposed facade:
export type StoragePulseEvent = Readonly<{
key: string | null;
newValue: string | null;
storageArea: "EXPECTED_LOCAL_STORAGE" | "OTHER_OR_UNKNOWN";
}>;
Core receiveStorage admits only EXPECTED_LOCAL_STORAGE. Register the pulse key in storage-keys.ts at the same time:
CACHE_INVALIDATION_PULSE:
backend localStorage
classification opaque-cache
valueCodec opaque-string-v1
ttl null
migration discard
quotaFallback no-persist
The storage adapter need not own pulse I/O; the registry owns its physical-key policy.
### N-10 — cursor pagination can admit a page after cancellation
- Severity: Medium
- Confidence: High
- Activation: AVAILABLE_NOT_COMPOSED pagination runtime
Evidence:
- cursor-pagination-runtime.ts:29-33 checks signal only before await loadPage.
- There is no post-await signal check before page validation/accumulation and success at lines 34-59.
- A non-cooperative loadPage that resolves after abort can make the last page return success.
- The architecture status claims an abort test, but cursor-pagination-runtime.test.ts currently covers finite chain, invalid invariants, loop and snapshot drift only.
Required decision:
Race loadPage with the signal or check immediately after await and before observing the page. Prefer an awaitWithAbort helper so a never-settling loader cannot hold loadAll forever. Late page completion is ignored. Return REQUEST_ABORTED / PAGINATION_ABORTED without partial items.
### N-11 — non-finite queue capacities bypass boundedness
- Severity: Low
- Confidence: High
- Activation: custom adapter construction only; bootstrap uses defaults
Evidence:
- bounded-diagnostics.ts:21 uses Math.max(1, maxEntries). NaN remains NaN and Infinity remains Infinity.
- best-effort-telemetry.ts:49 has the same issue.
- Comparisons against NaN/Infinity can disable intended eviction.
Fix: require Number.isSafeInteger and a documented upper ceiling, throwing TypeError at construction. This is configuration validation, not a runtime drop.
## 6. Selection-dependent improvements and explicitly separated hypotheses
These are not confirmed defects at the same level as N-01 through N-11.
### O-01 — mixed BroadcastChannel/storage-only tabs
- Confidence: Medium
- Evidence: browser-cross-context-invalidation.ts:318-342 returns immediately after a successful BroadcastChannel post and does not pulse storage. A second tab whose BroadcastChannel constructor failed but whose storage works listens only to storage.
- Existing tests at cross-tab-invalidation.test.ts:447-488 cover a sender whose BroadcastChannel post fails, then storage fallback. Browser capability tests cover BroadcastChannel/BroadcastChannel and storage/storage, not BroadcastChannel sender/storage-only receiver.
- Product decision: if per-tab capability asymmetry must be supported, mirror every accepted BroadcastChannel event to storage and rely on existing eventId dedupe. If “priority fallback” assumes partition-homogeneous capability, document that assumption and keep single-write behavior.
- Trade-off: mirroring increases synchronous localStorage writes and storage-event fan-out. This protocol is a best-effort hint with focus/stale revalidation, so do not build a durable/exactly-once bus.
### O-02 — beforeunload attachment comment does not match implementation
- Confidence: High for mismatch; Low material impact
- browser-lifecycle.ts:39-43 says the listener exists only while a source reports dirty.
- syncBeforeUnload at 135-143 attaches whenever any source is registered; it cannot observe a callback changing from false to true.
- onBeforeUnload rechecks actual dirtiness, so users are not incorrectly prompted.
- Preferred minimal action: document “while at least one dirty reporter is registered”. Add an observable update handle only if listener-count optimization is a real requirement.
### O-03 — async scope/coordinator teardown
- Confidence: Medium
- ServerStateScopeRuntime.dispose and QueryInvalidationCoordinator.dispose are void while reset/flush promises may exist.
- Current generation checks and disposed flags prevent reactivation; no stale cache admission was demonstrated.
- If runtime shutdown needs a “all background state work settled” guarantee, introduce async close and await it in composition. Otherwise characterize late work and retain the simpler void API.
### O-04 — QueryCachePort clone-on-read
- Confidence: Medium
- tanstack-query-cache.ts clones writes but returns TanStacks object reference on read.
- Docs say cached mapped values are immutable, but QueryCachePort returns unknown rather than a readonly type.
- Decide whether the port guarantees immutable values or isolation. If isolation is required, clone on read and return QUERY_CACHE_FAILURE on clone failure. Do not add cost to production TanStack hooks based only on this legacy port.
### O-05 — caller-abort versus already-buffered successful response
- Confidence: Medium; not included in confirmed findings
- V3 aborts the fetch signal, but admitResponse does not directly inspect terminalCancellation after a custom/buffered reader resolves.
- Before changing semantics, add a deterministic test where readBoundedResponseBytes aborts the caller and then returns valid bytes. Product must choose first-terminal-owner-wins versus completed-response-wins. The designs CancellationOwner wording suggests first-owner-wins, but this report does not claim it without characterization.
## 7. Patterns to apply, and patterns to reject
Apply:
1. Profile/Strategy registry for auth. The operation selects an immutable profile; the credential owner supplies only proof material.
2. Stable tuple codec for validator keys. It directly solves injectivity and keeps storage private.
3. Monotonic certainty lattice for logical command execution. Physical attempts cannot downgrade already-observed uncertainty.
4. Structured cancellation context. One lifetime signal/deadline is passed through credential, recovery, retry sleep, fetch and response admission.
5. Small terminal lifecycle state for telemetry. ACTIVE/DISPOSED plus one joined flush promise is sufficient.
6. Adapter-boundary predicate for StorageEvent.storageArea. Browser identity checks belong at native capability capture.
7. Characterization-first consolidation for legacy body reading. Delegate to the proven bounded reader while preserving old error codes.
Reject:
- A generic HTTP interceptor/middleware pipeline: it obscures authority/order and recreates the transport-header bug.
- A generic repository abstraction for query cache, ETag store and storage pulse: their consistency and lifecycle semantics differ.
- Durable/exactly-once cross-tab messaging: invalidation is a bounded hint; server revalidation remains authoritative.
- Persistent telemetry retry/offline queue: current contract is best-effort and has no consent/retention decision.
- A new retry library or circuit breaker: current retry bounds are explicit and adequate once replay proof/certainty is corrected.
- Event sourcing for scope/reset: synchronous generation fencing plus ordered participants is simpler and already correct.
## 8. Exact implementation manifest
Implement as small reviewable changes. “Delete: none” and “Move: none” applies to the immediate remediation; legacy removals occur only after the compatibility window.
### Change set A — V3 observability and monotonic effect
Modify:
- src/adapters/http/http-execution-v3.ts
- src/adapters/http/http-effect-certainty.ts
- src/bootstrap/runtime-adapters.ts
- src/features/reference-feature/adapters/create-reference-feature-input.ts
- src/contracts/diagnostics.ts only if effect_certainty is approved; otherwise do not modify its allowlist
- tests/unit/http-execution-v3.test.ts
- tests/unit/runtime-adapters.test.ts
- tests/features/reference-feature/reference-runtime-composition.test.ts
- docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md
- docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md
Create:
- tests/integration/http-execution-v3-observability.test.ts
Delete: none.
Move: none.
### Change set B — profile-authoritative credentials and auth cancellation
Modify:
- src/contracts/rest-profiles.ts
- src/contracts/external-contract-runtime.ts for strict authProfileId grammar only
- src/adapters/http/http-contract-bridge.ts
- src/adapters/http/http-execution-v3.ts
- src/application/ports/auth-session-port.ts
- src/adapters/auth/external-session-adapter.ts
- src/bootstrap/runtime-adapters.ts
- src/features/reference-feature/adapters/create-reference-feature-input.ts to map AUTH_INTEGRATION_FAILURE
- tests/unit/rest-profile-contract.test.ts
- tests/unit/auth-session-adapter.test.ts
- tests/unit/http-execution-v3.test.ts
- tests/unit/runtime-adapters.test.ts
- tests/features/reference-feature/reference-runtime-composition.test.ts
- docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md
Create:
- tests/integration/http-execution-v3-auth-profile.test.ts
Delete: none.
Move: none.
### Change set C — telemetry lifecycle
Modify:
- src/adapters/telemetry/best-effort-telemetry.ts
- src/bootstrap/runtime-adapters.ts
- tests/unit/telemetry.test.ts
- tests/unit/runtime-adapters.test.ts
- docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md
Create: none.
Delete: none.
Move: none.
### Change set D — state sidecars and cancellation
Modify:
- src/adapters/query-cache/conditional-validator-store.ts
- src/adapters/query-cache/cursor-pagination-runtime.ts
- tests/unit/conditional-validator-store.test.ts
- tests/unit/cursor-pagination-runtime.test.ts
- docs/architecture/api-contract-schema-mapper-and-server-state.md
Create: none.
Delete: none.
Move: none.
### Change set E — legacy rollback hardening and reader consolidation
Modify:
- src/contracts/mutation-intent.ts
- src/application/ports/auth-session-port.ts
- src/adapters/auth/external-session-adapter.ts
- src/adapters/http/client.ts
- src/adapters/http/http-execution-v3.ts to reuse the common key validator/context
- src/adapters/http/bounded-json.ts to delegate to bounded-body-reader
- tests/integration/http-client.test.ts
- tests/integration/auth-recovery.test.ts
- tests/integration/http-execution-contract.test.ts
- tests/unit/bounded-body-reader.test.ts
- docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md
Create:
- tests/unit/bounded-json-compatibility.test.ts
Delete immediately: none.
Move: none.
Later removal after zero runtime callers and an expired rollback window:
- Delete src/adapters/http/client.ts
- Delete src/adapters/http/bounded-json.ts
- Delete src/adapters/http/request-builder.ts
- Delete src/adapters/http/resource-mapper.ts
- Delete src/adapters/http/schema-registry.ts
- Remove createRuntimeHttpClient from src/bootstrap/runtime-adapters.ts
- Remove V2-only tests/fixtures after V3 equivalents exist
Do not delete retry-policy.ts while V3 imports parseRetryAfter.
### Change set F — exact storage fallback admission
Modify:
- src/contracts/storage-keys.ts
- src/adapters/cross-context-invalidation/browser-cross-context-host.ts
- src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts
- src/adapters/cross-context-invalidation/index.ts
- tests/unit/cross-tab-invalidation.test.ts
- tests/browser-capabilities/cross-context-invalidation.spec.ts
- docs/architecture/client-cache-and-storage.md
- docs/architecture/decisions/VD-13-client-cache-scope-and-persistence.md
Create:
- tests/unit/browser-cross-context-host.test.ts
Delete: none.
Move: none.
Optional O-01 mirroring must be a separate change set and must not be bundled with the exact storageArea security check.
### Change set G — capacity and lifecycle documentation hardening
Modify:
- src/adapters/diagnostics/bounded-diagnostics.ts
- src/adapters/telemetry/best-effort-telemetry.ts
- tests/unit/diagnostics.test.ts
- tests/unit/telemetry.test.ts
- src/adapters/platform/browser-lifecycle.ts comment only, unless observable dirty state is selected
Create a browser-lifecycle unit test only if behavior changes.
Delete: none.
Move: none.
## 9. TDD matrix
Write each test red first.
| Test name | Input/setup | Expected result |
| --- | --- | --- |
| records_v3_terminal_outcome_with_allowlisted_context_once | V3 success and terminal 503; concrete diagnostics adapter | one record per logical execution; route/operation/status/attempt/duration are safe bucket keys; dropped map empty |
| emits_v3_terminal_non_abort_failure_once | V3 retry exhaustion | one api.request.failed after final attempt, none per attempt |
| does_not_emit_v3_failure_telemetry_for_caller_or_scope_abort | caller abort and scope fence | diagnostic once, telemetry zero |
| forwards_reference_route_id_to_v3_observation | list/detail/create gateway requests | exact registry route IDs reach observation; no raw URL/intent |
| rejects_unknown_auth_profile_during_runtime_composition | installed contract refers to missing profile | composition throws before any feature can execute |
| rejects_bearer_ready_patch_without_authorization | authenticated session, empty READY patch | AUTH_INTEGRATION_FAILURE, NOT_STARTED, fetch zero |
| rejects_credential_patch_that_owns_accept_content_type_or_credentials | hostile credential adapter | integration/contract failure, fetch zero |
| sends_exact_profile_credentials_and_required_headers | valid bearer and cookie profiles | exact init.credentials and header subset |
| forwards_lifetime_abort_to_external_credential_owner | hanging owner, deadline/caller abort | owner signal aborts; execution settles with correct closed error |
| preserves_prior_maybe_applied_when_retry_is_fenced_before_dispatch | IDEMPOTENT command, first 429, scope false only at retry final invariant | SCOPE_FENCED and MAYBE_APPLIED; fetch called once |
| never_decreases_logical_command_certainty_across_attempts | table of NOT_STARTED/NOT_APPLIED/MAYBE combinations | join follows lattice |
| dispose_prevents_queued_and_future_telemetry_delivery | captured schedule, emit, dispose, callback, emit | fetch zero, queue zero |
| dispose_aborts_active_telemetry_delivery_without_reschedule | fetch waits on signal, dispose | signal aborted, no reschedule |
| concurrent_flush_joins_active_delivery | call flush twice while sink pending | both promises settle only after same fetch settles; fetch once |
| runtime_dispose_disposes_telemetry_before_state_dependencies | spied telemetry/lifecycle | pagehide removed and sink aborted during composition dispose |
| keeps_delimiter_ambiguous_validator_bindings_distinct | the A/B binding pair from N-05 | prepare(A)=etag A, prepare(B)=etag B |
| rejects_unbounded_or_invalid_validator_definition_identity | empty/oversize/invalid fingerprint | install false, no row |
| rejects_empty_control_and_oversize_legacy_idempotency_keys_before_send | "", whitespace, control, 257-byte key | VALIDATION_REJECTED/IDEMPOTENCY_KEY_INVALID; auth/fetch/timer zero |
| reuses_one_valid_legacy_key_on_every_retry | keyed 503 then success | identical non-empty header on each physical attempt |
| settles_legacy_hanging_credential_at_total_deadline | owner never resolves | REQUEST_TIMEOUT; fetch zero; listeners/timer removed |
| ignores_late_legacy_recovery_completion | recovery resolves after abort/scope transition | terminal result unchanged; no replay/notification from late completion |
| bounded_json_never_rejects_when_cancel_or_release_fails | hostile stream methods | closed legacy error, no rejection |
| content_type_mismatch_cancels_legacy_response_body_once | non-JSON response with cancellable stream | CONTENT_TYPE_MISMATCH and cancel called once |
| ignores_storage_event_from_non_local_storage_area | exact pulse key/value but OTHER_OR_UNKNOWN area | delivery zero |
| accepts_storage_event_only_from_captured_local_storage | native-like event with captured identity | delivery once |
| captures_local_storage_getter_once | getter returns different objects per access | getter called once; event/write identity coherent |
| ignores_late_cursor_page_after_abort | loader aborts signal then resolves final page | REQUEST_ABORTED/PAGINATION_ABORTED |
| settles_never_resolving_cursor_loader_on_abort | loader never settles | loadAll settles promptly with abort |
| rejects_non_finite_adapter_capacities | NaN, Infinity, fractional, excessive values | TypeError at construction |
Targeted commands:
corepack pnpm exec vitest run \
tests/unit/http-execution-v3.test.ts \
tests/integration/http-execution-v3-observability.test.ts \
tests/integration/http-execution-v3-auth-profile.test.ts \
tests/features/reference-feature/reference-runtime-composition.test.ts
corepack pnpm exec vitest run \
tests/unit/telemetry.test.ts \
tests/unit/runtime-adapters.test.ts \
tests/unit/diagnostics.test.ts
corepack pnpm exec vitest run \
tests/unit/conditional-validator-store.test.ts \
tests/unit/cursor-pagination-runtime.test.ts \
tests/unit/cross-tab-invalidation.test.ts \
tests/unit/browser-cross-context-host.test.ts
corepack pnpm exec vitest run \
tests/integration/http-client.test.ts \
tests/integration/auth-recovery.test.ts \
tests/integration/http-execution-contract.test.ts \
tests/unit/bounded-json-compatibility.test.ts
Browser evidence:
corepack pnpm test:browser-capabilities
Required final gates:
corepack pnpm check:types
corepack pnpm lint
corepack pnpm check:architecture
corepack pnpm check:diagnostics
corepack pnpm test:unit
corepack pnpm test:integration
corepack pnpm test:reference-feature
If storage registry changes, also run:
corepack pnpm check:registries
corepack pnpm verify:compatibility
## 10. Compatibility and migration order
1. Land characterization tests only. They must fail for N-01 through N-05 and remain isolated from implementation.
2. Add routeId to InstalledContractOperationExecutor and HttpExecutionContext. Update every compile-time call site in reference gateway/tests in one commit. This is source-breaking but has no wire change.
3. Add typed V3 observation fields and runtime projection. Keep diagnostic/telemetry registries closed; use existing buckets. Deploy read operations first and verify non-empty, non-dropped V3 records.
4. Add logical certainty accumulation. No wire/API change outside the exported outcome values; downstream code must already handle MAYBE_APPLIED.
5. Extend RestAuthProfile with requiredCredentialHeaders and build the profile index at composition. Deploy fail-closed validation before changing credential owners.
6. Extend auth cancellation context as optional. Update internal/demo/external adapters and both clients. After one compatibility release, make it required for external owners.
7. Make demo authentication explicit. Do not relax REFERENCE_EXTERNAL_BEARER. Validate the selected demo behavior only against loopback/test provider evidence before enabling.
8. Fix telemetry lifecycle and call dispose from infrastructure teardown. This changes only post-dispose behavior and flush-await semantics.
9. Replace validator key codec before the first conditional HTTP composition. It is memory-only, so no data migration is required.
10. Harden V2 idempotency/cancellation/body reading before documenting it as a rollback target. Add deprecation notices and audit createRuntimeHttpClient callers.
11. Add storage registry entry and exact storageArea check without changing the invalidation wire envelope/version.
12. Run native browser capability evidence. Decide mixed-transport mirroring separately.
13. Only after zero V2 callers, V3/provider evidence, and expiration of the rollback window, delete legacy files.
Compatibility notes:
| Change | Compatibility |
| --- | --- |
| Required routeId | TypeScript source break; no network wire break. Update all executor callers atomically. |
| Observation shape | Internal dependency seam but exported type; tests/custom composition must update. |
| AUTH_INTEGRATION_FAILURE outcome | Exhaustive switch source break; add mapping to existing ApiFailure AUTH_INTEGRATION_FAILURE. |
| RestAuthProfile required headers | Source break for custom profiles; provide migration error naming profile ID only. |
| Optional AuthOperationContext phase | Backward compatible for owner implementation types; behavior improves immediately for updated owners. |
| Telemetry dispose | Intentional behavioral change only after ownership ends. |
| Validator key codec | No persisted state and no HTTP composition; safe replacement. |
| StoragePulseEvent area enum | Test/host facade source break; wire envelope unchanged. |
| Invalid legacy idempotency key | Intentional fail-fast behavior; callers relying on empty keys must be fixed, not grandfathered. |
## 11. Rollback sequence
Rollback must preserve security/correctness invariants.
1. Disable affected command operations first; do not route a command to legacy V2 unless V2 idempotency, auth deadline, final invariant and provider evidence are already fixed.
2. If observability sink causes incidents, set telemetry config off or wire noOpTelemetry. Keep V3 diagnostic projection, redaction registries and producer tests.
3. If strict auth composition rejects a bad deployment, fail the operation/provider as unavailable and repair the profile/owner. Do not restore broad credential headers or patch-owned credentials.
4. Read-only V3 operations may fall back only to a hardened V2 path with unexpired provider/security evidence, matching VD-23 lines 700-703.
5. The logical-effect accumulator must not be rolled back independently; downstream reconciliation depends on conservative MAYBE_APPLIED.
6. Conditional-validator codec rollback is simply disabling conditional request composition and clearing the in-memory store.
7. Storage-area hardening rollback should degrade to BroadcastChannel/local-only revalidation, not accept unverified storage events.
8. Cross-context wire version remains unchanged, so no coordinated tab upgrade is needed.
9. Roll back contract artifact, frontend and backend as one coherent set where operation/profile semantics changed.
10. Keep new regression tests during rollback; change only routing/configuration.
## 12. Existing tests/docs cross-check and false-positive controls
### What the passing tests genuinely prove
- http-execution-v3.test.ts proves descriptor projection, keyed intent validation, one-key reuse, no query key, schema containment, post-dispatch command uncertainty, scope fencing after a response, credential-wait deadline, deadline retry suppression, retry-sleep cancellation and forbidden-body stream failure.
- bounded-body-reader.test.ts has strong hostile stream/cancellation/release coverage; this is why consolidation is preferred.
- server-state-scope-runtime.test.ts proves synchronous fencing, participant order, fail-closed reset/activation and identity close.
- tanstack-cache-coordinator.test.ts proves topic mapping, lease deferral, reset ordering and disposal behavior under current contract.
- cross-tab-invalidation.test.ts proves invalid/stale/self/duplicate/gap handling, bounded fallback and cleanup.
- browser capability spec proves actual BroadcastChannel/BroadcastChannel and storage/storage delivery in supported browsers.
- diagnostics.test.ts and telemetry.test.ts prove projector allowlists, bounded queues, hostile context containment and pagehide listener removal.
- integration/http-diagnostics.test.ts proves exactly-once diagnostics/telemetry for legacy createHttpClient.
- reference runtime composition proves V3 URLs/headers and that private intent values do not appear in collected evidence.
- 21 selected files / 144 tests pass, so findings do not rely on a generally broken baseline.
### Why those tests do not invalidate the findings
- Legacy HTTP diagnostics tests import createHttpClient, not createContractHttpExecutor. They cannot validate V3 runtime-adapters projection.
- reference runtime composition only asserts private values are absent; an empty diagnostics array also satisfies it.
- check:diagnostics counts/inspects source producers but does not execute their concrete context through projectDiagnosticRecord.
- auth-session tests validate the external owners current header allowlist, but V3s exported CredentialPatchOutcome and final invariant still grant broader authority; they also do not require Authorization for the declared bearer profile.
- current V3 command is KEYED with retryBudget zero. It does not exercise an IDEMPOTENT retry followed by a final-invariant fence.
- telemetrys disposal test calls dispose after pagehide has already flushed and checks only listener removal.
- conditional-validator tests use delimiter-unambiguous values and docs explicitly mark the sidecar not composed.
- storage tests check exact key and envelope but the facade has no storageArea field to assert.
- browser docs explicitly list pulse registration and storageArea as unfinished, confirming N-09 rather than contradicting it.
- mixed-transport asymmetry is left as O-01 because the documented priority fallback can reasonably be read as a deliberate single-transport policy.
- beforeunload does not prompt falsely because the event callback rechecks dirty state; only the attachment comment is mismatched.
- no claim is made that conditional validators or cursor pagination currently corrupt the installed reference HTTP path; both are activation blockers for future composition.
## 13. Design worth preserving
- V3 keeps operation semantics in installed descriptors and re-verifies a bounded final request rather than accepting arbitrary URLs/headers from features.
- Bounded response admission avoids Response.json, enforces byte ceilings, uses strict UTF-8, and isolates stream cleanup failures.
- Mutation intent and command effect are explicit public concepts; post-dispatch uncertainty is represented instead of guessed from HTTP status.
- Server-state scope fences synchronously before any reset await, aborts the old signal, closes identity registries and creates a new QueryClient generation.
- Query invalidation sends only registry topic/version/epoch, never query keys, cached data, account IDs or mutation payloads. Remote authority is invalidate-only.
- Cross-context event parsing is closed and bounded with TTL, event dedupe, source epoch/sequence and gap escalation.
- TanStack retry is disabled so the HTTP layer remains the single retry authority.
- Diagnostics/telemetry have closed registries, low-cardinality value policies, redaction and failure isolation.
- External auth owner never returns raw tokens to application code; it returns a constrained header patch.
- Composition tears down optional capabilities before base state, which is the right dependency order.
- No unnecessary persistence/offline mutation queue/exactly-once protocol is claimed.
## 14. Recommended delivery order
P0:
1. N-01 V3 observability.
2. N-02 profile-authoritative auth.
3. N-03 monotonic command certainty.
4. N-04 telemetry terminal lifecycle.
P1 before enabling currently available capabilities or trusting rollback:
5. N-05 conditional-validator key codec.
6. N-06/N-07 V2 replay and auth deadline.
7. N-09 exact storageArea and registered pulse.
8. N-10 pagination cancellation.
P2 cleanup:
9. N-08 body-reader consolidation.
10. N-11 capacity validation.
11. O-02 documentation alignment.
12. Decide O-01/O-03/O-04/O-05 with explicit product requirements and characterization tests.
This ordering closes silent current-path failures and security/effect authority first, then makes latent capabilities safe to compose, and only then removes duplication.
@@ -0,0 +1,459 @@
# Realtime / Browser RPC adapter 구현 리뷰
- 리뷰 기준: `4dc033c` (2026-08-13, Asia/Seoul)
- 구현 범위: `src/adapters/realtime/**`, `src/adapters/browser-rpc/**`
- 추적 범위: 대응 contracts, application ports, bootstrap 조립, unit/boundary tests, architecture docs
- 방식: 코드 리뷰만 수행했다. 이 문서 외 구현 파일은 수정하지 않았다.
- 결론: **Critical 0, High 4, Medium 3**이다. R-01~R-06은 코드상 확정된 lifecycle/immutability/resource 문제이고, R-07은 문서에도 미완료라고 명시된 production promotion blocker다. 두 runtime 모두 현재 `AVAILABLE_NOT_COMPOSED`이므로 production traffic 사고로 과장하지 않는다.
## 1. 21/21 파일 inventory와 책임
아래 경로는 모두 저장소 루트 기준 full path이며, 범위의 구현 파일 21개를 모두 읽었다.
| # | full path | 책임 | 주요 의존성 / downstream | 판정 |
|---:|---|---|---|---|
| 1 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | operation을 unary/server-stream application port로 bind하고 request schema/encoder, deadline/retry, transport, response schema/mapper, generation fence를 순서대로 집행 | `application/ports/browser-rpc`, `ClockPort`, Browser RPC contract, schema/mapper registry, `transport.ts`, `AppFailure` | R-01, R-04, R-06, R-07 |
| 2 | `src/adapters/browser-rpc/index.ts` | Browser RPC public adapter export surface | runtime, transport, unavailable adapter | 새 lease/install type export 필요 |
| 3 | `src/adapters/browser-rpc/transport.ts` | provider-neutral unary/stream transport result와 runtime identity 계약 | `src/contracts/browser-rpc.ts` | R-01, R-07 |
| 4 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | 선택되지 않은 runtime의 명시적 fail-closed Null Object | `transport.ts` | 유지; 새 stream lease shape만 맞춤 |
| 5 | `src/adapters/realtime/event-codec.ts` | raw JSON byte/shape/registry/schema 검증, immutable DTO와 semantic fingerprint 생성 | realtime contracts, schema registry, JSON scanner, result codec | 유지 |
| 6 | `src/adapters/realtime/event-consumer.ts` | SSE/WS cursor 규칙을 codec 결과와 결합하고 common stream coordinator outcome으로 투영 | realtime ports/contracts, event codec, stream coordinator | 유지 |
| 7 | `src/adapters/realtime/index.ts` | common realtime adapter public export surface | codec, consumer, reconnect, handoff, stream, sub-index | R-02/R-03 lifecycle type export 필요 |
| 8 | `src/adapters/realtime/json-member-scanner.ts` | `JSON.parse` 전 duplicate member와 structure budget을 비재귀적으로 검사 | 독립 utility | 유지 |
| 9 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | LIVE/POLL 단일 effect writer, generation fence, quiescence/checkpoint, probe buffer와 전환 | `ClockPort`, realtime result/ports | R-03 |
| 10 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | visible/online finite lease, single-flight poll, retry hint, response/apply deadline, non-cooperative task drain | bounded polling policy, `ClockPort`, realtime result | 유지 |
| 11 | `src/adapters/realtime/polling/index.ts` | polling public exports | bounded poll coordinator | 유지 |
| 12 | `src/adapters/realtime/reconnect-coordinator.ts` | 단일 reconnect owner, online gate, retry budget, session close authority, exact recovery proof, post-abort DRAINING | reconnect policy, `ClockPort`, realtime ports/result | 유지 |
| 13 | `src/adapters/realtime/reconnect-policy.ts` | immutable reconnect policy, full jitter, elapsed budget, Retry-After 계산/검증 | 독립 policy | 유지 |
| 14 | `src/adapters/realtime/result.ts` | hostile/mutable collaborator result를 exact own-data snapshot으로 canonicalize | realtime ports/contracts | 유지; R-04의 기준 패턴 |
| 15 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | fixed same-origin fetch-stream SSE, response/media/open gate, read/event deadline, cursor rule, bounded reader cancel | `ClockPort`, event authority/result, parser, reconnect policy | 유지; R-02 common drain과 함께 검증 |
| 16 | `src/adapters/realtime/sse/index.ts` | SSE public exports | fetch connection, parser | 유지 |
| 17 | `src/adapters/realtime/sse/sse-parser.ts` | strict incremental UTF-8 SSE parser, BOM/line ending/id/retry/event buffer ceiling | realtime contracts/result | 유지 |
| 18 | `src/adapters/realtime/stream-coordinator.ts` | per-stream sequential effect, dedupe/order, recovery, checkpoint/barrier, scope generation fence | event authority port, realtime contracts, mapper registry, codec/result | R-02 |
| 19 | `src/adapters/realtime/websocket/index.ts` | WebSocket connection/protocol public exports | connection, protocol | 유지 |
| 20 | `src/adapters/realtime/websocket/websocket-connection.ts` | 한 physical WS의 handshake/subscription/tombstone/FIFO/heartbeat/apply gate/recovery close | `ClockPort`, realtime contracts/result, WS protocol | 유지; R-02 upstream timeout과 함께 검증 |
| 21 | `src/adapters/realtime/websocket/websocket-protocol.ts` | exact closed JSON frame decode/encode, duplicate key/structure/sequence/frame byte 검증 | realtime contracts, JSON scanner | R-05 |
## 2. 추적한 contracts, ports, bootstrap, tests, docs
| 계층 | 읽은 파일과 근거 | 대조 결과 |
|---|---|---|
| Realtime contracts | `src/contracts/realtime-streams.ts`, `src/contracts/realtime-events.ts` | registry가 stream/event/recovery/queue ceiling을 닫고 cursor/sequence/scope 문법을 소유한다. adapter가 이를 우회하지 않는다. |
| Realtime ports | `src/application/ports/realtime/shared.ts:1-66`, `src/application/ports/realtime/event-authority.ts:15-202`, `src/application/ports/realtime/index.ts:1-31` | native error/payload/cursor 없는 closed result, exact recovery checkpoint identity, effect/recovery commit authority를 확인했다. R-02 lifecycle inspection 확장이 필요하다. |
| Browser RPC contract | `src/contracts/browser-rpc.ts:66-179,256-469,472-591` | wire/profile/operation join과 hard limit은 풍부하지만 validate-only mutable binding이다(R-04). `maxBufferedBytes`는 선언/검증만 된다(R-07). |
| Browser RPC port | `src/application/ports/browser-rpc/browser-rpc.ts:4-35`, `src/application/ports/browser-rpc/index.ts` | application에는 typed unary/stream Result만 보이고 generated type/frame/endpoint는 노출되지 않는다. 변경 불필요. |
| Clock / Result | `src/application/ports/clock-port.ts`, `src/adapters/platform/system-clock.ts`, `src/application/result.ts`, `src/contracts/errors.ts` | injected clock/fence failure도 port Result 의미로 닫아야 한다(R-06). |
| Bootstrap | `src/bootstrap/optional-runtime-host.ts:21-29,65-70,87-92,142-150` | `realtime: null`, health `UNAVAILABLE`, 제품 contribution 전 미조립은 의도다. Browser RPC 조립도 없다. 미조립 자체는 결함이 아니다. |
| Boundary gates | `scripts/check-realtime-boundaries.ts`, `scripts/lib/realtime-boundaries.ts`, `scripts/check-realtime-boundary-fixtures.ts`, `scripts/test-realtime-runtime-removal.ts`; `tests/fixtures/realtime-boundaries/allowed/**`, `forbidden/**` | native realtime API 소유권과 unselected composition을 정적 검사한다. Browser RPC에는 아직 같은 별도 boundary gate가 없다. |
| Realtime docs | `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`, `docs/architecture/realtime-events-web-push-and-bounded-polling.md`, `docs/architecture/optional-adapter-recipes.md` | fixed endpoint, exact barrier, single writer, overflow fail-close, bounded cleanup/DRAINING, 미조립 상태를 코드와 대조했다. |
| Browser RPC docs | `docs/architecture/protobuf-browser-transport-and-rest-gateway.md`, `docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md`, `docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md` | common lifecycle만 구현됐고 concrete framing/raw-byte/provider/browser conformance는 pending이라고 명시한다. |
대조한 16개 테스트 파일:
- `tests/unit/browser-rpc/browser-rpc-contract.test.ts`
- `tests/unit/browser-rpc/browser-rpc-runtime.test.ts`
- `tests/unit/realtime/bounded-poll-coordinator.test.ts`
- `tests/unit/realtime/bounded-polling-policy.test.ts`
- `tests/unit/realtime/event-codec.test.ts`
- `tests/unit/realtime/event-consumer.test.ts`
- `tests/unit/realtime/fetch-sse-connection.test.ts`
- `tests/unit/realtime/live-poll-handoff-coordinator.test.ts`
- `tests/unit/realtime/realtime-reconnect-coordinator.test.ts`
- `tests/unit/realtime/realtime-reconnect-policy.test.ts`
- `tests/unit/realtime/realtime-stream-registry.test.ts`
- `tests/unit/realtime/result.test.ts`
- `tests/unit/realtime/sse-parser.test.ts`
- `tests/unit/realtime/stream-coordinator.test.ts`
- `tests/unit/realtime/websocket-connection.test.ts`
- `tests/unit/realtime/websocket-protocol.test.ts`
## 3. 분류
### 확정 결함
| ID | 심각도 | 확신도 | 요약 |
|---|---|---|---|
| R-01 | High | High | Browser RPC server-stream 종료가 non-cooperative iterator에서 무기한 멈춘다. |
| R-02 | High | High | common stream coordinator가 non-cooperative effect/recovery 하나로 영구 wedge된다. |
| R-03 | High | High | LIVE↔POLL overflow fail-close가 active lease를 잃어 이후 close가 거짓 성공한다. |
| R-04 | High | High | Browser RPC bindings는 validate-then-use TOCTOU이며 exact immutable install이 아니다. |
| R-05 | Medium | High | WS frame byte ceiling 전에 입력 전체 UTF-8 copy를 추가 할당한다. |
| R-06 | Medium | High | Browser RPC clock/fence 예외가 Result 경계를 탈출하고 cleanup을 건너뛴다. |
### 미조립 단계 promotion blocker / 선택 개선
| ID | 심각도 | 확신도 | 요약 |
|---|---|---|---|
| R-07 | Medium, promotion blocker | High | `maxBufferedBytes`의 concrete transport 집행 및 provider/browser conformance가 아직 없다. 문서에도 pending으로 명시되어 현재 common runtime bug로 세지 않는다. |
## 4. 확정 결함 상세
### R-01 — Browser RPC server-stream 종료가 non-cooperative iterator에서 무기한 멈춘다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/adapters/browser-rpc/transport.ts:53-61`은 stream을 `AsyncIterable` 하나로 표현한다. 명시적 `cancel`/`waitClosed`/cleanup bound가 없다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:488-512``iterator.next()`를 deadline과 race하지만, timeout 뒤 원래 `next()` task는 남을 수 있다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:630-640``finally`에서 `await iterator.return()`을 deadline 없이 기다린다. pending `next()`가 signal을 무시하면 async generator의 queued `return()`도 완료되지 않는다.
- 영향: caller abort, idle/total timeout, response limit, consumer `break` 뒤 application iterator completion이 무기한 pending이다. 외부 abort listener 수명도 `finally` 완료 전까지 닫히지 않는다. timeout Result를 선택했어도 iterator가 끝나지 않아 total deadline 의미가 깨진다.
- 기존 증거와 gap: `tests/unit/browser-rpc/browser-rpc-runtime.test.ts:343-369`은 cooperative generator가 abort를 보고 `finally`로 끝나는 경우만 확인한다. `docs/architecture/protobuf-browser-transport-and-rest-gateway.md:381-399`는 reader cancel/release, bounded consumer queue, terminal envelope, EOF non-success를 요구한다.
- 적용 패턴: **Explicit Stream Lease + structured concurrency + retained DRAINING task**. 암묵적인 `AsyncIterable.return()`에 transport lifecycle authority를 숨기지 않는다.
- 결정:
1. app-facing generator는 idle/total/limit/caller abort 후 cleanup bound 안에 끝난다.
2. commit/admission generation은 즉시 fence한다.
3. underlying task가 bound 안에 끝나지 않으면 transport lease는 `DRAINING`에 남고 실제 `waitClosed()` settlement까지 추적한다.
4. `return()`/`waitClosed()` rejection은 이미 선택한 application failure를 덮지 않는다.
### R-02 — common stream coordinator가 non-cooperative authority 하나로 영구 wedge된다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/adapters/realtime/stream-coordinator.ts:231-247`은 event를 `state.tail`에 직렬 연결한다.
- `src/adapters/realtime/stream-coordinator.ts:373-408`은 effect authority를 직접 `await`한다. AbortSignal을 무시하는 Promise에 deadline/drain state가 없다.
- recovery는 `src/adapters/realtime/stream-coordinator.ts:497-529`에서 기존 tail을 기다리고 `:543-560`에서 recovery authority를 다시 무기한 기다린다.
- `close():809-834`는 controller만 abort하고 즉시 `void`로 끝나 실제 settlement/DRAINING을 나타내지 않는다.
- 영향: WS `maxApplyMs`(`websocket-connection.ts:1060-1080`)나 SSE event timeout(`fetch-sse-connection.ts:321-355`)은 transport caller만 끝낸다. common tail은 pending이라 새 generation event와 queue-overflow recovery까지 영구 대기한다. generation fence는 late commit을 막지만 liveness/resource convergence는 보장하지 않는다.
- 기존 증거와 gap:
- `tests/unit/realtime/stream-coordinator.test.ts:383-466`의 in-flight effect는 결국 resolve되고 `:791-821`의 non-cooperative recovery도 테스트 끝에서 settle한다. never-settling authority와 bounded close는 없다.
- `VD-28...md:218-224,250-256,437-446`은 terminal/idempotent close와 bound를 넘긴 task가 실제 settle할 때까지 `DRAINING`을 유지하도록 정한다.
- 적용 패턴: **per-stream State Machine + Task Lease Registry + generation capability**.
- 결정:
1. freshness와 별도로 lifecycle `OPEN | DRAINING | CLOSED`를 둔다.
2. effect/recovery deadline에 commit capability를 영구 false로 만들고 abort한다.
3. caller에는 `IDLE_TIMEOUT` (`operation: APPLY | RECOVER`, non-retryable)을 bounded하게 반환하고 실제 task는 retain한다.
4. DRAINING 중 새 event/recovery를 허용하지 않는다. actual settle 뒤 `STALE`에서 authoritative recovery를 요구하거나 close 요청이면 `CLOSED`로 간다.
5. `close()``Promise<RealtimeResult<void>>`로 bounded quiescence 결과를 반환한다.
### R-03 — LIVE↔POLL overflow 뒤 active writer reference를 잃는다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/adapters/realtime/live-poll-handoff-coordinator.ts:274-286`은 active tail overflow 시 `failClosed()`를 호출한다.
- `failClosed():774-788`은 controller를 abort한 뒤 `active = null`로 지우지만 해당 lease/tail을 retired set에 보존하지 않는다.
- `performClose():672-700`은 현재 active/probe/quiescing/transitionCandidate만 모으므로 이미 버린 non-cooperative active writer를 기다리지 않고 success할 수 있다.
- 영향: 256건/4MiB overflow로 generation 전체를 닫았지만 effect는 계속 실행 중이고 lifecycle owner가 추적하지 않는다. teardown success가 quiescence를 뜻하지 않아 새 runtime과 old task가 겹칠 수 있다. `isCurrent()`는 commit만 fence한다.
- 기존 증거와 gap:
- `tests/unit/realtime/live-poll-handoff-coordinator.test.ts:166-215`는 non-cooperative overflow를 만들지만 이후 `close()`를 호출하지 않는다.
- `:466-493`의 close test는 reference를 잃기 전 active writer만 다룬다.
- ADR `VD-28...md:422-427,443-446`은 overflow full-generation fail-close와 actual settlement까지 DRAINING을 요구한다.
- 적용 패턴: **Retired Lease Registry + two-phase close**.
- 결정: `failClosed()`는 모든 lease를 abort하고 `retiredWriters`에 옮겨 admission을 닫는다. `close()`는 current+retired를 dedupe해 bounded하게 기다리고, timeout에는 `IDLE_TIMEOUT/CLOSE`를 반환하되 마지막 tail settlement까지 DRAINING을 유지한다.
### R-04 — Browser RPC bindings가 validate-then-use TOCTOU이다
- 심각도: **High**
- 확신도: **High**
- 근거:
- `src/contracts/browser-rpc.ts:256-285``define*`는 shallow spread/freeze만 하고 exact own key/data descriptor를 검사하지 않는다. extra/accessor property가 남는다.
- `validateBrowserRpcContractBindings():330-469`은 원본 registry/row를 읽어 `true`만 반환하며 installed snapshot을 만들지 않는다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:102-128`은 factory에서 검증한 뒤 `bind()` 때 원본 `dependencies.*`를 다시 읽는다.
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:644-687`도 validation용 runtime identity만 복사하며 operations/profiles/schema/mappers/encoders/transports 원본을 계속 사용한다.
- 영향: TypeScript `Readonly`는 runtime 보호가 아니다. factory 이후 mutation으로 replay policy, attempt/deadline, byte ceiling, mapper/transport selection을 validation과 다르게 만들 수 있다. operation/profile 객체의 extra property도 transport가 해석할 수 있다.
- 기존 증거와 gap: `tests/unit/browser-rpc/browser-rpc-contract.test.ts:173-202`는 raw invalid row를 재검증하지만 검증 후 mutation, accessor non-invocation, extra/symbol key 거절은 없다. realtime `result.test.ts:15-151`과 reconnect policy tests에는 exact descriptor snapshot 패턴이 이미 있다.
- 적용 패턴: **Parse/Validate/Install anti-corruption layer + immutable exact registry snapshot**.
- 결정:
1. factory 시작 시 registry own descriptors를 한 번 캡처하고 null-prototype exact map으로 복사/freeze한다.
2. operation/profile/encoder/schema/mapper/transport row를 허용 key의 own data property로 snapshot한다. getter, extra, symbol, revoked proxy는 composition-time `TypeError`다.
3. runtime과 transport call은 installed snapshot만 사용한다.
4. parse/map/encode/invoke function identity는 snapshot하되 row/registry를 재독하지 않는다.
### R-05 — WS byte cap 전에 전체 UTF-8 copy를 할당한다
- 심각도: **Medium**
- 확신도: **High**
- 근거: `src/adapters/realtime/websocket/websocket-protocol.ts:215-228`은 먼저 `utf8ByteLength(input)`을 호출하고 `:469-470``new TextEncoder().encode(input)`으로 전체 크기의 두 번째 buffer를 만든다.
- 영향: hostile/buggy server가 큰 text frame을 보냈을 때 negotiated cap으로 즉시 거절하지 못하고 cap 확인 전에 전체 UTF-8 copy를 추가 할당한다. browser가 원본 string을 materialize했다는 사실과 adapter의 추가 peak allocation은 별개다.
- 기존 증거와 gap: `tests/unit/realtime/websocket-protocol.test.ts:130-166`은 결과 코드와 multibyte bytes는 확인하지만 pre-allocation reject는 확인하지 않는다. `event-codec.ts:102-115``raw.length > maxBytes` 선검사를 이미 사용한다.
- 적용 패턴: **admission before allocation + bounded incremental accounting**.
- 결정: `input.length > maxFrameBytes`를 먼저 거절한다. 남은 입력은 allocation 없는 code-point loop로 UTF-8 bytes를 누적해 초과 즉시 중단하며 lone surrogate는 `TextEncoder`와 동일하게 replacement 3 bytes로 센다.
### R-06 — Browser RPC collaborator exception이 Result 경계를 탈출한다
- 심각도: **Medium**
- 확신도: **High**
- 근거:
- unary `src/adapters/browser-rpc/browser-rpc-runtime.ts:188-191,219-221,307-323``clock.now()`를 safe wrapper 없이 호출한다.
- `mapResponse():778-786,835-845``generationFence.isCurrent()``clock.now()`도 throw를 잡지 않는다.
- `raceWithin():1094-1120`은 abort listener를 붙인 뒤 `clock.sleep()` synchronous throw 또는 race 예외를 감싸는 `finally`가 없다.
- unary 전체에 outer `try/finally`가 없어 `linked.cleanup():198`은 정상 `finish()` 경로에서만 보장된다.
- 영향: application port가 `Promise<Result<...>>`/`AsyncIterable<Result<...>>` 대신 native rejection을 노출한다. clock/scope owner 실패 시 listener/timer cleanup과 observation도 빠질 수 있다.
- 기존 증거와 gap: standard `systemClock`, 정상 fence, generation change는 테스트하지만 throwing clock/fence와 listener balance는 없다. bounded poll/reconnect는 `safeNow`, `safeIsCurrent`, `finally` cleanup을 이미 사용한다.
- 적용 패턴: **Result boundary guard + RAII-style finally**.
- 결정: clock failure는 `SERVER_FAILURE/RPC_RUNTIME_DEPENDENCY_FAILED`, capture/isCurrent 실패는 fail-closed `SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`로 canonicalize한다. linked listener/timer는 단일 outer `finally`에서 정확히 한 번 해제한다.
## 5. 미조립/promotion blocker
### R-07 — `maxBufferedBytes` 집행 증거가 없다
- 심각도: **Medium, production promotion blocker**
- 확신도: **High**
- 확정 사실:
- `src/contracts/browser-rpc.ts:114-120,519-528``maxBufferedBytes`를 선언/검증한다.
- common runtime은 `src/adapters/browser-rpc/browser-rpc-runtime.ts:587-593`에서 yielded message count/per-message/aggregate만 센다.
- `src/adapters/browser-rpc/transport.ts:58-60`의 bare `AsyncIterable`에는 buffer admission/inspection contract가 없다.
- 문서로 확인한 현재 상태: `docs/architecture/protobuf-browser-transport-and-rest-gateway.md:42-61,363-366,381-399`는 selected transport/raw-byte cap/provider-browser conformance가 pending이라고 명시한다. 따라서 common runtime이 wire framing/internal buffer를 직접 집행하지 않는 것 자체는 현재 결함이 아니다.
- promotion 위험: callback/stock client가 consumer보다 빨리 frame을 쌓으면 common runtime이 item을 받기 전에 heap cap이 깨질 수 있다. `maxTotalResponseBytes`는 aggregate이고 `maxBufferedBytes`와 다른 backpressure 축이다.
- 적용 패턴: **transport conformance contract + enqueue-time backpressure admission**.
- 결정: concrete Connect/gRPC-Web transport가 enqueue 전에 `operation.maxBufferedBytes`, raw/decompressed ceiling을 집행하고 overflow 시 lease cancel + `RESPONSE_BODY_LIMIT`을 낸다는 conformance suite를 통과하기 전 bootstrap/product traffic을 금지한다. common runtime의 message/aggregate guard는 second line으로 유지한다.
## 6. 상태머신, protocol, framing, backpressure와 cleanup 결정
| 축 | 명시 결정 | 이유 |
|---|---|---|
| Common stream state | freshness `UNKNOWN/CURRENT/STALE/RESYNCING`와 lifecycle `OPEN/DRAINING/CLOSED`를 직교 축으로 둔다. timeout/abort 뒤 actual task가 남으면 DRAINING이다. | commit fence와 resource settlement는 다른 사실이다(R-02). |
| Reconnect | 기존 `IDLE/RUNNING/DRAINING/CLOSED`, 단일 retry owner, full jitter, exact bounded server hint, exact branded recovery proof를 유지한다. offline에는 retry timer를 두지 않고 protocol 자동 downgrade를 금지한다. | 구현/ADR/test가 일치한다. |
| Poll lease | 기존 single-flight `IDLE/RUNNING/DRAINING/CLOSED`, visible+online finite lease, one-request HTTP retry owner를 유지한다. | non-cooperative execute/apply를 이미 fence+track한다. |
| LIVE↔POLL handoff | Poll은 probe 동안 유일 authoritative writer다. old writer fence→abort→quiesce→checkpoint→buffer drain 뒤 LIVE를 활성화한다. overflow는 generation terminal이며 retired lease actual settlement까지 DRAINING이다. | silent overlap/lost update 방지(R-03). |
| SSE framing | strict UTF-8, blank-line terminated SSE, incomplete EOF discard, CURSOR일 때만 explicit `id`, exact status/media/same-origin 규칙을 유지한다. | tests/docs와 일치한다. |
| WS framing | text JSON + exact frame keys + duplicate-member/structure/uint64 검증을 유지한다. byte cap은 allocation 전에 집행한다. malformed/overflow는 whole generation close + snapshot recovery다. | classic WS에는 receive pause가 없고 delta drop은 안전하지 않다. |
| Browser RPC framing | common runtime은 logical message/terminal/failure만 받는다. Connect 5-byte envelope/EndStream과 gRPC-Web trailer authority는 concrete transport가 각각 소유하며 서로 추론/혼합하지 않는다. EOF alone은 success가 아니다. | provider-neutral layer와 wire semantics를 분리한다. |
| Backpressure | WS inbound/outbound와 `bufferedAmount`, handoff queues, Browser RPC transport buffer를 count+bytes로 admission한다. cap 초과는 silent drop/자동 상향 없이 terminal close/failure다. | state-bearing delta의 부분 유실은 복구 없이는 안전하지 않다. |
| Cancel/timer/listener | listener를 얻은 scope의 `finally`에서 제거하고 모든 sleep timer controller를 abort한다. non-cooperative task의 caller wait만 bounded하고 reference는 actual settlement까지 retain한다. | bounded response와 resource convergence를 함께 만족한다. |
| Error semantics | `QUEUE_OVERFLOW`=admission/backpressure와 recovery 필요, `IDLE_TIMEOUT`=handler/quiescence cleanup deadline, `APPLY_FAILED`=authority reject/throw/invalid result, `PROVIDER_UNAVAILABLE`=clock/host dependency 실패, `PROTOCOL_MISMATCH`=shape/framing 위반, `SCOPE_FENCED`=old generation. raw/native 원인은 노출하지 않는다. | retry/rollback/운영 대응을 원인별로 닫는다. |
## 7. 제안 인터페이스와 정확한 파일 작업
### 7.1 새/변경 interface signature
```ts
// src/adapters/browser-rpc/transport.ts
export type BrowserRpcStreamCancelReason =
| "CALLER_ABORT"
| "IDLE_TIMEOUT"
| "TOTAL_DEADLINE"
| "LIMIT_EXCEEDED"
| "CONTRACT_FAILURE"
| "CONSUMER_CLOSED";
export type BrowserRpcTransportStream = Readonly<{
frames: AsyncIterable<BrowserRpcStreamFrame>;
cancel(reason: BrowserRpcStreamCancelReason): void;
waitClosed(): Promise<void>;
}>;
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity & Readonly<{
invokeUnary?(call: BrowserRpcTransportCall): Promise<BrowserRpcUnaryTransportResult>;
openServerStream?(call: BrowserRpcTransportCall): BrowserRpcTransportStream;
}>;
```
```ts
// src/contracts/browser-rpc.ts
export type InstalledBrowserRpcContractBindings = Readonly<{
operations: Readonly<Record<string, BrowserRpcOperationV3>>;
profiles: Readonly<Record<string, BrowserRpcProviderProfile>>;
schemaCodecs: Readonly<Record<string, RuntimeSchemaCodec>>;
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
requestEncoders: Readonly<Record<string, BrowserRpcRequestEncoder>>;
runtimeBindings: Readonly<Record<string, BrowserRpcRuntimeBindingIdentity>>;
}>;
export function installBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): InstalledBrowserRpcContractBindings;
```
```ts
// src/adapters/browser-rpc/browser-rpc-runtime.ts
export type BrowserRpcRuntimeDependencies = Readonly<{
// existing registries/collaborators stay
streamCleanupTimeoutMs?: number; // default 2_000, implementation max 30_000
}>;
```
```ts
// src/application/ports/realtime/event-authority.ts
export type RealtimeStreamLifecycle = "OPEN" | "DRAINING" | "CLOSED";
export type RealtimeStreamInspection = Readonly<{
lifecycle: RealtimeStreamLifecycle;
// existing freshness/queue/dedupe/barrier fields unchanged
}>;
```
```ts
// src/adapters/realtime/stream-coordinator.ts
export type RealtimeStreamTaskLimits = Readonly<{
effectTimeoutMs: number;
recoveryTimeoutMs: number;
drainTimeoutMs: number;
}>;
export type RealtimeStreamCoordinatorDependencies = Readonly<{
// existing dependencies stay
clock?: ClockPort;
taskLimits: RealtimeStreamTaskLimits;
}>;
export type RealtimeStreamCoordinator = Readonly<{
// existing methods stay
close(): Promise<RealtimeResult<void>>;
}>;
```
```ts
// src/adapters/realtime/live-poll-handoff-coordinator.ts
export type LivePollHandoffState =
| "LIVE_ACTIVE"
| "POLL_ACTIVE"
| "LIVE_PROBING"
| "DRAINING"
| "CLOSED";
export type LivePollHandoffInspection = Readonly<{
// existing fields stay
drainingWriters: number;
}>;
```
### 7.2 정확한 생성/수정/삭제/이동 목록
**생성:** 없음. lifecycle/install type은 기존 owner 파일에 둔다. 이 리뷰 문서 `docs/reviews/adapters/02-realtime-and-browser-rpc.md`만 리뷰 산출물로 새로 생성했다.
**수정:**
1. `src/contracts/browser-rpc.ts` — exact descriptor snapshot installer와 installed type.
2. `src/adapters/browser-rpc/transport.ts` — explicit stream lease/cancel/closed receipt.
3. `src/adapters/browser-rpc/browser-rpc-runtime.ts` — installed snapshot만 사용, bounded stream cleanup/DRAINING, safe clock/fence, outer cleanup.
4. `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` — unavailable stream을 즉시 closed lease로 반환.
5. `src/adapters/browser-rpc/index.ts` — installed/stream lifecycle types export.
6. `src/application/ports/realtime/event-authority.ts` — stream lifecycle inspection.
7. `src/application/ports/realtime/index.ts``RealtimeStreamLifecycle` export.
8. `src/adapters/realtime/stream-coordinator.ts` — bounded task lease registry, lifecycle state, async close.
9. `src/adapters/realtime/live-poll-handoff-coordinator.ts` — retired writer set과 DRAINING convergence.
10. `src/adapters/realtime/index.ts` — lifecycle/limit types export.
11. `src/adapters/realtime/websocket/websocket-protocol.ts` — allocation-free bounded UTF-8 counter.
12. `tests/unit/browser-rpc/browser-rpc-contract.test.ts` — mutation/accessor/extra-key installer tests.
13. `tests/unit/browser-rpc/browser-rpc-runtime.test.ts` — non-cooperative stream, throwing clock/fence, cleanup balance tests와 fixture lease 전환.
14. `tests/unit/realtime/stream-coordinator.test.ts` — never-settling effect/recovery, DRAINING/async close tests.
15. `tests/unit/realtime/live-poll-handoff-coordinator.test.ts` — overflow 뒤 retired writer close test.
16. `tests/unit/realtime/websocket-protocol.test.ts` — oversize preflight/multibyte/lone-surrogate tests.
17. `docs/architecture/protobuf-browser-transport-and-rest-gateway.md` — stream lease, buffer owner, promotion evidence.
18. `docs/architecture/realtime-events-web-push-and-bounded-polling.md` — common stream/handoff DRAINING와 error semantics.
19. `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md` — actual-settlement lifecycle amendment.
**삭제:** 없음.
**이동:** 없음.
**의도적으로 변경하지 않음:** `src/bootstrap/optional-runtime-host.ts`는 제품/provider 선택 전 `null/UNAVAILABLE` 유지가 맞다. `src/application/ports/browser-rpc/browser-rpc.ts`의 app-facing API도 변경할 필요가 없다.
## 8. TDD 테스트 계획
먼저 아래 테스트를 실패시키고(red), 최소 구현 후 개별 green, 마지막에 전체 범위를 실행한다.
| 테스트 이름 | 입력/준비 | 기대 결과 |
|---|---|---|
| `bounds_non_cooperative_stream_cancel_and_completes_consumer` | Browser RPC stream의 `next()``waitClosed()`가 signal/cancel을 무시; idle deadline 진행 | caller iterator는 cleanup bound 안에 `REQUEST_TIMEOUT` 후 done; `cancel("IDLE_TIMEOUT")` 1회; lease DRAINING |
| `rejects_new_stream_while_prior_lease_is_draining` | 위 stream actual settlement 전 같은 transport에 두 번째 open | network side effect 없이 `SERVER_FAILURE/RPC_STREAM_DRAINING`; old settle 후 새 open 가능 |
| `consumer_break_cancels_and_bounds_stream_cleanup` | 첫 message 뒤 consumer `break`; close non-cooperative | `cancel("CONSUMER_CLOSED")`; generator return bounded; listener/timer 0 |
| `runtime_snapshots_bindings_before_later_mutation` | factory 뒤 원본 operation retry/deadline/profile/transport map mutation | execute는 installed snapshot만 사용; mutation이 의미 변경 불가 |
| `binding_installer_rejects_extra_and_accessor_keys_without_invoking_them` | operation/profile/registry에 getter, symbol, extra key | getter 호출 0; composition-time `TypeError` |
| `returns_canonical_failure_and_cleans_listener_when_clock_throws` | transport 전/후 `clock.now`/`sleep` synchronous throw; listener-counting signal | rejection 없음; 지정 `AppFailure`; listener/timer 0; observation 1회 |
| `fences_when_generation_fence_throws` | `capture` 또는 `isCurrent` throw | `SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`; mapped value 미commit |
| `keeps_stream_draining_until_non_cooperative_effect_actually_settles` | effect Promise never settles; fake clock가 effect/drain deadline 진행 | accept는 bounded `IDLE_TIMEOUT/APPLY`; `isCurrent=false`; DRAINING; 새 effect 0 |
| `bounds_non_cooperative_recovery_and_rejects_late_checkpoint` | recovery가 timeout 뒤 늦게 success checkpoint 반환 | bounded `IDLE_TIMEOUT/RECOVER`; late checkpoint 미commit; settle 후 STALE/recovery 필요 |
| `close_waits_for_all_tracked_stream_tasks_and_times_out` | effect와 recovery pending 중 close | controller 모두 abort; bound 뒤 `IDLE_TIMEOUT/CLOSE`; settlement까지 DRAINING, 이후 CLOSED |
| `close_after_active_queue_overflow_tracks_retired_writer` | handoff active effect never settles, queue cap 초과 후 close | overflow `QUEUE_OVERFLOW`; close 즉시 success 금지; bound 뒤 `IDLE_TIMEOUT`; late settle 시 draining 0/CLOSED |
| `rejects_oversized_ascii_frame_before_utf8_copy` | `"x".repeat(maxFrameBytes + 1)` | `FRAME_TOO_LARGE`; full-size byte copy 경로 없음 |
| `counts_multibyte_and_lone_surrogate_like_text_encoder` | ASCII/2-byte/3-byte/surrogate pair/lone surrogate 경계 | 기존 byte 의미와 동일한 exact accept/reject |
| `transport_conformance_enforces_max_buffered_bytes_before_enqueue` | push/callback fake transport가 consumer 정지 중 cap+1 byte enqueue | enqueue 거부, lease cancel, raw/message 미노출; provider suite 없이는 promotion 금지 |
실행 명령:
```sh
corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-contract.test.ts
corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-runtime.test.ts
corepack pnpm exec vitest run tests/unit/realtime/stream-coordinator.test.ts
corepack pnpm exec vitest run tests/unit/realtime/live-poll-handoff-coordinator.test.ts
corepack pnpm exec vitest run tests/unit/realtime/websocket-protocol.test.ts
corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4
corepack pnpm run check:types:app
corepack pnpm run check:types:test
corepack pnpm run check:realtime-boundaries
corepack pnpm run check:realtime-boundaries:fixture
```
제품 transport 선택 시 별도 필수 evidence:
```sh
# 실제 provider contribution이 script 이름과 target browser matrix를 고정해야 한다.
corepack pnpm run test:browser-rpc-transport-conformance
corepack pnpm run test:browser-rpc-target-browsers
```
## 9. compatibility, migration, rollback
Migration 순서:
1. 새 tests와 lifecycle inspection을 먼저 추가한다. runtime은 미조립 상태라 production traffic 영향은 없다.
2. `createBrowserRpcRuntime`은 raw input을 받아 내부에서 installer를 호출해 기존 caller signature를 유지한다. mutation에 의존한 fixture는 composition-time 오류로 고친다.
3. 한 migration release 동안 기존 `AsyncIterable` transport를 internal adapter로 `BrowserRpcTransportStream`에 감쌀 수 있다. deprecated wrapper의 `waitClosed``iterator.return()` settlement이고 common cleanup bound가 이를 감싼다. provider 선택 전 legacy branch를 제거한다.
4. common stream `close(): Promise<Result>`로 바꾸고 모든 test/향후 composition owner는 `await`한다. 기존 fire-and-forget 호출은 typecheck로 식별한다.
5. handoff retired set을 도입하고 `DRAINING`에는 writer/probe admission을 막는다.
6. WS byte counter는 wire/error shape가 같아 독립적으로 먼저 적용할 수 있다.
7. actual provider/browser/load evidence와 R-01~R-07 closure 전까지 `AVAILABLE_NOT_COMPOSED`를 유지한다. bootstrap composition은 마지막 단계다.
Compatibility 결정:
- application-facing Browser RPC unary/stream port shape는 유지한다.
- wire protocol, frame shape, failure kind, retry owner는 바꾸지 않는다.
- `RealtimeStreamInspection.lifecycle`는 additive다. `close` 반환형은 source-compatible fire-and-forget일 수 있으나 lifecycle correctness를 위해 owner는 await하도록 migration한다.
- exact installer가 과거 extra/accessor/mutable row를 거절하는 것은 의도된 fail-closed tightening이다.
Rollback 순서:
1. traffic admission을 `DISABLED`로 전환한다.
2. connection/runtime lifecycle을 `DRAINING`으로 만들고 actual leases settlement 또는 bounded failure를 기록한다.
3. 살아 있는 lease를 버리고 즉시 이전 runtime을 열지 않는다.
4. source commit을 revert하되 installed snapshot과 allocation-before-cap 수정은 보안/정확성 강화이므로 우선 유지한다.
5. cursor/checkpoint를 합성하지 않고 authoritative snapshot recovery를 수행한다.
6. SSE↔WS, Connect↔gRPC-Web↔REST, live↔Poll을 장애 때문에 즉석 자동 전환하지 않는다. fallback은 registry/ADR에 선언된 새 semantic operation/generation으로만 시작한다.
## 10. 유지할 좋은 설계
1. `RealtimeFailure`/`AppFailure`로 native error, raw close reason, payload, cursor, provider metadata를 경계 밖에 내보내지 않는다.
2. realtime result/registry의 exact own-data snapshot, accessor 거절, immutable recovery checkpoint object identity.
3. fixed same-origin SSE/WS endpoint, URL/subprotocol credential 금지, exact media/subprotocol 검증.
4. SSE, WebSocket, Browser RPC stream, Poll을 서로 다른 delivery/protocol 의미로 유지하고 자동 downgrade/replay하지 않는다.
5. WS inbound/outbound FIFO, count+byte+`bufferedAmount` ceiling과 overflow whole-generation recovery.
6. reconnect의 단일 retry owner, full jitter, hint not-before, finite budget, stable proof 뒤 reset, post-abort DRAINING.
7. Poll의 visible/online finite single-flight lease와 non-cooperative execute/apply tracking.
8. LIVE↔POLL의 one-writer generation, probe buffer, activation 전 quiescence/checkpoint.
9. SSE parser의 incremental strict UTF-8, incomplete EOF discard, bounded reader cancellation.
10. unavailable Browser RPC adapter와 optional runtime host의 `null/UNAVAILABLE`; 조용한 network fallback이 없다.
## 11. false-positive 방지 대조
| 의심 항목 | 최종 판정과 근거 |
|---|---|
| Realtime/Browser RPC가 bootstrap에 조립되지 않음 | 결함 아님. `optional-runtime-host.ts:91-92,143`와 architecture docs가 제품 선택 전 미조립을 요구한다. |
| Common Browser RPC가 Connect/gRPC-Web raw framing을 decode하지 않음 | 결함 아님. `protobuf...md:57-61,381-399`상 concrete transport 책임이다. R-07은 이 미완료 상태를 무시한 promotion만 막는다. |
| Reconnect가 offline 동안 timer 없이 기다림 | 의도. ADR과 `realtime-reconnect-coordinator.test.ts:370-406`가 explicit online signal을 요구한다. |
| healthy session `waitClosed()`에 deadline 없음 | 의도. ADR은 abort 후 drain만 bounded하고 active close receipt는 authoritative하게 기다린다. |
| WS overflow에서 일부 event drop 대신 connection close | 의도. ADR과 `websocket-connection.test.ts:579-651`은 receive pause 없는 classic WS에서 snapshot recovery를 택한다. |
| SSE 204와 incomplete EOF | 각각 terminal/no reconnect와 incomplete discard가 맞다. fetch/parser tests가 확인한다. |
| exact recovery object identity | 의도된 capability token이다. `event-authority.ts:17-23`, reconnect/stream barrier tests가 clone/forgery를 막는다. |
| Handoff overflow 자체 | 이미 fail-close한다. R-03은 overflow 판정이 아니라 그 직후 retired tail reference를 잃는 cleanup bug다. |
| Transport에 effect timeout이 이미 있음 | transport caller는 bounded해도 common `state.tail`은 settle하지 않는다. R-02는 commit fence가 아니라 retained task/liveness 문제다. |
## 12. baseline 검증
1. `corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4`
- exit 0, **16 files / 185 tests passed**.
2. `corepack pnpm run check:realtime-boundaries`
- exit 0, `Realtime boundaries: PASS (src)`.
3. `check:realtime-boundaries:fixture` wrapper는 이 sandbox에서 child-process 제한 때문에 진단 없이 exit 1이었다. 같은 allowed/forbidden child 명령을 직접 실행해 allowed exit 0, forbidden exit 1과 세 규칙 `UNSELECTED_REALTIME_RUNTIME_COMPOSED`, `PRESENTATION_INTERVAL_OWNER`, `NATIVE_REALTIME_API_OUTSIDE_ADAPTER`를 확인했다. adapter defect로 세지 않는다.
4. `test:realtime-removal`의 별도 복제에서 범위 tests는 통과했으나 저장소 전체 baseline의 CI authority count drift, 누락 `.npmrc`, child `spawnSync ... EPERM`, architecture report 문제로 최종 exit 1이었다. 검토 범위 failure 증거로 사용하지 않는다.
## 13. 구현 우선순위
1. R-03 retired writer tracking: 국소적이고 확정적인 cleanup bug다.
2. R-02 common stream task lifecycle: SSE/WS 양쪽 liveness 기반을 닫는다.
3. R-01 Browser RPC explicit stream lease와 bounded cleanup.
4. R-04 installed immutable bindings, 이어 R-06 exception/cleanup guard.
5. R-05 allocation-before-cap 제거.
6. R-07 concrete transport conformance는 provider 선택과 함께 수행하되 완료 전 production composition을 금지한다.
@@ -0,0 +1,565 @@
# Storage / browser-file adapters 구현 준비 코드 리뷰
검토 저장소: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`
검토 범위: `src/adapters/storage/**`, `src/adapters/browser-files/**`, `src/adapters/browser-file-storage/**`, `src/adapters/cache-storage/**` 및 직접 연결된 application port, contract, bootstrap, test, architecture/operations 문서
검토 방식: 구현 파일을 수정하지 않은 read-only 리뷰. 아래 line은 현재 worktree 기준이다.
## 0. 결론과 우선순위
| ID | 판정 | 심각도 | 확신도 | 요약 |
| --- | --- | --- | --- | --- |
| STO-01 | 확정 결함 | **Critical** | 높음 | OPFS pre-commit 보상 cleanup 실패/취소를 무시하고 journal을 rollback한다. 늦게 도착한 generation-only cleanup이 후속 write의 같은 logical generation을 삭제할 수 있고, 그렇지 않아도 복구 근거와 quota를 잃는다. |
| STO-02 | 확정 결함 | **High** | 높음 | browser-managed download는 `baseOrigin`으로 상대 URL을 검증하지만 원문 `href``document.baseURI`로 실행한다. `<base>`가 있으면 검증한 origin과 실제 navigation origin이 달라진다. |
| STO-03 | 확정 결함 | **Medium** | 높음 | public cache policy가 `allowedVaryHeaderNames`를 허용하면서 response allowlist에서 `vary`를 제거하는 모순을 허용한다. stage는 성공할 수 있지만 저장 variant가 충돌하고 activation이 실패한다. |
| STO-04 | 확정 결함 | **Medium** | 높음 | 동일 manifest 재-stage가 marker와 count만 신뢰한다. marker 작성 뒤 browser eviction/부분 손상된 candidate를 성공으로 재사용하여 self-heal하지 못한다. activation은 fail-closed지만 staging success 의미가 약해진다. |
| STO-05 | 확정 결함 | **Medium** | 높음 | cache `activateRelease`/`cleanupOwned`가 네트워크 fetch를 쓰지 않는데도 공통 availability guard가 `fetcher`를 필수로 요구한다. offline activation/rollback/cleanup이 불필요하게 `UNSUPPORTED`가 된다. |
| STO-06 | 확정 계약 위반 | **Medium** | 높음 | IndexedDB codec migration은 commit transaction 내부의 연속 native operation 사이에 monotonic deadline을 재확인하지 않는다. 문서/port의 cooperative duration contract보다 오래 실행될 수 있다. |
| STO-07 | hardening 후보 | **Medium** | 높음 | OPFS worker envelope에 protocol version/response kind가 없고 client response parser가 `{requestId, ok}`만 검사한다. page/worker release 불일치와 malformed response를 `INCOMPATIBLE`로 닫을 수 없다. |
| STO-08 | 브라우저 검증 필요 | **Low** | 중간 | enhanced open/save picker 함수를 `Window`가 아니라 options 객체에 bind한다. Web IDL brand check가 있는 engine에서는 `Illegal invocation` 가능성이 있으나 현재 unit fake는 이를 검증하지 않는다. 실제 browser test로 먼저 확정한다. |
| GAP-01 | 문서화된 미구현 | **High readiness gap** | 높음 | preview pixel/decoded-byte/frame/decode probe가 없다. VD-15가 이미 `DESIGNED_NOT_IMPLEMENTED`로 명시했으므로 regression으로 오인하지 말고, untrusted image preview 조립의 promotion blocker로 취급한다. |
| GAP-02 | 문서화된 미구현 | **High readiness gap** | 높음 | Cache inspect/cleanup은 cursor/count/deadline 없이 전체 owned namespace를 순회한다. VD-15가 정확히 현 상태를 기록한다. |
| GAP-03 | 문서화된 미구현 | **High readiness gap** | 높음 | origin-wide pressure/write-admission/GC, OPFS/Cache forward migration, real OPFS preflight가 아직 없다. 기존 per-store primitive를 완성 증거로 삼지 않는다. |
즉시 순서는 **STO-01 write 차단/수정 → STO-02 canonical URL 실행 → STO-03~05 cache 불변식 → STO-06/07 hardening**이다. GAP 항목은 해당 capability를 제품에 선택·조립하기 전에 별도 promotion gate로 구현한다.
## 1. 누락 없는 범위 inventory: 책임과 의존성
### 1.1 `browser-file-storage`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/browser-file-storage/index.ts` | browser data 공통 Result와 StorageManager adapter barrel export | 내부 두 모듈만 export. 경계가 작고 유지 대상. |
| `src/adapters/browser-file-storage/result.ts` | native 예외를 closed `BrowserDataFailure`로 정규화하고 안전한 observation 제공 | `application/ports/browser-file-storage/shared.ts`; raw path/name/message 비노출, observer 예외 격리가 좋다. |
| `src/adapters/browser-file-storage/storage-manager-adapter.ts` | `estimate/persisted/persist` snapshot, pressure bucket, user-activation-bound persistence 요청 | storage durability port/result. estimate를 예약량으로 오인하지 않고 irreversible `persist()` truth를 보존한다. origin coordinator는 의도적으로 없음(GAP-03). |
### 1.2 `browser-files`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/browser-files/browser-file-picker.ts` | native input baseline 및 enhanced system picker, activation/abort/dismissal, vault capture | file port, vault, policy registry. baseline/enhancement 분리가 좋다. `showOpenFilePicker.bind(options)`는 STO-08. |
| `src/adapters/browser-files/browser-file-policy-registry.ts` | composition-owned selection/inspection/preview/download policy 등록·identity 확인·hard-cap reduction | file contracts, `file-policy.ts`. `WeakSet`/identity binding과 frozen snapshot을 유지한다. |
| `src/adapters/browser-files/browser-file-vault.ts` | transient native File/handle 보관, opaque ref, inspection receipt, bounded range/source | file port/shared/result/policy registry. File이 application 경계를 넘지 않고 receipt가 exact file/profile에 묶이는 설계가 좋다. |
| `src/adapters/browser-files/create-browser-file-runtime.ts` | vault/picker/preview/download를 선택적으로 조립하고 일괄 dispose | 위 adapters 및 application contracts. optional capability를 제품 선택 없이 bootstrap에 암묵 조립하지 않는 점을 유지. preview 조립 전 GAP-01 gate 필요. |
| `src/adapters/browser-files/download-delivery-adapter.ts` | browser handoff, foreground save stream, bounded object URL download, integrity/progress/cancellation | file/authorized-download ports, policy registry, object URL lease, Result. STO-02와 STO-08; stream close truth/backpressure는 유지. |
| `src/adapters/browser-files/file-observer.ts` | file-safe observation DTO를 공통 browser observation으로 변환 | shared port/result. raw filename/ref 비노출 유지. |
| `src/adapters/browser-files/file-policy.ts` | policy input validation, MIME/extension/signature/hard byte caps, immutable resolved policy | file/shared contracts. closed allowlist 및 absolute ceiling을 유지. |
| `src/adapters/browser-files/index.ts` | browser-file public exports | 위 모듈. native implementation detail export 확장을 피한다. |
| `src/adapters/browser-files/object-url-lease.ts` | 중앙 object URL lease cap/registry, transient preview, idempotent revoke/dispose | file/shared contracts, vault, policy registry. URL lifecycle은 좋으나 `create()` 256-303은 decode probe 없이 URL을 발급(GAP-01). |
### 1.3 `cache-storage`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/cache-storage/index.ts` | public cache policy/adapter barrel | optional public static cache만 export; private/range cache로 일반화하지 않는다. |
| `src/adapters/cache-storage/public-cache-policy.ts` | same-origin/public-only release 정책, URL/header/query/size/retention hard limits | cache ports/shared. STO-03 policy cross-field invariant 누락. 기본 policy에는 `vary`가 있어 기본-path 테스트는 통과한다. |
| `src/adapters/cache-storage/public-response-cache-adapter.ts` | manifest canonicalization/digest, anonymous fetch, bounded body 검증, candidate marker-last staging, explicit activation, exact lookup/reverify, owned cleanup/inspect | cache ports/result/policy, CacheStorage/fetch/Crypto/Web Lock snapshot. STO-03~05 및 GAP-02. private/auth/opaque/206 거절과 current+previous 보존은 유지. |
### 1.4 `storage` root / IndexedDB
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/storage/browser-storage-adapter.ts` | registry key별 local/session/memory 저장, TTL, failure overlay/tombstone, quota fallback | `storage-keys`, `StoragePort`, codec, diagnostics. strict registry와 stale persistent suppression을 유지. adjacent physical-key migration/sweep는 문서상 미구현. |
| `src/adapters/storage/browser-storage-codec.ts` | bounded exact JSON envelope, exotic/accessor/unsafe-key/cycle/depth/node 거절 | 독립 codec. prototype pollution/JSON silent coercion 방어가 좋다. |
| `src/adapters/storage/indexeddb/index.ts` | IndexedDB runtime/maintenance/governance/migration export | native IDB type을 application port 밖으로 내보내지 않는 구조 유지. |
| `src/adapters/storage/indexeddb/indexeddb-failure.ts` | IDB/DOM failure를 closed browser failure로 변환 | common Result. raw native detail 비노출 유지. |
| `src/adapters/storage/indexeddb/indexeddb-governance.ts` | opaque dataset scope/physical DB identity 및 frozen policy binding | indexeddb/shared ports. account/business ID를 physical name에 쓰지 않는 양방향 binding 유지. |
| `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | post-open codec migration 및 idempotency receipt prune, keyset checkpoint, budget/revision fencing | IndexedDB port/types/failure/governance. async transform outside tx, row+sidecar+budget+checkpoint atomic commit은 좋다. STO-06 및 temporal drain lease 개선 후보. |
| `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | additive-only contiguous DDL planner/validator | indexeddb types. destructive DDL 거절 유지. |
| `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | generic repository open/read/query/CAS/delete, idempotency, retention, lifecycle purge, connection lifecycle | indexeddb ports/types/governance/failure/migrations. transaction `complete` truth, versionchange close, shared open/abort isolation, exact budgets 유지. lifecycle proof는 현재 문서 계약(형식 검증 후 폐기)과 일치하므로 결함으로 분류하지 않았다. |
| `src/adapters/storage/indexeddb/indexeddb-types.ts` | adapter-local codec/query/schema/dependency contracts | application indexeddb/shared ports. `isOldWriterDrainConfirmed()` boolean은 provider가 전체 window를 보장한다는 문서 전제; lease형으로 강화 권고. |
### 1.5 `storage/opfs`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/storage/opfs/browser-opfs-runtime.ts` | OPFS support inspection 및 journal/worker/byte-store composition | OPFS ports, journal, byte-store, policy, worker client. property probe를 real readiness로 주장하지 않음(GAP-03). |
| `src/adapters/storage/opfs/index.ts` | OPFS runtime/journal/policy/protocol/client exports | optional capability barrel. |
| `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | logical object/journal/budget/chunk refcount의 IDB authority; begin/files-ready/commit/rollback/reconcile pages | OPFS ports, IDB failure, policy. journal+object+budget CAS atomicity가 좋다. STO-01 수정에서 incomplete row를 cleanup 확인 전 삭제하지 않아야 한다. |
| `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | logical journal과 physical worker를 saga로 조정, put/open/remove, reconcile/policy maintenance | OPFS/shared ports, journal, worker gateway, policy. STO-01의 journal/physical compensation ordering 결함 위치. |
| `src/adapters/storage/opfs/opfs-policy.ts` | root/lock/chunk/object/RPC/reconcile/GC hard limits 및 scope validation | shared/opfs ports. opaque physical path와 absolute caps 유지. |
| `src/adapters/storage/opfs/opfs-worker-client.ts` | request correlation/timeout/abort/transferable chunking, worker gateway, streamed reads | protocol/policy/shared Result. STO-01의 untracked abort cleanup 및 STO-07의 shallow response parse. |
| `src/adapters/storage/opfs/opfs-worker-protocol.ts` | page↔DedicatedWorker request/response union 및 gateway contract | OPFS/shared ports. STO-07; protocol version/kind/effect certainty 추가 필요. |
| `src/adapters/storage/opfs/opfs-worker-runtime.ts` | DedicatedWorker OPFS physical layout, lock lease, immutable chunk, manifest/staging receipt, abort/finalize/remove/GC | protocol/policy/OPFS ports/Web Lock/Crypto. STO-01의 generation-only cleanup과 lease release 순서. sync handle `finally close` 등은 유지. |
### 1.6 직접 연결 경계와 조립
- `src/application/ports/browser-file-storage/{shared,file,indexeddb-port,opfs-ports,cache-storage-ports,storage-durability-port}.ts`와 barrel을 읽었다. native `File/Blob/Cache/IDB*/Response/ReadableStream`을 application으로 노출하지 않는 포트 방향은 올바르다.
- `src/application/ports/storage-port.ts`, `src/contracts/storage-keys.ts`를 대조했다. Web Storage는 registry-owned typed key만 허용한다.
- `src/bootstrap/runtime-adapters.ts:17,260-266`은 Web Storage만 기본 조립한다. file/IndexedDB/OPFS/Cache가 없는 것은 문서의 `AVAILABLE_NOT_COMPOSED`와 일치하며 결함이 아니다.
## 2. 구체적 findings와 구현 방법
### STO-01 — OPFS 보상 cleanup이 journal보다 늦게 완료되거나 실패할 때 후속 generation 삭제 가능
**근거와 실패 연쇄**
1. 새 logical generation은 현재 committed generation+1로 재사용된다: `src/adapters/storage/opfs/opfs-byte-store-adapter.ts:158-195`(특히 183-195).
2. `preparePut` 또는 `markFilesReady` 실패 시 `rollbackBestEffort`를 호출한다: 같은 파일 `222-242`.
3. `rollbackBestEffort``worker.cleanupTransaction(..., callerSignal)``BrowserDataResult`를 검사하지 않고, 곧바로 `journal.rollback`을 호출한다: `833-845`. caller signal이 이미 abort되었으면 cleanup RPC는 시작조차 못 한다.
4. worker client도 prepare 단계 실패/timeout 때 별도의 un-signaled `ABORT_PUT`을 보내지만 timeout/실패를 삼키며 “journal reconciliation이 반복한다”고 가정한다: `src/adapters/storage/opfs/opfs-worker-client.ts:190-198,229-307`. 그런데 3번이 journal row를 삭제한다.
5. physical cleanup은 staging receipt에서 `(scope, objectId, generation)`만 읽어 해당 generation 디렉터리를 삭제한다: `src/adapters/storage/opfs/opfs-worker-runtime.ts:584-607,717-761`. manifest/path에 transaction-unique physical generation identity가 없다.
6. `abortPut`은 mutation lease를 먼저 release한 뒤 generation 삭제를 수행한다: 같은 파일 `501-529`(특히 519-527). `cleanupTransaction` 자체도 mutation lease를 얻지 않는다.
따라서 T1 cleanup RPC가 timeout 뒤 worker에서 계속되거나 T1 `ABORT_PUT`이 늦게 실행되는 동안 coordinator가 T1 journal을 rollback하면 T2가 같은 object의 동일 logical generation을 다시 시작할 수 있다. 늦은 T1 cleanup은 T2의 물리 디렉터리를 삭제할 수 있다. 삭제까지 겹치지 않아도 journal 부재로 stale staging/immutable chunks가 영구 잔존해 quota pressure를 만든다.
**패턴과 수정**
- cross-API ACID를 주장하지 말고 **durable saga + transactional outbox/compensation state**를 유지한다.
- “physical cleanup confirmed” 전에는 PREPARING/FILES_READY journal row와 budget reservation을 rollback하지 않는다. cleanup은 caller signal과 분리한 composition-owned bounded signal을 사용한다.
- worker client 내부에서 fire-and-forget abort를 중복 발행하지 않는다. coordinator가 `abortPreparedPut()` 한 번을 소유하고 결과가 `CLEANED|ALREADY_CLEAN`일 때만 journal rollback한다. timeout/crash는 `EFFECT_UNKNOWN`으로 남겨 reconcile한다.
- 장기적으로 **transaction-unique physical generation/fencing token**을 path, receipt, manifest, journal에 저장한다. stale T1 cleanup은 T1 token 경로만 삭제하고 T2를 건드릴 수 없어야 한다.
- cleanup/abort는 같은 origin mutation Web Lock을 physical 삭제 완료까지 보유한다. lease를 먼저 release하지 않는다.
**권장 새/변경 signature**
```ts
declare const opfsPhysicalGenerationBrand: unique symbol;
export type OpfsPhysicalGenerationId = string & {
readonly [opfsPhysicalGenerationBrand]: "OpfsPhysicalGenerationId";
};
export type OpfsPreparedObjectV2 = Readonly<{
physicalSchemaVersion: 2;
physicalGenerationId: OpfsPhysicalGenerationId;
descriptor: DurableObjectDescriptor; // logical generation은 그대로 유지
chunks: readonly OpfsChunkReference[];
}>;
export type OpfsCleanupEffect =
| Readonly<{ kind: "CLEANED" | "ALREADY_CLEAN" }>
| Readonly<{ kind: "EFFECT_UNKNOWN" }>;
export interface OpfsWorkerGateway {
abortPreparedPut(request: Readonly<{
scope: OpfsStorageScope;
transactionId: string;
physicalGenerationId: OpfsPhysicalGenerationId;
signal?: AbortSignal; // coordinator-owned compensation signal만 전달
}>): Promise<BrowserDataResult<OpfsCleanupEffect>>;
}
```
P0에서는 v1 read를 유지하면서 새 write만 v2/token path로 쓴다. `EFFECT_UNKNOWN`은 성공 Result로 취급하지 말고 journal 유지 + `OBJECT_RECONCILE`를 반환한다.
**기존 테스트와 false-positive 방지**
- `tests/unit/opfs-byte-store.test.ts:504-540`의 “keeps a committed journal row for reconciliation when cleanup fails”는 logical commit 뒤 finalize 실패만 검증한다. PREPARING/FILES_READY 보상 실패를 다루지 않는다.
- `tests/unit/opfs-worker-runtime.test.ts:232-408`은 BEGIN cancel/APPEND-vs-ABORT serialization/authority isolation을 검증하지만, journal rollback 뒤 다른 worker/context가 재사용한 generation에 대한 늦은 cleanup을 만들지 않는다.
- `indexeddb-opfs-journal.ts:997-1008`의 unique `logicalKey` index는 **journal row가 남아 있는 동안** T2를 막는다. 바로 그 row를 조기에 삭제하는 것이 문제이므로 이 index가 반증이 아니다.
### STO-02 — 검증 URL과 실제 download navigation URL의 base가 다름
**근거**
- `safeBrowserManagedTarget``new URL(href, new URL(baseOrigin))`으로 protocol/origin/query/hash를 검증한다: `src/adapters/browser-files/download-delivery-adapter.ts:1248-1269`.
- 성공 후 canonical `URL.href`가 아니라 원문 문자열을 host로 넘긴다: `435-459`.
- 실제 anchor는 `anchor.href = href`라서 document의 current `baseURI`를 기준으로 해석한다: `47-68`.
예: configured `baseOrigin=https://app.example`, capability `href="downloads/report"`, document에 `<base href="https://evil.example/">`가 있으면 검증은 app origin을 통과하지만 실제 anchor는 evil origin으로 향한다. capability receipt의 server binding이 있더라도 adapter의 same-origin 정책 주장이 깨진다.
**패턴과 수정**
- **Parse once / canonicalize then execute** 패턴을 적용한다. validator가 boolean이 아니라 canonical absolute URL을 반환하고 정확히 그 값을 handoff한다.
- cross-origin을 허용하는 별도 policy에서도 username/password/hash/query 규칙을 적용한 canonical string만 실행한다.
```ts
type ResolvedBrowserManagedTarget = Readonly<{ absoluteHref: string }>;
function resolveBrowserManagedTarget(
href: string,
baseOrigin: string,
policy: Readonly<{ allowCrossOrigin: boolean; allowQuery: boolean }>,
): BrowserDataResult<ResolvedBrowserManagedTarget>;
```
`context.options.host.handoff(target.value.absoluteHref, fileName)`로 변경한다. 더 엄격한 선택은 capability resolver가 absolute `https:` URL만 발행하게 하고 상대 URL을 거절하는 것이다.
**기존 테스트 대조**
- `tests/unit/browser-file-download.test.ts:222-255`는 raw 상대 path가 host에 그대로 전달된다고 고정한다. 이 기대값을 canonical `https://app.example/downloads/artifact-1`로 바꿔야 한다.
- `257-285`는 이미 absolute evil/query URL 거절만 검증해 `<base>` 불일치를 잡지 못한다.
### STO-03 — Vary 허용/보존 policy가 모순될 수 있음
**근거**
- policy validation은 vary name이 request allowlist에 포함되는지만 본다: `src/adapters/cache-storage/public-cache-policy.ts:110-141`, 특히 `132-134`. response allowlist에 `vary`가 있는지는 확인하지 않는다.
- network response의 Vary는 exact request headers와 검증한다: `src/adapters/cache-storage/public-response-cache-adapter.ts:1140,1228-1262`.
- 이후 `unknownResponseHeaderAction="STRIP"`이면 response allowlist에 없는 `vary`를 제거하고(`1264-1280`), 제거된 headers로 Cache에 put한다(`472-479`). 동일 URL variant가 충돌한다.
- activation은 모든 entry를 다시 digest/type/Vary 검증하므로 `592-617`에서 fail-closed한다. 따라서 현재 증거로 private-data disclosure를 주장하면 과장이다. 실제 영향은 impossible candidate에 대한 stage 성공, variant loss, activation/rollback availability 저하다.
**수정**
```ts
if (
policy.allowedVaryHeaderNames.length > 0 &&
!policy.allowedResponseHeaderNames.includes("vary")
) throw new TypeError("Vary must be preserved when variants are enabled.");
```
방어를 겹치려면 `sanitizedResponseHeaders`가 검증된 `Vary`를 generic strip과 무관하게 반드시 보존하도록 한다. **Policy cross-field invariant + fail-fast composition** 패턴이다.
`tests/unit/public-response-cache.test.ts:1030-1155`는 default response allowlist가 이미 `vary`를 포함(`public-cache-policy.ts:54-63`)하므로 이 custom-policy 조합을 놓친다.
### STO-04 — existing cache marker만 확인하는 stage idempotence
`src/adapters/cache-storage/public-response-cache-adapter.ts:424-442`는 cache name이 있고 marker의 release ID/digest/count가 맞으면 모든 cached response의 존재/내용을 보지 않고 stage 성공을 반환한다. marker-last는 첫 stage crash에는 강하지만 marker 이후 browser pressure eviction, manual deletion, partial corruption에는 충분하지 않다. activation이 `592-617`에서 재검증하므로 unsafe publish는 막지만, 같은 manifest로 restage해도 손상 candidate를 복구하지 못한다.
**수정:** `verifyReleaseCandidate(cache, normalized, policy, crypto, signal)`를 factor하고 stage fast path와 activation이 공유한다. 기존 candidate가 missing/mismatch면 owned candidate만 삭제하고 network restage한다. verification 중 abort/unknown error면 active pointer는 건드리지 않고 candidate를 유지 또는 정책대로 삭제하되 성공을 반환하지 않는다. 이는 **idempotent repair, marker as claim not evidence** 패턴이다.
### STO-05 — cache mutation availability가 fetcher에 과결합
`mutationAvailability`는 storage+fetcher+lock 모두를 요구한다: `public-response-cache-adapter.ts:1643-1651`. stage 호출 `392-396`에는 맞지만, fetch하지 않는 activate `538-542`와 cleanup `695-699`에도 같은 guard를 쓴다. 이미 검증된 release를 offline에서 활성화/rollback하거나 quota recovery cleanup하는 기능을 차단한다.
**수정:** operation별 capability guard로 분리한다.
```ts
function stageAvailability(d: Dependencies): BrowserFailureResult | null;
// cacheStorage + mutationLock + fetcher
function localMutationAvailability(
d: Dependencies,
operation: "CACHE_ACTIVATE" | "CACHE_DELETE",
): BrowserFailureResult | null;
// cacheStorage + mutationLock
```
**Dependency segregation**을 적용하고 recovery도 `ONLINE_ONLY`가 아니라 실제 operation에 맞는 `RETRY/REHYDRATE`로 유지한다.
### STO-06 — IndexedDB migration commit 중 duration budget 재확인 없음
- port는 async storage operation 사이 cooperative duration budget을 명시한다: `src/application/ports/browser-file-storage/indexeddb-port.ts:81-89`.
- docs도 각 native operation 사이 monotonic deadline 확인을 요구한다: `docs/architecture/browser-file-and-origin-storage.md:611-615`.
- transform phase는 clock을 확인한다: `src/adapters/storage/indexeddb/indexeddb-maintenance.ts:940-966`.
- 그러나 `commitPrepared`의 read/write/budget/sidecar/checkpoint chain은 `969-1233` 동안 clock을 호출하지 않는다. 최대 500 rows의 IDB callbacks가 invocation deadline 이후에도 계속될 수 있다.
**수정:** transaction을 시작하기 전 composition-owned `minimumCommitReserveMs`를 확인하고, prepared row 수를 budget에 맞춰 더 작게 제한한다. transaction을 연 뒤에는 각 record 시작 시 monotonic deadline을 확인하여 아직 어떤 write도 시작하지 않은 다음 record에서 transaction을 정상 종료하고 last-safe checkpoint까지만 commit한다. 이미 시작한 record의 row/sidecar/budget은 원자 완료하거나 tx 전체 abort해야 하며 부분 truth를 반환하면 안 된다. clock failure는 transaction abort + `UNAVAILABLE`다.
`tests/unit/indexeddb-maintenance.test.ts:562-597`은 transform 시작 전 budget exhaustion만 검증하므로 commit callback 중 clock advance 케이스를 추가한다.
### STO-07 — OPFS worker protocol version/strict response correlation 부재
- request/response envelope에 `protocolVersion`과 echoed `kind`가 없다: `src/adapters/storage/opfs/opfs-worker-protocol.ts:15-137`.
- worker는 requestId+known kind만 1차 검사한다: `opfs-worker-runtime.ts:1643-1669`.
- client는 `{requestId:string, ok:boolean}`만 검사한다: `opfs-worker-client.ts:666-675`. 실패 object/failure code/kind를 strict validate하지 않고 `response.failure.code`를 사용(`171-184`)한다.
- VD-15는 real preflight에서 protocol/schema mismatch를 `INCOMPATIBLE`로 닫으라고 한다: `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md:476-484`.
**수정:** `OPFS_WORKER_PROTOCOL_VERSION = 2 as const`; 모든 request/response에 version과 kind를 넣고 pending request가 expected kind를 보관한다. closed failure-code set과 per-kind value parser를 적용한다. 먼저 `HELLO/CAPABILITIES` handshake에서 supported physical schema와 protocol version을 교환하고 mismatch면 write/read를 금지한다. generic cancel은 초기 correctness 필수가 아니다. PUT은 effect certainty가 필요한 명시적 `ABORT_PUT`; read/verify RPC는 client-side abandon으로 충분하며, 자원 최적화가 필요할 때만 `CANCEL_REQUEST { targetRequestId }`를 추가한다.
### STO-08 — picker function receiver binding은 browser test로 먼저 확정
- open: `src/adapters/browser-files/browser-file-picker.ts:431-436`
- save/open-authorized callbacks: `src/adapters/browser-files/download-delivery-adapter.ts:201-235`
platform `Window.showOpenFilePicker/showSaveFilePicker`를 options object에 bind할 이유가 없고 Web IDL receiver brand check 가능성이 있다. 다만 현재 코드가 host facade 콜백을 의도했을 수도 있어 확정 전 browser matrix가 필요하다. 우선 실제 `window.showOpenFilePicker`를 전달한 capability test를 추가한다. 실패가 재현되면 API를 `SystemPickerHost { open; save? }`로 만들고 composition에서 올바른 owner에 bind한 host만 주입한다. arbitrary callback(`openAuthorizedSource`, integrity factory)은 bind하지 않고 함수 snapshot 그대로 호출한다.
## 3. 명시적 architecture 결정
### Transaction / crash recovery
- IndexedDB 한 domain mutation은 한 native transaction으로 row, retention sidecar, budget, idempotency receipt/checkpoint를 commit한다. request success가 아니라 transaction `complete`가 성공 truth다.
- IDB와 OPFS/Cache 사이에는 atomic transaction이 없다. OPFS는 journal-authoritative durable saga다. phase는 monotonic이고 physical side effect가 불명확하면 incomplete journal을 유지한다.
- compensation은 원 caller abort와 분리된 bounded signal로 실행한다. cleanup success가 확인될 때만 journal/budget rollback; unknown이면 reconcile owner에게 넘긴다.
- committed object를 in-place repair하지 않는다. 새 physical token/generation에 copy/verify 후 logical CAS publish한다.
### Migration / rollback
- 독립 version 축(IDB DDL, record codec, OPFS journal, OPFS physical, Cache control/release)을 합치지 않는다.
- 공통 순서는 expand → old-writer drain lease → bounded migrate/copy → atomic publish → N-1 observe/rollback window → 별도 contract release다.
- schema downgrade, whole DB/root/cache delete, read-time unbounded rewrite는 금지한다.
- IDB `isOldWriterDrainConfirmed()`는 현재 provider가 전체 migration/contract window를 보장한다는 문서 전제라 현 결함은 아니다. 다음 interface로 temporal guarantee를 실행 가능하게 강화한다:
```ts
export interface OldWriterDrainLease {
readonly leaseId: string;
readonly validUntilEpochMs: number;
assertValid(signal?: AbortSignal): Promise<BrowserDataResult<void>>;
release(): Promise<void>;
}
export interface IndexedDbDataMigrationPolicy<WireValue> {
acquireOldWriterDrainLease(input: Readonly<{
migrationId: string;
targetCodecVersion: number;
scope: IndexedDbDatasetScope;
signal?: AbortSignal;
}>): Promise<BrowserDataResult<OldWriterDrainLease>>;
// migrate/measure 기존 계약 유지
}
```
lease는 batch commit 직전 재검증하고, product rollout owner는 migration 완료 후 rollback/contract window까지 global fence를 유지한다.
### Quota / pressure / eviction
- StorageManager estimate는 rough signal일 뿐 free-space reservation이 아니다. 실제 `QuotaExceededError`가 authority다.
- per-dataset hard budget은 그대로 유지하고, origin coordinator는 Web Lock leader 한 개가 hysteresis(`70/85%`, 하향 `65/80%` 2회)를 적용한다.
- GC 순서: incomplete candidate/stale staging → expired reconstructable → grace 지난 unreferenced chunk → inactive public release → confirmed synced copy → 중지. user-authored/unsynced는 자동 삭제 금지.
- 기본 invocation 100 items/5s, 절대 500/30s. cursor는 owner/policy/release epoch에 binding한다.
- quota retry는 실제 quota rollback, 동일 idempotency/revision/digest, external publish 없음, GC가 실제 제거/pressure 하향, 새 admission token 조건을 모두 만족할 때 정확히 1회만 허용한다.
### Lease / destructive authority
- OPFS mutation Web Lock은 physical delete/cleanup 완료까지 보유한다. transaction-unique physical token이 stale cleanup fencing이다.
- object URL은 registry lease로만 만들고 persistence/log/analytics/global cache에 넣지 않는다. release/dispose는 idempotent다.
- IndexedDB lifecycle authority는 현재 문서대로 composition callback의 short-lived proof를 형식 검증 후 즉시 폐기한다. OPFS와 동일한 replay 방지가 제품 threat model에 필요하면 provider+atomic consumer의 one-shot lease로 별도 강화하되 application caller에게 token을 노출하지 않는다.
### Object URL / preview
- 현재 encoded size/signature/media/active-content denylist는 유지한다.
- 제품 untrusted image preview를 선택하기 전 object URL 발급 **앞**에 bounded header parser + native decode probe를 둔다. static JPEG/PNG/WebP/AVIF 등 명시 allowlist만; SVG/PDF/HTML/XML과 animated image는 별도 격리/re-encode capability가 없으면 attachment-only다.
```ts
export interface PreviewSafetyProbe {
inspect(input: Readonly<{
file: File; // adapter-local only
mediaType: string;
maxEncodedBytes: number;
maxPixels: number;
maxDecodedBytes: number;
maxFrames: number;
deadlineMs: number;
signal: AbortSignal;
}>): Promise<BrowserDataResult<Readonly<{
width: number;
height: number;
frameCount: number;
decodedBytes: number;
}>>>;
}
```
parser 산술은 overflow-safe여야 하고 native `createImageBitmap` 결과는 항상 `close()`. timeout/abort/failure면 `createObjectURL`을 호출하지 않는다.
### Stream / cancellation
- application boundary는 `ByteSource.stream(signal): AsyncIterable<BrowserDataResult<Uint8Array>>`를 유지한다. 첫 failure에서 producer/reader/writer를 모두 닫고 raw DOMException/EOF 성공으로 바꾸지 않는다.
- save stream은 backpressure를 따르고 `writer.close()` 완료 truth가 늦은 abort보다 우선한다. partial destination append/resume로 주장하지 않는다.
- Blob/object URL buffer는 hard cap 아래 fallback에서만 허용한다. public cache는 exact length/digest 검증 때문에 bounded buffer를 유지하되 cap을 넘으면 reader cancel.
- pre-start abort는 side effect 0. IDB 중간 abort는 tx abort. irreversible prompt/persist/close가 완료된 뒤에는 platform truth가 이긴다.
- worker mutation timeout은 effect unknown이지 rollback 확인이 아니다. read RPC는 응답을 버릴 수 있지만 mutation은 journal/explicit abort protocol로 종결한다.
### Worker protocol
- versioned handshake, request kind echo, requestId+kind correlation, strict discriminated parser, closed error set을 채택한다.
- wrong version/schema는 `INCOMPATIBLE` health로 write/read 금지. 이를 failure surface에 노출할 필요가 있으면 `BrowserDataFailureCode``INCOMPATIBLE`을 추가하고 모든 exhaustive mapper/fixture를 함께 갱신한다. 단순 `UNAVAILABLE` retry loop로 숨기지 않는다.
- generic `CANCEL_REQUEST`는 read CPU/resource 최적화로 후순위. PUT correctness는 transaction-scoped `ABORT_PUT`과 durable journal이 담당한다.
### Cache security / eviction
- anonymous same-origin public GET, credentials omit, exact query/request headers/Vary/type/length/digest만 cache한다. auth/private/no-store/no-cache/opaque/redirect/206/range는 계속 금지한다.
- verified marker는 모든 entries 이후 마지막에 쓰되 marker만 증거로 믿지 않는다. stage reuse와 activation/lookup에서 response를 재검증한다.
- current+verified previous release를 유지하고 rollback도 동일 activation validation을 다시 통과한다.
- partial eviction/miss는 `STORAGE_EVICTED` 또는 integrity failure로 fail-closed하고 network rehydrate한다. owned prefix 밖 cache나 user data는 절대 삭제하지 않는다.
## 4. 정확한 파일 변경 계획
### Phase 0 — 즉시 correctness/security fix
**수정**
- `src/application/ports/browser-file-storage/opfs-ports.ts`: v1|v2 prepared object read union, `OpfsPhysicalGenerationId`, journal row physical identity.
- `src/adapters/storage/opfs/opfs-worker-protocol.ts`: explicit abort/cleanup effect, protocol v2 envelope/kind correlation.
- `src/adapters/storage/opfs/opfs-worker-client.ts`: fire-and-forget duplicate abort 제거, strict response parser, coordinator-owned confirmed abort.
- `src/adapters/storage/opfs/opfs-worker-runtime.ts`: tokenized physical path/receipt/manifest, cleanup lock 보유, exact token delete.
- `src/adapters/storage/opfs/opfs-byte-store-adapter.ts`: cleanup result 확인 전 journal rollback 금지; independent compensation deadline; unknown effect reconcile.
- `src/adapters/storage/opfs/indexeddb-opfs-journal.ts`: v2 prepared/journal validation, incomplete row 유지 및 migration metadata.
- `tests/unit/opfs-byte-store.test.ts`, `tests/unit/opfs-worker-runtime.test.ts`, `tests/unit/indexeddb-opfs-journal.test.ts`: 아래 race/crash tests.
- `src/adapters/browser-files/download-delivery-adapter.ts`: boolean validator를 canonical resolver로 변경; absolute URL 실행.
- `tests/unit/browser-file-download.test.ts`: canonical URL 및 hostile base regression.
- `src/adapters/cache-storage/public-cache-policy.ts`: Vary preservation cross-field invariant.
- `src/adapters/cache-storage/public-response-cache-adapter.ts`: stage candidate full verify/self-repair, availability 분리.
- `tests/unit/public-response-cache.test.ts`: custom Vary, damaged candidate, no-fetcher activate/cleanup.
### Phase 1 — bounded lifecycle / protocol / preview promotion
**생성**
- `src/application/ports/browser-file-storage/origin-storage-lifecycle-port.ts`
- `src/adapters/storage/origin-storage-lifecycle-coordinator.ts`
- `tests/unit/origin-storage-lifecycle-coordinator.test.ts`
- `src/adapters/browser-files/browser-image-preview-probe.ts`
- `tests/unit/browser-image-preview-probe.test.ts`
- `src/adapters/storage/opfs/opfs-physical-migration.ts`
- `tests/unit/opfs-physical-migration.test.ts`
- `tests/fixtures/origin-storage/opfs-v1-populated.ts`
- `tests/fixtures/origin-storage/cache-v1-populated.ts`
**수정**
- `src/application/ports/browser-file-storage/index.ts`: 새 lifecycle port export.
- `src/application/ports/browser-file-storage/file.ts`: preview safety policy/result를 native-free 형태로 추가하거나 probe를 adapter-internal dependency로 유지.
- `src/application/ports/browser-file-storage/cache-storage-ports.ts`: bounded maintenance page/cursor input.
- `src/application/ports/browser-file-storage/indexeddb-port.ts`, `src/adapters/storage/indexeddb/indexeddb-types.ts`: drain lease contract.
- `src/adapters/storage/indexeddb/indexeddb-maintenance.ts`: commit reserve/deadline checks 및 lease revalidation.
- `src/adapters/browser-files/object-url-lease.ts`, `src/adapters/browser-files/create-browser-file-runtime.ts`: probe success 전 URL 생성 금지.
- `src/adapters/cache-storage/public-response-cache-adapter.ts`: cursor/deadline bounded inspect/cleanup.
- `src/adapters/storage/opfs/browser-opfs-runtime.ts`: real worker/lock/journal/write-read-delete-cleanup preflight 조립 hook.
- `tests/browser-capabilities/{browser-files,opfs-runtime,public-cache-storage,indexeddb-runtime}.spec.ts``opfs-test.worker.ts`: real engine evidence.
- `docs/architecture/browser-file-and-origin-storage.md`, `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md`, `docs/operations/browser-file-storage-recovery.md`, `docs/operations/client-cache-and-storage-recovery.md`: 상태를 구현 후에만 `AVAILABLE_NOT_COMPOSED`로 승격.
**삭제/이동**: 없음. v1 reader/fixtures와 old cache prefix는 rollback window 종료 전 삭제하지 않는다. barrel 재배치도 불필요하다.
### Cache bounded port signature
```ts
declare const publicCacheCursorBrand: unique symbol;
export type PublicCacheMaintenanceCursor = string & {
readonly [publicCacheCursorBrand]: "PublicCacheMaintenanceCursor";
};
export type PublicCacheMaintenanceInput = Readonly<{
maxCaches?: number; // default 100, absolute 500
maxDurationMs?: number; // default 5_000, absolute 30_000
cursor?: PublicCacheMaintenanceCursor;
signal?: AbortSignal;
}>;
export type PublicCacheMaintenancePage = Readonly<{
inspectedCaches: number;
deletedCaches: number;
retainedCaches: number;
unreadableCaches: number;
nextCursor: PublicCacheMaintenanceCursor | null;
moreAvailable: boolean;
deadlineReached: boolean;
}>;
cleanupOwned(input?: PublicCacheMaintenanceInput):
Promise<BrowserDataResult<PublicCacheMaintenancePage>>;
inspectOwned(input?: PublicCacheMaintenanceInput):
Promise<BrowserDataResult<PublicCacheMaintenancePage>>;
```
cursor는 caller-readable cache name이 아니며 owned prefix, active pointer epoch, policy fingerprint에 서명/opaque binding한다. stale cursor는 `STALE_RESULT`.
## 5. TDD 계획: 이름, 입력, 기대 결과
| 테스트 이름 | 핵심 입력/fixture | 기대 결과 |
| --- | --- | --- |
| `keeps PREPARING journal when compensating cleanup is aborted or unavailable` | `preparePut` failure; caller signal aborted; worker cleanup `ABORTED/UNAVAILABLE` | `journal.rollback` 미호출, reservation/journal 유지, `OBJECT_RECONCILE` recovery; 후속 same object begin conflict |
| `delayed stale cleanup cannot delete a reused logical generation` | T1 generation 1 abort RPC 지연; T2 generation 1 v2 token으로 commit; T1 cleanup resume | T1 token path만 제거; T2 verify/open bytes 성공; T2 manifest/chunks 유지 |
| `holds the OPFS mutation lease until exact physical cleanup completes` | cleanup delete promise를 gate하고 concurrent begin 시도 | delete 완료 전 T2 lease 미획득; release 후 진행 |
| `does not roll back journal after an unknown worker mutation effect` | cleanup RPC timeout 후 worker operation pending | incomplete journal 유지; reconcile가 exact transaction을 종결 |
| `rejects mismatched OPFS worker protocol and response kind` | v1 response 또는 requestId는 같지만 wrong kind/malformed failure | `INCOMPATIBLE`/closed failure; pending request success로 resolve하지 않음; write side effect 0 |
| `hands off the canonical URL validated against baseOrigin` | `href="downloads/a"`, baseOrigin app, document base evil | host receives `https://app.example/downloads/a`; evil URL never assigned |
| `rejects a policy that enables variants but strips Vary` | allowed vary `accept-language`, allowed response headers without `vary`, STRIP | composition `TypeError`, Cache/fetch side effect 0 |
| `preserves Vary for every stored custom variant` | en/ko same URL with exact request header | stage+activate+both exact match succeed; stored response has Vary |
| `restages an evicted entry even when the release marker remains` | successful stage 후 one asset delete, same manifest stage again | missing asset re-fetch; all entries reverify; success only after repair |
| `activates and cleans a prestaged cache without a fetcher` | seeded valid cache/pointer, cacheStorage+lock, no fetcher | activate/cleanup success; no network call |
| `stops codec migration commit at the cooperative deadline` | fake clock advances during IDB record callbacks, prepared N rows | only last atomically safe prefix+checkpoint commit; `MORE`, `budgetExhausted`; no orphan sidecar/budget delta |
| `requires an old-writer drain lease to remain valid before batch commit` | lease valid at acquire, expires before commit | tx write 0/abort; `BLOCKED`; checkpoint unchanged |
| `rejects oversized raster dimensions before object URL creation` | small encoded PNG with huge width/height or overflow dimensions | `LIMIT_EXCEEDED/POLICY_REJECTED`; `createObjectURL` 0 calls |
| `closes a decoded bitmap on preview abort and failure` | probe aborts after native decode begins | bitmap `close` once, URL 0, closed `ABORTED` |
| `rejects animated and truncated preview containers` | animated WebP/GIF, truncated PNG/JPEG | fail before URL, no leaked decoder resource |
| `pages cache cleanup by count deadline and opaque cursor` | 700 owned caches + foreign caches; max 100/5s | <=100 inspected, foreign untouched, `moreAvailable`, bound cursor; repeated pages converge |
| `rejects cache maintenance cursor after active pointer epoch changes` | page1 cursor 후 activation | `STALE_RESULT`, delete 0 |
| `retries quota failure exactly once only after productive GC` | reconstructable write quota fail, GC deleted >0, same idempotency/digest | attempt 2 최대 한 번; second fail no third; user-authored untouched |
| `uses the real Window receiver for enhanced system pickers` | actual browser `window.showOpenFilePicker/showSaveFilePicker` facade (feature-gated) | supported engine에서 illegal invocation 없음; dismissal closed outcome |
### 실행 명령
```bash
# 가장 빠른 red/green loop
corepack pnpm exec vitest run \
tests/unit/opfs-byte-store.test.ts \
tests/unit/opfs-worker-runtime.test.ts \
tests/unit/indexeddb-opfs-journal.test.ts \
tests/unit/browser-file-download.test.ts \
tests/unit/public-response-cache.test.ts \
tests/unit/indexeddb-maintenance.test.ts \
tests/unit/browser-image-preview-probe.test.ts \
tests/unit/origin-storage-lifecycle-coordinator.test.ts
# 정적 경계
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm check:browser-file-storage-boundaries
corepack pnpm lint
# 실제 browser/storage semantics
corepack pnpm exec playwright test --config playwright.capabilities.config.ts \
tests/browser-capabilities/browser-files.spec.ts \
tests/browser-capabilities/indexeddb-runtime.spec.ts \
tests/browser-capabilities/opfs-runtime.spec.ts \
tests/browser-capabilities/public-cache-storage.spec.ts \
tests/browser-capabilities/storage-manager.spec.ts
# 전체 회귀
corepack pnpm test:unit
corepack pnpm test:browser-file-storage-removal
corepack pnpm verify:documentation
```
## 6. 데이터 호환성, migration, deployment, rollback 순서
1. **즉시 containment:** 제품에 OPFS v1 write가 조립돼 있다면 kill switch로 신규 write를 read-only/export-required로 전환한다. read/export와 journal reconcile는 유지한다. file/IDB/OPFS/cache가 template bootstrap 기본 조립이 아니라는 사실은 영향 범위를 줄이지만 product-specific composition을 확인해야 한다.
2. **N expand release:** journal DDL을 additive upgrade하고 v1+v2 `OpfsPreparedObject` reader를 배포한다. worker protocol v2 handshake를 먼저 넣되 v1 data read는 지원한다. v2 physical path는 unique token을 포함하고 새 write만 v2로 쓴다.
3. **old writer drain:** 모든 N-1 page/worker가 write를 중단했다는 release/lease evidence를 확인한다. BroadcastChannel hint만으로 판단하지 않는다. v2 write traffic은 SHADOW/canary부터 연다.
4. **resume/reconcile:** PREPARING/FILES_READY v1 journal을 bounded하게 처리한다. cleanup effect가 불명확하면 row를 삭제하지 않는다. logical committed v1은 authority이며 in-place 수정하지 않는다.
5. **copy-on-write migration:** v1 committed object → v2 staging/token path → bounded chunk read/copy → manifest/tree digest verify → journal generation/fencing CAS publish. publish 전 crash는 v1, publish 후 crash는 v2가 authority다.
6. **Cache migration:** old active verified release를 byte rewrite하지 말고 새 prefix/control schema에 network restage → full verify → explicit activation. current+previous와 old prefix를 rollback/grace window 동안 유지한다.
7. **Web Storage:** current keys는 registry `DISCARD` semantics를 유지한다. adjacent migration이 제품에 필요할 때만 exact owned old physical key를 read-once/validate/write-current/delete-old한다. 전체 localStorage sweep 금지.
8. **Canary observation:** multi-tab/worker timeout, crash between every phase, partial eviction, quota fault, N-1 read-only/online-only fixture를 통과한다. user-authored bytes export/sync path도 확인한다.
9. **Rollback:** traffic admission과 새 writer부터 끈다. schema/database version을 내리지 않는다. compatible N reader 또는 N-1 online-only/read-only bundle로 전환하고, OPFS는 publish authority에 따라 v1/v2 source를 선택한다. Cache는 검증된 previous release로 같은 activate protocol을 실행한다.
10. **Contract release:** 모든 active/rollback clients drain, grace/authority evidence, historical fixtures 후에만 v1 physical generation/old cache prefix를 bounded cursor cleanup한다. DB/root/cache blanket delete는 하지 않는다.
## 7. 유지해야 할 좋은 설계
- closed `BrowserDataResult`, safe recovery vocabulary, observer exception 격리 및 PII/path/name 비노출.
- File policy가 composition-owned immutable identity이고 selection/inspection/preview/download receipt가 exact file/profile에 binding되는 구조.
- native input baseline과 optional enhanced picker 분리, user activation 전에 await하지 않는 규칙, dismissal과 failure 구분.
- 중앙 object URL lease cap, idempotent revoke/dispose, typed Blob, active-content denylist.
- `ByteSource` chunk별 Result/cancellation, download backpressure, close 완료 truth, bounded object URL fallback.
- Web Storage의 typed registry, physical key versioning, strict exact JSON codec, TTL, quota memory overlay와 tombstone.
- IndexedDB의 opaque physical identity/governance binding, additive-only planner, transaction-complete semantics, CAS/idempotency/retention/budget atomicity, versionchange late-close.
- OPFS의 IDB logical authority, phase journal, immutable digest chunks/refcount, hard budget reservation, fail-closed staging GC, no user-readable physical paths.
- Cache의 anonymous public-only same-origin policy, exact query/header/Vary/type/length/digest, marker-last candidate, explicit activation, current+previous retention, owned-prefix-only cleanup, read/activate 재검증.
- optional adapters를 bootstrap에서 자동 조립하지 않고 `AVAILABLE_NOT_COMPOSED`로 남긴 현재 composition posture.
## 8. 기존 테스트·문서 대조와 false-positive 경계
### 실행한 기존 검증
다음 명령을 이 리뷰 중 실행했고 **7 files / 105 tests 전부 통과**했다.
```bash
corepack pnpm exec vitest run \
tests/unit/opfs-byte-store.test.ts \
tests/unit/opfs-worker-runtime.test.ts \
tests/unit/public-response-cache.test.ts \
tests/unit/browser-file-download.test.ts \
tests/unit/indexeddb-maintenance.test.ts \
tests/unit/indexeddb-runtime.test.ts \
tests/unit/storage-registry.test.ts --reporter=default
```
이는 finding이 현재 green suite가 보호하지 않는 interleaving/custom-policy/browser-base case임을 뜻하며, 기존 behavior가 전반적으로 깨졌다는 뜻은 아니다.
### 반증/과장 방지 표
| 의심 항목 | 기존 증거 | 최종 판단 |
| --- | --- | --- |
| OPFS commit 뒤 finalize cleanup 실패 | `opfs-byte-store.test.ts:504-540`가 COMMITTED row 보존 검증 | 보호됨. STO-01은 **commit 전 cleanup 실패/늦은 RPC + generation reuse**로 좁힘. |
| OPFS concurrent operations | `opfs-worker-runtime.test.ts:232-408`가 lock wait cancel, APPEND/ABORT, authority isolation 검증 | 같은 worker의 active put 일부는 보호됨. journal 조기 rollback 후 cross-context late cleanup은 미검증. |
| Cache Vary가 곧 private leak | activation/lookup이 response를 재검증(`public-response-cache-adapter.ts:592-617,341-365`) | 직접 disclosure 주장은 철회. stage success/variant loss/activation availability 결함으로 Medium. |
| Cache 기본 policy Vary | default response allowlist에 `vary` 포함(`public-cache-policy.ts:54-63`), unit `1030-1155` green | 기본은 보호됨. custom policy cross-field invariant만 결함. |
| damaged cache가 active로 publish | activation full reverify | publish는 fail-closed. STO-04는 idempotent stage/self-repair contract. |
| Web Storage schema mismatch | `storage-registry.test.ts:231-244`가 current physical key의 old envelope discard 검증 | 보호됨. old **physical key** sweep/adjacent migration은 문서상 미구현이며 현재 작은 preference의 readiness gap. |
| IndexedDB transaction success/abort | `indexeddb-runtime.test.ts:319-380`가 commit failure rollback과 abort 검증 | 보호됨. STO-06은 migration commit-loop duration budget에 한정. |
| IndexedDB old-writer drain이 전혀 없음 | maintenance test `269-301`, docs `604-609`가 provider confirmation을 전제 | 현 계약상 provider 책임이므로 결함으로 세지 않음. temporal lease는 enforceability 강화. |
| preview decode safety가 몰래 누락 | `browser-file-and-origin-storage.md:360-365`, VD-15 `19-31,574+`, runbook `96-108`가 미구현을 명시 | regression 아님. 제품 preview promotion blocker(GAP-01). |
| Cache unbounded cleanup이 발견되지 않은 bug | VD-15 `486-515`, runbook `382-429`가 정확히 명시 | known `DESIGNED_NOT_IMPLEMENTED` readiness gap(GAP-02). |
| origin pressure/migration coordinator 부재 | VD-15 `19-31,90-103`, `browser-file-storage-recovery.md:10-14` | known gap. 기존 per-store maintenance를 coordinator로 오인하지 않는다. |
| optional adapters가 bootstrap에 없음 | `runtime-adapters.ts:260-266`; docs status `AVAILABLE_NOT_COMPOSED` | 의도된 skeleton posture, 결함 아님. |
| picker receiver | unit tests가 모두 arrow/fake callback을 사용 | 확정 증거 부족. STO-08은 browser test 선행의 낮은 심각도 hypothesis로 격리. |
## 9. 리뷰 범위 밖으로 확장하지 않은 항목
- Service Worker lifecycle, private/range cache, persistent directory/file handles, Range resumable download는 문서상 별도 `NOT_SELECTED`/`DESIGNED_NOT_IMPLEMENTED` capability다. public cache/file adapter에 섞어 고치지 않는다.
- application/product dataset, schema, rollout authority가 없으므로 optional IndexedDB/OPFS/Cache를 현재 default bootstrap에 새로 조립하지 않는다.
- 전체 origin eviction은 모든 IndexedDB/OPFS/Cache metadata가 함께 사라질 수 있어 client-only로 완전 판별할 수 없다. server rehydrate/export UX와 generation/session authority가 필요하다.
---
최종 권고: STO-01은 production composition이 하나라도 있으면 release blocker로 취급한다. STO-02는 작은 canonicalization patch로 즉시 닫을 수 있다. Cache 세 항목은 동일 변경 묶음으로 TDD하고, VD-15 gap들은 상태 문서를 먼저 바꾸지 말고 executable unit+browser evidence와 rollback fixture가 생긴 후에만 승격한다.
@@ -0,0 +1,405 @@
# Adapter Review — Browser Transfer
> 검토 기준: `develop` / `4dc033c` (2026-08-13)
>
> 범위: `src/adapters/browser-transfer/**`, 직접 연결된 application port, unit test, `docs/architecture/presigned-transfer-and-image-cdn.md`
## 결론
브라우저 전송 계열은 URL·header·subscription material을 application/presentation에서 차단하고, identity capability와 strict decoder를 사용하는 방향이 좋다. 특히 presigned single-use vault, multipart checkpoint CAS, image preset registry와 private descriptor 서명 검증은 유지해야 한다.
다만 실제 조합 전에 해결해야 할 P1 항목이 세 개 있다.
1. presigned download는 `open()`에서 이미 fetch와 timeout을 시작하지만 반환된 source에는 `close()`가 없다. 호출자가 stream을 늦게 열거나 열지 않으면 정상 API 사용만으로 body/timeout 자원이 방치된다 (`BT-PRE-01`).
2. IndexedDB checkpoint partition 삭제는 `BLOCKED`를 반환한 뒤에도 native `deleteDatabase()`가 늦게 commit될 수 있다. 반환 결과가 실제 effect certainty를 표현하지 못한다 (`BT-UP-03`).
3. presigned capability wire envelope에는 top-level protocol literal이 없다. 이미 아키텍처 문서가 요구한 `PRESIGNED_TRANSFER_V1`을 실제 request/response decoder가 아직 강제하지 않는다 (`BT-PRE-02`).
파일 크기만을 이유로 나누면 안 되지만, `resumable-upload-runtime.ts` 2,196줄과 `image-cdn-runtime.ts` 1,340줄은 각각 state transition, I/O orchestration, retry, persistence, presentation projection을 동시에 소유한다. characterization test를 먼저 고정한 뒤 State Machine·Saga·Strategy 경계로 분리하는 것이 안전하다.
## 판정 기준
| 표기 | 의미 |
| --- | --- |
| P1 | 조합 또는 배포 전에 수정. 결과 거짓 보고, 보안/정합성, 자원 수명주기 결함 |
| P2 | 다음 리팩터링 묶음에서 수정. 계약 모호성, 실패 격리, 유지보수 위험 |
| P3 | 동작을 고정한 뒤 정리. 테스트 seam, 중복, 가독성 |
| `VERIFIED_DEFECT` | 현재 코드 경로만으로 재현 가능한 결함 |
| `CONTRACT_GAP` | provider/consumer 간 의미가 타입이나 decoder에 충분히 고정되지 않음 |
| `REFACTOR` | 현재 외부 동작은 보존하면서 내부 책임을 재배치 |
| `PLANNED_GAP` | 기존 아키텍처 문서가 이미 미구현으로 선언한 항목. 현재 구현의 회귀로 계산하지 않음 |
| `KEEP` | 의도와 테스트가 일치하므로 변경하지 않음 |
## 전체 파일 판정
| 모듈 | 현재 역할 | 판정 | 후속 항목 |
| --- | --- | --- | --- |
| `browser-transfer/index.ts` | 하위 capability export | KEEP | public export 증가는 각 capability 계획에서만 수행 |
| `presigned/index.ts` | presigned public surface | KEEP | `BT-PRE-04`에서 vault issuer 노출만 축소 검토 |
| `presigned/incremental-sha256.ts` | streaming SHA-256 | KEEP | WebCrypto `digest()`로 바꾸면 전체 buffering이 되므로 교체 금지 |
| `presigned/presigned-capability-http-provider.ts` | BFF capability 발급, strict decode | CONTRACT_GAP | `BT-PRE-02`, `BT-PRE-03`, `BT-X-01` |
| `presigned/presigned-capability-vault.ts` | identity capability 보관/폐기 | REFACTOR | `BT-PRE-04` |
| `presigned/presigned-transfer-executor.ts` | GET stream/PUT part 실행 | VERIFIED_DEFECT | `BT-PRE-01`, `BT-PRE-03`, `BT-X-01` |
| `resumable-upload/checkpoint-schema.ts` | durable schema guard | KEEP | schema V1 golden fixture 유지 |
| `resumable-upload/fetch-json-transport.ts` | bounded JSON control transport | VERIFIED_DEFECT | `BT-UP-01`, `BT-UP-02`, `BT-X-01` |
| `resumable-upload/http-control-plane-adapter.ts` | operation별 wire decoder | KEEP/REFACTOR | runtime 분리 뒤 decoder만 남김 |
| `resumable-upload/index.ts` | resumable public surface | KEEP | facade 호환 유지 |
| `resumable-upload/indexeddb-checkpoint-store.ts` | scope-bound CAS store/admin | VERIFIED_DEFECT | `BT-UP-03` |
| `resumable-upload/presigned-upload-part-executor.ts` | multipart와 presigned bridge | VERIFIED_DEFECT | `BT-UP-04` |
| `resumable-upload/resumable-upload-runtime.ts` | session state/retry/part scheduling/commit | REFACTOR | `BT-UP-05`, `BT-UP-06` |
| `resumable-upload/runtime-policy.ts` | hard bound snapshot | KEEP | 값 변경은 contract migration으로만 수행 |
| `resumable-upload/upload-byte-source.ts` | stream/range source snapshot과 hashing | KEEP/REFACTOR | runtime에서 source preparation Strategy로 주입 |
| `resumable-upload/upload-cancellation-channel.ts` | best-effort cross-context cancel hint | KEEP | backend/CAS가 authority라는 주석과 동작 유지 |
| `resumable-upload/upload-mutation-lock.ts` | Web Lock exclusive mutation | PLANNED_GAP | `BT-UP-07` |
| `image-cdn/README.md` | 안전한 composition 예제 | KEEP | resolve signal 결정 반영 필요 |
| `image-cdn/browser-image-probe.ts` | bounded fetch/header/static decode probe | VERIFIED_DEFECT | `BT-IMG-02` |
| `image-cdn/image-cdn-policy.ts` | origin/preset/hard-limit registry | KEEP | composition-owned identity reference 유지 |
| `image-cdn/image-cdn-runtime.ts` | asset acceptance, signature, URL/projection | REFACTOR | `BT-IMG-01`, `BT-IMG-03` |
| `image-cdn/image-header-metadata.ts` | PNG/JPEG/WebP/AVIF static header parser | KEEP | 별도 fuzz/golden corpus로 보호; 작은 parser로 임의 분해 금지 |
| `image-cdn/p256-image-capability-verifier.ts` | P-256 P1363 verifier | KEEP | key overlap contract 유지 |
| `image-cdn/index.ts` | image public surface | KEEP | descriptor provider가 생길 때만 export 확장 |
직접 연결 경계도 다음과 같이 대조했다: `src/application/ports/browser-transfer/authorized-download.ts`, `src/application/ports/browser-transfer/image-cdn.ts`, `src/application/ports/browser-transfer/presigned-transfer.ts`, `src/application/ports/browser-transfer/resumable-upload.ts`, barrel `src/application/ports/browser-transfer/index.ts`, 그리고 presigned source의 직접 consumer `src/adapters/browser-files/download-delivery-adapter.ts`. native URL/header/File/Response를 application port로 올리지 않는 방향은 유지하며, `BT-PRE-01``close()` migration은 이 consumer까지 포함한다.
## Presigned transfer 상세
### BT-PRE-01 — `open()`이 반환되기 전에 download lease가 시작됨
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `presigned-transfer-executor.ts:114-192`, `:408-631`
- 현재 동작:
- `openDownload()`이 capability를 claim/consume한 뒤 즉시 `fetch()`를 수행한다.
- timeout scope도 `open()` 안에서 시작한다.
- response body와 scope는 반환된 `PresignedDownloadByteSource.stream()`을 완주하거나 실패해야만 해제된다.
- source port에는 `close()`/`dispose()`가 없다.
- 영향:
- 호출자가 source를 받은 뒤 stream 시작을 늦추면, 실제 consumer deadline이 아니라 `open()` 시점의 timeout으로 실패한다.
- 호출자가 stream을 열지 않으면 body cancellation과 listener/timer cleanup을 명시적으로 수행할 방법이 없다.
- capability는 이미 single-use로 소비되므로 동일 source를 복구할 수도 없다.
결정: **lazy, single-start lease로 변경한다.** `open()`은 policy/vault 검증과 capability consume까지만 수행하고 fetch는 첫 `stream(signal)` 진입 시 시작한다. source에 `close(): void`를 추가해 미사용 lease도 명시적으로 폐기한다. `close()`와 stream의 first-start는 하나의 state machine을 공유한다.
```ts
type PresignedDownloadByteSource = Readonly<{
byteLength: number;
capability: PresignedDownloadCapability;
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
stream(signal: AbortSignal): AsyncIterable<BrowserDataResult<Uint8Array>>;
close(): void;
}>;
type DownloadLeaseState = "READY" | "STREAMING" | "CLOSED";
```
구현 규칙:
1. `READY -> STREAMING`만 fetch를 시작한다.
2. `READY -> CLOSED`는 network I/O 없이 끝낸다.
3. `STREAMING -> CLOSED`는 composed signal abort, reader/body cancel, timer/listener release를 한 번만 수행한다.
4. 두 번째 `stream()`은 기존처럼 `CONFLICT / REISSUE_CAPABILITY`다.
5. digest 성공 전 chunk는 현재의 `VERIFIED_ON_SUCCESSFUL_EXHAUSTION` 의미를 유지한다. consumer는 최종 success 전 파일을 commit하면 안 된다.
6.`stream()` 직전에 capability expiry와 minimum remaining lifetime을 다시 확인하고, `open()` 때 받은 outer signal과 stream signal을 함께 적용한다. 오래 보관되어 만료된 source는 fetch를 시작하지 않는다.
테스트 추가 (`tests/unit/presigned-transfer.test.ts`):
- `does not fetch until the returned download source starts streaming`
- `closes an unused source without issuing a request`
- `starts the transfer deadline at first stream consumption`
- `close during a pending read cancels the reader and releases listeners once`
- `stream after close returns one terminal conflict without fetching`
마이그레이션: port에 `close()`를 추가한 뒤 직접 consumer인 `src/adapters/browser-files/download-delivery-adapter.ts`를 포함한 모든 consumer를 source 획득 직후 `try/finally { source.close(); }`로 감싼다. size reject, `createWritable()`/prompt 실패, object-URL strategy의 stream 전 실패도 `tests/unit/browser-file-download.test.ts`로 고정한다. 그 다음 fetch를 lazy로 옮긴다. rollback은 eager fetch 구현으로 되돌릴 수 있지만 `close()` API는 유지한다.
완료 조건: 위 테스트와 기존 presigned suite가 통과하고, source를 생성만 한 테스트에서 fetch 호출 수와 active timer가 모두 0이다.
### BT-PRE-02 — capability wire envelope의 protocol version 부재
- 우선순위/분류: **P1 / CONTRACT_GAP**, 기존 문서의 미완료 항목
- 근거: `presigned-capability-http-provider.ts:173-210`, `:436-462`; `docs/architecture/presigned-transfer-and-image-cdn.md:93-108`
- 현재 동작: request body와 strict response key set에 top-level transfer protocol이 없다. multipart binding 내부 protocol만으로 전체 capability envelope version을 식별한다.
- 영향: 서버가 필드를 추가/재해석할 때 old/new client가 같은 shape를 서로 다른 의미로 받아들일 수 있다. strict decoder라서 단순 필드 추가도 곧바로 장애가 되지만, 장애가 version mismatch로 분류되지 않는다.
결정:
- request와 response에 `protocol: "PRESIGNED_TRANSFER_V1"`을 필수로 추가한다.
- missing/unknown protocol은 현재 closed taxonomy의 `POLICY_REJECTED`, retryable `false`, recovery `REISSUE_CAPABILITY`로 닫는다. 이 변경에서 새 failure code를 만들지 않는다.
- multipart의 `PRESIGNED_MULTIPART_V1`은 하위 binding protocol로 그대로 유지한다.
- protocol은 `PresignedTransferCapability`, `PresignedCapabilityRegistration/Binding`, vault snapshot, executor common-binding validator까지 전파해 request → registration → consumption exact parity를 보장한다.
- server는 request shape를 협상해 legacy request에는 legacy response, V1 request에는 V1 response를 반환한다. strict legacy decoder를 깨뜨리므로 legacy response에 V1 field를 먼저 emit하거나 한 response에 dual fields를 넣지 않는다.
테스트 추가:
- request body exact-key snapshot과 protocol literal
- missing, V0, V2 protocol response 거절
- V1 download와 V1 multipart capability 수락
- protocol mismatch가 vault `register()` 전에 종료됨
배포 순서: request-shape negotiated provider 배포 → V1 client 배포 → old-client drain 기간 관찰 → provider legacy request/response 제거. rollback 시 provider는 두 request shape를 계속 수락하되 각각 matching exact response를 반환한다.
### BT-PRE-03 — timeout이 non-cooperative fetch를 실제로 bound하지 못함
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
- 근거: capability provider `:186-214`, executor `presigned-transfer-executor.ts:157-190`, 각 파일의 `createAbortScope()`
- 현재 동작: timer는 AbortController만 abort한다. injected fetcher 또는 host가 signal을 무시하면 `await fetcher(...)` 자체는 끝나지 않는다.
- 영향: API가 선언한 timeout이 hard bound가 아니며, teardown도 fetch settlement에 묶인다.
결정: 공통 `AbortableOperationScope``race(task, onLateValue)`를 사용한다 (`BT-X-01`). deadline/caller abort가 먼저 끝나면 즉시 typed failure를 반환하고, 늦게 온 `Response`는 body를 취소한다. timer 생성 실패 시 이미 붙인 external listener를 즉시 제거한다.
테스트 추가:
- signal을 무시하는 fetch Promise가 timeout 뒤에도 pending인 fixture
- timeout 결과가 정시에 반환되고 late response body가 취소되는지 검증
- scheduler `setTimeout`/`clearTimeout` throw 시 listener 누수와 public rejection이 없는지 검증
### BT-PRE-04 — vault가 스스로 registration invariant를 소유하지 않음
- 우선순위/분류: **P2 / REFACTOR**
- 근거: `presigned-capability-vault.ts:112-174`
- 현재 동작: HTTP provider가 URL, header, expiry, byte/digest를 검사하지만 exported vault의 `register()`는 전달받은 registration을 그대로 snapshot한다.
- 영향: 다른 issuer adapter가 추가되거나 테스트/조합 코드가 vault를 직접 사용하면 동일한 capability 타입에 더 약한 invariant가 들어갈 수 있다.
결정: issuer/consumer 권한을 wiring 단계에서 분리하고 공통 invariant validator를 적용한다.
1. `createPresignedCapabilityVault()``{ issuer: PresignedCapabilityIssuer; consumer: PresignedCapabilityConsumer }`를 반환한다. provider option에는 issuer만, executor option에는 consumer만 전달한다. root barrel에는 factory와 consumer-facing type만 export하고 issuer type은 provider의 구조적 parameter로 숨긴다.
2. issuer 등록 직전 공통 `validatePresignedCapabilityRegistration()`으로 method/binding/URL/header/status/bytes/digest/expiry를 다시 검증한다.
3. HTTP decoder는 wire-specific shape를 검사하고, vault validator는 runtime invariant만 검사한다. decoder 로직을 통째로 중복하지 않는다.
테스트: malformed registration을 직접 issuer seam에 넣는 table test와, HTTP provider의 valid 결과가 동일 snapshot으로 등록되는 parity test를 추가한다.
### BT-PRE-05 — encoded path의 provider 해석 차이
- 우선순위/분류: **P2 / SECURITY_HARDENING**
- 근거: `presigned-capability-http-provider.ts:517-533`, `:1078-1097`
- 현재 동작: literal `.`/`..`와 backslash는 거절하지만 `%2f`, `%5c`, `%25...` 같은 encoded separator가 object-store/CDN에서 한 번 더 decode되는지 계약이 없다.
- 결정: raw `URL.pathname`의 각 segment를 strict UTF-8 percent-decode한다. decoded segment에서 `/`, backslash, NUL, `.`/`..`, 그리고 literal `%` 뒤 두 hex digit을 거절한 뒤, 대문자 percent-hex canonical encoder 결과와 raw segment를 비교한다. 이 규칙은 `%252e%252e` double encoding을 닫고 valid opaque UTF-8 segment는 허용한다. CDN/provider conformance fixture가 같은 canonicalizer를 사용한다.
- 테스트: `%2F`, `%5C`, `%252e%252e`, mixed-case encoding, valid UTF-8 opaque segment를 포함한다.
## Resumable upload 상세
### BT-UP-01 — AbortSignal 구조 검증과 cleanup 사용이 불일치
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
- 근거: `fetch-json-transport.ts:548-582`, `:670-677`
- 현재 동작: `isAbortSignal()``aborted``addEventListener`만 검사하지만 `FetchAttempt.release()``removeEventListener()`를 무조건 호출한다.
- 영향: 구조적으로 허용된 signal이 finally에서 throw하여 typed result 대신 Promise rejection을 만든다.
- 수정: native getter 기반 또는 최소한 `removeEventListener`까지 포함한 공통 guard를 사용하고, release cleanup은 terminal result를 덮지 않도록 catch한다.
- 테스트: remove가 없는 structural fake는 입력에서 `INVALID_INPUT`; remove가 cleanup 중 throw하는 hostile facade는 typed terminal result를 보존.
### BT-UP-02 — transport clock/scheduler가 전역에 고정됨
- 우선순위/분류: **P3 / REFACTOR**
- 근거: `fetch-json-transport.ts:571-580`, `:626-636`
- 현재 동작: request timeout은 global timer, HTTP-date `Retry-After``Date.now()`를 직접 사용한다.
- 결정: dependencies에 `clock.now()``scheduler`를 추가하고 snapshot/validate한다. delta-seconds와 HTTP-date parsing은 같은 captured `now`를 사용한다.
- 테스트: fake clock으로 경계값, clock rollback, invalid date, max clamp를 결정론적으로 검증.
### BT-UP-03 — `deleteDatabase()` timeout 뒤 late delete effect
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `indexeddb-checkpoint-store.ts:375-439`
- 현재 동작: `deletePartition()``onblocked` 후 timer가 끝나면 `BLOCKED`를 반환한다. 그러나 IndexedDB delete request는 취소할 수 없고, 다른 tab이 닫히면 반환 이후 `onsuccess`로 실제 DB가 삭제될 수 있다.
- 영향: caller가 `BLOCKED``NOT_APPLIED`로 해석할 수 있지만 native request는 나중에 성공/실패할 수 있어 반환값과 effect certainty가 모순된다. 다른 realm의 open/delete ordering까지 현재 증거 없이 단정하지 않는다.
결정: delete dispatch 이후에는 failure certainty를 `NOT_APPLIED`로 표현하지 않는다. port outcome을 다음처럼 명시한다.
```ts
type PartitionDeleteOutcome =
| { state: "DELETED"; effect: "APPLIED" }
| { state: "PENDING"; effect: "UNKNOWN"; reason: "BLOCKED_DEADLINE" };
```
- pre-dispatch invalid/aborted/unsupported만 기존 failure다.
- `PENDING`을 받은 runtime은 해당 store instance를 terminal closed로 유지한다. 같은 JS realm에서는 `(IDBFactory identity, databaseName)` pending-deletion registry가 새 factory 생성을 막고 late `onsuccess/onerror`에서 해제한다. 다른 realm은 native IndexedDB blocked ordering과 명시적 recovery UX로 처리하며 client-only global registry를 주장하지 않는다.
- late `onsuccess`/`onerror`는 observer에 기록한다. 다시 확인하려면 별도 `inspectPartitionDeletion()` 또는 새 page generation에서 DB 목록/open 결과를 사용한다.
- 단순히 timer를 제거해 무한 대기시키지는 않는다.
테스트 추가 (`tests/unit/resumable-upload-checkpoint.test.ts`): blocked deadline → PENDING → late success, blocked deadline → late error, PENDING 뒤 store method가 UNAVAILABLE, caller abort before dispatch, concurrent new runtime 금지.
### BT-UP-04 — bridge clock의 non-finite 값이 expiry 검사를 통과함
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
- 근거: `presigned-upload-part-executor.ts:36-70`
- 현재 동작: `now()``NaN` 또는 음수이면 expiry 비교를 우회한다. `+Infinity`는 현재도 expiry 비교에서 거절되지만 dependency failure가 capability policy failure로 잘못 분류된다.
- 수정: `Number.isSafeInteger(nowEpochMs) && nowEpochMs >= 0`을 먼저 검사하고 실패 시 `UNAVAILABLE / RESUME`을 반환한다.
- 테스트: NaN/음수의 현재 bypass, +Infinity의 현재 rejection, 수정 후 모든 non-finite/negative clock의 `UNAVAILABLE / RESUME`, throw, 만료 경계 `expiresAt === now`, 유효 `now + 1`.
### BT-UP-05 — runtime의 상태 전이와 side effect가 한 파일에 결합됨
- 우선순위/분류: **P2 / REFACTOR**
- 근거: `resumable-upload-runtime.ts` 2,196줄; session resolution, retry, hashing, scheduler, CAS, abort saga, validation과 telemetry를 함께 소유
- 외부 facade는 유지하고 다음 내부 경계만 추출한다.
| 새 내부 모듈 | 책임 | 적용 패턴 |
| --- | --- | --- |
| `upload-session-state-machine.ts` | ACTIVE/ABORT_PENDING/completed transition의 순수 함수 | State Machine |
| `upload-session-reconciler.ts` | local checkpoint와 server status 수렴 | Reconciler |
| `upload-part-scheduler.ts` | memory/server/client concurrency와 receipt serialization | Bounded Work Queue |
| `upload-retry-executor.ts` | retry budget, Retry-After, jitter, attempt deadline | Policy + Template Method |
| `upload-abort-saga.ts` | local tombstone → backend abort → checkpoint removal | Saga/Compensation |
| `resumable-upload-runtime.ts` | public facade, lifecycle, mutation lock orchestration만 | Facade |
추출 순서:
1. 기존 `tests/unit/resumable-upload-runtime.test.ts`에 observable call-order characterization를 추가한다.
2. 순수 transition 함수와 table test를 먼저 만든다.
3. retry executor, reconciler, part scheduler, abort saga 순서로 한 모듈씩 이동한다.
4. 각 이동 뒤 기존 suite 전체를 그대로 실행한다. fixture expected 값을 리팩터링에 맞춰 바꾸지 않는다.
변경 금지:
- part idempotency key derivation
- server-authoritative status reconciliation
- accepted receipt의 순차 CAS persistence
- checkpoint에 URL/credential을 저장하지 않는 규칙
- first part failure 뒤 이미 시작한 sibling의 확정 receipt를 기다려 저장하는 현재 정책. 이를 즉시 cancel하면 remote success가 ambiguous해질 수 있으므로 별도 behavior change로 다룬다.
### BT-UP-06 — sync `close()`가 drain 완료를 증명하지 못함
- 우선순위/분류: **P2 / LIFECYCLE_REFACTOR**
- 근거: `resumable-upload-runtime.ts:280-287`
- 현재 동작: lifetime abort 직후 checkpoint store를 닫고 반환한다. native fetch/IDB가 signal에 반응해 정리될 것으로 기대하지만 caller는 active operation의 terminal settlement를 기다릴 수 없다.
- 결정: application `ResumableUploadPort``close(): void`는 admission을 닫고 같은 single-flight drain을 시작하는 호환 facade로 유지한다. adapter runtime lifecycle surface에 향후 composition owner가 `await``dispose(): Promise<void>`를 추가한다. `dispose()`는 이미 시작된 drain promise를 공유하고 active operation registry를 abort한 뒤 bounded `allSettled` 후 store/channel을 닫는다. 현재 production bootstrap consumer가 있다고 가정하지 않는다.
- 테스트: close 중 신규 admission 거절, active fetch/IDB abort, 중복 dispose single-flight, cleanup deadline, late provider success가 checkpoint를 다시 쓰지 못함.
### BT-UP-07 — Web Locks 비지원 정책이 composition 결과로 표현되지 않음
- 우선순위/분류: **P1 before composition / PLANNED_GAP**
- 근거: `upload-mutation-lock.ts:19-58`; 아키텍처 completion ledger의 optional capability decision
- 현재 동작: factory는 LockManager가 없으면 throw한다. multi-tab 안전성을 희생하는 in-memory fallback은 없다.
- 결정: silent fallback은 추가하지 않는다. composition이 Web Locks 미지원 시 resumable upload capability를 `UNSUPPORTED`로 명시하고 일반 foreground upload 또는 재선택 UX로 degrade한다. 실제 지원 browser matrix가 확정되기 전 default composition에는 설치하지 않는다.
## Image CDN 상세
### BT-IMG-01 — resolve signal을 일관되게 필수화할지에 대한 API 단순화
- 우선순위/분류: **P3 / API CONSISTENCY DECISION**, 현재 동작 결함 아님
- 근거: application port `image-cdn.ts:167-172`; runtime `image-cdn-runtime.ts:518-527`
- 현재 동작: optional signal을 허용하고 `PRIMARY_REQUIRED` preset은 signal 부재를 명시적 `UNSUPPORTED`로 표현한다. 문서가 signal 없는 probe 성공을 약속하지 않으므로 defect는 아니다.
- 결정: hidden preset precondition을 줄이기 위해 다음 major contract 정리에서 `resolve()` signal을 필수화한다. 이는 runtime correctness fix가 아니라 API consistency 개선이다.
- 마이그레이션: `tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts`와 대응 typecheck script를 먼저 추가하고 모든 caller/README에 lifecycle signal을 전달한 뒤 port와 optional 분기를 바꾼다. P1/P2와 같은 PR에 섞지 않는다.
### BT-IMG-02 — Cache-Control quoted value parser가 malformed 값을 수락
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
- 근거: `browser-image-probe.ts:272-373`
- 현재 동작: `rawValue.replace(/^"|"$/gu, "")`는 한쪽 quote만 있는 `max-age="60` 또는 `max-age=60"`도 숫자 `60`으로 만들 수 있다.
- 영향: probe가 malformed cache policy를 immutable public response로 승인할 수 있다.
- 수정:
- comma split 전에 quote/escape-aware tokenizer를 사용해 quoted extension의 comma를 directive 경계로 취급하지 않는다.
- quoted-string은 시작/종료 quote가 모두 있고 escape/control 문자가 유효할 때만 unquote한다.
- numeric directives는 unquoted digits 또는 완전한 quoted digits만 허용한다.
- private response는 `no-store`가 필수이며 `public`, `private`, `immutable`, `max-age`, `s-maxage`, `no-cache`, `must-revalidate`, `proxy-revalidate`가 함께 있으면 fail-closed한다. 문법상 유효한 unknown extension만 무시한다.
- parser는 중복 directive를 계속 거절한다.
- 테스트: unmatched quote, escaped quote, duplicate, comma-in-quoted extension, contradictory public/private directives, valid quoted max-age.
### BT-IMG-03 — acceptance, verification, URL projection의 응집도 분리
- 우선순위/분류: **P3 / REFACTOR**
- 근거: `image-cdn-runtime.ts` 1,340줄
- facade와 WeakMap capability identity는 유지하고 다음 내부 모듈만 추출한다.
| 새 내부 모듈 | 책임 |
| --- | --- |
| `image-asset-decoder.ts` | public/private exact shape snapshot |
| `image-capability-verification.ts` | canonical payload, digest, key verifier deadline |
| `image-presentation-projector.ts` | candidate URL/srcset/descriptor 생성 |
| `image-cdn-runtime.ts` | issued reference WeakMap, close, facade orchestration |
`image-header-metadata.ts`는 format parser라는 단일 책임을 이미 가진다. LOC만 보고 더 쪼개지 말고 fuzz corpus와 malformed container table을 보강한다.
### BT-IMG-04 — descriptor provider/refresh는 아직 구현 대상
- 우선순위/분류: **P1 before composition / PLANNED_GAP**
- 근거: `docs/architecture/presigned-transfer-and-image-cdn.md:574-610`
- 현재 상태: signature 검증/runtime/probe는 있으나 BFF에서 descriptor를 가져오고 single-flight refresh하는 provider와 `<picture>` renderer는 없다.
- 결정: 현재 runtime을 직접 product composition에 노출하지 않는다. 향후 provider는 `protocol: "IMAGE_CDN_DESCRIPTOR_V1"`, exact authority/request binding, minimum remaining TTL, single-flight refresh, close-generation fence를 필수로 한다. renderer는 descriptor 필드만 투영하고 alt/error/placeholder 정책은 feature 소유로 둔다.
## 공통 개선
### BT-X-01 — abort/deadline/late-result mechanics 통합
- 우선순위: **P2 / REFACTOR**
- 중복 근거: presigned provider/executor, image probe/runtime, resumable runtime, browser files, HTTP, Web Push에 `createAbortScope`, `combineAbortSignals`, `awaitWithAbort`, `readWithSignal` 변형이 반복된다.
- 결정: result taxonomy는 각 adapter에 남기고 **mechanics만** `src/adapters/platform/abortable-operation.ts`로 추출한다.
필수 API:
```ts
type AbortableOperationScope = Readonly<{
signal: AbortSignal;
terminal(): "OPEN" | "CALLER_ABORT" | "DEADLINE" | "CLOSED";
race<T>(
task: Promise<T>,
onLateValue?: (value: T) => void,
): Promise<
| { kind: "VALUE"; value: T }
| { kind: "TERMINAL"; terminal: "CALLER_ABORT" | "DEADLINE" | "CLOSED" }
>;
close(): void;
}>;
```
불변식:
- caller abort와 deadline 중 최초 하나만 terminal authority다.
- `close()`는 idempotent하고 timer/listener cleanup throw를 삼킨다.
- late rejection은 항상 관찰되어 unhandled rejection이 되지 않는다.
- late `Response`/`ImageBitmap`/native handle은 caller가 제공한 compensator로 닫고 값을 버린다. 각 subsystem adapter가 `TERMINAL`을 자기 Result taxonomy로 변환한다.
- 이 utility는 `BrowserDataResult`, `WebPushResult`, HTTP outcome을 import하지 않는다.
적용 순서: 새 utility golden test → presigned → image → resumable transport → 다른 adapter. 한 PR에서 모든 subsystem을 동시에 바꾸지 않는다.
## 유지해야 할 설계
- raw presigned URL/header가 application port를 통과하지 않고 identity capability vault 안에만 존재한다.
- capability는 exact WeakMap identity이며 single-use claim 후 vault에서 제거된다.
- upload byte는 hash/network await 전에 snapshot한다.
- multipart checkpoint에는 URL, credential, capability material을 저장하지 않는다.
- multipart receipt는 revision CAS로 순차 commit하고 server status가 복구 authority다.
- cross-context cancellation은 hint일 뿐 backend idempotency/Web Lock/CAS를 대체하지 않는다.
- public image는 revision rollover, private image는 signed expiry/revocation으로 구분한다.
- private image는 exact signed URL, credential omit, no-store, static container와 decode budget을 확인한다.
- composition hard limit은 adapter implementation ceiling보다 느슨해질 수 없다.
- P-256 key overlap set과 terminal `close()` generation fence를 유지한다.
## 실행 순서와 의존성
1. `BT-UP-03`, `BT-PRE-01`, `BT-PRE-02`를 각각 독립 PR로 해결한다.
2. `BT-X-01` utility golden test를 만들고 `BT-PRE-03`, `BT-UP-01`, `BT-UP-02`를 이관한다.
3. `BT-UP-04`, `BT-IMG-01`, `BT-IMG-02`, `BT-PRE-04/05`를 작은 contract-hardening PR로 처리한다.
4. behavior suite가 모두 green인 뒤 `BT-UP-05/06`, `BT-IMG-03` 구조 분리를 수행한다.
5. 실제 product 선택이 있을 때만 `BT-UP-07`, `BT-IMG-04`를 composition plan으로 연다.
각 PR 공통 gate:
```bash
corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts \
tests/unit/resumable-upload-checkpoint.test.ts \
tests/unit/resumable-upload-fetch-transport.test.ts \
tests/unit/resumable-upload-http-control-plane.test.ts \
tests/unit/resumable-upload-runtime.test.ts \
tests/unit/image-cdn-runtime.test.ts
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm lint
git diff --check
```
실제 browser gate도 capability promotion 전에 실행한다.
```bash
corepack pnpm test:browser-capabilities -- \
tests/browser-capabilities/presigned-streaming.spec.ts \
tests/browser-capabilities/resumable-upload.spec.ts \
tests/browser-capabilities/image-cdn.spec.ts
```
해당 browser/provider 환경이 없으면 이 gate는 `UNVERIFIED`로 남기며 capability availability를 승격하지 않는다.
## 구현 완료 정의
- 모든 P1 항목에 failing-before/fixed-after test가 있다.
- wire version과 migration 순서가 provider fixture에 반영된다.
- 어떤 timeout 경로도 non-cooperative Promise 때문에 public API를 무한 대기시키지 않는다.
- delete partition 결과가 late native commit 가능성을 숨기지 않는다.
- runtime facade의 public capability identity, failure taxonomy, persisted V1 schema는 명시된 migration 외에는 바뀌지 않는다.
- 기존 문서의 `AVAILABLE_NOT_COMPOSED`/`PLANNED_GAP` 상태를 code defect 완료로 오인하지 않는다.
@@ -0,0 +1,344 @@
# Adapter Review — Service Worker and Web Push
> 검토 기준: `develop` / `4dc033c` (2026-08-13)
>
> 범위: `src/adapters/service-worker/**`, `src/adapters/web-push/**`, `src/contracts/service-worker.ts`, `src/contracts/web-push.ts`, 관련 build input·unit test·architecture 문서
## 결론
서비스 워커는 registration ownership, static asset install의 byte/digest 검증, activation drain handshake, `clients.claim()` 금지와 staged removal이라는 좋은 기반을 갖고 있다. Web Push도 raw endpoint/key를 durable control record에서 분리하고, push/click 전에 association fence를 두 번 확인하며, notification copy/route를 closed registry로 제한한다. 이 경계들은 유지해야 한다.
현재 코드에는 조합 전에 고쳐야 할 P1 항목이 있다.
- generated manifest는 root-relative URL을 가지지만 fetch 분류는 absolute `Request.url`과 비교해 정적 cache path가 사용되지 않을 수 있다 (`SW-URL-01`).
- Cache Storage 전체에서 match하여 현재 release가 아닌 구 cache response를 반환할 수 있다 (`SW-01`).
- reset가 소유권 parser가 아니라 문자열 prefix만 사용해 유사 이름의 타 cache까지 삭제한다 (`SW-02`).
- `unregister()``false`를 성공으로 보고하며 removal mode도 실패/ownership mismatch를 `DISABLED`로 숨긴다 (`SW-03`, `SW-04`).
- build input의 static manifest decoder가 asset row와 set digest를 실제로 검증하지 않는다 (`SW-05`).
- Push fence CAS adapter가 repository의 다음 revision을 확인하지 않고, deadline 뒤 late mutation effect도 표현하지 못한다 (`WP-01`, `WP-02`).
- backend registration response가 request의 전체 authority를 echo/bind하지 않아 client가 잘못 묶인 association을 검출할 수 없다 (`WP-03`).
Web Push는 현재 `AVAILABLE_NOT_COMPOSED`이고 제품 선택도 `NOT_SELECTED`다. service worker entry에 연결되지 않은 사실 자체는 회귀가 아니다. 아래 P1 계약을 해결하고 product-owned registry/provider/consent가 준비되기 전에는 default composition에 추가하지 않는다.
## 파일별 판정
| 파일 | 역할 | 판정 | 후속 |
| --- | --- | --- | --- |
| `service-worker-entry.ts` | 단일 physical worker entry와 event wiring | KEEP/REFACTOR | `SW-06`, `SW-10`; 두 번째 registration 생성 금지 |
| `service-worker-lifecycle.ts` | install/activate/fetch/activation/reset | VERIFIED_DEFECT | `SW-URL-01`, `SW-01`, `SW-02`, `SW-07`, `SW-08` |
| `service-worker-page-controller.ts` | registration/update/activation/reset page facade | VERIFIED_DEFECT | `SW-04`, `SW-06` |
| `service-worker-protocol.ts` | page-worker strict message codec/nonce | CONTRACT_GAP | `SW-06`, `SW-10` |
| `service-worker-removal.ts` | exact registration/cache ownership cleanup | VERIFIED_DEFECT | `SW-03` |
| `service-worker-static-assets.ts` | static manifest/install/cache policy | KEEP/REFACTOR | `SW-01`, `SW-05`, `SW-09` |
| `web-push/index.ts` | public exports | KEEP | product selection 전 surface 확대 금지 |
| `web-push/notification-registry.ts` | closed copy/route registry | KEEP | arbitrary copy/URL 허용 금지 |
| `web-push/push-association-fence-store.ts` | durable authority state machine | VERIFIED_DEFECT | `WP-01`, `WP-02` |
| `web-push/push-codec.ts` | bounded hint/click codec | KEEP | exact keys, expiry, no raw text 유지 |
| `web-push/push-registration-gateway.ts` | fixed backend commands/decoders | CONTRACT_GAP | `WP-03`, `WP-04` |
| `web-push/push-subscription-adapter.ts` | window consent/native/backend/local orchestration | VERIFIED_DEFECT/REFACTOR | `WP-04`, `WP-05`, `WP-06` |
| `web-push/runtime-support.ts` | deadline/link/observation mechanics | CONTRACT_GAP | `WP-02`, `WP-07` |
| `web-push/service-worker-runtime.ts` | push/click/subscriptionchange handler composition | REFACTOR | `WP-06` |
| `web-push/service-worker-scope-host.ts` | native scope facade | KEEP | single worker entry 내부에서만 사용 |
| `web-push/inbound/push-event-adapter.ts` | hint → fence → safe notification | KEEP/CONTRACT_GAP | `WP-07` |
| `web-push/inbound/notification-click-adapter.ts` | click → fence → safe route handoff | KEEP/CONTRACT_GAP | `WP-07` |
직접 경계 inventory도 대조했다: `src/contracts/service-worker.ts`는 protocol/cache ownership identity, `src/contracts/web-push.ts`는 push protocol/selection을 소유한다. `src/bootstrap/register-service-worker.ts`는 page composition, `scripts/lib/service-worker-build-input.ts``scripts/generate-service-worker-assets.ts`는 build decode/generation, `vite.service-worker.config.ts`는 worker bundle entry를 소유한다. 이 파일들은 `SW-05`/`SW-10`의 shared codec과 rollout scope에 포함한다.
## Service Worker 상세
### SW-URL-01 — generated root-relative manifest와 absolute fetch URL의 분류 불일치
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: generator `scripts/generate-service-worker-assets.ts`는 asset URL을 `/assets/...`로 생성하고, `service-worker-lifecycle.ts`는 그 문자열 set을 absolute `Request.url`과 직접 비교한다.
- 영향: generator output을 그대로 사용하면 verified static URL이 manifest member로 분류되지 않아 current cache lookup path에 들어가지 않고 network fallback이 된다. `SW-01`의 cache 선택을 고쳐도 URL identity를 먼저 맞추지 않으면 cache path는 여전히 작동하지 않는다.
- 결정: runtime 생성 시 각 root-relative manifest URL을 `new URL(asset.url, scope.registrationScope).href`로 canonicalize하고 same-origin을 재확인한 frozen absolute URL set을 만든다. install cache key, fetch classification, lookup/delete validation이 이 canonical URL identity를 공유한다. generator의 persisted manifest shape는 root-relative로 유지한다.
- 테스트: generator-shaped `/assets/app.<hash>.js` fixture와 absolute `https://app.example/assets/app.<hash>.js` request를 사용해 `onFetch()`가 current cache로 들어가는지 직접 검증한다. 다른 origin, scope 밖 path, query/hash 변형은 거절한다.
### SW-01 — fetch가 current static cache가 아닌 전역 CacheStorage를 조회
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-lifecycle.ts:166-192`; worker facade `service-worker-entry.ts:38-44`
- 현재 동작: verified static URL에 `scope.caches.match(request.url)`을 호출한다. CacheStorage-wide match는 current, previous 또는 같은 URL을 가진 다른 cache 중 먼저 찾은 response를 반환할 수 있다.
- 영향:
- current release manifest에 URL이 포함되어 있어도 구 cache의 동일 URL response가 반환될 수 있다.
- invalid hit를 발견해도 삭제는 current cache에만 수행하므로 실제로 반환된 stale cache entry는 남는다.
결정: `onFetch()``config.manifest.setDigest`로 계산한 current cache를 `open()`하고 그 cache에서만 `match()`한다. worker scope facade의 CacheStorage-wide `match`는 제거한다.
테스트 추가 (`tests/unit/service-worker-runtime.test.ts`):
- current/previous cache에 같은 URL과 다른 bytes가 있을 때 current만 반환
- previous에만 entry가 있으면 network fallback (`null`)
- current invalid response만 current cache에서 삭제
- unrelated cache의 same URL은 조회/삭제하지 않음
완료 조건: runtime fetch path에 `caches.match` 호출이 0이고 current cache name이 exact digest에서 파생된다.
### SW-02 — cache reset가 exact ownership 대신 prefix를 사용
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-lifecycle.ts:333-365`; exact helper `src/contracts/service-worker.ts:111-116`
- 현재 동작: `name.startsWith("ca-static-v1-")`이면 삭제한다. `isOwnedStaticCacheName()`은 정확히 16자리 lower-hex suffix를 요구하지만 reset path가 이를 사용하지 않는다.
- 영향: `ca-static-v1-not-owned`, suffix가 더 긴 이름 등 같은 prefix를 가진 타 기능/cache가 삭제될 수 있다.
- 수정: import되어 있는 `isOwnedStaticCacheName(name)`만 사용한다. cache name 상수 literal도 lifecycle에서 제거한다.
- 테스트: valid 16-hex 두 개만 삭제하고 short/long/non-hex/upper-hex/unrelated cache를 보존한다.
### SW-03 — `unregister() === false`를 `UNREGISTERED`로 보고
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-removal.ts:89-120`
- 현재 동작: Promise가 resolve하면 boolean을 무시하고 `UNREGISTERED`를 반환한다.
- 수정: `const unregistered = await registration.unregister()``true`만 성공으로 인정한다. `false``{ kind: "FAILED", operation: "UNREGISTER" }`로 닫는다. 새 outcome을 추가할 필요는 없다.
- 테스트: true, false, rejection, absent, ownership mismatch를 각각 고정한다.
### SW-04 — explicit removal mode가 cleanup 실패를 `DISABLED`로 숨김
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-page-controller.ts:78-121`
- 현재 동작:
- `REMOVE_REGISTRATION``PURGE_OWNED_RESOURCES`는 실제 outcome과 무관하게 `DISABLED`를 반환한다.
- `disabledCleanup``OWNERSHIP_MISMATCH``DISABLED`로 반환한다.
- 영향: staged removal이 끝난 것으로 판단해 다음 release에서 worker source/handler를 제거할 수 있지만 실제 registration 또는 cache가 남아 있을 수 있다.
결정 매핑:
| cleanup outcome | page start outcome |
| --- | --- |
| `ABSENT`, `UNREGISTERED`, `PURGED` | `DISABLED` |
| `OWNERSHIP_MISMATCH` | `INCOMPATIBLE` |
| `FAILED` | `FAILED` (`DISABLE_CLEANUP_FAILED`, `REMOVE_FAILED`, `PURGE_FAILED`) |
관찰 이벤트만 남기고 success로 바꾸지 않는다. 테스트는 selection 세 종류와 위 outcome matrix를 모두 table-driven으로 작성한다.
### SW-05 — build input의 manifest row와 set digest 검증 부재
- 우선순위/분류: **P1 / CONTRACT_GAP**
- 근거: `scripts/lib/service-worker-build-input.ts:82-94`; runtime의 부분 검사 `service-worker-static-assets.ts:85-112`; 생성 canonical hash `scripts/generate-service-worker-assets.ts:48-93`
- 현재 동작:
- build input은 manifest top-level shape만 보고 `assets`를 type cast한다.
- runtime validator도 build/release identity, exact row keys, unique/canonical URL, content type type/allowlist, set digest 재계산을 확인하지 않는다.
- 잘못된 `contentType``storeAsset()``.toLowerCase()`에서 typed rejection이 아니라 throw가 될 수 있다.
결정: runtime-neutral shared manifest codec이 exact row keys, content-type/extension allowlist, root-relative canonical URL, length-prefixed canonical byte serialization을 소유한다. generator와 Node build gate는 같은 bytes를 Node SHA-256으로 hash하고 worker는 injected WebCrypto digest로 같은 bytes를 재검증한다. Node `crypto` 구현을 worker에서 import하지 않는다. 이 작업은 기존 2026-08-01 plan Task 5/SW-10의 **선행 build-decoder 단계**로 병합하며 canonical digest를 별도 PR에서 두 번 구현하지 않는다.
Build gate 필수 조건:
- top-level/asset row exact keys
- buildId/releaseId exact match
- sorted unique same-origin root-relative hashed asset URL
- 허용 content type/extension pair
- non-negative safe byte length와 전체 bound
- lower-hex SHA-256
- generator와 같은 length-prefixed canonical algorithm으로 `setDigest` 재계산
테스트 (`tests/unit/service-worker-build-input.test.ts`): 각 row field tamper, duplicate/reorder, cross-origin URL, dot segment, wrong extension/content type, wrong set digest, unknown field. valid generator output을 decoder에 다시 넣는 parity test도 추가한다.
### SW-06 — activation/reset command의 source identity와 single-flight 부재
- 우선순위/분류: **P2 / CONTRACT_HARDENING**
- 근거: `service-worker-page-controller.ts:174-211`, `:231-305`, `:308-369`
- 현재 동작:
- activation 전용 listener는 `event.origin``event.source`를 검증하지 않는다.
- general listener/reset은 origin 일부만 확인하며 expected waiting/controller source와 correlation하지 않는다.
- 동시에 `requestActivation()` 또는 `resetOwnedCaches()`를 여러 번 호출하면 nonce와 listener가 중복 생성된다.
결정:
- activation reply는 request 시 capture한 `registration.waiting``event.source`가 같아야 한다.
- reset reply는 request 시 capture한 `container.controller`와 같아야 한다.
- long-lived `CLIENT_DRAIN_REQUEST` listener도 expected `registration.waiting` source와 correlation한다. nonce가 없더라도 arbitrary same-origin source가 page admission을 닫게 하지 않는다.
- empty origin을 신뢰 근거로 사용하지 않고 source identity + nonce + target identity를 함께 검증한다.
- 각 command를 single-flight Promise로 만들고 concurrent caller는 같은 Promise를 받는다.
- message 수신 직전에 `event.source`, captured source, 현재 `registration.waiting`/`container.controller`가 모두 동일한지 확인한다. 교체되었으면 ignore 후 timeout이 아니라 즉시 `PROTOCOL_MISMATCH`로 종료한다.
테스트: wrong source with correct nonce, source swap, concurrent 10 calls가 postMessage 한 번, stop 중 pending 종료, retry after terminal.
### SW-07 — zero-client drain 의미가 불필요하게 activation을 막음
- 우선순위/분류: **P2 / VERIFIED_BEHAVIOR_CHANGE**
- 근거: `service-worker-lifecycle.ts:268-300`
- 현재 동작: scope 내 client가 0이면 `false`를 반환한다. requester가 request 직후 닫힌 경우 dirty client가 없는데도 waiting worker가 거절된다.
- 결정: empty set은 vacuously drained이므로 `true`다. 단, `clients.matchAll()` 실패는 reject/throw로 유지한다.
- 테스트: zero clients → skipWaiting, one missing ack → timeout/reject, out-of-scope only → zero in-scope로 처리.
### SW-08 — client `postMessage()` 예외가 activation event 전체를 깨뜨림
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
- 근거: `service-worker-lifecycle.ts:225-265`, `:290-299`
- 현재 동작: drain request/accepted/reload notification loop에 per-client 예외 격리가 없다.
- 결정: drain request 전달 실패는 해당 expected client를 failed 처리하고 pending state를 즉시 정리한다. drain 완료 뒤 `skipWaiting()` 호출 성공을 activation admission commit으로 기록한다. 그 다음 `ACTIVATE_ACCEPTED`/reload 알림은 client별 best effort로 보내고 실패를 degraded observation으로 남긴다. 현재 코드의 pre-commit `ACTIVATE_ACCEPTED` 순서는 바꾸거나 protocol V2에서 그 message를 제거한다. `skipWaiting()` 실패는 `REJECTED/FAILED`이고 accepted 성공으로 관찰하지 않는다.
- 테스트: 첫/중간/마지막 client throw, skipWaiting throw, partial delivery, pending map leak 없음.
### SW-09 — install deadline 뒤 late candidate 작업
- 우선순위/분류: **P2 / LIFECYCLE_HARDENING**
- 근거: `service-worker-static-assets.ts:119-153`, `:156-254`, `:259-274`
- 현재 동작: deadline Promise가 먼저 끝나면 candidate cache를 삭제하고 반환하지만, signal을 무시한 fetch/digest/cache put은 뒤늦게 계속될 수 있다. digest rejection도 `storeAsset()`에서 직접 typed outcome으로 변환되지 않는다.
- 결정: public install result는 overall 60초에 닫고 candidate generation fence를 세워 뒤늦은 worker가 새 fetch/digest/put을 시작하지 못하게 한다. late `Response` body는 compensator로 취소한다. 이미 시작한 `cache.put`은 취소할 수 없으므로 background settlement를 관찰한 뒤 candidate cache를 다시 exact-delete하는 second cleanup을 등록한다. cleanup을 public completion에 포함하려면 그 budget을 총 60초 안에 미리 예약하며, 60초 뒤 별도 cleanup deadline을 await해 public bound를 늘리지 않는다. 모든 dependency exception은 closed `FETCH_FAILED`/`INTEGRITY_MISMATCH`로 mapping한다.
- 테스트: non-cooperative late fetch/digest, late cache put, digest rejection, delete rejection, unhandled rejection 없음.
### SW-10 — message protocol을 kind별 discriminated schema와 full identity로 승격
- 우선순위/분류: **P1 before release hardening / 기존 계획 승계**
- 근거: `service-worker-protocol.ts:44-143`; `SERVICE_WORKER_PROTOCOL_VERSION = 1`; 기존 `docs/superpowers/plans/2026-08-01-http-worker-adapter-remediation.md` Task 5
- 현재 동작: 모든 kind가 하나의 optional field bag을 공유하고 page-worker correlation은 주로 buildId에 의존한다. `service-worker-entry.ts:151-166`의 sync message는 codec 대신 V1 literal을 직접 만든다.
결정:
- 기존 계획대로 protocol V2에서 protocol/cache schema/build/release/contract/static set 전체 canonical identity digest를 교환한다.
- kind별 exact required/forbidden field schema를 사용한다. activation/reset kinds에는 nonce와 target identity가 필수다.
- 모든 message, including `SYNC_WAKE_OBSERVED`,는 `createServiceWorkerMessage()`만 사용한다.
- V1/V2 worker가 같은 scope에서 교차 activation하지 않도록 mismatch는 fail-closed하고 강제 skipWaiting 하지 않는다.
이 항목은 기존 계획을 **유지**한다. 정확한 sequence는 `SW-URL-01`, `SW-01`~`SW-04` → 기존 plan Task 4 bounded activation-marker reader → `SW-05` build decoder와 기존 Task 5/`SW-10` 통합 → `SW-06`~`SW-09`다. 같은 canonical digest/codec을 중복 구현하지 않는다.
## Web Push 상세
### WP-01 — CAS success receipt의 expected next revision 미검증
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `push-association-fence-store.ts:475-513`; remove는 `:516-546`에서 next revision을 검사함
- 현재 동작: compareAndSwap success는 key/revision type/replayed만 확인하고 `revision === (expectedRevision ?? 0) + 1`을 확인하지 않는다.
- 영향: repository가 stale/임의 receipt를 반환하면 adapter가 실제로 확인되지 않은 control을 새 revision으로 포장한다. 이후 CAS authority가 틀어진다.
- 수정: write와 remove 모두 exact next revision, expected key, replay semantics를 같은 validator로 검증한다. replayed receipt도 동일 idempotency command의 exact revision이어야 한다.
- 테스트 (`tests/unit/web-push-fence-store.test.ts`): stale/same/skipped/huge revision, wrong key, malformed replay, valid initial/next/replayed receipt.
### WP-02 — deadline 뒤 local fence mutation effect가 UNKNOWN일 수 있음
- 우선순위/분류: **P1 / CONTRACT_GAP**
- 근거: `runtime-support.ts:51-113`; fence store `:411-423`, `:493-503`
- 현재 동작: deadline은 signal을 abort하고 실패를 반환하지만 generic `PushControlRepository`가 signal을 무시하거나 commit 경계 직후 늦게 resolve하면 CAS는 반환 이후 적용될 수 있다.
- 영향: security fence adapter가 `DEADLINE_EXCEEDED`를 반환한 뒤 ACTIVE/REVOKED record가 실제로 바뀔 수 있다.
결정:
1. read deadline wrapper와 mutation wrapper를 분리한다. repository는 commit 전 abort 시 `NOT_APPLIED`, commit 후 success receipt를 반환한다. deadline뿐 아니라 caller abort와 commit/receipt race도 unknown일 수 있다.
2. `WebPushFailureCode``MUTATION_OUTCOME_UNKNOWN`과 recovery reason을 추가한다. lifecycle은 `OPEN | RECONCILIATION_REQUIRED | CLOSED`이며 unknown 뒤 mutation admission을 닫는다.
3. 복구는 새 bounded read로 exact revision/authority/state를 확인한 뒤에만 한다.
4. `withAbortableDeadline`을 mutation의 correctness authority로 사용하지 않는다. deadline은 caller wait bound이며 effect는 repository receipt/read-back이 결정한다.
테스트: timeout-before-commit, timeout-racing-commit, late success, late rejection, recovery read, dispose 중 late ACTIVE 금지.
### WP-03 — backend commit이 전체 request authority에 binding되지 않음
- 우선순위/분류: **P1 / CONTRACT_GAP**
- 근거: request `push-registration-gateway.ts:73-121`; response `:170-218`; activation check `push-subscription-adapter.ts:682-711`
- 현재 동작: request는 `fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`을 보낸다. response는 `associationEpoch``sessionBindingEpoch`만 반환하고 adapter도 session epoch만 비교한다.
- 영향: provider/server bug 또는 stale response가 다른 fence/release request의 association을 반환해도 local current fence가 unchanged이면 ACTIVE로 commit할 수 있다.
결정: register와 reconcile의 request/response protocol을 V2로 올리고 서로 다른 exact response union을 사용한다.
```ts
type WebPushRegisterCommitV2 = Readonly<{
protocol: "WEB_PUSH_REGISTRATION_RECEIPT_V2";
associationEpoch: string;
fenceGeneration: string;
sessionBindingEpoch: string;
releaseEpoch: string;
requestBindingSha256: string;
replacedAssociationEpoch: string | null;
}>;
type WebPushReconciliationV2 =
| Readonly<{
protocol: "WEB_PUSH_RECONCILIATION_V2";
state: "ACTIVE";
associationEpoch: string;
fenceGeneration: string;
sessionBindingEpoch: string;
releaseEpoch: string;
requestBindingSha256: string;
}>
| Readonly<{
protocol: "WEB_PUSH_RECONCILIATION_V2";
state: "ABSENT";
fenceGeneration: string;
sessionBindingEpoch: string;
releaseEpoch: string;
requestBindingSha256: string;
}>;
```
`WEB_PUSH_PROTOCOLS`가 V2 literal과 length-prefixed field order를 소유한다. register digest에는 operation, authority tuple, subscription fingerprint, idempotency key, expected previous association epoch를 넣는다. reconcile에는 idempotency key가 없으므로 명시적으로 제외한다. decoded fixed-length digest bytes를 비교한 뒤 fence CAS를 수행한다.
배포: server가 V1 request에는 V1 response, V2 request에는 V2 response를 반환하도록 request protocol negotiation 배포 → V2 client → old client drain → V1 제거. exact decoder를 깨뜨리는 response dual-emit은 하지 않는다. authority field mutation과 reconcile `ABSENT` fixture를 추가한다.
### WP-04 — repeated enable의 backend upsert/rotation 의미가 타입에 없음
- 우선순위/분류: **P2 / CONTRACT_GAP**
- 근거: `push-subscription-adapter.ts:162-275`, fence `prepare():175-215`, `activate():219-270`
- 현재 동작: 같은 authority가 이미 ACTIVE여도 `enable()`은 새 idempotency key로 backend register를 다시 수행한다. server atomic installation upsert가 같은 association을 반환하거나 old association을 폐기한다는 문서 요구가 gateway receipt에 표현되지 않는다.
- 결정: public `enable()`이 public `reconcile()`을 호출하지 않는다. permission/prepare 뒤 private `reconcilePrepared()` flow를 공유해 exclusive guard 내부에서 호출한다. ACTIVE + valid native material이면 먼저 reconcile하고 `ABSENT`일 때만 register한다. request에 `expectedPreviousAssociationEpoch: string | null`을 보내고 receipt의 `replacedAssociationEpoch`과 exact match해야 한다. local activate는 old ACTIVE와 다른 epoch를 무조건 덮어쓰지 않는다.
- 테스트: double enable same epoch, reconcile active, server absent then register, replacement receipt, replacement without old epoch rejection, compensation on CAS failure.
### WP-05 — pre-aborted operation이 항상 INSPECT로 기록됨
- 우선순위/분류: **P3 / VERIFIED_DEFECT**
- 근거: `push-subscription-adapter.ts:462-478`
- 수정: `webPushFailure("ABORTED", failureOperation)`을 사용한다.
- 테스트: enable/reconcile/revoke/inspect 각각 pre-aborted operation field.
### WP-06 — bounded truncation을 성공으로 관찰
- 우선순위/분류: **P2 / EVIDENCE_CORRECTNESS**
- 근거: subscriptionchange client handoff `service-worker-runtime.ts:139-172`; notification cleanup `push-subscription-adapter.ts:908-945`
- 현재 정책: client 32개, notification 64개/2초로 bounded best effort이다. architecture 문서는 notification cleanup을 privacy guarantee로 보지 않고 account-neutral copy를 요구하므로 상한 자체는 결함이 아니다.
- 문제: 목록이 상한을 넘었는데도 success로 관찰해 운영자가 일부 처리만 된 사실을 알 수 없다.
- 수정: `WebPushObservation``countBucket: "0" | "1_8" | "9_32" | "33_64" | "GT_64"``truncated: boolean`을 추가한다. subscriptionchange는 32 초과 시 `LIMIT_EXCEEDED/DEGRADED`; notification cleanup은 64 초과 시 revoke authority와 분리된 cleanup observation을 `DEGRADED`로 기록하고 `{ complete: false }`를 반환한다. 무제한 loop나 전체 정리를 주장하지 않는다.
- 테스트: 33 clients, 65 notifications, owned item이 cap 밖에 있는 경우, account-neutral copy/click fence가 계속 안전함.
### WP-07 — user-visible native effect와 deadline result의 certainty
- 우선순위/분류: **P2 / CONTRACT_GAP**
- 근거: `runtime-support.ts:51-113`, `push-event-adapter.ts:144-174`, `notification-click-adapter.ts:144-176`
- 현재 동작: deadline/abort가 먼저 반환된 뒤 `showNotification`, `focus`, `openWindow`가 늦게 성공할 수 있다. 결과는 failure지만 user-visible effect는 발생할 수 있다.
- 결정: native 호출 전 terminal=`NOT_APPLIED`, native Promise pending 중 terminal=`MAYBE_APPLIED`, fulfillment=`CONFIRMED`로 phase를 고정한다. native-effect 전용 observation union에 effect를 두고 wrapper가 `onLateValue/onLateError`로 outer result 종료 뒤에도 safe observation을 한 번 남긴다. 이 observation을 authorization/retry에 사용하지 않는다. account-neutral notification과 click-time fence가 최종 안전 장치다.
## 유지해야 할 설계
- 한 scope에 physical Service Worker registration은 하나만 둔다.
- static install은 immutable hashed asset만 대상으로 하고 byte/digest 검증 후 all-or-nothing으로 공개한다.
- navigation, runtime config, release manifest, API response는 static cache에 넣지 않는다.
- `skipWaiting()`은 page/client drain handshake 이후에만 호출하고 baseline에서 `clients.claim()`은 사용하지 않는다.
- registration과 cache ownership을 exact scope/script/cache parser로 확인한다.
- Web Push endpoint, p256dh, auth, account/user ID, notification content를 durable fence/diagnostics에 저장하지 않는다.
- push와 click 모두 initial/final fence를 확인하고 arbitrary URL 또는 backend raw copy를 사용하지 않는다.
- revoke는 local generation fence를 먼저 commit하고 backend/native cleanup은 bounded best effort로 수행한다.
- notification cleanup 성공을 privacy 보장으로 주장하지 않는다. copy는 항상 account-neutral이어야 한다.
- `WEB_PUSH`가 선택되지 않은 현재 baseline에서 worker import/handler를 억지로 추가하지 않는다.
## 실행 순서
1. `SW-URL-01`, `SW-01`~`SW-04`, `WP-01`~`WP-03`을 독립 P1 PR로 처리한다.
2. 기존 2026-08-01 plan Task 4 bounded activation-marker reader를 완료한다.
3. `SW-05` shared build decoder와 기존 Task 5/`SW-10` protocol V2를 한 sequence로 구현한다.
4. `SW-06`~`SW-09`, `WP-04`~`WP-07`을 protocol/lifecycle PR로 나눈다.
5. 제품이 Web Push를 선택할 때 별도 composition 계획으로 registry/provider/consent/browser evidence를 추가한다.
집중 검증:
```bash
corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts \
tests/unit/service-worker-build-input.test.ts \
tests/unit/web-push-codec.test.ts \
tests/unit/web-push-fence-store.test.ts \
tests/unit/web-push-store-port-compatibility.test.ts \
tests/unit/web-push-runtime-support.test.ts \
tests/unit/web-push-subscription-adapter.test.ts \
tests/unit/web-push-worker-runtime.test.ts
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm lint
git diff --check
```
## 완료 정의
- generated root-relative asset가 canonical absolute request와 일치하고, current cache 외 response가 반환되지 않으며 exact owned cache만 삭제된다.
- unregister/removal 결과가 실제 browser outcome을 숨기지 않는다.
- build gate가 static manifest row와 canonical set digest tamper를 거절한다.
- every command reply는 expected worker source, nonce, target full identity에 묶인다.
- fence mutation receipt가 exact next revision과 effect certainty를 보장한다.
- backend association receipt가 authority 3-tuple과 request digest에 묶인다.
- bounded truncation과 MAYBE_APPLIED native effect가 성공으로 과장되지 않는다.
- Web Push의 미조합 상태를 구현 완료로 오인하지 않는다.
+129
View File
@@ -0,0 +1,129 @@
# Adapter 파일 전수 inventory
> 검토 기준: `develop` / `4dc033cf33a5b6173bbf960d5eb464a406dc4c92` (2026-08-13)
>
> `rg --files src/adapters | sort` 결과 118개를 하나씩 고정한 coverage ledger다. 책임·의존성·finding·유지/변경 판정은 연결된 상세 리뷰의 파일별 표를 따른다.
| # | full path | 상세 리뷰 |
| ---: | --- | --- |
| 1 | `src/adapters/auth/external-session-adapter.ts` | [Network/state](./01-network-and-state.md) |
| 2 | `src/adapters/browser-file-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 3 | `src/adapters/browser-file-storage/result.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 4 | `src/adapters/browser-file-storage/storage-manager-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 5 | `src/adapters/browser-files/browser-file-picker.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 6 | `src/adapters/browser-files/browser-file-policy-registry.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 7 | `src/adapters/browser-files/browser-file-vault.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 8 | `src/adapters/browser-files/create-browser-file-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 9 | `src/adapters/browser-files/download-delivery-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 10 | `src/adapters/browser-files/file-observer.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 11 | `src/adapters/browser-files/file-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 12 | `src/adapters/browser-files/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 13 | `src/adapters/browser-files/object-url-lease.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 14 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 15 | `src/adapters/browser-rpc/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 16 | `src/adapters/browser-rpc/transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 17 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 18 | `src/adapters/browser-transfer/image-cdn/README.md` | [Browser transfer](./04-browser-transfer.md) |
| 19 | `src/adapters/browser-transfer/image-cdn/browser-image-probe.ts` | [Browser transfer](./04-browser-transfer.md) |
| 20 | `src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
| 21 | `src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
| 22 | `src/adapters/browser-transfer/image-cdn/image-header-metadata.ts` | [Browser transfer](./04-browser-transfer.md) |
| 23 | `src/adapters/browser-transfer/image-cdn/index.ts` | [Browser transfer](./04-browser-transfer.md) |
| 24 | `src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts` | [Browser transfer](./04-browser-transfer.md) |
| 25 | `src/adapters/browser-transfer/index.ts` | [Browser transfer](./04-browser-transfer.md) |
| 26 | `src/adapters/browser-transfer/presigned/incremental-sha256.ts` | [Browser transfer](./04-browser-transfer.md) |
| 27 | `src/adapters/browser-transfer/presigned/index.ts` | [Browser transfer](./04-browser-transfer.md) |
| 28 | `src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts` | [Browser transfer](./04-browser-transfer.md) |
| 29 | `src/adapters/browser-transfer/presigned/presigned-capability-vault.ts` | [Browser transfer](./04-browser-transfer.md) |
| 30 | `src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
| 31 | `src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts` | [Browser transfer](./04-browser-transfer.md) |
| 32 | `src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts` | [Browser transfer](./04-browser-transfer.md) |
| 33 | `src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts` | [Browser transfer](./04-browser-transfer.md) |
| 34 | `src/adapters/browser-transfer/resumable-upload/index.ts` | [Browser transfer](./04-browser-transfer.md) |
| 35 | `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts` | [Browser transfer](./04-browser-transfer.md) |
| 36 | `src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
| 37 | `src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
| 38 | `src/adapters/browser-transfer/resumable-upload/runtime-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
| 39 | `src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts` | [Browser transfer](./04-browser-transfer.md) |
| 40 | `src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts` | [Browser transfer](./04-browser-transfer.md) |
| 41 | `src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts` | [Browser transfer](./04-browser-transfer.md) |
| 42 | `src/adapters/cache-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 43 | `src/adapters/cache-storage/public-cache-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 44 | `src/adapters/cache-storage/public-response-cache-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 45 | `src/adapters/cross-context-invalidation/browser-cross-context-host.ts` | [Network/state](./01-network-and-state.md) |
| 46 | `src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts` | [Network/state](./01-network-and-state.md) |
| 47 | `src/adapters/cross-context-invalidation/index.ts` | [Network/state](./01-network-and-state.md) |
| 48 | `src/adapters/diagnostics/bounded-diagnostics.ts` | [Network/state](./01-network-and-state.md) |
| 49 | `src/adapters/http/bounded-body-reader.ts` | [Network/state](./01-network-and-state.md) |
| 50 | `src/adapters/http/bounded-json.ts` | [Network/state](./01-network-and-state.md) |
| 51 | `src/adapters/http/client.ts` | [Network/state](./01-network-and-state.md) |
| 52 | `src/adapters/http/http-contract-bridge.ts` | [Network/state](./01-network-and-state.md) |
| 53 | `src/adapters/http/http-effect-certainty.ts` | [Network/state](./01-network-and-state.md) |
| 54 | `src/adapters/http/http-execution-v3.ts` | [Network/state](./01-network-and-state.md) |
| 55 | `src/adapters/http/request-builder.ts` | [Network/state](./01-network-and-state.md) |
| 56 | `src/adapters/http/resource-mapper.ts` | [Network/state](./01-network-and-state.md) |
| 57 | `src/adapters/http/retry-policy.ts` | [Network/state](./01-network-and-state.md) |
| 58 | `src/adapters/http/schema-registry.ts` | [Network/state](./01-network-and-state.md) |
| 59 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) |
| 60 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) |
| 61 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) |
| 62 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) |
| 63 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) |
| 64 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) |
| 65 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) |
| 66 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) |
| 67 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 68 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 69 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 70 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 71 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 72 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 73 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 74 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 75 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 76 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 77 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 78 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 79 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 80 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 81 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 82 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 83 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 84 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 85 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 86 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 87 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 88 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 89 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 90 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 91 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 92 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 93 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 94 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 95 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 96 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 97 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 98 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 99 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 100 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 101 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 102 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 103 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 104 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 105 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 106 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 107 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
| 108 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 109 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 110 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 111 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 112 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 113 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 114 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 115 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 116 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 117 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 118 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
합계: **118/118**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
+135
View File
@@ -0,0 +1,135 @@
# Adapter 전수 리뷰 — 통합 인덱스와 확정 결정
> 검토 기준: `develop` / `4dc033cf33a5b6173bbf960d5eb464a406dc4c92` (2026-08-13)
>
> 검토 범위: `src/adapters/**`의 117개 TypeScript 파일과 1개 README, 총 53,475 TypeScript LOC. 직접 연결된 contracts, application ports, bootstrap composition, feature gateway, unit/integration test, ADR와 운영 문서를 함께 대조했다.
## 결론
adapter 계층의 큰 방향은 유지할 가치가 있다. native 객체와 raw provider material을 application 경계 밖에 두고, strict decoder·immutable capability·generation fence·bounded queue·typed failure를 사용하며, 선택되지 않은 capability를 조용히 fallback하지 않는 구조는 일관적이다. 정적 architecture gate도 현재 계층 위반을 찾지 않았다.
반면 lifecycle과 effect certainty에는 반복되는 공백이 있다. 가장 높은 위험은 OPFS 보상 정리의 journal 순서이며, 현재 조립 경로에서는 V3 HTTP 관찰 전체 유실, auth profile 미강제, retry 중 command effect 하향, telemetry의 dispose 이후 동작이 우선 수정 대상이다. 선택되지 않은 realtime, Browser RPC, Web Push, image/transfer capability의 결함은 현재 production incident로 과장하지 않되, 해당 capability를 조립하기 전 필수 promotion gate로 둔다.
이 문서와 하위 리뷰는 구현자가 추가 제품 결정을 요청하지 않도록 다음을 고정한다.
- 현재 코드로 재현되는 결함, contract gap, 구조 리팩터링, 문서화된 미구현을 분리한다.
- 각 finding마다 적용 패턴, 수정할 파일/API, 테스트 이름과 기대 결과, migration·deployment·rollback을 지정한다.
- 기존 public facade와 persisted/wire V1 호환을 언제 유지하고 언제 version-up할지 명시한다.
- default bootstrap에 optional capability를 새로 조립하지 않는다. 구현과 browser/provider evidence가 준비된 뒤 별도 product selection으로 승격한다.
## 보고서 구성과 범위
| 문서 | 구현 범위 | 파일 수 | 핵심 주제 |
| --- | --- | ---: | --- |
| [01 — Network and state](./01-network-and-state.md) | `http`, `auth`, `query-cache`, `cross-context-invalidation`, `platform`, `diagnostics`, `telemetry` | 24 | HTTP authority/effect, diagnostics·telemetry, ETag key, cancellation |
| [02 — Realtime and Browser RPC](./02-realtime-and-browser-rpc.md) | `realtime`, `browser-rpc` | 21 | stream lease, DRAINING, handoff writer, immutable binding, backpressure |
| [03 — Storage and browser files](./03-storage-and-browser-files.md) | `storage`, `browser-files`, `browser-file-storage`, `cache-storage` | 32 | OPFS saga, IndexedDB maintenance, file URL, public cache, quota/migration |
| [04 — Browser transfer](./04-browser-transfer.md) | `browser-transfer` | 24 | presigned capability, resumable upload, image CDN |
| [05 — Service Worker and Web Push](./05-service-worker-and-web-push.md) | `service-worker`, `web-push` | 17 | cache ownership, activation/removal, worker protocol, push authority |
합계는 118/118 파일이다. [전수 inventory](./INVENTORY.md)가 full path와 상세 리뷰를 일대일로 연결하고, 각 하위 문서의 파일 표가 책임, 직접 dependency/downstream, 판정을 기록한다.
## 최우선 finding
| 순서 | ID | 상태/심각도 | 확정 영향 | 구현 결정 |
| ---: | --- | --- | --- | --- |
| 1 | `STO-01` | 확정 / Critical | OPFS pre-commit cleanup 실패·취소 뒤 journal을 지워 복구 근거를 잃고, 늦은 generation-only cleanup이 후속 write를 삭제할 수 있다. | cleanup 확인 전 journal/budget rollback 금지, compensation signal 분리, transaction-unique physical generation token, cleanup 종료까지 mutation lease 유지 |
| 2 | `N-01` | 확정 / High / 현재 V3 | HTTP V3 observation의 미허용 context key 때문에 모든 request diagnostic이 drop되고 terminal failure telemetry도 없다. | typed observation을 closed diagnostic/telemetry bucket으로 투영하고 route ID를 executor context에 보존 |
| 3 | `N-02` | 확정 / High / 현재 V3 | `authProfileId`가 조립·강제되지 않아 bearer 필수 header와 transport-owned credentials/header invariant를 증명하지 못한다. | immutable auth Profile/Strategy registry, credential owner는 허용된 proof header만 제공, missing/extra는 fetch 전 fail-close |
| 4 | `N-03` | 확정 / High | 이미 dispatch된 command가 retry-time scope fence에서 `MAYBE_APPLIED`에서 `NOT_STARTED`로 하향될 수 있다. | logical execution 전체에 monotonic effect-certainty join 적용 |
| 5 | `N-04` | 확정 / High | telemetry가 dispose 뒤 scheduled/new/in-flight delivery를 계속하고 composition teardown이 dispose를 호출하지 않는다. | `ACTIVE/DISPOSED`, joined flush, in-flight abort, infrastructure teardown 연결 |
| 6 | `STO-02` | 확정 / High | download URL은 `baseOrigin`으로 검증하지만 원문 상대 URL은 `document.baseURI`로 실행된다. | parse-once canonical absolute URL만 handoff |
| 7 | `SW-URL-01`, `SW-01`~`SW-05` | 확정/gap / P1 | generated URL 분류 불일치, stale static response 선택, 과도한 prefix delete, 거짓 unregister/removal success, manifest 검증 부재 | canonical absolute runtime URL set, current-cache-only lookup, exact ownership parser, truthful cleanup result, shared strict manifest codec |
| 8 | `WP-01`~`WP-03` | 확정/gap / P1 / 미조립 | fence revision·mutation effect·backend authority receipt가 충분히 묶이지 않는다. | exact next revision, unknown effect recovery, V2 full authority/request binding receipt |
| 9 | `R-01`~`R-04` | 확정 / High / 미조립 | non-cooperative stream/effect가 무한 대기하거나 active writer가 유실되고 Browser RPC binding이 TOCTOU다. | explicit stream lease, retained DRAINING registry, retired writer set, immutable parse/validate/install |
| 10 | `BT-PRE-01`, `BT-PRE-02`, `BT-UP-03` | 확정/gap / P1 / 미조립 | eager download 자원 누수, wire envelope version 부재, late IndexedDB delete effect 오보고 | lazy closeable lease, protocol literal, `PENDING/effect UNKNOWN` outcome |
하위 문서의 나머지 Medium/P2/P3 항목도 생략 대상이 아니다. 위 표는 release·promotion을 막는 순서만 압축한 것이다.
## 공통 설계 결정
### D-01 — effect certainty는 단조 증가한다
한 번 native/network mutation을 dispatch한 뒤에는 새 retry가 아직 시작되지 않았다는 이유로 전체 logical operation을 `NOT_STARTED`로 되돌리지 않는다. 결과는 `NOT_STARTED → NOT_APPLIED/MAYBE_APPLIED → APPLIED_CONFIRMED`의 보수적 lattice로 join한다. IndexedDB/OPFS/Web Push처럼 deadline 뒤 native commit 가능성을 취소할 수 없는 API는 `UNKNOWN`을 명시하고 bounded read-back/reconcile만 허용한다.
### D-02 — commit fence와 resource settlement를 분리한다
abort/deadline 시 late commit capability는 즉시 폐기하지만, non-cooperative Promise·stream·writer reference는 실제 settlement까지 버리지 않는다. public wait은 bounded하게 끝내되 내부 lifecycle은 `DRAINING`으로 남고 같은 physical owner의 신규 admission을 막는다. `close()`가 성공했다면 tracked task가 실제로 quiescent여야 한다.
### D-03 — 외부/조립 입력은 parse → validate → install한다
TypeScript `Readonly`나 한 번의 boolean validator를 runtime immutability로 취급하지 않는다. registry, contract binding, provider response는 exact own-data descriptor와 closed key set을 검사한 immutable snapshot으로 설치하고 이후 원본을 다시 읽지 않는다. getter, extra/symbol key, revoked proxy는 composition/decoder 경계에서 fail-close한다.
### D-04 — 검증한 값을 그대로 실행한다
URL·path·header·manifest는 parse-once canonical form을 반환하고 network/navigation/cache operation은 그 canonical 값을 사용한다. boolean 검증 후 원문을 다른 base/decoder로 다시 해석하지 않는다. provider별 double-decode 가능성이 있는 encoded separator는 계약 fixture로 닫는다.
### D-05 — marker와 hint는 권위가 아니다
cache release marker는 “작성 완료 주장”일 뿐 모든 entry의 존재·digest 증거가 아니다. BroadcastChannel/storage event와 realtime cancellation은 hint이며 server/CAS/generation authority를 대신하지 않는다. 재사용·activation·복구 경로는 exact identity와 content를 다시 검증한다.
### D-06 — operation별 최소 dependency만 요구한다
stage에는 fetch가 필요하지만 local activate/cleanup에는 필요하지 않다. capability availability를 편의상 하나의 공통 guard로 묶지 않고 operation별로 분리한다. offline rollback/cleanup을 네트워크 부재 때문에 차단하지 않는다.
### D-07 — state machine과 Saga 경계로만 큰 runtime을 나눈다
파일 길이만으로 분해하지 않는다. 먼저 facade의 success/failure/cancel/call-order characterization을 고정한 뒤 순수 transition, retry policy, bounded scheduler, persistence reconciler, compensation saga를 추출한다. public capability identity, failure taxonomy, persisted schema, wire semantics는 별도 versioned migration 없이는 바꾸지 않는다.
### D-08 — abort/deadline mechanics만 공유한다
listener/timer 정리, first-terminal-owner, late rejection 관찰, late native handle compensation은 platform utility로 통합할 수 있다. HTTP, browser data, Web Push, realtime의 result taxonomy와 recovery vocabulary는 각 adapter에 남긴다. 범용 middleware/interceptor나 하나의 generic repository로 합치지 않는다.
### D-09 — optional capability의 미조립 상태를 유지한다
`AVAILABLE_NOT_COMPOSED`, `DESIGNED_NOT_IMPLEMENTED`, `NOT_SELECTED`는 defect status가 아니다. realtime, Browser RPC, Web Push, resumable upload, image provider, storage coordinator를 이번 remediation만으로 default bootstrap에 설치하지 않는다. 관련 P1/P2 closure, actual browser/provider/load evidence, product-owned policy·consent·registry가 모두 준비되어야 별도 selection change를 연다.
## 구현 순서
서로 다른 subsystem을 한 PR에 섞지 않는다. 각 항목은 failing characterization → 최소 수정 → focused green → type/architecture/lint → commit 순서다.
1. **Containment:** product-specific composition에서 OPFS v1 writer 사용 여부를 확인하고, 사용 중이면 신규 write admission을 read-only/export-required로 닫는다. template 기본 bootstrap은 OPFS를 조립하지 않는다.
2. **현재 실행 경로:** `STO-01`, `N-01`~`N-04`, `STO-02`를 독립 PR로 수정한다.
3. **기존 rollback/sidecar:** `N-05`~`N-11`과 legacy HTTP V2 hardening을 처리한다. V2를 지우는 일은 zero-caller와 rollback-window 종료 뒤 별도 PR이다.
4. **선택 capability correctness:** `SW-URL-01`, `SW-01`~`SW-09`, `WP-01`~`WP-07`, `R-01`~`R-06`, browser-transfer P1/P2를 subsystem별 PR로 닫는다.
5. **기존 version/migration 계획:** Service Worker V2(`SW-10`), OPFS physical/protocol V2, presigned/Web Push receipt V2를 expand → dual-read/emit → old-writer drain → contract 순서로 배포한다.
6. **구조 리팩터링:** behavior가 모두 green인 상태에서 resumable upload, image CDN, OPFS worker, public cache, download strategy를 characterization-preserving extraction으로 나눈다.
7. **Promotion gaps:** preview decode, bounded origin/cache maintenance, Browser RPC concrete transport, image descriptor provider 등 명시된 gap을 실제 browser/provider conformance와 함께 구현한다. 완료 전 availability state를 올리지 않는다.
질문 없는 세부 실행 절차는 [Adapter Remediation Implementation Plan](../../superpowers/plans/2026-08-13-adapter-remediation.md)에 있으며, finding별 exact API·test·migration은 각 하위 리뷰가 source of truth다.
## 기존 계획과의 우선권
| 기존 계획 | 유지할 내용 | 이번 리뷰가 추가하는 선행 조건 |
| --- | --- | --- |
| [2026-08-01 HTTP/worker remediation](../../superpowers/plans/2026-08-01-http-worker-adapter-remediation.md) Tasks 13 | installed HTTP contract 단일 권위, provider-neutral outcome, bound-only query API | `N-01`~`N-03` auth/observation/effect 결함을 같은 V3 migration에 먼저 포함 |
| 같은 계획 Task 4 | bounded Service Worker marker reader | 그대로 유지; `SW-01`~`SW-09`의 cache/lifecycle truth를 함께 닫은 뒤 V2로 이동 |
| 같은 계획 Task 5 | full identity Service Worker protocol V2 | `SW-10`으로 승계. 새 protocol을 두 번 설계하지 않는다. |
| 같은 계획 Task 6 | shared IndexedDB persisted-row schema | 그대로 유지하되 `STO-06` deadline/drain lease test를 extraction 전 추가 |
| 같은 계획 Task 7 | OPFS/cache/download cohesive decomposition | `STO-01`~`STO-05` correctness fix와 characterization이 먼저다. |
| [2026-08-01 runtime correctness](../../superpowers/plans/2026-08-01-runtime-correctness-remediation.md) Tasks 15 | query key/invalidation, application mutation intent, keyed command preflight, effect-aware settlement | 새 plan이 대체하지 않는다. `N-03`, `N-05`, `N-06`을 동일 certainty/key authority에 병합한다. |
충돌 시 우선순위는 **현재 재현 결함의 fail-close 수정 → 기존 plan의 계약 통합 → 구조 추출 → optional capability 조립**이다. 두 기존 plan을 완료로 표시하거나 삭제하지 않는다.
## 검증 기준선
- `corepack pnpm check:types`: 통과.
- `corepack pnpm lint`: 통과.
- `corepack pnpm check:architecture`: sandbox child-process 제약에서는 실패했으나 동일 명령을 허용된 실행 환경에서 다시 수행해 286 modules / 854 dependencies, 12 fixture, TS-only/allowed/forbidden gate가 모두 통과했다.
- 영역별 focused baseline:
- network/state: 21 files / 144 tests 통과, `check:diagnostics` 통과.
- realtime/Browser RPC: 16 files / 185 tests 통과, source boundary gate 통과.
- storage/files/cache: 7 files / 105 tests 통과.
- browser transfer: 6 files / 93 tests 통과; Service Worker/Web Push: 8 files / 52 tests 통과(독립 재감사 실행).
- 전체 `test:unit`은 이 sandbox에서 child `spawnSync ... EPERM`이 발생한 세 CI/evidence test file 때문에 108 files 통과, 3 files 실패(1465 tests 통과, 50 실패)였다. adapter focused suite의 실패가 아니며 전체 green으로 주장하지 않는다.
최종 산출물 검증은 118/118 inventory 포함, placeholder/깨진 local path 검사, Markdown diff 검사, focused adapter tests, type/architecture/lint를 다시 실행한다.
## 명시적으로 하지 않는 변경
- 이 리뷰에서는 production source를 수정하거나 optional adapter를 bootstrap에 조립하지 않는다.
- private/range cache, persistent browser handles, resumable range download, arbitrary Web Push copy/URL 같은 별도 미선택 capability를 기존 adapter에 섞지 않는다.
- timeout을 이유로 irreversible native mutation이 적용되지 않았다고 추정하지 않는다.
- cleanup 실패를 observation만 남기고 success로 바꾸지 않는다.
- schema/database version을 downgrade하거나 broad prefix/root/database 전체 삭제를 rollback으로 사용하지 않는다.
- SSE↔WebSocket, Connect↔gRPC-Web↔REST를 장애 중 자동 전환하지 않는다.
File diff suppressed because it is too large Load Diff
+26 -8
View File
@@ -1,24 +1,26 @@
import type {
AuthSessionPort,
CredentialOperationContext,
CredentialPatch,
CredentialRequestBinding,
SessionState,
} from "../../application/ports/auth-session-port.ts";
import { CREDENTIAL_HEADER_NAMES } from "../../contracts/rest-profiles.ts";
export type ExternalSessionOwner = Readonly<{
readState(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
attachCredential(binding: CredentialRequestBinding): Promise<CredentialPatch>;
attachCredential(
binding: CredentialRequestBinding,
context?: CredentialOperationContext,
): Promise<CredentialPatch>;
recoverSession(): Promise<"restored" | "no-session">;
notifyUnauthenticated(): void;
}>;
const ALLOWED_CREDENTIAL_HEADERS = new Set([
"authorization",
"x-csrf-token",
]);
const ALLOWED_CREDENTIAL_HEADERS = new Set<string>(CREDENTIAL_HEADER_NAMES);
const MAX_HEADER_VALUE_BYTES = 8_192;
export function validateCredentialPatch(value: unknown): CredentialPatch {
@@ -54,8 +56,10 @@ export function createExternalAuthSessionAdapter(
subscribe: (listener) => owner.subscribe(listener),
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
signOut: () => owner.signOut(),
async credentialPatch(binding) {
return validateCredentialPatch(await owner.attachCredential(binding));
async credentialPatch(binding, context) {
return validateCredentialPatch(
await owner.attachCredential(binding, context),
);
},
async recover() {
const result = await owner.recoverSession();
@@ -85,9 +89,23 @@ export function createAnonymousSessionAdapter(): AuthSessionPort {
export type DemoSessionAdapter = AuthSessionPort &
Readonly<{ setState(next: SessionState): void }>;
/**
* §7.7. `AUTH_MODE=demo` still runs against the strict
* `REFERENCE_EXTERNAL_BEARER` profile, so the demo owner must supply a real
* proof header. This marker is a fixed, non-secret placeholder: it exists so
* the demo path satisfies the bearer contract instead of weakening it.
*/
export const DEMO_AUTHORIZATION_MARKER = "Bearer demo-session-not-a-secret";
const DEMO_PATCH = Object.freeze({
headers: Object.freeze({ authorization: DEMO_AUTHORIZATION_MARKER }),
});
export function createDemoSessionAdapter(
initialState: SessionState = "unauthenticated",
demoPatch: CredentialPatch = DEMO_PATCH,
): DemoSessionAdapter {
const patch = validateCredentialPatch(demoPatch);
let state = initialState;
const listeners = new Set<() => void>();
const setState = (next: SessionState) => {
@@ -106,7 +124,7 @@ export function createDemoSessionAdapter(
async signOut() {
setState("unauthenticated");
},
credentialPatch: async () => EMPTY_PATCH,
credentialPatch: async () => patch,
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
+112 -11
View File
@@ -2,6 +2,11 @@ import {
HTTP_EXECUTION_CEILINGS,
type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
import {
CREDENTIAL_HEADER_NAMES,
type CredentialHeaderName,
type RestAuthProfile,
} from "../../contracts/rest-profiles.ts";
/**
* §7.4–§7.7. Descriptor-driven request projection.
@@ -11,21 +16,99 @@ import {
* bounds and re-verifies them.
*/
/**
* §7.7 / VD-23. A credential owner contributes proof headers only. Fetch
* `credentials` belongs to the installed auth profile, so it is deliberately
* absent from this outcome.
*/
export type CredentialPatchOutcome =
| Readonly<{
kind: "READY";
headers: Readonly<Record<string, string>>;
credentials: RequestCredentials;
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
}>
| Readonly<{ kind: "UNAUTHENTICATED" }>
| Readonly<{ kind: "UNAVAILABLE" }>
| Readonly<{ kind: "SCOPE_FENCED" }>;
/** §7.7. The complete set of headers a credential bridge may contribute. */
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"x-csrf-token",
"x-tenant-context",
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set(
CREDENTIAL_HEADER_NAMES,
);
export type CredentialAdmissionFailure =
| "TRANSPORT_OWNED_HEADER"
| "CREDENTIAL_HEADER_NOT_ALLOWED"
| "CREDENTIAL_HEADER_VALUE_INVALID"
| "MISSING_REQUIRED_CREDENTIAL_HEADER";
export type CredentialAdmissionOutcome =
| Readonly<{
ok: true;
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
}>
| Readonly<{ ok: false; failure: CredentialAdmissionFailure }>;
const MAX_CREDENTIAL_HEADER_VALUE_BYTES = 8_192;
/**
* §7.7. Admits a credential patch against the resolved profile before any
* header object is built. A rejection here guarantees `fetch()` is not called:
* a credential owner cannot widen the profile, replace a transport-owned
* header, or turn an authenticated profile into an anonymous request.
*/
export function admitCredentialHeaders(
patchHeaders: Readonly<Record<string, unknown>>,
profile: Readonly<{
allowedCredentialHeaders: readonly CredentialHeaderName[];
requiredCredentialHeaders: readonly CredentialHeaderName[];
}>,
): CredentialAdmissionOutcome {
const admitted: Partial<Record<CredentialHeaderName, string>> = {};
const seen = new Set<CredentialHeaderName>();
for (const [name, value] of Object.entries(patchHeaders)) {
const lower = name.toLowerCase();
if (TRANSPORT_OWNED_HEADERS.has(lower) || FORBIDDEN_REQUEST_HEADERS.has(lower)) {
return frozenAdmissionFailure("TRANSPORT_OWNED_HEADER");
}
if (
!ALLOWED_CREDENTIAL_HEADERS.has(lower) ||
!profile.allowedCredentialHeaders.includes(lower as CredentialHeaderName)
) {
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
}
const credentialName = lower as CredentialHeaderName;
if (seen.has(credentialName)) {
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
}
if (
typeof value !== "string" ||
value.length === 0 ||
/[\r\n]/.test(value) ||
encoder.encode(value).byteLength > MAX_CREDENTIAL_HEADER_VALUE_BYTES
) {
return frozenAdmissionFailure("CREDENTIAL_HEADER_VALUE_INVALID");
}
seen.add(credentialName);
admitted[credentialName] = value;
}
for (const required of profile.requiredCredentialHeaders) {
if (!seen.has(required)) {
return frozenAdmissionFailure("MISSING_REQUIRED_CREDENTIAL_HEADER");
}
}
return Object.freeze({ ok: true as const, headers: Object.freeze(admitted) });
}
function frozenAdmissionFailure(
failureKind: CredentialAdmissionFailure,
): CredentialAdmissionOutcome {
return Object.freeze({ ok: false as const, failure: failureKind });
}
const TRANSPORT_OWNED_HEADERS: ReadonlySet<string> = new Set([
"accept",
"content-type",
"idempotency-key",
]);
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
@@ -217,6 +300,8 @@ export type FinalInvariantInput = Readonly<{
requestByteLimit: number;
deadlineRemainingMs: number;
scopeIsCurrent: boolean;
/** The resolved installed profile this dispatch must match exactly. */
authProfile: RestAuthProfile;
}>;
export type FinalInvariantFailure =
@@ -224,7 +309,10 @@ export type FinalInvariantFailure =
| "URL_NOT_ALLOWED"
| "REDIRECT_MODE_INVALID"
| "CREDENTIALS_MODE_INVALID"
| "CREDENTIALS_MODE_MISMATCH"
| "HEADER_NOT_ALLOWED"
| "CREDENTIAL_HEADER_NOT_ALLOWED"
| "MISSING_REQUIRED_CREDENTIAL_HEADER"
| "FORBIDDEN_HEADER"
| "REQUEST_BODY_TOO_LARGE"
| "DEADLINE_EXPIRED"
@@ -258,17 +346,30 @@ export function checkFinalInvariants(
) {
return "CREDENTIALS_MODE_INVALID";
}
// The profile is the transport authority: a credential collaborator cannot
// move the request onto a different Fetch credentials mode.
if (input.init.credentials !== input.authProfile.credentials) {
return "CREDENTIALS_MODE_MISMATCH";
}
const presentCredentialHeaders = new Set<string>();
for (const name of Object.keys(input.headers)) {
const lower = name.toLowerCase();
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
if (TRANSPORT_OWNED_HEADERS.has(lower)) continue;
if (!ALLOWED_CREDENTIAL_HEADERS.has(lower)) return "HEADER_NOT_ALLOWED";
if (
lower !== "accept" &&
lower !== "content-type" &&
lower !== "idempotency-key" &&
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
!input.authProfile.allowedCredentialHeaders.includes(
lower as CredentialHeaderName,
)
) {
return "HEADER_NOT_ALLOWED";
return "CREDENTIAL_HEADER_NOT_ALLOWED";
}
presentCredentialHeaders.add(lower);
}
for (const required of input.authProfile.requiredCredentialHeaders) {
if (!presentCredentialHeaders.has(required)) {
return "MISSING_REQUIRED_CREDENTIAL_HEADER";
}
}
+86 -3
View File
@@ -17,10 +17,16 @@ import {
readBoundedBytes,
} from "./bounded-body-reader.ts";
import {
admitCredentialHeaders,
checkFinalInvariants,
projectRequest,
type CredentialAdmissionFailure,
type CredentialPatchOutcome,
} from "./http-contract-bridge.ts";
import {
INSTALLED_REST_AUTH_PROFILES,
type InstalledRestAuthProfiles,
} from "../../contracts/rest-profiles.ts";
import {
certaintyForAbandonedAttempt,
classifyProblemEffect,
@@ -124,6 +130,29 @@ export type HttpExecutionOutcome<Value, Problem> =
| Readonly<{
kind: "CANCELLED";
effect: "NOT_STARTED" | "MAYBE_APPLIED";
}>
| Readonly<{
kind: "AUTH_INTEGRATION_FAILURE";
reason: AuthIntegrationFailureReason;
effect: "NOT_APPLICABLE" | "NOT_STARTED";
}>;
/**
* §7.7 / VD-23. A configuration or collaborator contract breach, never a user
* session state. `UNAUTHENTICATED` stays reserved for the latter.
*/
export type AuthIntegrationFailureReason =
| "UNKNOWN_AUTH_PROFILE"
| CredentialAdmissionFailure;
/**
* §8.5. Credential collaborators receive the operation lifetime so a
* cooperative owner can abandon its own work; a non-cooperative one is still
* bounded by the executor's race against the same signal.
*/
export type AuthOperationContext = Readonly<{
signal: AbortSignal;
deadlineAtMonotonicMs: number;
}>;
export type CancellationOwner =
@@ -206,6 +235,8 @@ function observationErrorKind(
: outcome.failure.kind;
case "CANCELLED":
return "REQUEST_ABORTED";
case "AUTH_INTEGRATION_FAILURE":
return outcome.reason;
}
}
@@ -221,12 +252,15 @@ export type ContractHttpExecutorDependencies = Readonly<{
baseUrl: string;
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
maxRetryAttempts: number;
/** The installed profile registry; the executor never invents a profile. */
authProfiles?: InstalledRestAuthProfiles;
attachCredentials(
operation: Readonly<{
operationId: string;
authProfileId: string;
method: string;
}>,
context: AuthOperationContext,
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
fetcher?: typeof fetch;
/** Adapter seam for the common bounded response reader. */
@@ -341,6 +375,8 @@ export function createContractHttpExecutor(
dependencies: ContractHttpExecutorDependencies,
): ContractHttpExecutor {
const fetcher = dependencies.fetcher ?? fetch;
const authProfiles =
dependencies.authProfiles ?? INSTALLED_REST_AUTH_PROFILES;
const readResponseBytes =
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
const now = dependencies.monotonicNow ?? (() => performance.now());
@@ -447,6 +483,16 @@ export function createContractHttpExecutor(
try {
// §7.7. The installed registry is the only source of a profile. Composition
// already rejects unknown identities; this is the runtime fail-close.
const authProfile = authProfiles.get(policy.authProfileId);
if (!authProfile) {
return finish(
authIntegrationFailure("UNKNOWN_AUTH_PROFILE", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// §7.4 step 1-2: capture the scope and verify it is still current.
if (!context.scope.isCurrent()) {
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
@@ -503,11 +549,17 @@ export function createContractHttpExecutor(
let patchOperation: Promise<CredentialPatchOutcome>;
try {
patchOperation = Promise.resolve(
dependencies.attachCredentials({
dependencies.attachCredentials(
{
operationId: contract.operationId,
authProfileId: policy.authProfileId,
method: contract.method,
},
Object.freeze({
signal: lifetimeController.signal,
deadlineAtMonotonicMs: deadlineAt,
}),
),
);
} catch {
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
@@ -548,6 +600,9 @@ export function createContractHttpExecutor(
// A missing credential never downgrades into an anonymous request.
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
}
// The idempotency key is contract-owned, so a credential owner supplying it
// stays the more specific request-contract violation.
if (
Object.keys(patch.headers).some(
(name) => name.toLowerCase() === "idempotency-key",
@@ -559,9 +614,21 @@ export function createContractHttpExecutor(
);
}
// §7.7. The profile, not the patch, decides what may travel. Rejection here
// means zero fetch calls.
const admission = admitCredentialHeaders(patch.headers, authProfile);
if (!admission.ok) {
return finish(
authIntegrationFailure(admission.failure, isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// Transport-owned headers are written last so no credential entry can
// shadow Accept or Content-Type through key ordering.
const headers: Record<string, string> = {
...admission.headers,
Accept: "application/json",
...patch.headers,
};
if (contract.requestBody === "JSON") {
headers["Content-Type"] = "application/json";
@@ -619,7 +686,7 @@ export function createContractHttpExecutor(
headers,
redirect: "error",
referrerPolicy: "no-referrer",
credentials: patch.credentials,
credentials: authProfile.credentials,
cache: "no-store",
signal: controller.signal,
...(projected.request.bodyBytes
@@ -636,6 +703,7 @@ export function createContractHttpExecutor(
requestByteLimit: policy.requestByteLimit,
deadlineRemainingMs: remaining(),
scopeIsCurrent: context.scope.isCurrent(),
authProfile,
});
if (invariantFailure) {
clearTimeout(deadlineTimer);
@@ -1232,6 +1300,21 @@ function scopeFenced<Value, Problem>(
return violation("SCOPE_FENCED", "RESPONSE", effect);
}
/**
* §7.7. A credential collaborator or profile-binding breach. It always resolves
* before dispatch, so the command effect is `NOT_STARTED` and fetch count zero.
*/
function authIntegrationFailure<Value, Problem>(
reason: AuthIntegrationFailureReason,
isCommand: boolean,
): HttpExecutionOutcome<Value, Problem> {
return Object.freeze({
kind: "AUTH_INTEGRATION_FAILURE" as const,
reason,
effect: isCommand ? ("NOT_STARTED" as const) : ("NOT_APPLICABLE" as const),
});
}
function preDispatchEffect(isCommand: boolean): HttpEffectCertainty {
return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE";
}
+14 -1
View File
@@ -22,8 +22,21 @@ export type CredentialPatch = Readonly<{
headers: Readonly<Record<string, string>>;
}>;
/**
* §8.5. The transport lifetime handed to a credential owner. A cooperative
* owner abandons its own work on abort; a non-cooperative one is still bounded
* because the transport races the same signal.
*/
export type CredentialOperationContext = Readonly<{
signal: AbortSignal;
deadlineAtMonotonicMs: number;
}>;
export type CredentialAttacher = Readonly<{
credentialPatch(binding: CredentialRequestBinding): Promise<CredentialPatch>;
credentialPatch(
binding: CredentialRequestBinding,
context?: CredentialOperationContext,
): Promise<CredentialPatch>;
onUnauthenticated(): void;
}>;
+13 -5
View File
@@ -29,7 +29,10 @@ import { createBrowserMutationIntentFactory } from "../adapters/platform/browser
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
import {
createRestProviderProfile,
INSTALLED_REST_AUTH_PROFILES,
} from "../contracts/rest-profiles.ts";
import type { ClockPort } from "../application/ports/clock-port.ts";
import type { MutationIntent } from "../contracts/mutation-intent.ts";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
@@ -375,7 +378,10 @@ export async function createRuntimeAdapters(
baseUrl: config.API_BASE_URL,
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
fetcher: context.fetcher,
async attachCredentials(operation) {
// §7.7. The installed registry owns Fetch credentials and the exact
// credential-header sets; this collaborator only supplies proof headers.
authProfiles: INSTALLED_REST_AUTH_PROFILES,
async attachCredentials(operation, authContext) {
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
@@ -387,18 +393,20 @@ export async function createRuntimeAdapters(
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
}
try {
const patch = await authSession.credentialPatch({
const patch = await authSession.credentialPatch(
{
origin: new URL(config.API_BASE_URL).origin,
method: operation.method,
operationId: operation.operationId,
});
},
authContext,
);
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
return Object.freeze({
kind: "READY" as const,
headers: patch.headers,
credentials: "omit" as const,
});
} catch {
return Object.freeze({ kind: "UNAVAILABLE" as const });
@@ -7,6 +7,8 @@
* applies before a contribution may be composed.
*/
import { INSTALLED_REST_AUTH_PROFILES } from "./rest-profiles.ts";
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
defaultRequestBytes: 262_144,
@@ -313,6 +315,11 @@ function assertExecutionPolicy(
) {
fail(`${label}: frontend execution policy identity`);
}
// §7.7 / VD-23. A declared profile that the installed registry does not own
// is a composition failure; the executor must never resolve it at runtime.
if (!INSTALLED_REST_AUTH_PROFILES.has(policy.authProfileId)) {
fail(`${label}: unknown authProfileId ${policy.authProfileId}`);
}
if (
!Number.isSafeInteger(policy.requestByteLimit) ||
policy.requestByteLimit < 0 ||
+138 -1
View File
@@ -8,13 +8,34 @@ export type RestProviderProfile = Readonly<{
referrerPolicy: "no-referrer";
}>;
/**
* VD-23. The complete closed set of headers a credential owner may contribute.
* Transport-owned headers (`accept`, `content-type`, `idempotency-key`) and
* every forbidden request header are deliberately absent.
*/
export const CREDENTIAL_HEADER_NAMES = Object.freeze([
"authorization",
"x-csrf-token",
"x-tenant-context",
] as const);
export type CredentialHeaderName = (typeof CREDENTIAL_HEADER_NAMES)[number];
export type RestAuthProfile = Readonly<{
authProfileId: string;
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
credentials: FetchCredentialsMode;
allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[];
allowedCredentialHeaders: readonly CredentialHeaderName[];
/**
* Proof headers the transport must observe before dispatch. A missing entry
* fails closed with zero `fetch()` calls rather than sending an anonymous
* request under an authenticated profile.
*/
requiredCredentialHeaders: readonly CredentialHeaderName[];
}>;
export type InstalledRestAuthProfiles = ReadonlyMap<string, RestAuthProfile>;
export type RestCsrfProfile = Readonly<{
csrfProfileId: string;
mode: "NONE" | "HEADER";
@@ -27,15 +48,131 @@ export const REST_AUTH_PROFILES = Object.freeze({
transport: "BEARER_HEADER",
credentials: "omit",
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
requiredCredentialHeaders: Object.freeze(["authorization"] as const),
}),
ANONYMOUS: Object.freeze({
authProfileId: "ANONYMOUS",
transport: "ANONYMOUS",
credentials: "omit",
allowedCredentialHeaders: Object.freeze([]),
requiredCredentialHeaders: Object.freeze([]),
}),
} satisfies Readonly<Record<string, RestAuthProfile>>);
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
return (
typeof value === "string" &&
(CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value)
);
}
function exactHeaderSet(
names: unknown,
label: string,
): readonly CredentialHeaderName[] {
if (!Array.isArray(names)) {
throw new TypeError(`REST auth profile ${label} must be an array.`);
}
const seen = new Set<string>();
for (const name of names) {
if (!isCredentialHeaderName(name) || seen.has(name)) {
throw new TypeError(`REST auth profile ${label} is not an exact set.`);
}
seen.add(name);
}
return Object.freeze([...(names as readonly CredentialHeaderName[])]);
}
/**
* §7.7 / VD-23. Installs the composition-owned auth profile registry once.
*
* The registry not a credential collaborator owns Fetch `credentials` and
* the exact allowed/required credential-header sets. An incoherent profile is a
* composition failure, never a runtime downgrade.
*/
export function installRestAuthProfileRegistry(
profiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
): InstalledRestAuthProfiles {
const installed = new Map<string, RestAuthProfile>();
for (const [key, candidate] of Object.entries(profiles)) {
if (!candidate || typeof candidate !== "object") {
throw new TypeError(`REST auth profile ${key} is not an object.`);
}
const authProfileId = candidate.authProfileId;
if (
typeof authProfileId !== "string" ||
authProfileId.length === 0 ||
authProfileId !== key
) {
throw new TypeError(`REST auth profile ${key} has a mismatched identity.`);
}
const allowed = exactHeaderSet(
candidate.allowedCredentialHeaders,
"allowedCredentialHeaders",
);
const required = exactHeaderSet(
candidate.requiredCredentialHeaders,
"requiredCredentialHeaders",
);
if (!required.every((name) => allowed.includes(name))) {
throw new TypeError(
`REST auth profile ${key} requires a header it does not allow.`,
);
}
const credentials = candidate.credentials;
if (!["omit", "same-origin", "include"].includes(credentials)) {
throw new TypeError(`REST auth profile ${key} has invalid credentials.`);
}
switch (candidate.transport) {
case "ANONYMOUS":
if (
credentials !== "omit" ||
allowed.length > 0 ||
required.length > 0
) {
throw new TypeError(
`Anonymous REST auth profile ${key} cannot carry credentials.`,
);
}
break;
case "BEARER_HEADER":
if (credentials !== "omit" || !required.includes("authorization")) {
throw new TypeError(
`Bearer REST auth profile ${key} must require authorization with omitted credentials.`,
);
}
break;
case "SAME_ORIGIN_COOKIE":
if (credentials === "omit" || allowed.includes("authorization")) {
throw new TypeError(
`Cookie REST auth profile ${key} must send ambient credentials without a bearer header.`,
);
}
break;
default:
throw new TypeError(`REST auth profile ${key} has unknown transport.`);
}
installed.set(
authProfileId,
Object.freeze({
authProfileId,
transport: candidate.transport,
credentials,
allowedCredentialHeaders: allowed,
requiredCredentialHeaders: required,
}),
);
}
if (installed.size === 0) {
throw new TypeError("REST auth profile registry cannot be empty.");
}
return Object.freeze(new Map(installed)) as InstalledRestAuthProfiles;
}
/** The single installed registry every composition root shares. */
export const INSTALLED_REST_AUTH_PROFILES: InstalledRestAuthProfiles =
installRestAuthProfileRegistry();
export const REST_CSRF_PROFILES = Object.freeze({
NO_CSRF_BEARER: Object.freeze({
csrfProfileId: "NO_CSRF_BEARER",
@@ -121,6 +121,15 @@ function projectExecutionOutcome(
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
effect: outcome.effect,
});
case "AUTH_INTEGRATION_FAILURE":
// §7.7. A configuration or collaborator breach, not a session state, so
// it must not drive the re-authentication surface.
return failure(
"AUTH_INTEGRATION_FAILURE",
operationId,
outcome.reason,
{ effect: outcome.effect },
);
case "TRANSPORT_FAILURE":
return failure(
outcome.failure.kind === "TIMEOUT"
+2 -2
View File
@@ -61,7 +61,7 @@ const readPolicy = Object.freeze({
responseByteLimit: 32_768,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "TEST_AUTH",
authProfileId: "ANONYMOUS",
diagnosticsOperation: "test.read",
});
@@ -192,7 +192,7 @@ export const TEST_CREATE_HTTP_CONTRACT: InstalledHttpContract<
responseByteLimit: 32_768,
totalDeadlineMs: 10_000,
retryBudget: 0 as const,
authProfileId: "TEST_AUTH",
authProfileId: "ANONYMOUS",
diagnosticsOperation: "test.create",
}),
});
@@ -0,0 +1,289 @@
import { describe, expect, it, vi } from "vitest";
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
import {
composeContractContributions,
ContractContributionError,
} from "../../src/contracts/external-contract-runtime.ts";
import {
installRestAuthProfileRegistry,
REST_AUTH_PROFILES,
} from "../../src/contracts/rest-profiles.ts";
import {
TEST_CONTRACT_CONTRIBUTION,
TEST_LIST_HTTP_CONTRACT,
} from "../helpers/external-contract-fixture.ts";
const ROUTE_ID = "TEST_ROUTE";
/**
* The shipped fixture policy declares `TEST_AUTH`; the installed registry owns
* the concrete transport rules, so this suite installs an equivalent bearer
* profile under that identity.
*/
const TEST_PROFILES = installRestAuthProfileRegistry({
TEST_AUTH: {
authProfileId: "TEST_AUTH",
transport: "BEARER_HEADER",
credentials: "omit",
allowedCredentialHeaders: ["authorization"],
requiredCredentialHeaders: ["authorization"],
},
TEST_ANONYMOUS: {
authProfileId: "TEST_ANONYMOUS",
transport: "ANONYMOUS",
credentials: "omit",
allowedCredentialHeaders: [],
requiredCredentialHeaders: [],
},
});
function scopeSnapshot(isCurrent: () => boolean = () => true) {
return Object.freeze({
generation: 1,
fingerprint: "scope-1",
identities: Object.freeze({}) as never,
signal: new AbortController().signal,
isCurrent,
});
}
function operationWithProfile(authProfileId: string, deadlineMs?: number) {
return {
...TEST_LIST_HTTP_CONTRACT,
frontend: {
...TEST_LIST_HTTP_CONTRACT.frontend,
authProfileId,
...(deadlineMs === undefined ? {} : { totalDeadlineMs: deadlineMs }),
},
};
}
const bearerOperation = () => operationWithProfile("TEST_AUTH");
const anonymousOperation = () => operationWithProfile("TEST_ANONYMOUS");
describe("V3 installed auth profile authority", () => {
it("rejects an unknown auth profile during composition", () => {
const contribution = {
...TEST_CONTRACT_CONTRIBUTION,
http: [
{
...TEST_LIST_HTTP_CONTRACT,
frontend: {
...TEST_LIST_HTTP_CONTRACT.frontend,
authProfileId: "NO_SUCH_PROFILE",
},
},
] as unknown as readonly never[],
};
let thrown: unknown;
try {
composeContractContributions([contribution] as never);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(ContractContributionError);
expect((thrown as ContractContributionError).reason).toContain(
"unknown authProfileId NO_SUCH_PROFILE",
);
expect(() =>
installRestAuthProfileRegistry({
BROKEN: {
authProfileId: "BROKEN",
transport: "BEARER_HEADER",
credentials: "include",
allowedCredentialHeaders: [],
requiredCredentialHeaders: ["authorization"],
},
}),
).toThrow(TypeError);
});
it("rejects credential attempts to replace Accept Content-Type or credentials", async () => {
for (const hostileHeaders of [
{ accept: "text/plain" },
{ "content-type": "text/plain" },
{ cookie: "session=1" },
]) {
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () =>
({
kind: "READY" as const,
headers: {
authorization: "Bearer token",
...hostileHeaders,
},
}) as never,
fetcher: fetcher as unknown as typeof fetch,
});
const outcome = await executor.execute(
bearerOperation(),
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome).toMatchObject({
kind: "AUTH_INTEGRATION_FAILURE",
effect: "NOT_APPLICABLE",
});
expect(fetcher).not.toHaveBeenCalled();
}
});
it("requires authorization for a bearer profile before fetch", async () => {
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
}),
fetcher: fetcher as unknown as typeof fetch,
});
const outcome = await executor.execute(
bearerOperation(),
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome).toMatchObject({
kind: "AUTH_INTEGRATION_FAILURE",
reason: "MISSING_REQUIRED_CREDENTIAL_HEADER",
});
expect(fetcher).not.toHaveBeenCalled();
});
it("forbids credential headers for an anonymous profile", async () => {
const withCredential = vi.fn();
const rejected = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () => ({
kind: "READY" as const,
headers: { authorization: "Bearer token" },
}),
fetcher: withCredential as unknown as typeof fetch,
});
const rejectedOutcome = await rejected.execute(
anonymousOperation(),
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(rejectedOutcome).toMatchObject({
kind: "AUTH_INTEGRATION_FAILURE",
reason: "CREDENTIAL_HEADER_NOT_ALLOWED",
});
expect(withCredential).not.toHaveBeenCalled();
const observed: Array<Readonly<Record<string, string>>> = [];
const accepted = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () => ({ kind: "READY" as const, headers: {} }),
fetcher: (async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
observed.push(
Object.freeze(Object.fromEntries(headers.entries())),
);
expect(init?.credentials).toBe("omit");
return Response.json([]);
}) as unknown as typeof fetch,
});
const acceptedOutcome = await accepted.execute(
anonymousOperation(),
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(acceptedOutcome.kind).toBe("SUCCESS");
expect(observed).toHaveLength(1);
expect(observed[0]?.authorization).toBeUndefined();
expect(observed[0]?.accept).toBe("application/json");
});
it("bounds a non-cooperative credential owner by the operation lifetime", async () => {
const fetcher = vi.fn();
let observedSignal: AbortSignal | undefined;
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: (_operation, context) => {
observedSignal = context.signal;
// Non-cooperative: it never settles on its own.
return new Promise<never>(() => {});
},
fetcher: fetcher as unknown as typeof fetch,
});
const outcome = await executor.execute(
operationWithProfile("TEST_AUTH", 10),
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome).toMatchObject({
kind: "TRANSPORT_FAILURE",
failure: { kind: "TIMEOUT" },
});
expect(fetcher).not.toHaveBeenCalled();
expect(observedSignal?.aborted).toBe(true);
});
it("ignores a late credential completion after caller abort or scope fence", async () => {
const fetcher = vi.fn();
let release: ((patch: unknown) => void) | undefined;
const caller = new AbortController();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () =>
new Promise((resolve) => {
release = resolve as (patch: unknown) => void;
}) as never,
fetcher: fetcher as unknown as typeof fetch,
});
const execution = executor.execute(
bearerOperation(),
{ limit: 5 },
{
routeId: ROUTE_ID,
scope: scopeSnapshot(),
signal: caller.signal,
},
);
await Promise.resolve();
caller.abort();
const outcome = await execution;
expect(outcome.kind).toBe("CANCELLED");
release?.({
kind: "READY",
headers: { authorization: "Bearer late" },
});
await Promise.resolve();
expect(fetcher).not.toHaveBeenCalled();
});
it("keeps the shipped bearer profile strict", () => {
expect(REST_AUTH_PROFILES.REFERENCE_EXTERNAL_BEARER).toMatchObject({
transport: "BEARER_HEADER",
credentials: "omit",
requiredCredentialHeaders: ["authorization"],
});
expect(REST_AUTH_PROFILES.ANONYMOUS.requiredCredentialHeaders).toEqual([]);
});
});
@@ -231,10 +231,11 @@ async function executeScenario(
const executor = createContractHttpExecutor({
baseUrl: "https://api.test",
maxRetryAttempts: 2,
// The reference contribution declares REFERENCE_EXTERNAL_BEARER, so the
// credential owner must supply the required proof header before dispatch.
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
headers: { authorization: "Bearer scenario-token" },
}),
fetcher,
random: () => 0,