Compare commits
187
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5434760ddf | ||
|
|
9ca5c3f668 | ||
|
|
711d61e73f | ||
|
|
0a97d235e4 | ||
|
|
b23a1b80ca | ||
|
|
10a04d3695 | ||
|
|
3ea3397691 | ||
|
|
7485cd86e4 | ||
|
|
dfb7734674 | ||
|
|
a0fbafb77b | ||
|
|
8157ad4029 | ||
|
|
d5e7f4127a | ||
|
|
d7b35cfca3 | ||
|
|
39a4a973a8 | ||
|
|
aa8ac35600 | ||
|
|
632b230c82 | ||
|
|
df18349682 | ||
|
|
cc91fc6ae0 | ||
|
|
8d6d84bfcc | ||
|
|
5a76f95291 | ||
|
|
46e067e555 | ||
|
|
c0f53d1855 | ||
|
|
a7390e3b3a | ||
|
|
250531aa43 | ||
|
|
af7f35058b | ||
|
|
69cb7e35ca | ||
|
|
fb5b449031 | ||
|
|
efc577de63 | ||
|
|
bd90e0c983 | ||
|
|
6a8281a941 | ||
|
|
ca210d3bc5 | ||
|
|
f4bfdf0365 | ||
|
|
3b481eb4cf | ||
|
|
78f1bb273e | ||
|
|
000a2581af | ||
|
|
976c8a8da4 | ||
|
|
db52f02d73 | ||
|
|
f6098242be | ||
|
|
fce8e046ea | ||
|
|
58efe6ddbd | ||
|
|
cc4e875c2d | ||
|
|
8f67974f68 | ||
|
|
2f29ccbf1a | ||
|
|
c9e820aed5 | ||
|
|
4fe924ee0f | ||
|
|
b893d95b36 | ||
|
|
ba79060a83 | ||
|
|
618da9abf5 | ||
|
|
6d1e44f206 | ||
|
|
e06e4377ca | ||
|
|
4e87bacdf3 | ||
|
|
67cc5b6d2c | ||
|
|
f7bec8274b | ||
|
|
4dc033cf33 | ||
|
|
30ceac23c1 | ||
|
|
42ffb79997 | ||
|
|
f49d147b01 | ||
|
|
1bb2cc4a20 | ||
|
|
777ce5c9ed | ||
|
|
8565b96782 | ||
|
|
d2eb320936 | ||
|
|
e08d8c2dd8 | ||
|
|
abdd90ad5d | ||
|
|
76bf9f1aa3 | ||
|
|
5cc6b8a51c | ||
|
|
e0373de4d9 | ||
|
|
5cecbb9820 | ||
|
|
6e05a35790 | ||
|
|
8d6fbb97e9 | ||
|
|
67cd37659d | ||
|
|
5a73f7a1b5 | ||
|
|
f487823442 | ||
|
|
1b4b0c2821 | ||
|
|
92e5cace5c | ||
|
|
7c5ed80407 | ||
|
|
100a3bb6ba | ||
|
|
76d0ab0f62 | ||
|
|
d6c98489ee | ||
|
|
381d5549e2 | ||
|
|
c9f5887cac | ||
|
|
2c3cab2518 | ||
|
|
172a26b8bd | ||
|
|
990603e24a | ||
|
|
184bd98d92 | ||
|
|
d9afccdd60 | ||
|
|
15645541b7 | ||
|
|
fa2f699125 | ||
|
|
cbcc7b5ed7 | ||
|
|
53d181fbe4 | ||
|
|
0eb23875cb | ||
|
|
73a50426d6 | ||
|
|
853c2e3f30 | ||
|
|
92c3d438ab | ||
|
|
a49c76b5b2 | ||
|
|
c6da03369c | ||
|
|
9c959ea2a5 | ||
|
|
40c1870873 | ||
|
|
6c52cdb916 | ||
|
|
d3ef801fe6 | ||
|
|
3d810ef695 | ||
|
|
6c73b845bd | ||
|
|
638f5f71bd | ||
|
|
8b4f875c1c | ||
|
|
a64708f3de | ||
|
|
98d4fd4960 | ||
|
|
3f634eb655 | ||
|
|
5173b6c8d6 | ||
|
|
2fa0baa577 | ||
|
|
668bf05b48 | ||
|
|
b8c0444217 | ||
|
|
13f28ef811 | ||
|
|
e49d90f713 | ||
|
|
b327d7370b | ||
|
|
fdcf0de5bf | ||
|
|
c11be43f20 | ||
|
|
980981bc86 | ||
|
|
ce0040e407 | ||
|
|
a33e93d4d4 | ||
|
|
ad55e21a3d | ||
|
|
8aaaa033c0 | ||
|
|
2dda17cf19 | ||
|
|
38ad69236b | ||
|
|
0fed35586a | ||
|
|
1a1747c737 | ||
|
|
68342e25ce | ||
|
|
cb195f8773 | ||
|
|
5a7a6c4ae5 | ||
|
|
8ea825a8a1 | ||
|
|
236909be64 | ||
|
|
68d9efbda3 | ||
|
|
4cfbe5a29e | ||
|
|
bee5c158c6 | ||
|
|
3581ead595 | ||
|
|
a8e3db1aec | ||
|
|
baeda39057 | ||
|
|
0925d252d9 | ||
|
|
9a120e6d45 | ||
|
|
9200c80149 | ||
|
|
f7e8ef6ee4 | ||
|
|
e70b1a4ad9 | ||
|
|
6b4b956d51 | ||
|
|
7d4daea23a | ||
|
|
c089e749d0 | ||
|
|
2d199a5a23 | ||
|
|
23c47a1eb9 | ||
|
|
8a38805c01 | ||
|
|
2725c35c28 | ||
|
|
50dc803f19 | ||
|
|
976f444692 | ||
|
|
c7191d7615 | ||
|
|
a2a97ebcc7 | ||
|
|
28f5585a56 | ||
|
|
4667cafa43 | ||
|
|
6a0c60180c | ||
|
|
18bea3a852 | ||
|
|
72fd295556 | ||
|
|
cc6cf29c79 | ||
|
|
8198886dab | ||
|
|
c5e218d37a | ||
|
|
69d7e26a5b | ||
|
|
6dd5b85c8c | ||
|
|
75c3f5b08c | ||
|
|
b1625252d5 | ||
|
|
15ddb8d474 | ||
|
|
eb37cbe8be | ||
|
|
9d40724ab2 | ||
|
|
b82bc73c6c | ||
|
|
89f3c69413 | ||
|
|
5ad6fb032e | ||
|
|
52f4896b63 | ||
|
|
3c347d40d8 | ||
|
|
6f88915c7a | ||
|
|
675603c3a2 | ||
|
|
4a3110974b | ||
|
|
6db96b6ef5 | ||
|
|
caf09ecd56 | ||
|
|
f6300c5d1d | ||
|
|
9a92c11792 | ||
|
|
a023c3b645 | ||
|
|
c6b7a9b9bc | ||
|
|
04c4bad43c | ||
|
|
c37d571eb3 | ||
|
|
eb16c2ffe7 | ||
|
|
b221453c15 | ||
|
|
bfc642875c | ||
|
|
a9a7db0231 | ||
|
|
3e126c0ddd |
@@ -1,49 +0,0 @@
|
||||
/** @type {import("dependency-cruiser").IConfiguration} */
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
name: "domain-is-framework-neutral",
|
||||
severity: "error",
|
||||
from: { path: "^src/domain" },
|
||||
to: {
|
||||
path: "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application-does-not-know-concrete-runtime",
|
||||
severity: "error",
|
||||
from: { path: "^src/application" },
|
||||
to: {
|
||||
path: "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "presentation-does-not-know-adapters",
|
||||
severity: "error",
|
||||
from: { path: "^src/presentation" },
|
||||
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
|
||||
},
|
||||
{
|
||||
name: "adapters-do-not-know-presentation",
|
||||
severity: "error",
|
||||
from: { path: "^src/adapters" },
|
||||
to: { path: "^src/(presentation|bootstrap)" },
|
||||
},
|
||||
{
|
||||
name: "no-circular-dependencies",
|
||||
severity: "error",
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
],
|
||||
options: {
|
||||
doNotFollow: { path: "node_modules" },
|
||||
exclude: {
|
||||
path: "^(dist|artifacts|tests/fixtures)",
|
||||
},
|
||||
enhancedResolveOptions: {
|
||||
exportsFields: ["exports"],
|
||||
conditionNames: ["import", "require", "node", "default"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"forbidden": [
|
||||
{
|
||||
"name": "domain-is-framework-neutral",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/domain"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "application-does-not-know-concrete-runtime",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/application"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "presentation-does-not-know-adapters",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/presentation/(?!adapters/query)"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(adapters|bootstrap)|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page-templates-own-layout-only",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/presentation/templates"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "icon-vendor-is-facade-only",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src",
|
||||
"pathNot": "^src/presentation/design-system/icons/vendors/lucide\\.tsx$"
|
||||
},
|
||||
"to": {
|
||||
"path": "^lucide-react$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adapters-do-not-know-presentation",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/adapters"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/(presentation|bootstrap)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "feature-domain-is-framework-neutral",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/features/[^/]+/domain"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "feature-application-does-not-know-runtime",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/features/[^/]+/application"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "feature-presentation-does-not-know-outbound-adapters",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/features/[^/]+/presentation"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "feature-adapters-do-not-know-presentation",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/features/[^/]+/adapters"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "concrete-adapters-compose-only-in-bootstrap",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/(domain|application|presentation|contracts)"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "external-contract-package-single-import-path",
|
||||
"comment": "§4.1: a generated service package may only be imported from src/features/<feature>/contracts/*-contract-contribution.ts",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src",
|
||||
"pathNot": "^src/features/[^/]+/contracts/[^/]+-contract-contribution\\.ts$"
|
||||
},
|
||||
"to": {
|
||||
"path": "^@org-contracts/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "presentation-does-not-fetch-directly",
|
||||
"comment": "§9.2 / appendix B: a page or hook never opens a socket, worker or HTTP adapter itself",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/(presentation|features/[^/]+/presentation)"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/(http|realtime|service-worker|web-worker|storage)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "generic-worker-has-no-network-or-credentials",
|
||||
"comment": "§16.13 / §21.10: a CPU worker never imports HTTP, realtime or auth",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/adapters/web-worker"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/(http|realtime|auth|web-push)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "service-worker-entry-is-not-page-code",
|
||||
"comment": "§17.2.1: the worker realm never imports React, presentation or bootstrap page code",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/adapters/service-worker"
|
||||
},
|
||||
"to": {
|
||||
"path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contracts-do-not-know-application",
|
||||
"comment": "§4. `src/contracts` is the lower of the two packages: application reads contracts, never the other way round. Before this rule the shared Result carrier and the compatibility predicate lived in application and were imported back down by contracts, so neither package owned the shared vocabulary and the coupling was invisible to every gate.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/contracts"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/(application|features)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "generic-presentation-does-not-compose-the-product",
|
||||
"comment": "§4 / §9. Which features are installed is a product decision that belongs to bootstrap. Generic presentation reads the installed registries directly today; the paths below are the exact set that does so, frozen so the coupling cannot spread while the assembly is lifted into bootstrap.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/presentation/",
|
||||
"pathNot": "^src/presentation/(layouts/app-shell\\.tsx|pages/(not-found-page|home-page)\\.tsx|routes/(route-contract|route-codecs|app-router|navigation-policy)\\.(ts|tsx)|i18n/catalog\\.ts|examples/platform-overview-page\\.tsx)$"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/features/installed-"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adapters-do-not-know-other-concrete-adapters",
|
||||
"comment": "docs/architecture/layers.md §4: a concrete adapter never depends on another concrete adapter. Only the adapter kernel is shared — `src/adapters/platform` (clock, abort primitive, capacity guard) and the browser-data result helpers. `query-cache` still reads two collaborator types from `cross-context-invalidation`; that edge is named here rather than left silent, and closes when those types are lifted to a port.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/adapters/([^/]+)/"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/([^/]+)/",
|
||||
"pathNot": "^src/adapters/($1/|platform/|browser-file-storage/result\\.ts$|cross-context-invalidation/index\\.ts$)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no-circular-dependencies",
|
||||
"severity": "error",
|
||||
"from": {},
|
||||
"to": {
|
||||
"circular": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"doNotFollow": {
|
||||
"path": "node_modules"
|
||||
},
|
||||
"exclude": {
|
||||
"path": "^(dist|artifacts|tests/fixtures)"
|
||||
},
|
||||
"enhancedResolveOptions": {
|
||||
"exportsFields": [
|
||||
"exports"
|
||||
],
|
||||
"conditionNames": [
|
||||
"import",
|
||||
"require",
|
||||
"node",
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"tsConfig": {
|
||||
"fileName": "tsconfig.app.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Build-time inputs (§6.1). These are compiled into the bundle by Vite, so
|
||||
# everything here is public by definition. Never put a secret in this file or in
|
||||
# any `.env*` file: a frontend has no confidential storage, and a value that
|
||||
# reaches the browser has been published.
|
||||
#
|
||||
# Runtime configuration — API endpoints, auth mode, telemetry, capability
|
||||
# switches — is NOT here. It lives in `config/runtime/<profile>.json` and is
|
||||
# materialized into `dist/config.json` at build time, so it can be changed
|
||||
# without rebuilding. See docs/architecture/layers.md.
|
||||
#
|
||||
# Copy to `.env.local` (git-ignored) to override locally.
|
||||
|
||||
# Identifies the build in release manifests and the runtime document.
|
||||
# CI supplies the real value; a developer build falls back to "local-build".
|
||||
VITE_BUILD_ID=local-build
|
||||
|
||||
# Source revision the bundle was produced from.
|
||||
VITE_COMMIT_SHA=local
|
||||
|
||||
# Sub-path the app is served under. Must start and end with "/".
|
||||
# Feeds the router, the Service Worker scope and Vite's asset base together.
|
||||
VITE_ROUTER_BASE_PATH=/
|
||||
|
||||
# Where the browser fetches the runtime document from at boot.
|
||||
VITE_RUNTIME_CONFIG_URL=/config.json
|
||||
@@ -0,0 +1,460 @@
|
||||
# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.
|
||||
name: frontend-quality-gates
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stage:
|
||||
description: Highest promotion tier to evaluate
|
||||
required: true
|
||||
default: merge
|
||||
type: choice
|
||||
options:
|
||||
- merge
|
||||
- release
|
||||
- production
|
||||
- field
|
||||
- documentation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: "true"
|
||||
VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
VITE_COMMIT_SHA: "${{ gitea.sha }}"
|
||||
RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"
|
||||
|
||||
jobs:
|
||||
merge_gate:
|
||||
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { gate: FE-GATE-001, name: manifest-lockfile, browser: false }
|
||||
- { gate: FE-GATE-002, name: lint, browser: false }
|
||||
- { gate: FE-GATE-003, name: typecheck, browser: false }
|
||||
- { gate: FE-GATE-004, name: runtime-schema, browser: false }
|
||||
- { gate: FE-GATE-005, name: unit, browser: false }
|
||||
- { gate: FE-GATE-006, name: component, browser: false }
|
||||
- { gate: FE-GATE-007, name: integration, browser: false }
|
||||
- { gate: FE-GATE-008, name: e2e, browser: true }
|
||||
- { gate: FE-GATE-009, name: accessibility, browser: true }
|
||||
- { gate: FE-GATE-010, name: architecture, browser: false }
|
||||
- { gate: FE-GATE-011, name: build, browser: false }
|
||||
- { gate: FE-GATE-013, name: security, browser: false }
|
||||
- { gate: FE-GATE-020, name: removability, browser: false }
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Install Playwright browsers
|
||||
if: ${{ matrix.browser }}
|
||||
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
- name: Run blocking gate
|
||||
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
||||
- name: Upload merge gate evidence
|
||||
if: always()
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
|
||||
release_gate:
|
||||
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||
needs: merge_gate
|
||||
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
HOSTING_BASE_URL: "${{ vars.HOSTING_BASE_URL }}"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { gate: FE-GATE-012, name: bundle, browser: false }
|
||||
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
|
||||
- { gate: FE-GATE-019, name: hosting-header, browser: false }
|
||||
- { gate: FE-GATE-026, name: lab-performance, browser: true }
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Install Playwright browsers
|
||||
if: ${{ matrix.browser }}
|
||||
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
- name: Run blocking gate
|
||||
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
||||
- name: Upload release gate evidence
|
||||
if: always()
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
|
||||
immutable_build:
|
||||
name: "FE-GATE-015 / immutable-release-candidate"
|
||||
needs: release_gate
|
||||
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
||||
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
|
||||
env:
|
||||
APP_PROFILE: "${{ vars.APP_PROFILE }}"
|
||||
RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Build candidate once and verify local evidence
|
||||
run: corepack pnpm ci:gate -- FE-GATE-015
|
||||
- name: Archive and validate the exact candidate file set
|
||||
id: candidate
|
||||
run: |
|
||||
mkdir -p .release
|
||||
tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \
|
||||
dist \
|
||||
pnpm-lock.yaml \
|
||||
artifacts/performance/bundle.json \
|
||||
artifacts/quality/vite-module-inventory.json \
|
||||
artifacts/release/build-manifest.json \
|
||||
artifacts/release/checksums.txt \
|
||||
artifacts/release/dependency-inventory.json \
|
||||
artifacts/release/provenance.json \
|
||||
artifacts/release/verification.json \
|
||||
artifacts/release/sbom.cdx.json \
|
||||
artifacts/security/dependency-diff.json \
|
||||
artifacts/security/license-report.json \
|
||||
artifacts/security/local-evidence-assessment.json \
|
||||
artifacts/security/scan.sarif \
|
||||
artifacts/security/supply-chain-coherence.json \
|
||||
artifacts/security/supply-chain-verification.json \
|
||||
artifacts/security/vulnerability-report.json \
|
||||
config/security/dependency-baseline.approval.json \
|
||||
config/security/dependency-baseline.json \
|
||||
config/security/dependency-change-evidence.json \
|
||||
config/security/dependency-policy.json \
|
||||
config/security/secret-scan-policy.json \
|
||||
config/security/vulnerability-exceptions.json \
|
||||
config/security/vulnerability-policy.json \
|
||||
schemas/artifacts/build-manifest.schema.json \
|
||||
schemas/artifacts/dependency-inventory.schema.json \
|
||||
schemas/artifacts/supply-chain-verification.schema.json \
|
||||
scripts/contracts/release-artifacts.ts \
|
||||
scripts/create-release-candidate.ts \
|
||||
scripts/generate-supply-chain.ts \
|
||||
scripts/lib/build-manifest-outputs.ts \
|
||||
scripts/lib/json-schema.ts \
|
||||
scripts/lib/local-policy-evidence.ts \
|
||||
scripts/lib/local-release-evidence.ts \
|
||||
scripts/lib/release-candidate.ts \
|
||||
scripts/lib/release-input-evidence.ts \
|
||||
scripts/lib/release-runtime-coherence.ts \
|
||||
scripts/lib/repository-file-inventory.ts \
|
||||
scripts/lib/secret-scan-evaluator.ts \
|
||||
scripts/lib/secret-scan-policy.ts \
|
||||
scripts/lib/secret-scan.ts \
|
||||
scripts/lib/supply-chain.ts \
|
||||
scripts/lib/validated-json-artifact.ts \
|
||||
src/contracts/release-artifacts.ts \
|
||||
src/features/installed-contract-contributions.ts \
|
||||
src/features/installed-feature-contracts.ts \
|
||||
artifacts/release/release-candidate.json
|
||||
node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"
|
||||
- name: Upload release candidate
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
if-no-files-found: error
|
||||
|
||||
vulnerability_provider:
|
||||
name: external-vulnerability-provider
|
||||
needs: immutable_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}
|
||||
env:
|
||||
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
CI_RUN_ID: "${{ gitea.run_id }}"
|
||||
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
|
||||
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
|
||||
VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
|
||||
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
|
||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Download release candidate
|
||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||
with:
|
||||
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: .release/vulnerability-candidate
|
||||
- name: Run and validate external vulnerability provider in one trusted supervisor
|
||||
id: supervise_vulnerability
|
||||
run: node scripts/run-and-validate-provider.ts --kind vulnerability
|
||||
- name: Confirm sealed vulnerability provider evidence
|
||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||
- name: Upload vulnerability provider evidence
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: provider-evidence/vulnerability-report.json
|
||||
if-no-files-found: error
|
||||
|
||||
provenance_provider:
|
||||
name: external-provenance-provider
|
||||
needs: immutable_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}
|
||||
env:
|
||||
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
CI_RUN_ID: "${{ gitea.run_id }}"
|
||||
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
|
||||
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
|
||||
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
|
||||
PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
|
||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
|
||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Download release candidate
|
||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||
with:
|
||||
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: .release/provenance-candidate
|
||||
- name: Run and validate external provenance provider in one trusted supervisor
|
||||
id: supervise_provenance
|
||||
run: node scripts/run-and-validate-provider.ts --kind provenance
|
||||
- name: Confirm sealed provenance provider evidence
|
||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||
- name: Upload provenance provider evidence
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: provider-evidence/provenance-attestation.json
|
||||
if-no-files-found: error
|
||||
|
||||
promotion:
|
||||
name: promote-verified-immutable-candidate
|
||||
needs: [immutable_build, vulnerability_provider, provenance_provider]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
CI_RUN_ID: "${{ gitea.run_id }}"
|
||||
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
|
||||
VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
||||
PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
|
||||
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
|
||||
VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
|
||||
PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Download release candidate
|
||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||
with:
|
||||
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: .release/candidate
|
||||
- name: Download vulnerability provider evidence
|
||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||
with:
|
||||
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: .release/vulnerability
|
||||
- name: Download provenance provider evidence
|
||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
|
||||
with:
|
||||
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: .release/provenance
|
||||
- name: Finalize verified promotion from inode-bound captured inputs
|
||||
id: finalize
|
||||
run: node scripts/stage-verified-promotion.ts
|
||||
- name: Upload promoted release
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
path: |
|
||||
${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz
|
||||
${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json
|
||||
${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json
|
||||
${{ steps.finalize.outputs.staging_root }}/provider-verification.json
|
||||
${{ steps.finalize.outputs.staging_root }}/promotion-verification.json
|
||||
if-no-files-found: error
|
||||
- name: Always remove private promotion staging
|
||||
if: always()
|
||||
env:
|
||||
PROMOTION_STAGING_ROOT: ${{ steps.finalize.outputs.staging_root }}
|
||||
PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }}
|
||||
PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }}
|
||||
PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }}
|
||||
PROMOTION_STAGING_DEV: ${{ steps.finalize.outputs.staging_dev }}
|
||||
PROMOTION_STAGING_INO: ${{ steps.finalize.outputs.staging_ino }}
|
||||
run: |
|
||||
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then
|
||||
node scripts/cleanup-verified-promotion.ts
|
||||
fi
|
||||
|
||||
production_gate:
|
||||
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||
needs: promotion
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { gate: FE-GATE-016, name: rollback-drill }
|
||||
- { gate: FE-GATE-021, name: runbook-boot-config }
|
||||
- { gate: FE-GATE-022, name: runbook-chunk-mismatch }
|
||||
- { gate: FE-GATE-023, name: runbook-api-degradation }
|
||||
- { gate: FE-GATE-024, name: runbook-telemetry }
|
||||
- { gate: FE-GATE-025, name: runbook-release-rollback }
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Run blocking gate
|
||||
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
|
||||
- name: Upload production gate evidence
|
||||
if: always()
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
|
||||
field_gate:
|
||||
name: "FE-GATE-018 / field-web-vitals"
|
||||
needs: production_gate
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
FIELD_WEB_VITALS_INPUT: "${{ vars.FIELD_WEB_VITALS_INPUT }}"
|
||||
MIN_ELIGIBLE_SAMPLES: "${{ vars.MIN_ELIGIBLE_SAMPLES }}"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Run blocking gate
|
||||
run: corepack pnpm ci:gate -- FE-GATE-018
|
||||
- name: Upload field gate evidence
|
||||
if: always()
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "FE-GATE-018-${{ gitea.run_id }}"
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
|
||||
documentation_gate:
|
||||
name: "FE-GATE-017 / diagram-review"
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Run documentation gate
|
||||
run: corepack pnpm ci:gate -- FE-GATE-017
|
||||
- name: Upload documentation gate evidence
|
||||
if: always()
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "FE-GATE-017-${{ gitea.run_id }}"
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
+15
@@ -1,11 +1,26 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.generated/
|
||||
.tmp/
|
||||
playwright-report/
|
||||
test-results/
|
||||
coverage/
|
||||
!tests/fixtures/coverage/
|
||||
!tests/fixtures/coverage/below-threshold.json
|
||||
artifacts/**/*.json
|
||||
artifacts/**/*.xml
|
||||
artifacts/**/*.txt
|
||||
artifacts/**/*.sarif
|
||||
artifacts/tests/e2e/
|
||||
artifacts/tests/browser-capabilities/
|
||||
artifacts/storybook/
|
||||
artifacts/tests/storybook/
|
||||
artifacts/tests/visual/
|
||||
!artifacts/**/.gitkeep
|
||||
|
||||
# Local environment overrides. `.env.example` is the tracked template; every
|
||||
# other `.env*` file is a developer's own machine and never enters the repo.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-a11y"],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../src/adapters/auth/external-session-adapter.ts";
|
||||
import { createQueryClient } from "../src/adapters/query-cache/tanstack-query-cache.ts";
|
||||
import { createApplication } from "../src/application/create-application.ts";
|
||||
import { LocaleProvider } from "../src/presentation/i18n/index.ts";
|
||||
import { ApplicationProvider } from "../src/presentation/providers/application-provider.tsx";
|
||||
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
|
||||
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
|
||||
import "../src/presentation/styles/theme.css";
|
||||
import { resolveProductFeatures } from "../src/contracts/product-features.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../src/features/installed-product-manifest.ts";
|
||||
|
||||
const preferences = new Map<string, unknown>();
|
||||
const application = createApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
preferences: {
|
||||
read: (name) => ({ ok: true, value: preferences.get(name) }),
|
||||
write: (name, value) => {
|
||||
preferences.set(name, structuredClone(value));
|
||||
return { ok: true };
|
||||
},
|
||||
remove: (name) => {
|
||||
preferences.delete(name);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
diagnostics: { record() {} },
|
||||
telemetry: { emit() {} },
|
||||
releaseInfo: {
|
||||
getCurrent: async () => ({
|
||||
buildId: "storybook-build",
|
||||
releaseId: "storybook-release",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "storybook-assets",
|
||||
routeChunks: {},
|
||||
}),
|
||||
refresh: async () => ({
|
||||
buildId: "storybook-build",
|
||||
releaseId: "storybook-release",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "storybook-assets",
|
||||
routeChunks: {},
|
||||
}),
|
||||
},
|
||||
// Storybook renders components, not a product: every declared feature is
|
||||
// shown as active so a story is never blank because of a deployment switch.
|
||||
productFeatures: {
|
||||
getSnapshot: () =>
|
||||
resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
),
|
||||
isActive: () => true,
|
||||
},
|
||||
runtimeCapabilities: {
|
||||
getSnapshot: () =>
|
||||
Object.freeze(
|
||||
(
|
||||
[
|
||||
"REALTIME",
|
||||
"WEB_WORKER",
|
||||
"SERVICE_WORKER",
|
||||
"OFFLINE_COMMANDS",
|
||||
] as const
|
||||
).map((capabilityId) =>
|
||||
Object.freeze({
|
||||
capabilityId,
|
||||
selected: 0,
|
||||
active: 0,
|
||||
override: "DEFAULT" as const,
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
navigation: { reload() {} },
|
||||
});
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ApplicationProvider application={application}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<div id="portal-root" />
|
||||
<main className="ui-page" style={{ padding: "1rem" }}>
|
||||
<Story />
|
||||
</main>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApplicationProvider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
test: "error",
|
||||
},
|
||||
controls: {
|
||||
expanded: true,
|
||||
},
|
||||
options: {
|
||||
storySort: {
|
||||
order: ["Platform"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,110 @@
|
||||
# Task 5 Report: Retain and reconcile uncertain optimistic mutations
|
||||
|
||||
## Status
|
||||
|
||||
Task 5 is implemented. Mutation settlement now follows explicit effect certainty, preserves unknown optimistic projections as ordered uncertain layers, and exposes one-at-a-time reconciliation bound to the original mutation record. Missing post-dispatch certainty is fail-safe `MAYBE_APPLIED`; only controller-owned pre-dispatch failures are marked `NOT_STARTED`.
|
||||
|
||||
## RED evidence
|
||||
|
||||
- Initial focused command: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`.
|
||||
- Initial result: exit `1`, 2 files, 11 failed / 28 passed. Missing lease/controller APIs failed directly; `APPLIED_CONFIRMED` and `MAYBE_APPLIED` were rolled back; effectless failures retained generic retry semantics.
|
||||
- Review-driven RED: the three-file focused command including `tests/component/async-surface.test.tsx` exited `1` with 7 failed / 56 passed. It exposed applied-confirmed retry actions, active-submit reset, double reconciliation, stale-scope queue retention, overlay priority, and the incorrect refreshing copy.
|
||||
- A final isolated RED proved a synchronous `NOT_APPLIED` double action could consume two FIFO records in one event turn.
|
||||
- The production-form RED exited `1` with 2 failed / 8 passed: applied reconciliation left the original command dirty/retryable, while an `APPLIED_CONFIRMED` failure rendered generic unavailable.
|
||||
- The final durability/lifecycle RED failed 2 / 2: an anonymous non-optimistic legacy channel did not survive remount, and render-time registry allocation exhausted the definition cap during an abandoned server render.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. `OptimisticLayerLease` now supports `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`. Layers are `pending | uncertain | committed`; only a committed prefix collapses into the base, while projection continues to apply every later layer in order.
|
||||
2. Reconciliation is single-settlement and idempotent. `APPLIED` converts the uncertain layer to committed; `NOT_APPLIED` removes only that layer; both then collapse/reproject later committed or pending layers. Scope expiry removes stale cache instead of restoring it.
|
||||
3. Legacy optimistic mutations use the same reusable always-current ordered runtime. This prevents an old manual snapshot from erasing a later successful mutation or authoritative projection. The runtime also supports optimistic entries whose base data was absent and removes them on a not-applied rollback.
|
||||
4. The mutation bridge derives effect before touching optimistic state:
|
||||
- `NOT_STARTED` / `NOT_APPLIED`: rollback;
|
||||
- `APPLIED_CONFIRMED`: commit, then best-effort invalidate;
|
||||
- `MAYBE_APPLIED`: retain as uncertain, do not invalidate, and enqueue explicit reconciliation.
|
||||
5. Missing or `NOT_APPLICABLE` command effects, returned failures after dispatch, and thrown execution failures normalize to `MAYBE_APPLIED`. Unknown effects are non-retryable with `contact-support`; applied-confirmed failures are non-retryable with no resend action. Controller-owned stale scope, duplicate admission, identity/preparation, and other known pre-dispatch failures carry `NOT_STARTED`.
|
||||
6. Unknown records retain their original intent, scope, layer lease, invalidation topics, and coordinator. A FIFO queue prevents parallel `ALLOW_PARALLEL` failures from overwriting each other. Reconciliation is locked through the event turn so a double action cannot consume the next intent, and it does not reset a newer active submit.
|
||||
7. Scope abort discards every queued record from that scope, settles only its local stale layers, performs no invalidation, and cannot later overwrite new-generation cache data.
|
||||
8. Async state adds the mutually exclusive `mutation-effect-unknown` overlay with priority `unknown > conflict > pending > stale-degraded > refreshing`. `AsyncSurface` uses dedicated safe copy and only `APPLIED` / `NOT_APPLIED` actions; it does not expose generic retry or mark the surface busy.
|
||||
9. Unknown-effect admissions live in a bounded QueryClient-owned registry, so bound and legacy controllers can remount without losing reconciliation state. Channels include definition version and generation, validate the exact scope owner, count active admissions globally in O(1), release after late settlement, and preserve FIFO order even when executions finish in reverse.
|
||||
10. Channel creation and scope-abort listener registration occur only in a committed React effect. An abandoned/server render performs no registry mutation and consumes no channel capacity. Non-optimistic legacy callers must provide a stable `definitionId`; optimistic legacy callers also include their query identity.
|
||||
11. The reference create form blocks all generic resubmission while effect certainty is unknown. `NOT_APPLIED` preserves input and re-enables submission; `APPLIED` reconciliation and `APPLIED_CONFIRMED` settlement use the form's success-equivalent reset path so the same create command cannot be resent.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- Certainty matrix for all four mutation effects, missing effect, `NOT_APPLICABLE`, thrown execution, ambiguous conflicts, and non-retry semantics.
|
||||
- Applied-confirmed commit-before-invalidate ordering and retained commit when invalidation fails.
|
||||
- Out-of-order later commits behind uncertain layers; both reconciliation outcomes; duplicate/reversed lease transitions; external cache projection; expired scope.
|
||||
- Bound and legacy reconciliation, no-layer legacy fallback, parallel unknown queues, same-turn double actions, active newer submit preservation, scope-wide stale cleanup, and no-prior-cache rollback.
|
||||
- Bound and non-optimistic legacy remount durability, unrelated legacy isolation, generation isolation, exact scope-owner collision handling, abandoned-render capacity, late empty-channel cleanup, QueryClient-global admission bounds, reverse completion, and fence-during-invalidation races.
|
||||
- Production create-form coverage for the `MAYBE_APPLIED` block, `NOT_APPLIED` input preservation, and success-equivalent `APPLIED` / `APPLIED_CONFIRMED` settlement.
|
||||
- Unknown overlay derivation, mutual-exclusion priority, dedicated localized copy, non-busy state, and reconciliation-only actions.
|
||||
- The negative async-overlay type fixture now includes `mutationEffectUnknown: false`, so it continues to fail for the intended pending/conflict exclusivity violation.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/presentation/adapters/query/optimistic-layer-runtime.ts`
|
||||
- `src/presentation/adapters/query/application-query.ts`
|
||||
- `src/application/view-models/async-state.ts`
|
||||
- `src/contracts/errors.ts`
|
||||
- `src/presentation/components/async-surface.tsx`
|
||||
- `src/presentation/forms/form-contracts.ts`
|
||||
- `src/presentation/forms/use-app-form.ts`
|
||||
- `src/presentation/i18n/catalog.ts`
|
||||
- `src/features/reference-feature/presentation/reference-resource-form-page.tsx`
|
||||
- `tests/unit/optimistic-layer-runtime.test.ts`
|
||||
- `tests/component/application-query.test.tsx`
|
||||
- `tests/component/async-surface.test.tsx`
|
||||
- `tests/features/reference-feature/reference-page.test.tsx`
|
||||
- `tests/fixtures/typecheck/invalid-async-overlay.ts`
|
||||
|
||||
The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scope expansion required to avoid rendering the new indicator as a background refresh and to preserve the overlay type contract.
|
||||
|
||||
## Verification
|
||||
|
||||
- Final focused command (error classification, optimistic runtime, mutation bridge, async surface, form facade, and production reference form): 6 files / 100 tests — PASS.
|
||||
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
|
||||
- `corepack pnpm lint` — PASS with zero warnings.
|
||||
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 741, component 123, integration 23, reference feature 24, recipes 17.
|
||||
- `git diff --check` — PASS.
|
||||
- `corepack pnpm run check:types:fixture:async-overlay` — expected non-zero; TypeScript rejects `mutationConflict: true` when `mutationPending: true`, confirming the negative fixture still reaches its intended invariant.
|
||||
|
||||
## Self-review decisions
|
||||
|
||||
- The plan-prescribed `reconcileUnknownEffect(resolution)` API remains intact. Rather than introduce a public token incompatible with that interface, the controller retains intent-bound FIFO records and serializes reconciliation through the current event turn. A repeated action after the first promise settles is an explicit action on the next visible unknown record.
|
||||
- Scope cleanup removes stale local projection without claiming or invalidating a server outcome. A stale generation cannot use its former record after the queue is discarded.
|
||||
- Legacy manual snapshot restoration was removed because it could erase later successful work. Shared ordered layers are the minimal mechanism that gives legacy and bound mutations the same re-projection guarantees.
|
||||
- A non-optimistic legacy mutation has no cache key from which a durable logical identity can be inferred. Its type contract therefore requires a stable caller-supplied `definitionId`; this preserves remount durability without merging unrelated controllers.
|
||||
- Registry mutation was moved out of render into the committed effect lifecycle. The server-render regression fills the nominal definition count with abandoned renders, then proves a committed mutation can still acquire and execute.
|
||||
- Browser/Playwright gates were not run; this task changed no browser-only integration. The jsdom component tests cover the new accessible status and actions.
|
||||
|
||||
## Final review
|
||||
|
||||
The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge.
|
||||
|
||||
## Runtime final-review fix round 3
|
||||
|
||||
The runtime-wide final review identified three additional Task 5 authorities. This round addresses only those findings; the provider-neutral HTTP operation port remains deferred to its separately owned remediation plans.
|
||||
|
||||
### RED evidence
|
||||
|
||||
- Composite optimistic admission: `tests/unit/optimistic-layer-runtime.test.ts` failed 2 / 8 cases because a base-valid candidate that threw only after the prior layer returned a lease and orphaned both rollback and reconciliation authority.
|
||||
- Candidate replay: the isolated admission test failed with candidate updater call count `2` instead of `1`; replay through `project()` could still delete the entry after successful preflight.
|
||||
- Form reconciliation: `tests/component/form-foundation.test.tsx` failed 3 / 8 cases. The hook admitted a second submit during unknown effect, settled edited value B instead of submitted snapshot A, and exposed no explicit not-applied release authority.
|
||||
- Production namespace parity: the mounted reference-page regression failed because `REFERENCE_RESOURCE_QUERY_NAMESPACE` was not exported; production list/detail hooks could only duplicate its id/version literals.
|
||||
|
||||
### Implementation
|
||||
|
||||
1. `OptimisticLayerRuntime.begin()` now computes the complete ordered projection before admission. A composite failure returns pessimistic fallback `null` without changing the existing entry, cache projection, layer IDs, or earlier lease authority. The admitted candidate is written from that precomputed value, so its updater runs exactly once during admission.
|
||||
2. `useAppForm` retains the exact parsed values for a `MAYBE_APPLIED` submission. The ref is the hook-level admission lock until `settleApplied`, `settleNotApplied`, or `reset` releases it; later edits preserve the unknown result and cannot trigger another command. With `resetOnSuccess: false`, APPLIED makes submitted A the baseline while edited B remains dirty. Success, applied-confirmed, validation/conflict/unavailable outcomes, reset, and explicit not-applied settlement clear the retained snapshot.
|
||||
3. The reference form routes both reconciliation outcomes into the corresponding form settlement authority.
|
||||
4. `REFERENCE_RESOURCE_QUERY_NAMESPACE` is exported from the governed feature contract. Both production list and detail query definitions consume its fields, while the mounted-key regression compares both real query prefixes with the installed invalidation edge.
|
||||
|
||||
### Verification
|
||||
|
||||
- Focused runtime/form/reference command: 5 files / 87 tests — PASS.
|
||||
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
|
||||
- `corepack pnpm lint` — PASS with zero warnings.
|
||||
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 744, component 126, integration 23, reference feature 25, recipes 17.
|
||||
- `git diff --check` — PASS.
|
||||
- Scoped re-review by the existing Task 5 reviewer: no findings, ready to merge. The reviewer independently passed the 5-file scoped suite (96 / 96), confirmed `git diff --check`, verified all three reconciliation authorities plus candidate single-invocation, and confirmed the deferred HTTP adapter remained untouched.
|
||||
@@ -1,2 +1,168 @@
|
||||
# clean-architecture-frontend-template
|
||||
# Clean Architecture Frontend Template
|
||||
|
||||
A React/Vite reference implementation where architecture boundaries,
|
||||
integration behavior, release coherence, accessibility, performance, and
|
||||
operations are executable contracts rather than conventions.
|
||||
|
||||
## Start locally
|
||||
|
||||
Requirements: the exact Node.js version in `.nvmrc` (currently 24.14.0) and
|
||||
Corepack. The repository pins pnpm in `package.json`.
|
||||
|
||||
Product source, tests, build/quality scripts, and supported tool configuration
|
||||
are TypeScript/TSX. `allowJs` is disabled. Node-side `.ts` scripts run directly
|
||||
on the pinned Node 24 runtime and are checked with NodeNext resolution plus
|
||||
erasable-syntax enforcement. Project-owned executable source contains no
|
||||
JavaScript-family files; negative architecture, security, and type-compatibility
|
||||
fixtures are TypeScript/TSX as well.
|
||||
|
||||
```bash
|
||||
corepack pnpm install --frozen-lockfile
|
||||
corepack pnpm dev
|
||||
```
|
||||
|
||||
Runtime-public settings live in `public/config.json` and are validated before
|
||||
the product tree mounts. Client secrets are forbidden.
|
||||
|
||||
## Included starter experience
|
||||
|
||||
The default build mounts a domain-neutral application shell with a header,
|
||||
responsive sidebar, route focus management, session integration status, and a
|
||||
persistent `system` / `light` / `dark` theme selector.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` | implementation readiness and starter links |
|
||||
| `/examples/ui` | buttons, fields, cards, alerts, badges, modal, and tokens |
|
||||
| `/examples/states` | loading, refresh, empty, error, auth, forbidden, and not-found states |
|
||||
| `/examples/auth` | reactive external-auth integration seam |
|
||||
| `/examples/reference-resources` | removable, session-required reference feature |
|
||||
|
||||
`AUTH_MODE=demo` is credential-free and accepted only in local/development
|
||||
environments. Deployments use `AUTH_MODE=external` and provide the opaque auth
|
||||
owner described in
|
||||
[`docs/architecture/starter-experience.md`](docs/architecture/starter-experience.md).
|
||||
The client route policy is user experience only; server authorization remains
|
||||
authoritative.
|
||||
|
||||
## Architecture
|
||||
|
||||
Dependencies point inward:
|
||||
|
||||
```text
|
||||
presentation -> application -> domain
|
||||
adapters -----^
|
||||
bootstrap composes concrete adapters
|
||||
contracts own cross-cutting registries
|
||||
```
|
||||
|
||||
See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
|
||||
`docs/architecture/starter-experience.md`. The removable vertical slice is
|
||||
under `src/features/reference-feature`; its domain, application input, HTTP
|
||||
adapter, contracts, route runtime, and presentation are installed through the
|
||||
feature contribution files in `src/features`. The generic starter routes
|
||||
continue to typecheck, test, and build after that contribution is removed.
|
||||
|
||||
### Platform capability review
|
||||
|
||||
The starter shell is implemented, but the repository review also records the
|
||||
remaining work required before feature teams can use every declared contract
|
||||
through one end-to-end application path:
|
||||
|
||||
- [platform capability review](docs/architecture/frontend-platform-capability-review.md)
|
||||
- [ports, adapters, and feature boundaries](docs/architecture/frontend-ports-adapters-and-boundaries.md)
|
||||
- [REST, GraphQL, Connect/gRPC-Web, Schema, Mapper, and Server State](docs/architecture/api-contract-schema-mapper-and-server-state.md)
|
||||
- [Protobuf browser transports and REST Gateway](docs/architecture/protobuf-browser-transport-and-rest-gateway.md)
|
||||
- [backend API and Server State handoff contract](docs/architecture/backend-api-and-server-state-contract.md)
|
||||
- [TypeScript, state ownership, and data flow](docs/architecture/typescript-state-and-data-flow.md)
|
||||
- [routing, page templates, and reusable patterns](docs/architecture/routing-pages-and-patterns.md)
|
||||
- [browser data capability completion ledger](docs/architecture/browser-data-capability-completion-ledger.md)
|
||||
- [browser file and origin-storage platform](docs/architecture/browser-file-and-origin-storage.md)
|
||||
- [client cache and storage](docs/architecture/client-cache-and-storage.md)
|
||||
- [realtime events, Web Push, and bounded polling](docs/architecture/realtime-events-web-push-and-bounded-polling.md)
|
||||
- [presigned transfer, resumable upload, streaming download, and Image CDN](docs/architecture/presigned-transfer-and-image-cdn.md)
|
||||
- [server file capability infrastructure](docs/architecture/server-file-capability-infrastructure.md)
|
||||
- [design-system platform](docs/styling/design-system-platform.md)
|
||||
- [frontend platform testing strategy](docs/testing/frontend-platform-testing-strategy.md)
|
||||
- [implementation roadmap](docs/architecture/frontend-platform-implementation-roadmap.md)
|
||||
|
||||
These documents distinguish repository defaults from opt-in adapters and
|
||||
project-owned integrations. They are target designs and review findings; a
|
||||
capability is not treated as implemented until its branch acceptance criteria
|
||||
and executable gates pass.
|
||||
|
||||
## Verification
|
||||
|
||||
Common local checks:
|
||||
|
||||
```bash
|
||||
corepack pnpm lint
|
||||
corepack pnpm check:types
|
||||
corepack pnpm check:types:app
|
||||
corepack pnpm check:types:node
|
||||
corepack pnpm check:types:test
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm test:all
|
||||
corepack pnpm test:e2e
|
||||
corepack pnpm test:a11y
|
||||
corepack pnpm build
|
||||
corepack pnpm check:bundle
|
||||
corepack pnpm test:performance
|
||||
corepack pnpm verify:compatibility
|
||||
corepack pnpm verify:release
|
||||
corepack pnpm check:registries
|
||||
corepack pnpm drill:runbooks
|
||||
corepack pnpm check:ci
|
||||
```
|
||||
|
||||
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
|
||||
project로 모두 검사한다. type/architecture/security/registry의 invalid
|
||||
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
|
||||
실행된다. 도구 호환성 결정은
|
||||
[VD-01](docs/architecture/decisions/VD-01-typescript-lint-tooling.md)에 기록돼
|
||||
있다.
|
||||
|
||||
Application feature input은 module augmentation으로 닫힌 ID와 정확한 input
|
||||
shape를 제공하며, 공통 `Result<Value, Failure = AppFailure>`는 error registry의
|
||||
failure kind만 application/presentation 경계를 통과시킨다. Architecture gate는
|
||||
TypeScript/TSX의 static, dynamic, type import를 별도 정적 그래프로 분석하고
|
||||
runtime/source 영역의 JavaScript 재유입도 거절한다. 해석되지 않은 import,
|
||||
parse failure, 금지 계층 edge와 순환 의존은 모두 fail-closed이며 전용
|
||||
TypeScript/TSX negative fixture로도 검증된다.
|
||||
|
||||
Install the pinned Playwright browser engines before the first cross-browser
|
||||
run:
|
||||
|
||||
```bash
|
||||
corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
```
|
||||
|
||||
Two gates intentionally need external evidence:
|
||||
|
||||
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
|
||||
for all ten registered routes: `APP_HOME`, `EXAMPLES_PLATFORM`,
|
||||
`EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, `NOT_FOUND`,
|
||||
`REFERENCE_RESOURCE_LIST`, `REFERENCE_RESOURCE_DETAIL`,
|
||||
`REFERENCE_RESOURCE_FORM` and `REFERENCE_RESOURCE_STATUS`.
|
||||
`verify:documentation` derives that list from the route registry and fails if
|
||||
this paragraph falls behind it.
|
||||
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
|
||||
eligible-sample threshold and 28 days of production data exist.
|
||||
|
||||
Live release verification additionally requires `HOSTING_BASE_URL`.
|
||||
|
||||
## CI and evidence
|
||||
|
||||
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
|
||||
`.gitea/workflows/quality-gates.yml`. It follows:
|
||||
|
||||
```text
|
||||
MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY
|
||||
```
|
||||
|
||||
`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning.
|
||||
Machine-readable evidence is written below `artifacts/`; generated evidence is
|
||||
ignored by Git while `.gitkeep` files preserve the taxonomy.
|
||||
|
||||
Operational details are in `docs/operations/`, with incident procedures in
|
||||
`docs/runbooks/`.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# APP_HOME accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: APP_HOME
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Automated axe, keyboard-focus, and reduced-motion evidence is available; human review pending.
|
||||
@@ -0,0 +1,18 @@
|
||||
# EXAMPLES_AUTH accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: EXAMPLES_AUTH
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Review session state announcements and unavailable external-integration behavior.
|
||||
@@ -0,0 +1,18 @@
|
||||
# EXAMPLES_PLATFORM accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: EXAMPLES_PLATFORM
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Review the horizontally scrollable registry tables for keyboard reachability of the scroll container, table caption and header association announced per row, capability status badges carrying their meaning in text rather than colour alone, and the release identity region announcing its update through aria-live without interrupting a reader mid row.
|
||||
@@ -0,0 +1,18 @@
|
||||
# EXAMPLES_STATES accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: EXAMPLES_STATES
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Review loading, refresh, empty, error, authentication, forbidden, and not-found announcements.
|
||||
@@ -0,0 +1,18 @@
|
||||
# EXAMPLES_UI accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: EXAMPLES_UI
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Review form primitives, Menu/Tabs keyboard behavior, Toast announcements, Tooltip supplemental copy, text-field error association and modal focus containment/restoration.
|
||||
@@ -0,0 +1,18 @@
|
||||
# NOT_FOUND accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: NOT_FOUND
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending.
|
||||
@@ -0,0 +1,18 @@
|
||||
# REFERENCE_RESOURCE_DETAIL accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: REFERENCE_RESOURCE_DETAIL
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending.
|
||||
@@ -0,0 +1,18 @@
|
||||
# REFERENCE_RESOURCE_FORM accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: REFERENCE_RESOURCE_FORM
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending.
|
||||
@@ -0,0 +1,18 @@
|
||||
# REFERENCE_RESOURCE_LIST accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: REFERENCE_RESOURCE_LIST
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending.
|
||||
@@ -0,0 +1,18 @@
|
||||
# REFERENCE_RESOURCE_STATUS accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: REFERENCE_RESOURCE_STATUS
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"families": {
|
||||
"api": {
|
||||
"additive": {
|
||||
"before": { "required": ["id"], "properties": { "id": {} } },
|
||||
"after": {
|
||||
"required": ["id"],
|
||||
"properties": { "id": {}, "displayName": {} }
|
||||
}
|
||||
},
|
||||
"breaking": {
|
||||
"before": { "required": ["id"], "properties": { "id": {} } },
|
||||
"after": {
|
||||
"required": ["id", "name"],
|
||||
"properties": { "id": {}, "name": {} }
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"additive": {
|
||||
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
|
||||
"after": {
|
||||
"required": ["APP_ENV"],
|
||||
"properties": { "APP_ENV": {}, "OPTIONAL_FLAG": {} }
|
||||
}
|
||||
},
|
||||
"breaking": {
|
||||
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
|
||||
"after": {
|
||||
"required": ["APP_ENV", "NEW_REQUIRED"],
|
||||
"properties": { "APP_ENV": {}, "NEW_REQUIRED": {} }
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"additive": {
|
||||
"before": { "properties": { "theme": {} } },
|
||||
"after": { "properties": { "theme": {}, "contrast": {} } }
|
||||
},
|
||||
"breaking": {
|
||||
"before": { "properties": { "theme": {} } },
|
||||
"after": { "properties": {} }
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"additive": {
|
||||
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
|
||||
"after": {
|
||||
"required": ["buildId"],
|
||||
"properties": { "buildId": {}, "builtAt": {} }
|
||||
}
|
||||
},
|
||||
"breaking": {
|
||||
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
|
||||
"after": {
|
||||
"required": ["buildId", "assetManifestHash"],
|
||||
"properties": { "buildId": {}, "assetManifestHash": {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"snapshotDigest": "96b95ef1d50cce36e9fca8a98776a9e2e9e3a5dca24a6288ad83bf29c94aebd8",
|
||||
"owner": "frontend-platform",
|
||||
"reason": "Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement",
|
||||
"approvedAt": "2026-08-01T15:15:45.537Z"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"changes": [
|
||||
{
|
||||
"changeId": "FE-REG-ENV:API_CONTRACT_VERSION:*:removed",
|
||||
"versionBump": "Runtime Config V2 (CONFIG_SCHEMA_VERSION 2.0) removes the scalar API contract version.",
|
||||
"migration": "Release Manifest V2 contractSet replaces the scalar: the compiled external contract package set is canonicalized and digested, and boot compares it against the manifest.",
|
||||
"compatibilityWindow": "The V1 config and V1 manifest readers stay in place for one release. A V1 document may still carry the scalar; a V2 document is rejected if it does.",
|
||||
"rollback": "Restore the V1 writer in scripts/generate-build-manifest.ts and the scalar key in public/config.json; the V1 reader is still present.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-QUERY:*:*:removed",
|
||||
"versionBump": "Query invalidation composition moves from the legacy flat query registry to the bounded many-to-many invalidation graph.",
|
||||
"migration": "Installed feature contracts now contribute topics, namespace identities, edges, and separate wire versions; bootstrap validates and indexes those contributions before constructing coordinators.",
|
||||
"compatibilityWindow": "Cross-context envelopes remain opaque topic/version pairs and release cache epochs isolate mixed releases; no query keys or cached values cross contexts.",
|
||||
"rollback": "Restore the flat QUERY_REGISTRY composition and its coordinator adapter together with the prior governance entry.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-QUERY:$contract:allowedValues:contract-field-changed",
|
||||
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
|
||||
"migration": "Every installed query row declares invalidate-only; older tabs remain local-only.",
|
||||
"compatibilityWindow": "Mixed releases are isolated by release cacheEpoch and never exchange query keys or values.",
|
||||
"rollback": "Remove the coordinator composition and the two query registry fields.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-QUERY:$contract:breakingFields:contract-field-changed",
|
||||
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
|
||||
"migration": "Every installed query row declares its opaque topic and transport policy.",
|
||||
"compatibilityWindow": "A release cacheEpoch rejects messages from a different deployed contract.",
|
||||
"rollback": "Remove the coordinator composition and restore the prior query registry contract.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-QUERY:$contract:fieldTypes:contract-field-changed",
|
||||
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
|
||||
"migration": "The installed reference query row and all consumers were updated atomically.",
|
||||
"compatibilityWindow": "Old clients do not consume the new fields; new clients validate them before boot.",
|
||||
"rollback": "Restore the previous query registry field types and local-only invalidation.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-QUERY:$contract:requiredFields:contract-field-changed",
|
||||
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
|
||||
"migration": "Missing topics now fail registry validation instead of silently degrading at runtime.",
|
||||
"compatibilityWindow": "Only a fully built release consumes its own installed registry snapshot.",
|
||||
"rollback": "Remove the newly required fields and cross-context composition together.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-QUERY:$contract:uniqueFields:contract-field-changed",
|
||||
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
|
||||
"migration": "Existing query namespaces received unique registry-issued opaque topics.",
|
||||
"compatibilityWindow": "Topics are scoped by release cacheEpoch, so mixed releases cannot collide.",
|
||||
"rollback": "Drop invalidationTopic uniqueness after removing the transport consumer.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-RELEASE:apiContractVersion:compatibilityRole:field-changed",
|
||||
"versionBump": "The release token registry adds contractSetDigest and demotes apiContractVersion to a legacy V1 scalar.",
|
||||
"migration": "compareReleaseToRuntime no longer sources the scalar from Runtime Config; when neither side declares it there is nothing to compare and contractSet verification owns contract coherence.",
|
||||
"compatibilityWindow": "A V1 manifest still supplies apiContractVersion and is still compared against a V1 config that declares one.",
|
||||
"rollback": "Restore the previous compatibilityRole text and remove the contractSetDigest token together with the V2 manifest writer.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-STORAGE:$contract:allowedValues:contract-field-changed",
|
||||
"versionBump": "Web Storage value codec contracts start at version 1.",
|
||||
"migration": "Existing keys retain their physical schema version; values outside the selected codec are discarded.",
|
||||
"compatibilityWindow": "Valid existing color-scheme and opaque-string records remain readable.",
|
||||
"rollback": "Restore the previous registry contract; no physical key deletion is required.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-STORAGE:$contract:breakingFields:contract-field-changed",
|
||||
"versionBump": "Web Storage value codec contracts start at version 1.",
|
||||
"migration": "Each existing row received an explicit codec; executable migration was never consumed and is now forbidden.",
|
||||
"compatibilityWindow": "Schema-versioned physical keys and the envelope shape remain unchanged.",
|
||||
"rollback": "Remove valueCodec enforcement and restore the prior metadata declaration.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-STORAGE:$contract:fieldTypes:contract-field-changed",
|
||||
"versionBump": "Web Storage value codec contracts start at version 1.",
|
||||
"migration": "The dead function migration union was narrowed to the only implemented discard policy.",
|
||||
"compatibilityWindow": "All installed definitions already used discard before this contract change.",
|
||||
"rollback": "Restore the former type metadata without rewriting persisted records.",
|
||||
"owner": "frontend-platform"
|
||||
},
|
||||
{
|
||||
"changeId": "FE-REG-STORAGE:$contract:requiredFields:contract-field-changed",
|
||||
"versionBump": "Web Storage value codec contracts start at version 1.",
|
||||
"migration": "All installed storage rows now declare their closed value codec.",
|
||||
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
|
||||
"rollback": "Remove the required codec field and runtime codec dispatch together.",
|
||||
"owner": "frontend-platform"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"sourceDirectories": [
|
||||
"src/application",
|
||||
"src/presentation",
|
||||
"src/domain"
|
||||
],
|
||||
"registries": [
|
||||
{
|
||||
"registryId": "FE-REG-ROUTE",
|
||||
"path": "src/features/installed-feature-contracts.ts",
|
||||
"exportName": "ROUTE_REGISTRY",
|
||||
"owner": "feature-frontend-routing-release-recovery-runtime",
|
||||
"keyField": "routeId",
|
||||
"requiredFields": [
|
||||
"routeId",
|
||||
"path",
|
||||
"paramsSchema",
|
||||
"searchSchema",
|
||||
"access",
|
||||
"loadingSurface",
|
||||
"errorSurface",
|
||||
"chunkId",
|
||||
"title",
|
||||
"navigationLabel",
|
||||
"navigationOrder"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"routeId": "string",
|
||||
"path": "string",
|
||||
"paramsSchema": "string|null",
|
||||
"searchSchema": "string|null",
|
||||
"access": "string",
|
||||
"loadingSurface": "string",
|
||||
"errorSurface": "string",
|
||||
"chunkId": "string",
|
||||
"title": "string",
|
||||
"navigationLabel": "string|null",
|
||||
"navigationOrder": "integer|null"
|
||||
},
|
||||
"uniqueFields": ["routeId", "path", "chunkId"],
|
||||
"allowedValues": {
|
||||
"access": ["public", "session-required"],
|
||||
"paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"],
|
||||
"searchSchema": [null, "ReferenceResourceListQuery"],
|
||||
"loadingSurface": [
|
||||
"app-shell",
|
||||
"example-page",
|
||||
"reference-resource-list",
|
||||
"reference-resource-detail",
|
||||
"reference-resource-form",
|
||||
"reference-resource-status",
|
||||
"none"
|
||||
],
|
||||
"errorSurface": [
|
||||
"route-boundary",
|
||||
"feature-boundary",
|
||||
"not-found"
|
||||
]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"field": "routeId",
|
||||
"registryId": "FE-REG-ROUTE-RUNTIME",
|
||||
"targetField": "routeId"
|
||||
},
|
||||
{
|
||||
"field": "paramsSchema",
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"targetField": "schemaId"
|
||||
},
|
||||
{
|
||||
"field": "searchSchema",
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"targetField": "schemaId"
|
||||
}
|
||||
],
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/presentation/routes/app-router.tsx",
|
||||
"token": "ROUTE_REGISTRY"
|
||||
}
|
||||
],
|
||||
"breakingFields": [
|
||||
"routeId",
|
||||
"path",
|
||||
"paramsSchema",
|
||||
"searchSchema",
|
||||
"access",
|
||||
"chunkId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-ROUTE-RUNTIME",
|
||||
"path": "src/features/installed-feature-contracts.ts",
|
||||
"exportName": "ROUTE_RUNTIME_CONTRACT",
|
||||
"owner": "feature-frontend-routing-release-recovery-runtime",
|
||||
"keyField": "routeId",
|
||||
"requiredFields": [
|
||||
"routeId",
|
||||
"moduleId",
|
||||
"paramsCodec",
|
||||
"searchCodec"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"routeId": "string",
|
||||
"moduleId": "string",
|
||||
"paramsCodec": "string",
|
||||
"searchCodec": "string"
|
||||
},
|
||||
"uniqueFields": ["routeId", "moduleId"],
|
||||
"references": [
|
||||
{
|
||||
"field": "routeId",
|
||||
"registryId": "FE-REG-ROUTE",
|
||||
"targetField": "routeId"
|
||||
},
|
||||
{
|
||||
"field": "paramsCodec",
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"targetField": "schemaId"
|
||||
},
|
||||
{
|
||||
"field": "searchCodec",
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"targetField": "schemaId"
|
||||
}
|
||||
],
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/presentation/routes/route-codecs.ts",
|
||||
"token": "ROUTE_RUNTIME_CONTRACT"
|
||||
}
|
||||
],
|
||||
"breakingFields": [
|
||||
"routeId",
|
||||
"moduleId",
|
||||
"paramsCodec",
|
||||
"searchCodec"
|
||||
]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-API",
|
||||
"path": "src/features/installed-feature-contracts.ts",
|
||||
"exportName": "API_OPERATIONS",
|
||||
"owner": "feature-frontend-api-client-response-envelope-contract",
|
||||
"keyField": "operationId",
|
||||
"requiredFields": [
|
||||
"method",
|
||||
"path",
|
||||
"operationId",
|
||||
"auth",
|
||||
"timeoutMs",
|
||||
"idempotency",
|
||||
"retry",
|
||||
"requestSource",
|
||||
"requestSchema",
|
||||
"responseSchema",
|
||||
"owner"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"method": "string",
|
||||
"path": "string",
|
||||
"operationId": "string",
|
||||
"auth": "string",
|
||||
"timeoutMs": "integer|null",
|
||||
"idempotency": "string",
|
||||
"retry": "string",
|
||||
"requestSource": "string",
|
||||
"requestSchema": "string",
|
||||
"responseSchema": "string",
|
||||
"owner": "string"
|
||||
},
|
||||
"uniqueFields": ["operationId"],
|
||||
"allowedValues": {
|
||||
"method": ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
"auth": ["none", "external-session"],
|
||||
"idempotency": ["safe", "keyed", "none"],
|
||||
"retry": ["runtime", "never"],
|
||||
"requestSource": ["none", "search", "body"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"field": "requestSchema",
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"targetField": "schemaId"
|
||||
},
|
||||
{
|
||||
"field": "responseSchema",
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"targetField": "schemaId"
|
||||
}
|
||||
],
|
||||
"consumerIdentityField": "operationId",
|
||||
"consumerDirectories": [
|
||||
"src/features/reference-feature/adapters",
|
||||
"src/features/reference-feature/application"
|
||||
],
|
||||
"breakingFields": [
|
||||
"method",
|
||||
"path",
|
||||
"operationId",
|
||||
"auth",
|
||||
"idempotency",
|
||||
"requestSource",
|
||||
"requestSchema",
|
||||
"responseSchema"
|
||||
]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-SCHEMA",
|
||||
"path": "src/features/installed-feature-contracts.ts",
|
||||
"exportName": "SCHEMA_REGISTRY",
|
||||
"owner": "feature-frontend-contract-schema-registry",
|
||||
"keyField": "schemaId",
|
||||
"requiredFields": ["schemaId", "boundary", "owner", "runtime"],
|
||||
"fieldTypes": {
|
||||
"schemaId": "string",
|
||||
"boundary": "string",
|
||||
"owner": "string",
|
||||
"runtime": "string"
|
||||
},
|
||||
"uniqueFields": ["schemaId"],
|
||||
"allowedValues": {
|
||||
"boundary": [
|
||||
"route-params",
|
||||
"route-search",
|
||||
"route-search-api-request",
|
||||
"api-request",
|
||||
"api-response"
|
||||
],
|
||||
"runtime": ["zod"]
|
||||
},
|
||||
"consumerIdentityField": "schemaId",
|
||||
"consumerDirectories": [
|
||||
"src/presentation/routes",
|
||||
"src/features/reference-feature/presentation",
|
||||
"src/features/reference-feature/contracts"
|
||||
],
|
||||
"breakingFields": ["schemaId", "boundary", "runtime"]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-ENV",
|
||||
"path": "src/contracts/env.ts",
|
||||
"exportName": "ENV_REGISTRY",
|
||||
"owner": "feature-frontend-env-runtime-config-contract",
|
||||
"requiredFields": ["phase", "classification", "required", "defaultValue"],
|
||||
"fieldTypes": {
|
||||
"phase": "string",
|
||||
"classification": "string",
|
||||
"required": "boolean",
|
||||
"defaultValue": "string|integer|boolean|null"
|
||||
},
|
||||
"allowedValues": {
|
||||
"phase": ["build", "runtime"],
|
||||
"classification": [
|
||||
"public",
|
||||
"public-sensitive",
|
||||
"public-metadata",
|
||||
"compile-time"
|
||||
]
|
||||
},
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/bootstrap/runtime-config-schema.ts",
|
||||
"token": "APP_ENV"
|
||||
},
|
||||
{
|
||||
"path": "src/contracts/env.ts",
|
||||
"token": "getBuildConfig"
|
||||
}
|
||||
],
|
||||
"breakingFields": ["phase", "classification", "required"]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-STORAGE",
|
||||
"path": "src/contracts/storage-keys.ts",
|
||||
"exportName": "STORAGE_REGISTRY",
|
||||
"owner": "feature-frontend-storage-registry-contract",
|
||||
"keyField": "logicalName",
|
||||
"requiredFields": [
|
||||
"logicalName",
|
||||
"physicalKey",
|
||||
"backend",
|
||||
"classification",
|
||||
"schemaVersion",
|
||||
"valueCodec",
|
||||
"ttl",
|
||||
"migration",
|
||||
"quotaFallback"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"logicalName": "string",
|
||||
"physicalKey": "string",
|
||||
"backend": "string",
|
||||
"classification": "string",
|
||||
"schemaVersion": "integer",
|
||||
"valueCodec": "string",
|
||||
"ttl": "integer|string|null",
|
||||
"migration": "string",
|
||||
"quotaFallback": "string"
|
||||
},
|
||||
"uniqueFields": ["logicalName", "physicalKey"],
|
||||
"allowedValues": {
|
||||
"backend": [
|
||||
"memory",
|
||||
"sessionStorage",
|
||||
"localStorage",
|
||||
"disabled",
|
||||
"forbidden"
|
||||
],
|
||||
"classification": [
|
||||
"public-preference",
|
||||
"opaque-cache",
|
||||
"sensitive-forbidden"
|
||||
],
|
||||
"valueCodec": [
|
||||
"color-scheme-v1",
|
||||
"opaque-string-v1",
|
||||
"none"
|
||||
],
|
||||
"migration": ["discard"],
|
||||
"quotaFallback": ["memory", "no-persist", "feature-disable"]
|
||||
},
|
||||
"consumerIdentityField": "logicalName",
|
||||
"consumerDirectories": ["src", "tests"],
|
||||
"orphanExemptRows": ["QUERY_PERSISTENCE", "AUTH_TOKEN"],
|
||||
"breakingFields": [
|
||||
"logicalName",
|
||||
"physicalKey",
|
||||
"backend",
|
||||
"classification",
|
||||
"schemaVersion",
|
||||
"valueCodec",
|
||||
"migration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-ERROR",
|
||||
"path": "src/contracts/errors.ts",
|
||||
"exportName": "ERROR_REGISTRY",
|
||||
"owner": "feature-frontend-error-classification-boundary-contract",
|
||||
"keyField": "kind",
|
||||
"requiredFields": [
|
||||
"kind",
|
||||
"defaultRetryable",
|
||||
"severity",
|
||||
"userMessageKey",
|
||||
"action",
|
||||
"telemetryEvent",
|
||||
"redaction"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"kind": "string",
|
||||
"defaultRetryable": "boolean",
|
||||
"severity": "string",
|
||||
"userMessageKey": "string",
|
||||
"action": "string",
|
||||
"telemetryEvent": "string",
|
||||
"redaction": "array"
|
||||
},
|
||||
"uniqueFields": ["kind"],
|
||||
"allowedValues": {
|
||||
"severity": ["info", "warning", "error"],
|
||||
"action": [
|
||||
"retry",
|
||||
"reauth",
|
||||
"navigate",
|
||||
"reload-once",
|
||||
"contact-support",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/adapters/http/client.ts",
|
||||
"token": "failure("
|
||||
}
|
||||
],
|
||||
"breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-QUERY-INVALIDATION",
|
||||
"path": "src/features/installed-feature-contracts.ts",
|
||||
"exportName": "INVALIDATION_REGISTRY",
|
||||
"rowsPath": "edges",
|
||||
"rowKeyFields": [
|
||||
"topicId",
|
||||
"namespace.namespaceId",
|
||||
"namespace.namespaceVersion"
|
||||
],
|
||||
"owner": "feature-frontend-server-state-caching-contract",
|
||||
"requiredFields": [
|
||||
"topicId",
|
||||
"namespace.namespaceId",
|
||||
"namespace.namespaceVersion"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"topicId": "string",
|
||||
"namespace.namespaceId": "string",
|
||||
"namespace.namespaceVersion": "integer"
|
||||
},
|
||||
"uniqueFieldSets": [
|
||||
[
|
||||
"topicId",
|
||||
"namespace.namespaceId",
|
||||
"namespace.namespaceVersion"
|
||||
]
|
||||
],
|
||||
"snapshotProjection": {
|
||||
"singletonRowKey": "invalidation-graph",
|
||||
"canonicalArrayKeyFields": {
|
||||
"topics": ["$value"],
|
||||
"namespaces": ["namespaceId", "namespaceVersion"],
|
||||
"edges": [
|
||||
"topicId",
|
||||
"namespace.namespaceId",
|
||||
"namespace.namespaceVersion"
|
||||
]
|
||||
}
|
||||
},
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/bootstrap/runtime-adapters.ts",
|
||||
"token": "indexInvalidationRegistry(INVALIDATION_REGISTRY)"
|
||||
}
|
||||
],
|
||||
"breakingFields": ["topics", "namespaces", "edges"]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION",
|
||||
"path": "src/features/installed-feature-contracts.ts",
|
||||
"exportName": "INVALIDATION_TOPIC_VERSIONS",
|
||||
"rowKeyFields": ["topicId"],
|
||||
"owner": "feature-frontend-server-state-caching-contract",
|
||||
"requiredFields": ["topicId", "topicVersion"],
|
||||
"fieldTypes": {
|
||||
"topicId": "string",
|
||||
"topicVersion": "integer"
|
||||
},
|
||||
"uniqueFields": ["topicId"],
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/bootstrap/runtime-adapters.ts",
|
||||
"token": "indexInvalidationTopicVersions("
|
||||
}
|
||||
],
|
||||
"breakingFields": ["topicId", "topicVersion"]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-TELEMETRY",
|
||||
"path": "src/contracts/telemetry.ts",
|
||||
"exportName": "TELEMETRY_REGISTRY",
|
||||
"owner": "feature-frontend-diagnostics-telemetry-runtime",
|
||||
"keyField": "eventName",
|
||||
"requiredFields": [
|
||||
"eventName",
|
||||
"trigger",
|
||||
"requiredAttributes",
|
||||
"optionalAttributes",
|
||||
"forbiddenAttributes",
|
||||
"sampling",
|
||||
"delivery"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"eventName": "string",
|
||||
"trigger": "string",
|
||||
"requiredAttributes": "array",
|
||||
"optionalAttributes": "array",
|
||||
"forbiddenAttributes": "array",
|
||||
"sampling": "string",
|
||||
"delivery": "string"
|
||||
},
|
||||
"uniqueFields": ["eventName"],
|
||||
"allowedValues": {
|
||||
"delivery": ["best-effort"]
|
||||
},
|
||||
"consumers": [
|
||||
{
|
||||
"path": "scripts/check-diagnostics.ts",
|
||||
"token": "TELEMETRY_REGISTRY"
|
||||
}
|
||||
],
|
||||
"breakingFields": [
|
||||
"eventName",
|
||||
"requiredAttributes",
|
||||
"forbiddenAttributes",
|
||||
"delivery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"registryId": "FE-REG-RELEASE",
|
||||
"path": "src/contracts/release-tokens.ts",
|
||||
"exportName": "RELEASE_TOKEN_REGISTRY",
|
||||
"owner": "feature-frontend-release-cache-rollback-contract",
|
||||
"keyField": "token",
|
||||
"requiredFields": ["token", "source", "compatibilityRole"],
|
||||
"fieldTypes": {
|
||||
"token": "string",
|
||||
"source": "string",
|
||||
"compatibilityRole": "string"
|
||||
},
|
||||
"uniqueFields": ["token"],
|
||||
"consumers": [
|
||||
{
|
||||
"path": "src/bootstrap/load-release-manifest.ts",
|
||||
"token": "assetManifestHash"
|
||||
}
|
||||
],
|
||||
"breakingFields": ["token", "source", "compatibilityRole"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"surfaces": {
|
||||
"index": {
|
||||
"path": "/",
|
||||
"cacheControl": "no-cache",
|
||||
"contentTypes": ["text/html"],
|
||||
"securityHeaders": true
|
||||
},
|
||||
"runtimeConfig": {
|
||||
"path": "/config.json",
|
||||
"cacheControl": "no-store",
|
||||
"contentTypes": ["application/json"],
|
||||
"securityHeaders": true
|
||||
},
|
||||
"releaseManifest": {
|
||||
"path": "/release-manifest.json",
|
||||
"cacheControl": "no-store",
|
||||
"contentTypes": ["application/json"],
|
||||
"securityHeaders": true
|
||||
},
|
||||
"hashedAsset": {
|
||||
"pathPattern": "/assets/*",
|
||||
"cacheControl": "public, max-age=31536000, immutable",
|
||||
"contentTypes": ["text/javascript", "application/javascript"],
|
||||
"securityHeaders": false
|
||||
},
|
||||
"sourceMap": {
|
||||
"public": false
|
||||
},
|
||||
"serviceWorker": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"responses": {
|
||||
"index": {
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"x-frame-options": "DENY",
|
||||
"referrer-policy": "strict-origin-when-cross-origin",
|
||||
"x-content-type-options": "nosniff",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()"
|
||||
},
|
||||
"runtimeConfig": {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"x-frame-options": "DENY",
|
||||
"referrer-policy": "strict-origin-when-cross-origin",
|
||||
"x-content-type-options": "nosniff",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()"
|
||||
},
|
||||
"releaseManifest": {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"x-frame-options": "DENY",
|
||||
"referrer-policy": "strict-origin-when-cross-origin",
|
||||
"x-content-type-options": "nosniff",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()"
|
||||
},
|
||||
"hashedAsset": {
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
"content-type": "text/javascript; charset=utf-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"headers": {
|
||||
"Content-Security-Policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundle": {
|
||||
"initialJsGzipBytes": 204800,
|
||||
"lazyChunkGzipBytes": 122880
|
||||
},
|
||||
"lab": {
|
||||
"lcpMs": 2500,
|
||||
"cls": 0.1,
|
||||
"namedInteractionMs": 200
|
||||
},
|
||||
"field": {
|
||||
"p75LcpMs": 2500,
|
||||
"p75Cls": 0.1,
|
||||
"p75InpMs": 200,
|
||||
"minimumEligibleSamples": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"releaseId": "local-release",
|
||||
"environment": "replace-with-production",
|
||||
"source": {
|
||||
"system": "",
|
||||
"exportId": ""
|
||||
},
|
||||
"privacy": {
|
||||
"approved": false,
|
||||
"approvalRef": ""
|
||||
},
|
||||
"window": {
|
||||
"start": "2026-06-01T00:00:00Z",
|
||||
"end": "2026-06-29T00:00:00Z"
|
||||
},
|
||||
"thresholdDecision": {
|
||||
"status": "pending",
|
||||
"minimumEligibleSamples": null,
|
||||
"owner": "",
|
||||
"reviewedAt": "",
|
||||
"evidenceRef": ""
|
||||
},
|
||||
"samples": []
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"$schema": "../../schemas/config/frontend-capability-recipes.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"decisionId": "VD-10",
|
||||
"defaultStatus": "NOT_INSTALLED",
|
||||
"productionRuntimeDependencies": [],
|
||||
"catalogOwner": "frontend-platform",
|
||||
"reviewOn": "project-capability-selection",
|
||||
"vendorPackagePatterns": [
|
||||
"@launchdarkly/*",
|
||||
"@sentry/*",
|
||||
"@opentelemetry/*",
|
||||
"@openapitools/openapi-generator-cli",
|
||||
"@reduxjs/toolkit",
|
||||
"@tanstack/react-virtual",
|
||||
"@uppy/*",
|
||||
"firebase",
|
||||
"idb",
|
||||
"react-window",
|
||||
"redux",
|
||||
"socket.io-client",
|
||||
"tus-js-client",
|
||||
"workbox-window",
|
||||
"xstate",
|
||||
"zustand"
|
||||
],
|
||||
"recipes": [
|
||||
{
|
||||
"id": "realtime",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"referenceRuntime": {
|
||||
"status": "AVAILABLE_NOT_COMPOSED",
|
||||
"coveredCapabilities": [
|
||||
"transport-independent event authority and recovery",
|
||||
"bounded reconnect ownership",
|
||||
"fetch-stream SSE",
|
||||
"bounded polling",
|
||||
"single-writer live and polling handoff",
|
||||
"WebSocket closed protocol",
|
||||
"Web Push window and Service Worker control"
|
||||
],
|
||||
"sourceRoots": [
|
||||
"src/application/ports/realtime",
|
||||
"src/application/ports/out/web-push-control.ts",
|
||||
"src/application/policies/bounded-polling.ts",
|
||||
"src/contracts/realtime-events.ts",
|
||||
"src/contracts/realtime-streams.ts",
|
||||
"src/contracts/web-push.ts",
|
||||
"src/adapters/realtime",
|
||||
"src/adapters/web-push"
|
||||
],
|
||||
"conformanceScripts": [
|
||||
"test:unit",
|
||||
"check:realtime-boundaries",
|
||||
"check:realtime-boundaries:fixture",
|
||||
"check:optional-recipes",
|
||||
"test:realtime-removal"
|
||||
],
|
||||
"productionComposition": false
|
||||
},
|
||||
"trigger": "The backend exposes ordered push events with a documented resume and authorization protocol.",
|
||||
"forbiddenWhen": ["Polling satisfies the measured freshness requirement.", "Event ordering and reconnect ownership are undefined."],
|
||||
"boundary": "transport-independent event authority plus separately owned SSE, WebSocket, bounded polling and Web Push adapters",
|
||||
"port": "RealtimeEventAuthority / WebPushControlPort / transport-specific connection and polling factories",
|
||||
"fake": "Deterministic event authority, transport facade, clock, repository and Service Worker test doubles",
|
||||
"failureKinds": ["abort", "disconnect-or-timeout", "protocol-or-mapping-mismatch", "duplicate-or-stale", "sequence-gap-or-cursor-expiry", "queue-overflow", "scope-fenced", "poll-budget-exhausted", "push-permission-or-subscription-failure"],
|
||||
"lifecycleMethods": ["close-or-dispose", "unsubscribe", "cancel-via-AbortSignal", "bounded-poll-lease", "revoke-push-association"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Validate every event envelope and closed transport frame before application effects.", "Bind stream state to the current opaque scope generation and advance checkpoints only after committed effects or authoritative recovery.", "Use fixed same-origin endpoints and an exact WebSocket subprotocol; never place credentials, cursors, subscription material or scope bindings in URLs or telemetry.", "Treat Web Push as a notification hint, fence registration and revocation with one durable compare-and-swap control record, and allow only registry-owned notification and route intents.", "Keep every queue, parser, reconnect, poll, notification and storage operation bounded and abortable."],
|
||||
"bundleBudgetGzipBytes": 40000,
|
||||
"fallback": "Bounded polling or explicitly stale UI.",
|
||||
"removal": ["Disable admission and close active readers, sockets, poll leases and worker handlers.", "Revoke and purge only the owned Web Push association and notification state.", "Remove composition, registries, adapters and any selected vendor dependency.", "Run realtime boundary, runtime-removal and production-bundle gates."],
|
||||
"serverStatePolicy": "query-cache-owned"
|
||||
},
|
||||
{
|
||||
"id": "offline-indexeddb",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"referenceRuntime": {
|
||||
"status": "AVAILABLE_NOT_COMPOSED",
|
||||
"coveredCapabilities": [
|
||||
"IndexedDB",
|
||||
"OPFS",
|
||||
"StorageManager estimate/persistence"
|
||||
],
|
||||
"sourceRoots": [
|
||||
"src/application/ports/browser-file-storage/indexeddb-port.ts",
|
||||
"src/application/ports/browser-file-storage/opfs-ports.ts",
|
||||
"src/application/ports/browser-file-storage/storage-durability-port.ts",
|
||||
"src/adapters/browser-file-storage",
|
||||
"src/adapters/storage/indexeddb",
|
||||
"src/adapters/storage/opfs"
|
||||
],
|
||||
"conformanceScripts": [
|
||||
"test:unit",
|
||||
"test:browser-capabilities",
|
||||
"verify:browser-capability-evidence",
|
||||
"check:browser-file-storage-boundaries",
|
||||
"check:optional-recipes",
|
||||
"test:browser-file-storage-removal"
|
||||
],
|
||||
"productionComposition": false
|
||||
},
|
||||
"trigger": "A product requirement needs indexed offline records, an unsynced command queue, or a large local binary sidecar beyond small public preferences.",
|
||||
"forbiddenWhen": ["The data contains credentials.", "The browser would connect directly to a server database or object store.", "A normal HTTP cache is sufficient.", "Partition, retention, quota and recovery ownership are undefined."],
|
||||
"boundary": "feature-specific async repository with registry-issued opaque dataset scope and immutable full-policy binding, plus an OPFS large-object sidecar whose logical commit authority and bidirectional scope binding are owned by an IndexedDB journal",
|
||||
"port": "IndexedDbRepositoryPort / IndexedDbMaintenancePort / DurableObjectStorePort / DurableObjectMaintenancePort / StorageDurabilityPort",
|
||||
"fake": "MemoryStructuredOfflineStore / MemoryDurableObjectStore / MemoryStorageDurabilityAdapter",
|
||||
"failureKinds": ["open-blocked", "versionchange", "quota", "corruption", "migration-rollback", "revision-conflict", "storage-eviction", "partial-object-write", "dataset-binding-mismatch", "dataset-budget-exceeded", "lifecycle-authorization-denied", "expired-resource"],
|
||||
"lifecycleMethods": ["close", "cancel-via-AbortSignal", "enforce-bounded-lifecycle-batch", "prune-expired-receipts", "reconcile", "enforcePolicies-with-composition-authority"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Classify every persisted field and binary namespace.", "Encrypting in the same client is not a credential protection boundary.", "Keep database schema and record codec versions separate.", "Derive the physical IndexedDB name only from registry-issued authority, namespace and partition tokens; readable namespace, business and account IDs are forbidden.", "Persist and revalidate an immutable scope plus full BrowserStoragePolicy binding during upgrade, post-open and maintenance; missing or mismatched existing bindings fail closed.", "Use the actual IndexedDB wire split: StoredRecord contains only key, codecVersion, revision and payload; writtenAtEpochMs, synchronization, measuredBytes and eligibleAtEpochMs belong to the retention sidecar, while idempotency receipts and governance binding/budget use separate stores.", "Include every store registered in lifecycleMetadataStores in bounded full-partition purge while retaining immutable governance identity.", "Measure conservative logical bytes through the codec and atomically enforce dataset usedBytes plus receiptCount with record, lifecycle and migration writes.", "Enforce TTL before sweep visibility, delete UNTIL_SYNCED only after explicit confirmation, and require a composition-authorized short-lived proof for every deleting IndexedDB lifecycle batch.", "Bound idempotency receipt retention to 31 days and configured count to the implementation ceiling of 1000000; bound migration to old-writer-drained batches no larger than 500 rows or 30000ms.", "Bind OPFS readable and physical scopes in both directions and use only /ca-frontend-opfs-v1/authorities/<authorityToken>/<namespaceToken>/<partitionToken>/ for physical dataset layout.", "For OPFS LOGOUT, UNTIL_SYNCED and ACCOUNT_DELETION maintenance, composition must provide both requestMaintenanceAuthority and consumeMaintenanceAuthority; issue a fresh proof bound to the exact frozen reason, scope and policy for at most five minutes, then atomically consume it to reject replay.", "Never expose an OPFS authority proof through the application request, persistence, diagnostics or telemetry.", "Never place user file names or identifiers in OPFS paths or diagnostics."],
|
||||
"bundleBudgetGzipBytes": 36000,
|
||||
"fallback": "Read-only or online-only query path; OPFS may degrade to a size-capped IndexedDB Blob only when the product policy approves it.",
|
||||
"removal": ["Stop writes and background migration.", "Reconcile or export unsynced data, then purge only governance-bound owned partitions and OPFS namespaces through authorized bounded lifecycle operations.", "Close all database, channel, worker and file handles.", "Remove repository composition and dependency."],
|
||||
"serverStatePolicy": "reference-or-command-only"
|
||||
},
|
||||
{
|
||||
"id": "service-worker-pwa",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"referenceRuntime": {
|
||||
"status": "AVAILABLE_NOT_COMPOSED",
|
||||
"coveredCapabilities": [
|
||||
"Cache Storage public-response administration"
|
||||
],
|
||||
"sourceRoots": [
|
||||
"src/application/ports/browser-file-storage/cache-storage-ports.ts",
|
||||
"src/adapters/browser-file-storage",
|
||||
"src/adapters/cache-storage"
|
||||
],
|
||||
"conformanceScripts": [
|
||||
"test:unit",
|
||||
"test:browser-capabilities",
|
||||
"verify:browser-capability-evidence",
|
||||
"check:browser-file-storage-boundaries",
|
||||
"check:optional-recipes",
|
||||
"test:browser-file-storage-removal"
|
||||
],
|
||||
"productionComposition": false
|
||||
},
|
||||
"trigger": "Installability, a measured offline-shell requirement, or an explicitly owned public HTTP representation cache is approved.",
|
||||
"forbiddenWhen": ["Hosting cache and worker cache ownership conflict.", "Update and rollback UX is undefined.", "Authenticated, private, opaque or personal responses would be cached.", "Cache freshness, byte and entry limits are undefined."],
|
||||
"boundary": "bootstrap update controller plus platform-local public Request/Response cache administration",
|
||||
"port": "ServiceWorkerUpdatePort / PublicResponseCacheAdmin recipe / PublicResponseCachePort / PublicResponseCacheAdminPort reference runtime",
|
||||
"fake": "FakeServiceWorkerUpdateAdapter / MemoryPublicResponseCache",
|
||||
"failureKinds": ["stale-worker", "update-loop", "offline-fallback", "incomplete-candidate", "integrity-mismatch", "cache-policy-rejection", "quota"],
|
||||
"lifecycleMethods": ["unregister", "rollback", "delete-owned-caches"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Cache only explicit same-origin public GET representations.", "Never cache authenticated, cookie-dependent, private, no-store, opaque or personal responses.", "Bind candidate cache names to release identity and a canonical manifest digest that includes normalized expectedContentType, exact request identity, expected byte length and integrity digest.", "Reject a response whose normalized Content-Type differs from manifest expectedContentType even when body integrity matches.", "Derive cleanup retention only from the verified active pointer and composition retainedPreviousReleaseCount; cleanup callers cannot submit cache names, release registry IDs or any retain set.", "Read active-pointer and release-marker control JSON through a strict UTF-8 stream capped at exactly 2 MiB (2097152 bytes), cancel on overflow and fail closed before parsing oversized metadata.", "Keep exact query and Vary semantics; ignoreSearch and ignoreVary are forbidden.", "Fail closed on malformed update metadata or integrity mismatch."],
|
||||
"bundleBudgetGzipBytes": 10000,
|
||||
"fallback": "Normal network application with hosting cache headers.",
|
||||
"removal": ["Deploy an unregister migration.", "Delete only parsed, owned cache namespaces after old controlled clients drain.", "Remove worker registration, cache metadata and manifest."],
|
||||
"serverStatePolicy": "network-cache-policy-only"
|
||||
},
|
||||
{
|
||||
"id": "file-transfer",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"referenceRuntime": {
|
||||
"status": "AVAILABLE_NOT_COMPOSED",
|
||||
"coveredCapabilities": [
|
||||
"File",
|
||||
"Blob",
|
||||
"native file input",
|
||||
"system file picker",
|
||||
"object URL preview",
|
||||
"download delivery",
|
||||
"presigned URL capability",
|
||||
"bounded streaming download",
|
||||
"multipart/resumable upload",
|
||||
"durable non-secret upload checkpoint",
|
||||
"Image CDN responsive delivery"
|
||||
],
|
||||
"sourceRoots": [
|
||||
"src/application/ports/browser-file-storage/file.ts",
|
||||
"src/application/ports/browser-transfer",
|
||||
"src/adapters/browser-file-storage",
|
||||
"src/adapters/browser-files",
|
||||
"src/adapters/browser-transfer"
|
||||
],
|
||||
"conformanceScripts": [
|
||||
"test:unit",
|
||||
"test:browser-capabilities",
|
||||
"verify:browser-capability-evidence",
|
||||
"check:browser-file-storage-boundaries",
|
||||
"check:optional-recipes",
|
||||
"test:browser-file-storage-removal"
|
||||
],
|
||||
"productionComposition": false
|
||||
},
|
||||
"trigger": "The product selects, inspects, previews, uploads, downloads or delivers image renditions with bounded memory, resumability, cancellation, expiry and integrity requirements.",
|
||||
"forbiddenWhen": ["Allowed count, byte, extension, MIME and content-signature policy is missing.", "The BFF does not own authorization, short-lived capability issuance, upload session reconciliation, quarantine and orphan cleanup.", "Long-lived credentials, presigned URLs or signed headers would enter persistence, application state or telemetry.", "Native File, Blob, object URL or file-system handles would cross into domain state or persistence.", "Large downloads would be returned as one in-memory byte array or Blob.", "Image callers could submit arbitrary CDN source URLs or transform parameters."],
|
||||
"boundary": "BFF-owned transfer control plane plus adapter-owned browser/object-storage data plane; presentation receives opaque file/image references, registered policies and bounded result streams only",
|
||||
"port": "FilePickerPort / FileContentPort / TransientPreviewPort / DownloadDeliveryPort / PresignedDownloadSourcePort / PresignedUploadPartPort / ResumableUploadPort / ImageCdnPresentationPort",
|
||||
"fake": "Memory file/preview/download adapters plus injected deterministic capability, upload-control-plane, part-executor and image-verifier test doubles",
|
||||
"failureKinds": ["dismissed", "permission-denied", "count-or-size-rejection", "type-or-signature-rejection", "file-changed", "abort", "integrity-failure", "partial-save", "expired-or-revoked-capability", "part-or-session-conflict", "checkpoint-conflict", "quarantined", "image-policy-rejection"],
|
||||
"lifecycleMethods": ["release-file-ref", "release-or-dispose-preview-leases", "cancel-via-AbortSignal", "reconcile-or-explicitly-abort-upload", "close-checkpoint-store", "dispose-capability-and-image-runtime"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Treat file name, extension, MIME and lastModified as untrusted metadata.", "Resolve only exact composition-issued file and image policy object identities; callers cannot raise byte, candidate, pixel, quality, format, lifetime or origin ceilings.", "Use opaque file references and verification receipts bound to an inspected immutable file snapshot and the exact registered profile; reject replay through another profile even when an inspection rule ID matches.", "Treat presigned URLs as bearer capabilities; bind exact method, resource or upload part, offset, length, media type, checksum, origin, path, query, headers and expiry in an in-memory identity vault.", "Use credentials omit, redirect error, no-referrer and no-store for direct data-plane fetch; never persist or observe URL, query, signed header, capability, file name or raw backend message, and never emit digest, raw ETag or receipt values to diagnostics or telemetry.", "A strict account-partitioned upload checkpoint may persist only the protocol-defined SHA-256 file fingerprint, per-part checksum and bounded opaque non-authorizing part receipt token required for server reconciliation; no bearer token or raw signed capability is allowed.", "Persist only strict non-authorizing upload checkpoints and reconcile them with server-authoritative status and re-hashed local parts before completion.", "Require a synchronous server-issued browser-managed download capability whose receipt exactly equals the caller's branded capability receipt and whose resource, media type, safe extension, maximum bytes, optional digest and expiry all match before handoff.", "Expose File, OPFS, Cache and transfer byte streams only as chunk-level closed Results; stop after the first failure, cancel native readers and never throw a raw native exception across the port.", "Accept Image CDN assets only through immutable allowlisted or signature-verified descriptors and registered preset identities; reject active formats, arbitrary transforms, pixel/decode-budget overflow and unsafe cache policy.", "Upload completion remains QUARANTINED until backend scan and promotion; client capability checks are not an authorization boundary.", "Active content preview requires isolation or download-only treatment."],
|
||||
"bundleBudgetGzipBytes": 54600,
|
||||
"fallback": "Accessible native file input, same-origin authorized server upload/download and a single bounded server-selected image rendition; generated artifacts above the buffer budget move to server-side generation.",
|
||||
"removal": ["Stop new capability and upload-session issuance, then cancel active reads and transfers.", "Reconcile or explicitly abort active multipart sessions and let backend TTL cleanup remove ambiguous orphans.", "Remove non-secret checkpoints according to account and retention policy.", "Release file references, revoke preview object-URL leases and dispose file, capability and image runtimes.", "Remove transfer/image feature facades and composition, then prove browser-transfer sources are absent from the production module inventory."],
|
||||
"serverStatePolicy": "query-cache-metadata-only"
|
||||
},
|
||||
{
|
||||
"id": "generated-api",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "A versioned backend contract justifies generated transport code.",
|
||||
"forbiddenWhen": ["Generated DTOs would escape into domain or presentation.", "Contract drift cannot block CI."],
|
||||
"boundary": "generated client wrapped by a feature gateway facade and mapper",
|
||||
"port": "GeneratedApiFacade",
|
||||
"fake": "FakeGeneratedApiAdapter",
|
||||
"failureKinds": ["contract-drift", "unsupported-field"],
|
||||
"lifecycleMethods": ["cancel-via-AbortSignal"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Generate from an authenticated source.", "Review generator execution and output.", "Do not log request bodies."],
|
||||
"bundleBudgetGzipBytes": 16000,
|
||||
"fallback": "Existing typed request builder and runtime response schema.",
|
||||
"removal": ["Restore handwritten gateway.", "Remove generated output and generator.", "Verify DTOs do not remain in public types."],
|
||||
"serverStatePolicy": "query-cache-owned"
|
||||
},
|
||||
{
|
||||
"id": "feature-flag",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "A staged rollout or kill switch has a named owner, default and stale policy.",
|
||||
"forbiddenWhen": ["A flag is used as authorization.", "Unknown and unavailable behavior is undefined."],
|
||||
"boundary": "application feature policy output port",
|
||||
"port": "FeatureFlagPort",
|
||||
"fake": "FakeFeatureFlagAdapter",
|
||||
"failureKinds": ["provider-unavailable", "unknown-flag", "stale-value"],
|
||||
"lifecycleMethods": ["dispose-provider-if-installed"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Flags are hints, never access control.", "Minimize targeting attributes.", "Apply consent rules to personal attributes."],
|
||||
"bundleBudgetGzipBytes": 10000,
|
||||
"fallback": "Typed local default with an explicit stale decision.",
|
||||
"removal": ["Resolve the rollout permanently.", "Delete flag key and branches.", "Remove provider composition and dependency."],
|
||||
"serverStatePolicy": "policy-cache-only"
|
||||
},
|
||||
{
|
||||
"id": "web-worker",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "Profiling shows CPU work blocking the main thread beyond the performance budget.",
|
||||
"forbiddenWhen": ["The task is primarily network I/O.", "Cancellation and stale-result ownership are undefined."],
|
||||
"boundary": "request/result/cancel output port with a validated message adapter",
|
||||
"port": "WorkerTaskPort",
|
||||
"fake": "FakeWorkerTaskAdapter",
|
||||
"failureKinds": ["crash", "stale-result", "transfer-failure"],
|
||||
"lifecycleMethods": ["cancel", "dispose"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Validate worker messages.", "Do not send credentials.", "Bound transferred data and worker count."],
|
||||
"bundleBudgetGzipBytes": 14000,
|
||||
"fallback": "Chunked or deferred main-thread execution within a measured limit.",
|
||||
"removal": ["Stop and dispose workers.", "Restore synchronous facade implementation.", "Remove worker entry and chunk."],
|
||||
"serverStatePolicy": "no-server-state"
|
||||
},
|
||||
{
|
||||
"id": "multi-tab",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "A documented workflow must synchronize non-sensitive events across tabs.",
|
||||
"forbiddenWhen": ["The server is the correct conflict authority.", "Event version and source identity are undefined."],
|
||||
"boundary": "versioned browser event output/input adapter",
|
||||
"port": "MultiTabPort",
|
||||
"fake": "FakeMultiTabAdapter",
|
||||
"failureKinds": ["self-echo", "duplicate", "conflict"],
|
||||
"lifecycleMethods": ["unsubscribe", "close"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Broadcast no credentials or personal payload.", "Validate versions.", "Treat events as hints rather than authorization."],
|
||||
"bundleBudgetGzipBytes": 4000,
|
||||
"fallback": "Refresh from the authoritative server on focus.",
|
||||
"removal": ["Close channels.", "Remove event registry entries.", "Restore focus-based refresh."],
|
||||
"serverStatePolicy": "invalidation-only"
|
||||
},
|
||||
{
|
||||
"id": "browser-permission",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "A user-initiated flow requires clipboard, notification or media access.",
|
||||
"forbiddenWhen": ["Permission would be requested at boot.", "Denied, dismissed and unsupported UX are not designed."],
|
||||
"boundary": "presentation input action through a browser capability output port",
|
||||
"port": "BrowserPermissionPort",
|
||||
"fake": "FakeBrowserPermissionAdapter",
|
||||
"failureKinds": ["denied", "dismissed", "unsupported"],
|
||||
"lifecycleMethods": ["stop-media-tracks-if-opened"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Require an explicit user gesture.", "Minimize requested scope.", "Do not persist permission as authorization."],
|
||||
"bundleBudgetGzipBytes": 3000,
|
||||
"fallback": "Manual input or copy/download instruction.",
|
||||
"removal": ["Stop acquired resources.", "Remove permission action and adapter.", "Retest denied-path accessibility."],
|
||||
"serverStatePolicy": "no-server-state"
|
||||
},
|
||||
{
|
||||
"id": "client-workflow",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "A measured cross-page client-only workflow cannot be represented by URL, local state, context or query cache.",
|
||||
"forbiddenWhen": ["The store would duplicate server response collections.", "A library is selected before state ownership is documented.", "Zustand and Redux Toolkit would both be installed."],
|
||||
"boundary": "workflow-specific local facade; vendor types remain in its adapter",
|
||||
"port": "ClientWorkflowPort",
|
||||
"fake": "FakeClientWorkflowAdapter",
|
||||
"failureKinds": ["reset", "version-mismatch", "server-state-duplication"],
|
||||
"lifecycleMethods": ["unsubscribe", "reset"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Persist only explicitly classified workflow fields.", "Never persist credentials.", "Define logout and version reset."],
|
||||
"bundleBudgetGzipBytes": 9000,
|
||||
"fallback": "URL, component state, context and TanStack Query ownership.",
|
||||
"removal": ["Move remaining state to its natural owner.", "Remove facade and one selected store dependency.", "Verify logout/reset."],
|
||||
"serverStatePolicy": "reference-only"
|
||||
},
|
||||
{
|
||||
"id": "large-data-ui",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "Production-like profiling proves a list or grid exceeds interaction and rendering budgets.",
|
||||
"forbiddenWhen": ["Pagination solves the scale requirement.", "Keyboard and screen-reader focus behavior is undefined."],
|
||||
"boundary": "presentation facade around virtualizer or data-grid behavior",
|
||||
"port": "LargeDataUiFacade",
|
||||
"fake": "FakeLargeDataUiAdapter",
|
||||
"failureKinds": ["focus-loss", "stale-row", "scale-limit"],
|
||||
"lifecycleMethods": ["dispose-observers-if-installed"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Render only authorized rows.", "Do not expose hidden row data to telemetry.", "Preserve accessible row identity."],
|
||||
"bundleBudgetGzipBytes": 30000,
|
||||
"fallback": "Accessible pagination and bounded result sets.",
|
||||
"removal": ["Restore paginated primitive.", "Remove facade adapter and dependency.", "Run keyboard and performance evidence."],
|
||||
"serverStatePolicy": "query-cache-owned"
|
||||
},
|
||||
{
|
||||
"id": "analytics-error-sink",
|
||||
"status": "RECIPE_AVAILABLE",
|
||||
"trigger": "A production provider, consent policy, retention owner and event registry are approved.",
|
||||
"forbiddenWhen": ["Consent and essential diagnostics are not separated.", "Arbitrary message or attribute keys can bypass redaction."],
|
||||
"boundary": "closed diagnostics/analytics port with provider adapter",
|
||||
"port": "AnalyticsErrorSink",
|
||||
"fake": "RecordingAnalyticsAdapter",
|
||||
"failureKinds": ["consent-denied", "queue-full", "provider-unavailable"],
|
||||
"lifecycleMethods": ["flush", "dispose"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Allowlist events and attributes.", "Redact before queueing.", "Apply consent, sampling and retention policy."],
|
||||
"bundleBudgetGzipBytes": 25000,
|
||||
"fallback": "Existing bounded local diagnostics and best-effort telemetry port.",
|
||||
"removal": ["Disable provider delivery.", "Flush or discard by policy.", "Remove adapter, runtime config and dependency."],
|
||||
"serverStatePolicy": "no-server-state"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fixtures": [
|
||||
{
|
||||
"name": "coherent-release",
|
||||
"expectedCompatible": true,
|
||||
"frontend": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "1.0",
|
||||
"apiContractVersion": "1.0",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
},
|
||||
"runtime": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "1.1",
|
||||
"apiContractVersion": "1.2",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mixed-html-and-assets",
|
||||
"expectedCompatible": false,
|
||||
"frontend": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "1.0",
|
||||
"apiContractVersion": "1.0",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
},
|
||||
"runtime": {
|
||||
"buildId": "build-b",
|
||||
"configSchemaVersion": "1.0",
|
||||
"apiContractVersion": "1.0",
|
||||
"assetManifestHash": "assets-b",
|
||||
"releaseId": "release-b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "incompatible-runtime-config",
|
||||
"expectedCompatible": false,
|
||||
"frontend": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "1.0",
|
||||
"apiContractVersion": "1.0",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
},
|
||||
"runtime": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "2.0",
|
||||
"apiContractVersion": "1.0",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "incompatible-api-contract",
|
||||
"expectedCompatible": false,
|
||||
"frontend": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "1.0",
|
||||
"apiContractVersion": "1.0",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
},
|
||||
"runtime": {
|
||||
"buildId": "build-a",
|
||||
"configSchemaVersion": "1.0",
|
||||
"apiContractVersion": "2.0",
|
||||
"assetManifestHash": "assets-a",
|
||||
"releaseId": "release-a"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"runbooks": {
|
||||
"FE-RB-001": {
|
||||
"title": "Boot configuration failure",
|
||||
"gateId": "FE-GATE-021",
|
||||
"triggerKinds": ["BOOT_CONFIG_FAILURE"],
|
||||
"containment": "stop product route mount, show the safe support shell, and refetch at most once",
|
||||
"window": "owner triage planned-default 5m",
|
||||
"escalation": ["env-config owner", "release owner"],
|
||||
"recoveryEvidence": [
|
||||
"clean-session boot",
|
||||
"product root mount",
|
||||
"config validation",
|
||||
"no repeated boot error"
|
||||
],
|
||||
"negativeFixture": "a valid config followed by an injected mount failure must fail recovery"
|
||||
},
|
||||
"FE-RB-002": {
|
||||
"title": "Chunk, manifest, or deployment mismatch",
|
||||
"gateId": "FE-GATE-022",
|
||||
"triggerKinds": [
|
||||
"CHUNK_LOAD_FAILURE",
|
||||
"RELEASE_MANIFEST_FAILURE",
|
||||
"DEPLOY_MISMATCH"
|
||||
],
|
||||
"containment": "warn for dirty state, fetch manifest no-store once, and allow one guarded reload",
|
||||
"window": "release owner triage planned-default 5m",
|
||||
"escalation": ["release-cache owner", "hosting/CDN owner"],
|
||||
"recoveryEvidence": [
|
||||
"entry and lazy assets reachable",
|
||||
"release tuple coherent",
|
||||
"second reload blocked",
|
||||
"critical route smoke"
|
||||
],
|
||||
"negativeFixture": "a second failure for the same release pair must not reload"
|
||||
},
|
||||
"FE-RB-003": {
|
||||
"title": "Backend API degradation",
|
||||
"gateId": "FE-GATE-023",
|
||||
"triggerKinds": [
|
||||
"TERMINAL_NETWORK_RATE",
|
||||
"REQUEST_TIMEOUT_RATE",
|
||||
"SERVER_FAILURE_RATE",
|
||||
"SCHEMA_MISMATCH"
|
||||
],
|
||||
"containment": "do not expand retry caps, serve safe stale reads, and never retry an unkeyed mutation",
|
||||
"window": "rolling 5m trigger; first classification planned-default 10m",
|
||||
"escalation": [
|
||||
"api-client owner",
|
||||
"backend operation owner",
|
||||
"release compatibility owner"
|
||||
],
|
||||
"recoveryEvidence": [
|
||||
"terminal failure rate at baseline",
|
||||
"no retry amplification",
|
||||
"critical read/write smoke",
|
||||
"schema fixtures"
|
||||
],
|
||||
"negativeFixture": "an unkeyed POST receiving 503 must not retry"
|
||||
},
|
||||
"FE-RB-004": {
|
||||
"title": "Telemetry sink failure",
|
||||
"gateId": "FE-GATE-024",
|
||||
"triggerKinds": ["TELEMETRY_FAILURE"],
|
||||
"containment": "keep product flow available, bound the queue, and never report recursively to the failing sink",
|
||||
"window": "platform triage planned-default 15m",
|
||||
"escalation": ["observability owner", "telemetry platform owner"],
|
||||
"recoveryEvidence": [
|
||||
"product flow unaffected",
|
||||
"delivery self-check",
|
||||
"queue drained within bound",
|
||||
"forbidden attributes absent"
|
||||
],
|
||||
"negativeFixture": "raw URL and query data must be removed from telemetry"
|
||||
},
|
||||
"FE-RB-005": {
|
||||
"title": "Coherent release rollback",
|
||||
"gateId": "FE-GATE-025",
|
||||
"triggerKinds": ["RELEASE_BLOCKING_DEFECT"],
|
||||
"containment": "select a prior immutable tuple, verify asset/config/API compatibility, atomically switch, and smoke",
|
||||
"window": "provider recovery target TBD",
|
||||
"escalation": ["release-cache owner", "release approver/hosting owner"],
|
||||
"recoveryEvidence": [
|
||||
"compatibility gate",
|
||||
"release coherence gate",
|
||||
"critical smoke",
|
||||
"release ID in incident timeline"
|
||||
],
|
||||
"negativeFixture": "HTML build A with asset manifest B must be rejected"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"APP_ENV": "development",
|
||||
"API_BASE_URL": "https://api.dev.example.com/",
|
||||
"REQUEST_TIMEOUT_MS": 15000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": false,
|
||||
"AUTH_MODE": "external",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"APP_ENV": "local",
|
||||
"API_BASE_URL": "http://localhost:8080/",
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": false,
|
||||
"AUTH_MODE": "demo",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"APP_ENV": "production",
|
||||
"API_BASE_URL": "https://api.example.com/",
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": true,
|
||||
"TELEMETRY_ENDPOINT": "https://telemetry.example.com/v1/events",
|
||||
"AUTH_MODE": "external",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"APP_ENV": "staging",
|
||||
"API_BASE_URL": "https://api.staging.example.com/",
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": true,
|
||||
"TELEMETRY_ENDPOINT": "https://telemetry.staging.example.com/v1/events",
|
||||
"AUTH_MODE": "external",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "ART-FE-FIELD-WEB-VITALS@1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"generatedAt",
|
||||
"window",
|
||||
"context",
|
||||
"metrics",
|
||||
"thresholds",
|
||||
"eligibility",
|
||||
"status",
|
||||
"passed"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"generatedAt": { "type": "string", "format": "date-time" },
|
||||
"window": { "type": "object", "required": ["days", "start", "end"] },
|
||||
"context": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source",
|
||||
"sourceSystem",
|
||||
"exportId",
|
||||
"network",
|
||||
"routeAggregation",
|
||||
"releaseId",
|
||||
"privacyApprovalRef",
|
||||
"thresholdDecisionRef",
|
||||
"validationFailures"
|
||||
],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"sourceSystem": { "type": ["string", "null"] },
|
||||
"exportId": { "type": ["string", "null"] },
|
||||
"network": { "const": "production-real-user" },
|
||||
"routeAggregation": { "const": "route-id-only" },
|
||||
"releaseId": { "type": ["string", "null"] },
|
||||
"privacyApprovalRef": { "type": ["string", "null"] },
|
||||
"thresholdDecisionRef": { "type": ["string", "null"] },
|
||||
"validationFailures": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"thresholds": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"p75LcpMs",
|
||||
"p75Cls",
|
||||
"p75InpMs",
|
||||
"minimumEligibleSamples"
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"required": ["p75LcpMs", "p75Cls", "p75InpMs"]
|
||||
},
|
||||
"eligibility": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"consentRequired",
|
||||
"totalSamples",
|
||||
"eligibleSamples",
|
||||
"minimumEligibleSamples",
|
||||
"routeSamples"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]
|
||||
},
|
||||
"passed": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "ART-FE-LAB@1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"generatedAt",
|
||||
"context",
|
||||
"metrics",
|
||||
"thresholds",
|
||||
"fixtures",
|
||||
"passed"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"generatedAt": { "type": "string", "format": "date-time" },
|
||||
"context": {
|
||||
"type": "object",
|
||||
"required": ["runner", "browser", "viewport", "network", "cpu", "cache", "build"]
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"required": ["lcpMs", "cls", "namedInteractionMs"]
|
||||
},
|
||||
"thresholds": { "type": "object" },
|
||||
"fixtures": { "type": "array", "minItems": 2 },
|
||||
"passed": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "ART-FE-003@1",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "generatedAt", "artifact", "fixtures", "passed"],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"generatedAt": { "type": "string", "format": "date-time" },
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"required": ["checked", "compatible", "mismatches"]
|
||||
},
|
||||
"fixtures": { "type": "array", "minItems": 2 },
|
||||
"passed": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "ART-FE-RUNBOOK-DRILL@1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"runbookId",
|
||||
"releaseId",
|
||||
"drillTimestamp",
|
||||
"triggerInjected",
|
||||
"triggerAsserted",
|
||||
"containmentAsserted",
|
||||
"escalationPathAsserted",
|
||||
"recoveryAssertions",
|
||||
"negativeFixtureFailedAsExpected",
|
||||
"windowObservedBucket",
|
||||
"passed"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"runbookId": { "pattern": "^FE-RB-00[1-5]$" },
|
||||
"releaseId": { "type": "string", "minLength": 1 },
|
||||
"drillTimestamp": { "type": "string", "format": "date-time" },
|
||||
"triggerInjected": { "type": "string" },
|
||||
"triggerAsserted": { "type": "boolean" },
|
||||
"containmentAsserted": { "type": "boolean" },
|
||||
"escalationPathAsserted": { "type": "boolean" },
|
||||
"recoveryAssertions": {
|
||||
"type": "array",
|
||||
"minItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["assertion", "evidence", "passed"]
|
||||
}
|
||||
},
|
||||
"negativeFixtureFailedAsExpected": { "type": "boolean" },
|
||||
"windowObservedBucket": { "type": "string" },
|
||||
"providerVerificationRequired": { "type": "boolean" },
|
||||
"passed": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"snapshotDigest": "ce4fa9b7944f27553067228bd6c9e73e7dc05875c283255b50d7eb3ad2923f6d",
|
||||
"owner": "frontend-platform",
|
||||
"reason": "RP-11-initial-transitive-inventory",
|
||||
"approvedAt": "2026-07-26T08:27:17.874Z"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"changes": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"allowedLicenses": [
|
||||
"(MIT OR CC0-1.0)",
|
||||
"0BSD",
|
||||
"Apache-2.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BlueOak-1.0.0",
|
||||
"CC-BY-4.0",
|
||||
"CC0-1.0",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"MPL-2.0"
|
||||
],
|
||||
"deniedLicensePatterns": [
|
||||
"(^|\\s)AGPL",
|
||||
"(^|\\s)GPL",
|
||||
"SSPL",
|
||||
"BUSL"
|
||||
],
|
||||
"unknownLicensePolicy": "allow-only-unmaterialized-platform-optional"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"trackedRoots": [
|
||||
"src",
|
||||
"recipes",
|
||||
"scripts",
|
||||
"tests",
|
||||
"config",
|
||||
"public",
|
||||
"schemas",
|
||||
".storybook",
|
||||
".gitea/workflows/quality-gates.yml",
|
||||
".dependency-cruiser.json",
|
||||
".nvmrc",
|
||||
".npmrc",
|
||||
"eslint.config.ts",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"vite.config.ts",
|
||||
"vite.service-worker.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.capabilities.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts"
|
||||
],
|
||||
"generatedRoots": ["dist", "artifacts/release"],
|
||||
"optionalRoots": ["artifacts/release"],
|
||||
"excludedPaths": [
|
||||
"tests/fixtures/security/secret-detection/forbidden"
|
||||
],
|
||||
"allowlist": [
|
||||
{
|
||||
"path": "tests/fixtures/security/secret-detection/allowed/test-credentials.ts",
|
||||
"ruleId": "assigned-secret",
|
||||
"owner": "frontend-platform",
|
||||
"reason": "Synthetic credential verifies the scoped test-only allowlist.",
|
||||
"expiresAt": "2027-07-26T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"exceptions": []
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"providerMode": "external-file",
|
||||
"inputEnvironment": "VULNERABILITY_REPORT_PATH",
|
||||
"blockAtSeverity": "high",
|
||||
"allowedSeverities": ["unknown", "low", "moderate", "high", "critical"],
|
||||
"missingProviderStatus": "FAIL_UNVERIFIED"
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"repositoryBaseline": 285,
|
||||
"generatedPaths": [],
|
||||
"summary": {
|
||||
"lines": 75,
|
||||
"statements": 73,
|
||||
"functions": 80,
|
||||
"branches": 68
|
||||
},
|
||||
"criticalModules": [
|
||||
{
|
||||
"path": "src/adapters/http/bounded-body-reader.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/bounded-json.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/http-execution-v3.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/request-builder.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/retry-policy.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
|
||||
"owner": "server-state-runtime",
|
||||
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
|
||||
"owner": "service-worker-runtime",
|
||||
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/storage/browser-storage-adapter.ts",
|
||||
"owner": "storage-runtime",
|
||||
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 }
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
|
||||
"owner": "telemetry-runtime",
|
||||
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 }
|
||||
},
|
||||
{
|
||||
"path": "src/application/create-application.ts",
|
||||
"owner": "application-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 }
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/compatibility.ts",
|
||||
"owner": "application-policy",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/performance-budgets.ts",
|
||||
"owner": "application-policy",
|
||||
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 }
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/promotion-readiness.ts",
|
||||
"owner": "release-runtime",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 }
|
||||
},
|
||||
{
|
||||
"path": "src/application/use-cases/decide-chunk-recovery.ts",
|
||||
"owner": "application-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 }
|
||||
},
|
||||
{
|
||||
"path": "src/bootstrap/load-release-manifest.ts",
|
||||
"owner": "release-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
||||
},
|
||||
{
|
||||
"path": "src/bootstrap/read-bounded-boot-json.ts",
|
||||
"owner": "bootstrap-runtime",
|
||||
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 }
|
||||
},
|
||||
{
|
||||
"path": "src/contracts/diagnostics.ts",
|
||||
"owner": "diagnostics-contracts",
|
||||
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 }
|
||||
},
|
||||
{
|
||||
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
|
||||
"owner": "reference-feature",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 }
|
||||
},
|
||||
{
|
||||
"path": "src/presentation/adapters/query/application-query.ts",
|
||||
"owner": "presentation-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
||||
}
|
||||
],
|
||||
"highRiskPaths": [
|
||||
"src/adapters/http/bounded-body-reader.ts",
|
||||
"src/adapters/http/bounded-json.ts",
|
||||
"src/adapters/http/http-execution-v3.ts",
|
||||
"src/adapters/http/request-builder.ts",
|
||||
"src/adapters/http/retry-policy.ts",
|
||||
"src/adapters/query-cache/server-state-scope-runtime.ts",
|
||||
"src/adapters/service-worker/service-worker-lifecycle.ts",
|
||||
"src/adapters/storage/browser-storage-adapter.ts",
|
||||
"src/adapters/telemetry/best-effort-telemetry.ts",
|
||||
"src/application/create-application.ts",
|
||||
"src/application/policies/compatibility.ts",
|
||||
"src/application/policies/performance-budgets.ts",
|
||||
"src/application/policies/promotion-readiness.ts",
|
||||
"src/application/use-cases/decide-chunk-recovery.ts",
|
||||
"src/bootstrap/load-release-manifest.ts",
|
||||
"src/bootstrap/read-bounded-boot-json.ts",
|
||||
"src/contracts/diagnostics.ts",
|
||||
"src/features/reference-feature/adapters/reference-http-gateway.ts",
|
||||
"src/presentation/adapters/query/application-query.ts"
|
||||
],
|
||||
"waivers": []
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"scenarioCatalogs": [
|
||||
{
|
||||
"owner": "reference-feature",
|
||||
"path": "tests/mocks/scenarios/catalog.ts",
|
||||
"expectationExport": "HTTP_SCENARIO_EXPECTATIONS",
|
||||
"receiptPath": "artifacts/tests/http-scenario-executions.json",
|
||||
"receiptSchemaVersion": 1
|
||||
}
|
||||
],
|
||||
"sourceContracts": [
|
||||
{
|
||||
"owner": "reference-feature",
|
||||
"path": "tests/mocks/handlers/reference-resources.ts",
|
||||
"requiredTokens": [
|
||||
"assertOperationScenario",
|
||||
"../scenarios/catalog.ts"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Manual accessibility review checklist
|
||||
|
||||
Automated axe checks do not establish WCAG conformance. A human reviewer must
|
||||
review all ten route records in `artifacts/tests/a11y-manual/` against one
|
||||
release candidate and sign them. The required scope is derived from the route
|
||||
registry: `APP_HOME`, `EXAMPLES_PLATFORM`, `EXAMPLES_UI`, `EXAMPLES_STATES`,
|
||||
`EXAMPLES_AUTH`, `NOT_FOUND`, `REFERENCE_RESOURCE_LIST`,
|
||||
`REFERENCE_RESOURCE_DETAIL`, `REFERENCE_RESOURCE_FORM` and
|
||||
`REFERENCE_RESOURCE_STATUS`. Copy the template fields exactly; the gate rejects
|
||||
blank identity/timestamp/signature fields, pending verdicts, mismatched release
|
||||
IDs, or missing routes.
|
||||
|
||||
This list is not maintained by hand: `verify:documentation` compares it against
|
||||
the installed route registry and fails when a registered route is absent. It
|
||||
said six routes while ten were registered, which put the platform overview and
|
||||
the three reference-resource screens outside the declared manual review scope
|
||||
without anyone deciding they should be.
|
||||
|
||||
Allowed item verdicts:
|
||||
|
||||
- `pass`
|
||||
- `not-applicable (<specific reason>)`
|
||||
|
||||
Required record:
|
||||
|
||||
```text
|
||||
Status: reviewed
|
||||
Route ID: APP_HOME
|
||||
Release ID: <immutable release ID>
|
||||
Reviewer: <human reviewer identity>
|
||||
Reviewed at: <RFC 3339 timestamp>
|
||||
Signature: <reviewer identity or approved signature reference>
|
||||
Attestation: accepted
|
||||
M1 Keyboard: pass
|
||||
M2 Visible focus: pass
|
||||
M3 Route focus: pass
|
||||
M4 Modal focus: not-applicable (no modal on this route)
|
||||
M5 Error association: not-applicable (no form error on this route)
|
||||
M6 Color signal: pass
|
||||
M7 Reduced motion: pass
|
||||
Screen reader: pass
|
||||
Notes: <observations and linked defect IDs>
|
||||
```
|
||||
|
||||
The reviewer must verify:
|
||||
|
||||
- M1: every action works without a pointing device
|
||||
- M2: every focused element has a visible indicator
|
||||
- M3: route transitions move focus to a deterministic target
|
||||
- M4: modal focus is trapped and restored, when a modal exists
|
||||
- M5: errors are programmatically associated with their controls, when present
|
||||
- M6: state never relies on color alone
|
||||
- M7: non-essential motion is suppressed with reduced-motion preference
|
||||
- Screen reader: headings, live regions, errors, and actions are announced once
|
||||
|
||||
`EXAMPLES_UI` requires real M4 modal and M5 field-error review; those items must
|
||||
not be marked not-applicable on that route. Passing automated evidence means
|
||||
only that tested pages had no critical or serious axe findings under the
|
||||
recorded Chromium, Firefox, and WebKit runs.
|
||||
+5957
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,973 @@
|
||||
# API contract, Schema, Mapper와 Server State platform
|
||||
|
||||
> **정본 안내 (non-authoritative for runtime capability decisions)**
|
||||
>
|
||||
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
|
||||
> Service Worker, offline command와 Background Sync의 구현 결정은
|
||||
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
|
||||
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
|
||||
|
||||
|
||||
- 상태: capability별 current/target 분리, production design accepted
|
||||
- 기준일: 2026-07-28
|
||||
- 범위: REST, GraphQL over HTTP, Connect-Web/Connect, gRPC-Web,
|
||||
Protobuf/REST Gateway, runtime Schema, boundary Mapper, TanStack Query 기반
|
||||
Server State Cache
|
||||
- 관련 결정:
|
||||
- [VD-23 API transport selection과 REST execution](./decisions/VD-23-api-transport-selection-and-rest-execution.md)
|
||||
- [VD-24 Runtime schema와 boundary mapper](./decisions/VD-24-runtime-schema-and-boundary-mapper.md)
|
||||
- [VD-25 Server state cache lifecycle](./decisions/VD-25-server-state-cache-lifecycle.md)
|
||||
- [VD-26 Persisted GraphQL operation](./decisions/VD-26-persisted-graphql-operation.md)
|
||||
- [VD-27 gRPC-Web unary와 server stream](./decisions/VD-27-grpc-web-unary-and-server-stream.md)
|
||||
- [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md)
|
||||
- [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md)
|
||||
- browser Protobuf/gateway 상세 설계:
|
||||
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
|
||||
- backend handoff:
|
||||
[Backend API와 Server State contract](./backend-api-and-server-state-contract.md)
|
||||
- 운영 절차:
|
||||
[API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md)
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 다음 질문을 하나의 production 계약으로 닫는다.
|
||||
|
||||
- REST, GraphQL, Connect와 gRPC-Web 중 무엇을 어디에 사용하는가
|
||||
- Protobuf contract와 REST Gateway가 transport/runtime과 어떻게 분리되는가
|
||||
- request/response가 어느 지점까지 untrusted wire data인가
|
||||
- TypeScript type, generated code와 runtime validation의 역할은 무엇인가
|
||||
- DTO를 domain/application projection으로 누가 변환하는가
|
||||
- server response를 어떤 query identity와 lifecycle로 cache하는가
|
||||
- schema, mapper, transport와 cache가 바뀔 때 어떻게 배포·관측·rollback하는가
|
||||
|
||||
각 protocol은 서로 대체 가능한 URL 호출 문법이 아니다. transport-specific
|
||||
codec, proxy와 failure semantics는 adapter가 소유한다. application은 transport
|
||||
종류, URL, GraphQL document, protobuf message나 TanStack Query를 직접 알지 않고
|
||||
feature-owned gateway와 application input만 호출한다.
|
||||
|
||||
## 2. 상태 모델
|
||||
|
||||
이 문서는 browser data 설계와 동일한 primary current-status literal을 사용한다.
|
||||
|
||||
| primary status | 의미 |
|
||||
| --- | --- |
|
||||
| `COMPOSED` | production bootstrap 또는 설치된 feature 호출 경로에 concrete runtime이 실제 연결돼 있다. |
|
||||
| `AVAILABLE_NOT_COMPOSED` | 실행 가능한 reference runtime과 test가 있지만 production graph에는 연결하지 않았다. |
|
||||
| `DESIGNED_NOT_IMPLEMENTED` | 계약·불변조건·failure와 promotion 기준은 승인됐지만 해당 runtime 또는 필수 orchestration이 없다. |
|
||||
| `NOT_SELECTED` | 제품 요구, owner와 비용이 승인되지 않아 의도적으로 선택하지 않았다. |
|
||||
| `PLATFORM_LIMITED` | target browser/protocol이 요구 semantics를 공통으로 보장하지 못한다. |
|
||||
|
||||
primary status와 다음 readiness 축을 섞지 않는다.
|
||||
|
||||
```text
|
||||
Selection
|
||||
NOT_SELECTED | SELECTED | REMOVING
|
||||
|
||||
TrafficAdmission
|
||||
DISABLED | SHADOW | CANARY | ENABLED
|
||||
|
||||
RuntimeHealth
|
||||
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
|
||||
|
||||
PromotionEvidence
|
||||
MISSING | PARTIAL | COMPLETE | EXPIRED
|
||||
```
|
||||
|
||||
`COMPOSED`는 traffic이 켜졌거나 provider가 conformant라는 뜻이 아니다.
|
||||
`AVAILABLE_NOT_COMPOSED`도 제품 bundle에 dependency가 들어갔다는 뜻이 아니다.
|
||||
|
||||
## 3. 현재 capability ledger
|
||||
|
||||
| capability | primary current status | 현재 증거 | 목표 또는 잔여 |
|
||||
| --- | --- | --- | --- |
|
||||
| installed REST reference vertical | `COMPOSED` | operation registry → request schema → HTTP → envelope/payload schema → mapper → application input → Query 화면 경로 | 아래 REST hardening delta와 실제 제품 provider 계약 |
|
||||
| shared REST JSON executor | `COMPOSED` | path/search/body codec projection, shared deadline/retry-sleep budget, AbortSignal, bounded auth recovery, exact envelope/media/status, safe failure와 diagnostics | 204/304/412 execution join과 actual provider conformance |
|
||||
| REST v2 security/execution baseline | `COMPOSED` | collision-aware operation composition, path placeholder↔codec key exact join, prefix-preserving HTTPS/loopback provider, named bearer/CSRF profile와 credential-mode ceiling, auth fail-before-fetch, bounded JSON, outbound correlation/status·physical-attempt 관측 | cookie-CSRF/CORS provider evidence, 204/304/412 conditional execution과 compatibility artifact |
|
||||
| GraphQL provider-neutral reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source/dependency/codegen/runtime 없음 | persisted-operation-only transport, GraphQL response decoder, mapper binding과 contract harness |
|
||||
| product GraphQL composition | `NOT_SELECTED` | endpoint/schema/persisted manifest/owner 없음 | 제품 query가 REST보다 GraphQL aggregation을 정당화할 때 선택 |
|
||||
| GraphQL batching, subscription, `@defer`/`@stream` | `NOT_SELECTED` | 없음 | 각각 독립 ADR, proxy/browser lifecycle과 cache semantics 필요 |
|
||||
| provider-neutral Browser RPC V3 contract/runtime | `AVAILABLE_NOT_COMPOSED` | operation/profile/schema/mapper/encoder/transport exact join, typed application port, bounded unary retry/deadline/abort, server-stream idle/total/message/terminal/generation fence와 fail-closed unavailable adapter test | selected descriptor/generated client와 protocol-specific bounded transport를 붙이고 actual provider/browser conformance |
|
||||
| gRPC-Web unary reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source/dependency/generated message 없음 | fixed method registry, protobuf codec, trailers/status/deadline와 proxy conformance |
|
||||
| gRPC-Web server-stream reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source 없음 | bounded frame/idle/total budget, sequence/resume application protocol과 stream port |
|
||||
| product gRPC-Web composition | `NOT_SELECTED` | service descriptor/proxy/owner 없음 | browser-facing gRPC-Web gateway가 실제 이점을 줄 때 선택 |
|
||||
| gRPC-Web client-streaming/bidi guarantee | `PLATFORM_LIMITED` | gRPC-Web browser baseline이 해당 semantics를 제공하지 않음 | REST upload, WebSocket/WebTransport 또는 별도 protocol을 선택 |
|
||||
| Protobuf schema/codegen governance | `DESIGNED_NOT_IMPLEMENTED` | `.proto`, Buf config, descriptor와 generated output 없음 | authenticated source, immutable descriptor, deterministic codegen과 compatibility evidence |
|
||||
| Connect-Web unary/server-stream reference adapter | `DESIGNED_NOT_IMPLEMENTED` | `@connectrpc/*`, `@bufbuild/protobuf`, generated service와 provider 없음 | exact Connect/gRPC-Web transport row, bounded decode와 actual browser/provider conformance |
|
||||
| product Connect protocol composition | `NOT_SELECTED` | service/provider/owner 없음 | Protobuf-first backend의 selected browser operation이 있을 때만 선택 |
|
||||
| Connect browser client-streaming/bidi guarantee | `PLATFORM_LIMITED` | Connect protocol 기능과 browser request-stream 지원은 다름 | 별도 duplex/application protocol 선택 |
|
||||
| Protobuf REST Gateway reference contract/harness | `DESIGNED_NOT_IMPLEMENTED` | HttpRule/transcoder/OpenAPI/provider fixture 없음 | selected kind의 deterministic/provider conformance |
|
||||
| product Protobuf REST Gateway composition | `NOT_SELECTED` | route/provider/owner 없음 | curated BFF, grpc-gateway 또는 Envoy transcoder 중 하나와 public HTTP contract 승인 |
|
||||
| feature runtime request/response Schema | `COMPOSED` | Zod request/payload schemas와 installed schema registry가 reference feature에 연결 | byte/depth/node ceiling, unknown-field profile, schema artifact/digest와 multi-protocol source governance |
|
||||
| schema/mapper v2 reference baseline | `COMPOSED` | collision-aware schema codec/mapper contribution install, operation schema/mapper reference resolution, request reject/response strip 방향, bounded collection, typed no-throw mapping result와 operation별 cast-free result guard | actual codec fingerprint, source provenance와 multi-protocol compatibility policy |
|
||||
| generated contract artifact governance | `DESIGNED_NOT_IMPLEMENTED` | `generated-api` recipe만 있고 generator/provider 선택 없음 | OpenAPI/GraphQL/proto source authentication, pinned generation, drift/breaking gate와 N/N-1 |
|
||||
| feature boundary Mapper | `COMPOSED` | installed mapper metadata composer와 response-schema exact join 뒤 typed no-throw MappingResult → immutable domain/application view 실행 | numeric/date/null/enum canonical rules와 generated-artifact join |
|
||||
| TanStack Query memory Server State | `COMPOSED` | QueryClient, cancellation, stale-degraded UI, session-generation cancel/clear fence와 invalidation coordinator | account identity projection과 bounded topic↔namespace many-to-many registry |
|
||||
| reference bound-query/server-state profile | `COMPOSED` | bound definition, strict canonical input, scope-private opaque identity, active lease/LRU/collision/entry-byte ceiling, per-profile policy와 result admission | account projection, conditional HTTP execution join과 pagination composition |
|
||||
| mutation duplicate coordinator baseline | `COMPOSED` | QueryClient runtime/scope 단위 exact semantic input identity로 identical만 join하고 distinct input을 합치지 않으며 late scope result를 폐기 | logical-key serialization과 effect certainty/reconcile |
|
||||
| optimistic ordered-layer runtime | `AVAILABLE_NOT_COMPOSED` | out-of-order commit/rollback, authoritative external update 재적용과 expired-scope 제거 test | 제품 mutation의 deterministic membership/revision contract 승인 뒤 definition에 연결 |
|
||||
| conditional validator CAS sidecar | `AVAILABLE_NOT_COMPOSED` | scope/representation/cache revision exact binding, ETag validation과 bounded capacity test; session 전환 clear는 production infrastructure에 연결 | HTTP If-None-Match/304 query transaction과 query removal lifecycle join |
|
||||
| bounded cursor chain runtime | `AVAILABLE_NOT_COMPOSED` | page invariant, cursor loop, snapshot drift, page/item/byte/cursor ceiling과 abort test | backend CursorPage DTO/next cursor 계약 후 reference/infinite-query binding |
|
||||
| cross-context server-state invalidation | `COMPOSED` | singular opaque topic 기반 invalidate-only coordinator와 session-generation local reset | account projection과 bounded topic↔namespace many-to-many registry |
|
||||
| normalized GraphQL entity cache | `NOT_SELECTED` | 없음 | TanStack operation-result cache로 해결되지 않는 측정된 요구가 있을 때 별도 선택 |
|
||||
| persisted query cache | reference `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` | Web Storage persistence는 금지, IndexedDB persister 없음 | VD-13의 scope/retention/restore gate를 별도 통과 |
|
||||
| offline mutation queue | `NOT_SELECTED` | foreground mutation만 존재 | backend idempotency/cursor/conflict protocol과 durable command owner 필요 |
|
||||
|
||||
현재 REST reference는 `Response.json()`이 아니라 byte-bounded reader를 사용하고,
|
||||
external auth owner는 allowlisted header patch만 반환한다. 인증 통합 실패는 fetch
|
||||
전에 닫히며 correlation, success status와 physical attempt가 terminal observation에
|
||||
반영된다. 다만 이 baseline을 conditional response, complete pagination,
|
||||
provider conformance나 GraphQL/gRPC runtime의 증거로 재사용하지 않는다.
|
||||
마찬가지로 Browser RPC V3 공통 coordinator의 `AVAILABLE_NOT_COMPOSED` 판정은
|
||||
vendor wire adapter의 구현 판정이 아니다. `@connectrpc/*`, official grpc-web,
|
||||
generated message와 descriptor가 없는 현재 상태에서 Connect/gRPC-Web 각 row는
|
||||
계속 `DESIGNED_NOT_IMPLEMENTED`다.
|
||||
|
||||
## 4. 최상위 경계
|
||||
|
||||
```text
|
||||
presentation
|
||||
-> feature application input
|
||||
-> feature use case
|
||||
-> feature gateway port
|
||||
-> operation registry
|
||||
-> REST adapter
|
||||
-> GraphQL adapter
|
||||
-> Connect adapter
|
||||
-> gRPC-Web adapter
|
||||
-> bounded wire decoder
|
||||
-> runtime schema / semantic validation
|
||||
-> boundary mapper
|
||||
-> immutable application projection
|
||||
-> server-state query adapter
|
||||
-> registry-owned query identity and policy
|
||||
-> mapped application result only
|
||||
```
|
||||
|
||||
금지 경로:
|
||||
|
||||
```text
|
||||
page -> fetch / GraphQL SDK / generated Connect/gRPC client
|
||||
page -> raw URL / query document / protobuf message
|
||||
transport DTO -> domain or presentation public type
|
||||
Response / GraphQL response / generated message -> Query cache
|
||||
Query cache -> authorization or business conflict authority
|
||||
```
|
||||
|
||||
application port는 use-case 의미를 표현한다. 예를 들어
|
||||
`listResources(filters)`, `createResource(command)`는 허용하지만
|
||||
`executeGraphql(document, variables)`, `grpcCall(service, method, bytes)`와
|
||||
`request(url, options)`는 허용하지 않는다.
|
||||
|
||||
## 5. Protocol-neutral operation contract
|
||||
|
||||
각 외부 호출은 build-time registry의 discriminated row 하나로 고정한다.
|
||||
application caller는 `operationId`와 schema가 허용한 input만 제출한다.
|
||||
|
||||
```text
|
||||
ApiOperationContractV3
|
||||
registryVersion
|
||||
operationId
|
||||
owner
|
||||
protocol = REST | GRAPHQL_HTTP | CONNECT_HTTP | GRPC_WEB
|
||||
semantics = QUERY | COMMAND | SERVER_STREAM
|
||||
authProfileId
|
||||
csrfProfileId
|
||||
replayPolicy = SAFE | IDEMPOTENT | KEYED_COMMAND | NON_REPLAYABLE
|
||||
idempotencyKeyPolicy = NONE | REQUIRED
|
||||
requestSchemaId
|
||||
responseSchemaId
|
||||
mapperId
|
||||
errorProfileId
|
||||
deadlineProfileId
|
||||
retryProfileId
|
||||
serverStateProfileId | null
|
||||
invalidationTopicRefs[] = { topicId, topicVersion }
|
||||
dataClassification
|
||||
compatibility
|
||||
globalApiContractVersion
|
||||
protocolArtifactId
|
||||
minimumServerVersion
|
||||
retirementEpoch | null
|
||||
protocolBinding
|
||||
```
|
||||
|
||||
`protocolBinding`은 transport별 closed union이다.
|
||||
|
||||
```text
|
||||
REST
|
||||
method
|
||||
relativePathTemplate
|
||||
requestProjection
|
||||
requestMediaProfile
|
||||
responseMediaProfile
|
||||
conditionalProfile
|
||||
|
||||
GRAPHQL_HTTP
|
||||
endpointId
|
||||
graphqlHttpProfileRevision
|
||||
persistedEnvelopeProfileId
|
||||
responseStatusMediaProfileId
|
||||
persistedOperationId
|
||||
persistedOperationSha256
|
||||
operationType
|
||||
partialDataPolicy
|
||||
|
||||
GRPC_WEB
|
||||
endpointId
|
||||
clientRuntimeId
|
||||
grpcWebWireSpecRevision
|
||||
transportProfile
|
||||
responseHttpStatusProfileId
|
||||
fullyQualifiedService
|
||||
method
|
||||
rpcKind = UNARY | SERVER_STREAM
|
||||
requestMessageId
|
||||
responseMessageId
|
||||
|
||||
CONNECT_HTTP
|
||||
endpointId
|
||||
clientRuntimeId
|
||||
connectProtocolRevision
|
||||
encoding = PROTO_JSON | PROTO_BINARY
|
||||
requestMethod = POST | GET
|
||||
fullyQualifiedService
|
||||
method
|
||||
rpcKind = UNARY | SERVER_STREAM
|
||||
descriptorArtifactId
|
||||
descriptorDigest
|
||||
```
|
||||
|
||||
registry validation은 다음을 build/boot 전에 거절한다.
|
||||
|
||||
- 중복 operation/mapper/profile ID
|
||||
- protocol과 맞지 않는 binding field
|
||||
- 등록되지 않은 schema, mapper, auth, deadline, retry와 cache profile
|
||||
- `QUERY`인데 replay policy가 `SAFE | IDEMPOTENT`가 아님
|
||||
- `KEYED_COMMAND`인데 idempotency key policy가 `REQUIRED`가 아니거나 backend
|
||||
dedupe/reconcile profile이 없음
|
||||
- `NON_REPLAYABLE`인데 network/401 replay가 enabled
|
||||
- `COMMAND`인데 cache profile이 query data owner로 지정됨
|
||||
- `SERVER_STREAM`인데 ordinary query cache profile을 사용
|
||||
- opening retry가 enabled인데 replay policy가 `SAFE | IDEMPOTENT`가 아님
|
||||
- `KEYED_COMMAND | NON_REPLAYABLE` server stream인데 opening retry가 enabled거나
|
||||
explicit resume/reconcile/dedupe profile이 없음
|
||||
- `SERVER_STREAM`인데 `protocol=CONNECT_HTTP | GRPC_WEB`과
|
||||
`rpcKind=SERVER_STREAM` 조합이 아님. REST SSE와 GraphQL subscription을 이
|
||||
registry 의미로 암묵 등록하지 않음
|
||||
- unsafe REST method 또는 GraphQL mutation인데 CSRF/replay/key 결정이 없음
|
||||
- gRPC-Web client/bidi method
|
||||
- Connect browser client/bidi method 또는 descriptor의 `NO_SIDE_EFFECTS`가 없는
|
||||
Connect GET
|
||||
- absolute URL, runtime GraphQL document 또는 caller-provided service/method
|
||||
- implementation hard ceiling보다 큰 timeout, byte, frame, page와 retry 값
|
||||
- GraphQL operation↔provider의 HTTP revision/envelope/status-media profile 또는
|
||||
Connect/gRPC-Web operation↔provider의 runtime/wire/status/capability tuple
|
||||
mismatch
|
||||
|
||||
현재 installed reference operation은 REST v2 metadata를 사용한다. operation,
|
||||
runtime schema codec과 mapper contribution은 object spread가 아니라 각각의
|
||||
collision-aware composer로 설치되며 duplicate ID를 덮어쓰기 전에 거절한다.
|
||||
boot-time binding 검증은 path placeholder와 codec key, provider/auth/CSRF profile,
|
||||
path/request/response schema와 mapper input schema를 exact resolve한 뒤 immutable
|
||||
registry를 발행한다. GraphQL/Connect/gRPC-Web discriminant와 protocol-specific
|
||||
binding은 해당 reference adapter가 아직 없으므로 source에 구현됐다고 표현하지
|
||||
않는다.
|
||||
|
||||
현재 `API_CONTRACT_VERSION`은 runtime config와 release manifest의 문자열 일치
|
||||
gate다. 목표 contract set은 REST/OpenAPI artifact, GraphQL schema/persisted
|
||||
manifest, protobuf descriptor, runtime schema/mapper registry digest를 포함한
|
||||
bounded manifest를 만들고 global compatibility version과 함께 release tuple에
|
||||
binding한다. 문자열 일치만 actual backend compatibility 증거로 사용하지 않는다.
|
||||
|
||||
## 6. 공통 실행 lifecycle
|
||||
|
||||
```text
|
||||
lookup exact operation
|
||||
-> freeze session/account/runtime generation
|
||||
-> validate and canonicalize application input
|
||||
-> derive exact query/command identity
|
||||
-> allocate total operation deadline
|
||||
-> encode transport request from registry binding
|
||||
-> attach credential/CSRF through approved owner
|
||||
-> execute bounded attempt
|
||||
-> bounded response/frame decode
|
||||
-> validate transport envelope/status
|
||||
-> validate operation DTO/message semantics
|
||||
-> map to immutable application projection
|
||||
-> re-check scope/generation
|
||||
-> return Result
|
||||
-> query adapter may admit mapped value to memory cache
|
||||
```
|
||||
|
||||
현재 v2 auth owner는 `Request`를 반환하지 않고 transport가 만든 immutable
|
||||
request binding에 대해 allowlisted credential patch만 제공한다. 최소한
|
||||
transport는 attach 뒤에도 URL, origin, method, body digest, content headers,
|
||||
idempotency와 conditional binding이 바뀌지 않았음을 다시 검증한다.
|
||||
auth-required operation은 session state가 unauthenticated/integration-failed이거나
|
||||
credential attachment가 실패하면 **fetch 0회**로 닫는다. 동시 401 recovery는
|
||||
session owner의 single-flight 한 번만 공유하며 replay-safe operation만 동일
|
||||
logical deadline/idempotency binding으로 한 번 재실행한다.
|
||||
|
||||
모든 async boundary와 terminal cache write 전에 captured generation을 확인한다.
|
||||
logout/account switch 뒤 끝난 response, mapper와 stream frame은 old runtime
|
||||
결과로 폐기한다.
|
||||
|
||||
deadline은 attempt마다 새로 시작하지 않는다.
|
||||
|
||||
```text
|
||||
total budget
|
||||
= credential attach
|
||||
+ network attempts
|
||||
+ retry delay
|
||||
+ body/frame read
|
||||
+ schema validation
|
||||
+ mapper
|
||||
```
|
||||
|
||||
각 phase에 별도 하위 ceiling을 둘 수 있지만 전체 deadline을 늘릴 수 없다.
|
||||
caller abort, runtime teardown, timeout과 provider cancellation은 서로 다른 safe
|
||||
failure로 정규화한다.
|
||||
|
||||
## 7. Transport 선택 기준
|
||||
|
||||
| 요구 | 기본 선택 | 이유 |
|
||||
| --- | --- | --- |
|
||||
| resource/command, HTTP cache/conditional semantics, 파일 handoff | REST | Web/BFF·CDN·운영 도구와 자연스럽고 failure/status가 명확함 |
|
||||
| 여러 aggregate를 한 화면 shape로 읽고 client별 selection이 유의미 | persisted GraphQL query | allowlisted operation으로 over/under-fetch를 줄일 수 있음 |
|
||||
| Protobuf-first backend의 내부 web UI, unary 또는 bounded server stream | Connect-Web/Connect 우선 평가 | generated descriptor와 Fetch 기반 browser RPC를 재사용 |
|
||||
| 기존 gRPC-Web proxy/conformance 자산 | selected gRPC-Web runtime | runtime별 binary/text/stream capability를 exact profile로 고정 |
|
||||
| ProtoJSON/HttpRule 자체가 승인된 public HTTP contract | generated REST Gateway 검토 | envelope/status/cache/idempotency를 별도 증명 |
|
||||
| 현재 REST envelope·ETag·Range·제품 DTO가 중요 | curated REST BFF 유지 | generated transcoder가 제품 HTTP 의미를 자동 제공하지 않음 |
|
||||
| browser client/bidi streaming | Connect/gRPC-Web 사용 금지 | protocol 자체 기능과 browser 공통 지원을 혼동하지 않음 |
|
||||
| arbitrary ad-hoc query | GraphQL 사용 금지 | cost, authorization, cache identity와 operation governance를 우회 |
|
||||
| 단순 CRUD인데 GraphQL/gRPC dependency만 추가 | REST 유지 | 복잡도와 bundle/proxy 비용을 정당화하지 못함 |
|
||||
|
||||
한 feature가 여러 protocol을 사용할 수 있지만 한 `operationId`는 한 protocol에만
|
||||
binding한다. query read를 shadow 비교하는 경우에도 secondary 결과는 사용자와
|
||||
cache에 반영하지 않는다. command는 protocol 장애를 이유로 자동 failover/replay
|
||||
하지 않는다.
|
||||
|
||||
## 8. REST 설계 요약
|
||||
|
||||
REST 상세 결정은 VD-23이 소유한다. 공통 baseline은 다음과 같다.
|
||||
|
||||
- base origin과 relative path template은 composition/registry가 소유한다.
|
||||
- provider base URL은 HTTPS와 exact origin/path-prefix를 고정하고 userinfo,
|
||||
query와 fragment를 금지한다. path join은 선택한 base prefix를 보존하며
|
||||
leading slash가 prefix를 조용히 제거하지 않는다.
|
||||
- method는 closed union이며 path/search/header/body는 각 runtime schema를 지난다.
|
||||
- replay semantics는 `SAFE`, `IDEMPOTENT`, `KEYED_COMMAND`,
|
||||
`NON_REPLAYABLE`로 method와 교차 검증한다.
|
||||
- caller-provided URL, header, `credentials`, redirect와 cache option을 금지한다.
|
||||
- cookie session이면 unsafe method에 approved CSRF owner가 필요하다.
|
||||
- `SAFE | IDEMPOTENT | KEYED_COMMAND` 중 exact retry profile과 실제 provider
|
||||
replay evidence가 있는 operation만 network retry한다.
|
||||
- keyed retry와 401 recovery는 같은 logical idempotency key를 유지한다.
|
||||
- total deadline, attempts, backoff와 `Retry-After`는 implementation ceiling 안이다.
|
||||
- JSON/error body는 present/valid `Content-Length` advisory preflight와 actual
|
||||
decoded-byte bounded stream reader 뒤 parse한다. encoded transfer cap은
|
||||
BFF/proxy/CDN가 집행한다.
|
||||
- status, content type, response media profile과 envelope 조합을 exact하게 검증한다.
|
||||
- 204, 304, 412, 422와 problem/envelope profile은 operation이 명시한 경우만 허용한다.
|
||||
- strong ETag는 cache identity가 아니라 exact representation revalidation
|
||||
metadata다. validator를 diagnostics에 기록하지 않는다. `Last-Modified`는
|
||||
별도 weak/time validator profile이 승인되기 전 이번 target에 포함하지 않는다.
|
||||
- cursor는 opaque하며 filter/sort/scope와 binding한다. arbitrary URL을
|
||||
`next` link로 따라가지 않는다.
|
||||
- browser HTTP cache와 application ETag/TanStack revalidation owner 중 하나를
|
||||
operation별로 선택한다. 두 cache의 freshness를 서로 추측해 합치지 않는다.
|
||||
- request/response body, URL query, authorization, CSRF, idempotency key와 raw
|
||||
backend copy를 log/telemetry에 넣지 않는다.
|
||||
|
||||
## 9. GraphQL 설계 요약
|
||||
|
||||
GraphQL 상세 결정은 VD-26이 소유한다. reference target은
|
||||
**persisted operation only**다.
|
||||
|
||||
- production bundle은 arbitrary GraphQL document string을 runtime에 받지 않는다.
|
||||
- build artifact가 operation name, stable ID, SHA-256, variables/result schema,
|
||||
schema digest와 owner를 manifest로 만든다.
|
||||
- BFF/router는 allowlist에 없는 ID/hash와 cost/depth limit 초과를 거절한다.
|
||||
- endpoint는 fixed HTTPS registry ID이고 POST가 기본이다.
|
||||
- selected provider가 지원하는 GraphQL-over-HTTP revision과 persisted-envelope
|
||||
extension을 profile에 고정한다. ID/hash-only request를 generic 표준 envelope로
|
||||
가장하지 않는다.
|
||||
- `Accept: application/graphql-response+json`을 우선하고 final URL/media/body cap
|
||||
뒤에는 허용 HTTP status의 GraphQL envelope를 bounded decode한 다음 status/body
|
||||
matrix를 교차 검증한다. legacy `application/json`은 별도 profile이다.
|
||||
- public cacheable query의 GET은 별도 threat/cache review 뒤에만 허용한다.
|
||||
- variables는 request schema와 byte/depth/node ceiling을 통과한다.
|
||||
- HTTP status와 GraphQL `data/errors/extensions`를 두 단계로 검증한다.
|
||||
- default `partialDataPolicy=REJECT`; 승인 operation만 typed completeness metadata와
|
||||
함께 partial을 application으로 투영할 수 있다.
|
||||
- error message, path value와 arbitrary extensions를 노출하지 않고 registered
|
||||
safe code/category만 `AppFailure`로 mapping한다.
|
||||
- APQ miss에서 full document를 자동 전송하지 않는다. manifest/version mismatch로
|
||||
fail-closed하고 coherent frontend/router artifact를 복구한다.
|
||||
- batching은 auth, deadline, cancel, observation과 partial failure owner가
|
||||
별도 승인되기 전 `NOT_SELECTED`다.
|
||||
- subscription, `@defer`, `@stream`은 ordinary query adapter에 암묵적으로 넣지
|
||||
않는다.
|
||||
- Apollo/urql normalized cache는 기본 dependency가 아니다. mapped operation
|
||||
result의 memory owner는 TanStack Query다.
|
||||
|
||||
## 10. Browser Protobuf RPC와 REST Gateway 설계 요약
|
||||
|
||||
축과 선택 기준의 상세 계약은
|
||||
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)가
|
||||
소유한다. Protobuf는 IDL/serialization, Connect와 gRPC-Web은 browser wire
|
||||
protocol, Connect-Web/official grpc-web은 client runtime, REST Gateway는 HTTP
|
||||
노출 방식이다. 네 이름을 하나의 대안 목록이나 하나의 auto-negotiating
|
||||
executor로 합치지 않는다.
|
||||
|
||||
### 10.1 gRPC-Web
|
||||
|
||||
gRPC-Web 상세 결정은 VD-27이 소유한다. reference target은 unary와 bounded
|
||||
server-stream만 다룬다.
|
||||
|
||||
- checked-in/generated artifact는 pinned proto descriptor/module digest에 묶는다.
|
||||
- vendor generated client/message는 feature adapter 내부에만 존재한다.
|
||||
- fully-qualified service/method와 endpoint는 registry가 고정한다.
|
||||
- client runtime과 gRPC-Web wire-spec revision을 operation/provider에 고정한다.
|
||||
official grpc-web runtime 기준 binary profile은 unary에만 사용하고 server
|
||||
stream은 `grpcwebtext`에 binding한다.
|
||||
- Connect-Web의 `createGrpcWebTransport()`는 Fetch 기반 binary/JSON unary와
|
||||
server stream profile이며 `grpcwebtext` profile이 아니다. official XHR runtime의
|
||||
capability matrix를 이 runtime에 적용하지 않는다. platform-authored custom
|
||||
binary streaming도 세 browser와 actual proxy의 incremental evidence가 있는
|
||||
별도 runtime profile만 허용한다.
|
||||
- frame header, message length, compression flag, total bytes와 frame count를
|
||||
bounded decoder가 검증한다.
|
||||
- HTTP status와 terminal status source를 함께 검사한다. terminal status source는
|
||||
body trailer frame 또는 zero-body trailers-only response header 중 정확히
|
||||
하나이며 중복/충돌을 거절한다.
|
||||
- `grpc-message`, binary error details와 metadata는 allowlist projection 없이
|
||||
application에 반환하지 않는다.
|
||||
- deadline은 `grpc-timeout`과 local total deadline의 더 짧은 값이며
|
||||
AbortSignal이 fetch/stream reader를 cancel한다.
|
||||
- unary `SAFE | IDEMPOTENT | KEYED_COMMAND` 중 provider evidence가 있는
|
||||
operation만 retry한다. stream reconnect는 retry가 아니라
|
||||
server-owned sequence/resume-token을 가진 별도 application protocol이다.
|
||||
- idle deadline, total deadline, max frame/message/count/buffer를 모두 둔다.
|
||||
- client-streaming/bidi는 지원한다고 가장하지 않는다.
|
||||
- Envoy/BFF/Connect/gRPC-Web proxy의 CORS, exposed trailers, content type,
|
||||
auth와 maximum message 설정을 actual provider conformance로 검증한다.
|
||||
- int64/uint64는 JavaScript number로 변환하지 않고 safe integer 범위를
|
||||
증명하거나 decimal string/adapter-private bigint로 mapping한다.
|
||||
|
||||
### 10.2 Connect-Web과 Connect protocol
|
||||
|
||||
Connect 상세 결정은 VD-29가 소유한다.
|
||||
|
||||
- `createConnectTransport()`와 `createGrpcWebTransport()`는 같은 package의 서로
|
||||
다른 wire protocol이다. decoder/status/terminal profile을 공유하지 않는다.
|
||||
- Connect unary의 JSON/binary와 POST를 operation row에 고정한다.
|
||||
- GET은 unary + `NO_SIDE_EFFECTS` descriptor + non-sensitive bounded input +
|
||||
exact URL/cache/CORS profile에서만 별도 승인한다.
|
||||
- Connect server stream은 final EndStream envelope를 확인하기 전 성공이 아니다.
|
||||
- stock runtime의 whole-body decode와 streaming compression 한계를 exact package
|
||||
version evidence로 확인한다. proxy cap이나 custom bounded transport가 없으면
|
||||
production raw-byte ceiling을 완료로 표시하지 않는다.
|
||||
- interceptor의 resolved onion order, auth 이후 final invariant, total deadline,
|
||||
exactly-one retry owner와 cancel handle을 manifest에 고정한다.
|
||||
- `@connectrpc/connect-query`를 기본 도입하지 않는다. generated service/message는
|
||||
adapter-private이고 mapped application value만 기존 TanStack Query에 들어간다.
|
||||
|
||||
### 10.3 Protobuf contract와 REST Gateway
|
||||
|
||||
Protobuf/REST Gateway 상세 결정은 VD-30이 소유한다.
|
||||
|
||||
- authenticated proto/Buf source, descriptor, generator/runtime/plugin version과
|
||||
generated digest를 coherent release artifact로 고정한다.
|
||||
- JSON 노출은 최소 `WIRE_JSON` compatibility를 요구하고 canonical HttpRule
|
||||
route manifest와 generated OpenAPI를 별도 semantic diff한다.
|
||||
- current reference REST envelope에는 curated BFF를 유지한다.
|
||||
- direct grpc-gateway/Envoy transcoder는 ProtoJSON, method/path/query/body,
|
||||
status/error/CORS/cache contract가 그대로 제품 API로 승인된 unary operation에만
|
||||
적용한다.
|
||||
- gateway는 idempotency store, CursorPage snapshot, ETag/304/412, file Range나
|
||||
안전한 domain error vocabulary를 자동 제공하지 않는다.
|
||||
- REST server streaming은 generated gateway의 부수 동작으로 활성화하지 않고
|
||||
framing/terminal/cache를 소유하는 별도 ADR 없이는 `NOT_SELECTED`다.
|
||||
|
||||
## 11. Schema trust boundary
|
||||
|
||||
TypeScript type과 generated code는 compile-time convenience이지 runtime proof가
|
||||
아니다. trust transition은 다음 순서를 지킨다.
|
||||
|
||||
```text
|
||||
untrusted bytes/frames
|
||||
-> bounded transport decoder
|
||||
-> transport envelope/status proof
|
||||
-> operation DTO/message runtime or semantic proof
|
||||
-> ValidatedWireValue (adapter-private)
|
||||
-> boundary mapper
|
||||
-> immutable application projection
|
||||
```
|
||||
|
||||
schema profile은 최소 다음을 고정한다. browser가 직접 집행하는 decoded ceiling과
|
||||
provider/BFF/proxy가 집행하는 wire/encoded ceiling의 owner를 분리한다.
|
||||
|
||||
```text
|
||||
schemaId
|
||||
schemaVersion
|
||||
boundary
|
||||
protocol
|
||||
sourceArtifactId + sourceDigest
|
||||
unknownFieldPolicy
|
||||
providerMaxEncodedBytes
|
||||
maxDecodedBytes
|
||||
maxDepth
|
||||
maxNodes
|
||||
maxStringBytes
|
||||
maxCollectionItems
|
||||
compatibilityPolicy
|
||||
owner
|
||||
```
|
||||
|
||||
unknown-field 기본 정책:
|
||||
|
||||
| 경계 | 정책 |
|
||||
| --- | --- |
|
||||
| request, config, capability, control envelope | `REJECT_UNKNOWN` |
|
||||
| evolvable ordinary response DTO | `STRIP_UNKNOWN` 후 mapper에 전달 |
|
||||
| discriminant/security/authorization 의미를 가진 union | unknown variant 거절 |
|
||||
| unknown data 보존 | adapter 내부 forward proxy가 아닌 한 금지 |
|
||||
|
||||
현재 reference request/path DTO는 `.strict()`로 unknown field를 거절하고,
|
||||
ordinary response DTO는 `.strip()` projection으로 additive server field를
|
||||
cache/domain 경계 밖에 버린다. discriminant/security union을 포함한 다른
|
||||
operation은 각 compatibility profile에 따라 별도로 결정한다.
|
||||
|
||||
## 12. Mapper 경계
|
||||
|
||||
mapper는 transport가 아니라 feature contract가 소유한다.
|
||||
|
||||
```text
|
||||
MapperDefinition
|
||||
mapperId
|
||||
inputSchemaId
|
||||
outputContractId
|
||||
mapperVersion
|
||||
collectionPolicy
|
||||
temporalPolicy
|
||||
numericPolicy
|
||||
nullabilityPolicy
|
||||
owner
|
||||
```
|
||||
|
||||
mapper는 pure, deterministic, side-effect-free이며 다음 union을 반환한다.
|
||||
|
||||
```text
|
||||
MappingResult<T>
|
||||
= { ok: true, value: T }
|
||||
| { ok: false, error: MAPPING_CONTRACT_VIOLATION }
|
||||
```
|
||||
|
||||
현재 reference mapper는 예상 가능한 drift를 throw하지 않고 closed
|
||||
`MappingResult` failure로 반환한다. registry 실행 경계는 예상하지 못한 mapper
|
||||
throw도 fail-closed mapping failure로 바꾸며 raw DTO, value, path와 backend
|
||||
message를 버린다.
|
||||
|
||||
공통 scalar 규칙:
|
||||
|
||||
- opaque ID는 trim/재해석하지 않는 bounded branded string이다.
|
||||
- ISO timestamp는 offset/precision 정책을 검증한 뒤 application instant로
|
||||
변환한다. locale date string과 invalid date normalization을 금지한다.
|
||||
- `int64`, decimal money와 high-precision value는 JSON number로 받지 않는다.
|
||||
- `null`, absent와 empty string/list는 schema와 domain에서 별도 의미로 결정한다.
|
||||
- unknown enum은 domain이 explicit `UNKNOWN`을 소유한 경우만 mapping한다.
|
||||
- collection mapping은 count/byte ceiling 안에서 fail-fast하고 partial array를
|
||||
cache하지 않는다.
|
||||
- mapper는 network, clock, storage, QueryClient, locale formatter와 telemetry를
|
||||
호출하지 않는다.
|
||||
|
||||
## 13. Server State Cache 경계
|
||||
|
||||
TanStack Query는 transport response cache가 아니라 mapped application projection의
|
||||
memory lifecycle owner다.
|
||||
|
||||
cache에 허용:
|
||||
|
||||
- immutable plain application projection
|
||||
- registered query identity로 찾을 수 있는 bounded collection/page
|
||||
- UI가 stale/refresh 상태를 계산하는 library metadata
|
||||
|
||||
cache에 금지:
|
||||
|
||||
- `Response`, raw JSON/GraphQL envelope, generated protobuf message
|
||||
- auth/CSRF/idempotency token, request header와 arbitrary URL
|
||||
- raw ETag, trace/span, backend error/details
|
||||
- File/Blob/stream/native handle
|
||||
- domain service, class instance, function, Promise와 `AbortSignal`
|
||||
|
||||
query profile은 caller가 raw option을 전달하는 대신 registry에서 선택한다.
|
||||
|
||||
```text
|
||||
ServerStateProfileV1
|
||||
profileId
|
||||
queryKeyCodecId
|
||||
scopePersistencePolicyId
|
||||
staleTimeMs
|
||||
gcTimeMs
|
||||
refetchOnFocus
|
||||
refetchOnReconnect
|
||||
networkMode
|
||||
maxResultBytes
|
||||
maxCollectionItems
|
||||
paginationProfileId | null
|
||||
revalidationProfileId | null
|
||||
invalidationTopicRefs[] = { topicId, topicVersion }
|
||||
placeholderPolicy
|
||||
owner
|
||||
```
|
||||
|
||||
query key는 validated/canonical application input과 scope projection으로 만든다.
|
||||
REST URL, GraphQL document/hash, protobuf bytes와 generated message serialization은
|
||||
query key가 아니다. transport 교체가 use-case identity를 바꾸지 않으면 같은
|
||||
application query family를 유지할 수 있지만, old/new representation을 한 cache
|
||||
entry에 shadow write하지 않는다.
|
||||
|
||||
network retry는 transport adapter가 소유하고 Query retry는 기본 `false`다.
|
||||
refresh failure에서 유효한 previous data는 stale-degraded로 유지한다. schema,
|
||||
mapper, scope, authorization와 contract mismatch는 stale data를 계속 노출해도
|
||||
되는지 query profile이 명시해야 하며 기본은 security-sensitive scope에서
|
||||
즉시 숨김/clear다.
|
||||
|
||||
VD-13이 scope/persistence profile, normative key layout, account/session
|
||||
generation과 late-result fence를 소유한다. 이 문서는 그 profile을 exact join하고
|
||||
operation/cache policy, pagination, revalidation과 mutation coherence를 소유한다.
|
||||
|
||||
## 14. Pagination과 conditional revalidation
|
||||
|
||||
cursor pagination은 다음 binding을 갖는다.
|
||||
|
||||
```text
|
||||
PaginationBinding
|
||||
query family fingerprint
|
||||
canonical filters/sort
|
||||
scope fingerprint
|
||||
server snapshot/revision policy
|
||||
page size ceiling
|
||||
opaque next cursor
|
||||
```
|
||||
|
||||
- cursor를 decode하거나 URL로 취급하지 않는다.
|
||||
- `hasMore === (nextCursor !== null)`을 codec에서 강제한다.
|
||||
- single-page cache는 runtime-scoped cursor fingerprint를 semantic key에 포함하고,
|
||||
infinite query만 cursor를 root key에서 제외해 bounded `pageParam`으로 둔다.
|
||||
- max pages/items/estimated bytes를 넘으면 더 불러오지 않는다.
|
||||
- 동일 cursor 반복, loop와 non-progress page를 contract failure로 닫는다.
|
||||
- offset pagination의 insert/delete drift를 자동 deduplicate로 숨기지 않는다.
|
||||
- page merge는 mapper가 보장한 stable identity가 있을 때만 deterministic하다.
|
||||
- previous filters의 page를 새 filter key에 재사용하지 않는다.
|
||||
|
||||
REST `304`는 cached data가 있다는 뜻이 아니라 representation이 바뀌지 않았다는
|
||||
transport 결과다. exact query fingerprint, scope/generation과 cached mapped
|
||||
value에 binding된 validator record가 모두 있을 때만 freshness를 갱신한다.
|
||||
cached value가 없거나 binding이 다르면 unconditional request를 한 번 수행하거나
|
||||
closed failure로 끝낸다.
|
||||
|
||||
GraphQL persisted operation과 gRPC-Web unary는 기본적으로 application-level
|
||||
validator가 없다. backend가 revision을 제공하면 response schema/mapper가 opaque
|
||||
revision을 application revalidation policy로 투영해야 하며 HTTP/gRPC metadata를
|
||||
임의로 ETag처럼 해석하지 않는다.
|
||||
|
||||
## 15. Mutation coherence
|
||||
|
||||
mutation과 query cache는 server commit authority가 아니다.
|
||||
|
||||
```text
|
||||
validate command
|
||||
-> derive logical key + exact command equality/opaque identity token
|
||||
-> runtime/scope coordinator applies concurrency + duplicate admission
|
||||
-> separately acquire invalidation-topic hint-coalescing lease
|
||||
-> cancel exact affected query reads
|
||||
-> capture bounded base cache revision and inverse patch
|
||||
-> install own ordered optimistic layer with revision CAS
|
||||
-> transport derives backend idempotency binding from operation policy
|
||||
-> execute command once with transport-owned retry policy
|
||||
-> success: registered exact seed/compare-and-apply + list/aggregate invalidation
|
||||
-> rejection: remove or invert only own layer with revision CAS
|
||||
-> uncertainty/CAS miss: preserve other commits + invalidate/authoritative refetch
|
||||
-> release invalidation lease + concurrency admission
|
||||
```
|
||||
|
||||
- duplicate submit 정책은 `JOIN_IDENTICAL`, `REJECT_DUPLICATE`,
|
||||
`ALLOW_INDEPENDENT` 중 operation별로 고정하고, 동일성은 전체 validated semantic
|
||||
input의 runtime-private exact equality guard와 opaque identity token으로
|
||||
판정한다.
|
||||
- 같은 hook instance의 Promise dedupe를 server idempotency로 간주하지 않는다.
|
||||
- logical key/identity admission은 local ordering이고 invalidation-topic lease는
|
||||
remote hint coalescing일 뿐이다. 둘 다 backend idempotency authority가 아니다.
|
||||
- optimistic patch는 raw DTO나 generated message를 만들지 않는다.
|
||||
- snapshot item/byte ceiling을 넘으면 optimistic update를 하지 않고 pending UX만
|
||||
제공한다.
|
||||
- 409/GraphQL conflict code/gRPC `ABORTED`는 동일한 safe conflict vocabulary로
|
||||
mapping하되 server revision과 merge policy는 feature use case가 소유한다.
|
||||
- server가 commit한 뒤 local invalidation 실패를 command 실패로 되돌리지 않는다.
|
||||
cache health를 degraded로 기록하고 bounded refetch/recovery를 예약한다.
|
||||
- command의 자동 protocol failover는 중복 side effect 위험 때문에 금지한다.
|
||||
|
||||
## 16. Server streaming과 cache
|
||||
|
||||
gRPC-Web server stream, GraphQL subscription과 incremental delivery는 ordinary
|
||||
queryFn과 다르다.
|
||||
|
||||
현재 installed API operation registry는 terminal REST operation만 소유한다.
|
||||
GraphQL HTTP와 gRPC-Web unary/server-stream은 각 reference adapter가 구현될 때
|
||||
protocol discriminant와 전용 binding으로 확장한다. SSE, WebSocket과 Web Push는
|
||||
VD-28 realtime registry가 소유하며 bounded polling은 registered terminal REST
|
||||
`QUERY`의 scheduling policy이지 새 protocol이나 automatic failover가 아니다.
|
||||
GraphQL `@defer`/`@stream`은 선택될 경우에도 한 HTTP operation의 finite
|
||||
incremental response이며 subscription과 같은 장기 realtime stream이 아니다.
|
||||
|
||||
```text
|
||||
ServerStreamPort
|
||||
open(frozen request, signal)
|
||||
-> AsyncIterable<Result<MappedEvent>>
|
||||
-> close()
|
||||
```
|
||||
|
||||
- frame/event마다 schema, mapper, scope와 generation을 재검증한다.
|
||||
- sequence, duplicate, gap과 resume token은 backend application protocol이다.
|
||||
- bounded queue, high-water mark, overflow, idle/total deadline을 선언한다.
|
||||
- stream event는 registered reducer로 immutable snapshot을 만들거나 query
|
||||
invalidation hint만 발행한다.
|
||||
- partial event를 ordinary query success로 cache하지 않는다.
|
||||
- stream 종료/재connect를 TanStack Query retry로 처리하지 않는다.
|
||||
|
||||
GraphQL subscription은 현재 `NOT_SELECTED`이고, gRPC-Web server-stream은
|
||||
`DESIGNED_NOT_IMPLEMENTED`다.
|
||||
|
||||
gRPC-Web `ServerStreamPort`는 operation-bound outbound stream result일 수 있다.
|
||||
API adapter가 protobuf frame/message decode, semantic schema와 boundary mapper를
|
||||
끝낸 뒤 제품이 runtime-wide notification projection을 명시적으로 선택한
|
||||
branch에서만 mapped event를 VD-28 common coordinator/`FeatureEventInput`에
|
||||
전달한다. protobuf를 `REALTIME_EVENT_V1` JSON으로 감싸지 않고, 첫 event 전
|
||||
opening replay와 이후 resume 규칙은 VD-27이 계속 소유한다.
|
||||
|
||||
## 17. Backend/provider 계약
|
||||
|
||||
구현 owner, 권장 topology, 현재 reference endpoint/envelope, idempotency store,
|
||||
Cursor/ETag/revision과 protocol별 handoff checklist는
|
||||
[Backend API와 Server State contract](./backend-api-and-server-state-contract.md)가
|
||||
소유한다. 아래 표는 frontend 설계가 요구하는 경계 요약이다.
|
||||
|
||||
| 경계 | backend/provider가 제공할 계약 |
|
||||
| --- | --- |
|
||||
| 공통 | authorization, contract version/artifact compatibility, encoded transfer와 decoded payload의 bounded owner, stable error code, correlation/trace projection, idempotency와 rate-limit semantics |
|
||||
| REST | exact method/path/media/status/envelope, CSRF strategy, idempotency retention, cursor binding, ETag/If-None-Match 또는 revision, retry-safe status와 CORS/cache policy |
|
||||
| GraphQL | schema registry, persisted-operation manifest, allowlist/cost/depth enforcement, safe error extension vocabulary, operation retirement과 N/N-1 router rollout |
|
||||
| Connect | proto/descriptor/codegen source, exact Connect-Web runtime/encoding/method, EndStream/status/error, timeout/cancel/compression/CORS와 browser/server conformance |
|
||||
| gRPC-Web | proto/descriptor source, Buf/protoc compatibility policy, gRPC-Web proxy, exact service/method, message/frame ceiling, status/trailer/CORS exposure와 stream resume protocol |
|
||||
| Protobuf REST Gateway | selected gateway kind/version, HttpRule/ProtoJSON/OpenAPI artifact, path/query/body/status/error/header mapping, edge→upstream cancellation과 N/N-1 conformance |
|
||||
| Schema | authenticated source artifact, additive/breaking classification, deprecation window, fixtures and source digest |
|
||||
| Mapper | domain meaning, temporal/numeric/null/enum semantics와 stable identity |
|
||||
| Cache | revision/conflict/idempotency/invalidation semantics; frontend TTL은 authorization 대체가 아님 |
|
||||
|
||||
frontend가 제공하는 Zod schema, generated type과 cache invalidation은 backend
|
||||
authorization, validation, idempotency와 conflict resolution을 대체하지 않는다.
|
||||
|
||||
## 18. Security와 privacy
|
||||
|
||||
- API base/GraphQL/Connect/gRPC-Web endpoint는 HTTPS registry ID로 고정한다.
|
||||
- caller가 URL, header, GraphQL document, service/method와 metadata를 제출하지
|
||||
못한다.
|
||||
- auth owner가 credential을 붙이고 application/query/cache에는 token을 노출하지
|
||||
않는다.
|
||||
- credential attachment 뒤 URL/method/origin/body digest와 registry-owned header
|
||||
binding을 재검증한다. auth integration unavailable 상태에서 request를 보내지
|
||||
않는다.
|
||||
- cookie session의 unsafe request는 CSRF token/header 또는 same-site BFF 정책을
|
||||
operation profile과 provider conformance로 증명한다.
|
||||
- GET/GraphQL variables에 sensitive filter를 넣는 operation은 별도 review 없이
|
||||
만들지 않는다.
|
||||
- response byte/depth/node/string/collection/frame cap으로 resource exhaustion을
|
||||
막는다.
|
||||
- GraphQL cost/depth와 gRPC message cap은 server/proxy에서도 강제한다.
|
||||
- mapper와 cache는 prototype/accessor/class/native object를 받아들이지 않는다.
|
||||
- PII/business ID를 query key, diagnostics label, persisted cache physical key에
|
||||
직접 넣지 않는다. 필요한 identity는 opaque partition/token policy를 쓴다.
|
||||
- command identity token/logical mutation key는 runtime-local control data이며
|
||||
diagnostics, cross-context wire와 persistence에 넣지 않는다.
|
||||
- raw request/response, GraphQL variables/errors, protobuf bytes/metadata, ETag,
|
||||
cursor, idempotency key와 validation value를 관측 데이터에 넣지 않는다.
|
||||
|
||||
## 19. Observability
|
||||
|
||||
허용된 bounded aggregate:
|
||||
|
||||
- operation registry ID, protocol, semantics
|
||||
- outcome/error kind, HTTP status group 또는 gRPC status code allowlist
|
||||
- GraphQL full/partial/rejected outcome
|
||||
- attempt/deadline/duration/encoded-byte/result-item/frame bucket
|
||||
- schema/mapper profile ID와 compatibility outcome
|
||||
- query hit/miss/stale/refetch/eviction bucket
|
||||
- mutation optimistic/rollback/conflict/invalidation outcome
|
||||
- provider/browser/runtime version의 low-cardinality bucket
|
||||
|
||||
금지:
|
||||
|
||||
- URL/path parameter/search/body/header
|
||||
- GraphQL document, variables, response path와 raw error message
|
||||
- protobuf message/metadata/trailer raw value
|
||||
- resource/account/tenant ID, cursor, validator, digest와 cache value
|
||||
|
||||
하나의 logical operation은 terminal observation 하나를 만든다. attempt span은
|
||||
sampling된 내부 detail로만 남기며 terminal success/failure count를 중복시키지
|
||||
않는다.
|
||||
|
||||
## 20. Failure와 fallback
|
||||
|
||||
| 실패 | 기본 결과 |
|
||||
| --- | --- |
|
||||
| registry/schema/mapper 누락 | network 전 fail-closed, operation traffic disable |
|
||||
| incompatible contract artifact | product mount 또는 해당 capability admission 차단 |
|
||||
| response cap/decode/schema mismatch | body/reader cancel, cache write 금지, provider incompatibility |
|
||||
| mapper violation | cache write 금지, safe contract failure |
|
||||
| REST retry exhaustion | stale 허용 profile만 previous data 유지 |
|
||||
| GraphQL persisted operation missing | full document fallback 금지, coherent artifact rollback |
|
||||
| GraphQL partial data | default reject; explicit profile만 completeness와 함께 사용 |
|
||||
| Connect missing/duplicate EndStream 또는 oversize whole body | call cancel, cache write 금지, provider/runtime incompatible |
|
||||
| gRPC-Web proxy/trailer mismatch | reader cancel, provider unavailable/incompatible |
|
||||
| REST Gateway HttpRule/OpenAPI/runtime drift | affected route admission 차단, coherent gateway artifact rollback |
|
||||
| server stream gap/overflow | snapshot 폐기 또는 authoritative refetch |
|
||||
| account/generation mismatch | late result 폐기, old-scope cache write 금지 |
|
||||
| invalidation failure after commit | command 성공 유지, cache degraded + recovery refetch |
|
||||
|
||||
GraphQL, Connect 또는 gRPC-Web failure를 REST로 자동 전환하지 않는다. 사전에 등록된
|
||||
read-only shadow/fallback operation이 있고 동일 authorization/mapper/result
|
||||
contract를 conformance suite로 증명한 경우만 selector가 새 logical query를
|
||||
시작할 수 있다.
|
||||
|
||||
## 21. Rollout과 removal
|
||||
|
||||
```text
|
||||
ADR + registry schema accepted
|
||||
-> deterministic codec/schema/mapper fixture
|
||||
-> provider-neutral adapter and fake
|
||||
-> negative boundary/removal gate
|
||||
-> AVAILABLE_NOT_COMPOSED
|
||||
-> product/provider/operation selection
|
||||
-> bootstrap composition behind TrafficAdmission=DISABLED
|
||||
-> COMPOSED
|
||||
-> shadow/read-only conformance
|
||||
-> browser/provider/operations evidence
|
||||
-> PromotionEvidence=COMPLETE
|
||||
-> CANARY
|
||||
-> ENABLED
|
||||
```
|
||||
|
||||
REST v2 local baseline은 installed reference operation에 연결됐다. 실제 provider
|
||||
traffic은 operation/profile 단위의 conformance와 canary를 거쳐야 하며,
|
||||
conditional/pagination은 backend 계약 없이 enabled하지 않는다.
|
||||
GraphQL/Connect/gRPC-Web/codegen/gateway dependency는 실제 selected operation이
|
||||
없으면 production inventory에 없어야 한다.
|
||||
|
||||
removal:
|
||||
|
||||
1. 신규 operation admission을 닫는다.
|
||||
2. query는 cancel하고 command/stream은 bounded drain 또는 explicit abort한다.
|
||||
3. 해당 invalidation listener, auth attachment와 provider를 close한다.
|
||||
4. current scope의 mapped memory cache를 clear한다.
|
||||
5. operation/schema/mapper/query profile과 generated artifact를 제거한다.
|
||||
6. dependency, config, proxy route, test fixture와 production module inventory가
|
||||
함께 제거됐음을 증명한다.
|
||||
7. backend persisted-operation/method retirement은 N/N-1 client window 뒤에 한다.
|
||||
|
||||
## 22. Test와 promotion evidence
|
||||
|
||||
### Deterministic
|
||||
|
||||
- operation registry closed union/reference/orphan/duplicate
|
||||
- request canonicalization과 query-key identity
|
||||
- timeout/total deadline/retry/idempotency/auth recovery
|
||||
- response byte/depth/node/collection cap
|
||||
- schema unknown-field, scalar, null/enum/numeric/date matrix
|
||||
- mapper success/failure/no raw value leakage
|
||||
- query stale/gc/refetch/pagination/mutation/rollback/generation fence
|
||||
|
||||
### Contract
|
||||
|
||||
- REST OpenAPI/envelope/status/media/cursor/conditional/idempotency
|
||||
- GraphQL schema + persisted manifest + variables/result/error/partial policy
|
||||
- proto descriptor + breaking check + gRPC status/trailer/frame fixture
|
||||
- Connect unary/stream JSON/binary/GET/EndStream/CORS/deadline fixture
|
||||
- HttpRule route manifest + ProtoJSON/OpenAPI/status/error/header mapping fixture
|
||||
- 같은 fixture를 fake, emulator/staging과 actual provider에 실행
|
||||
|
||||
### Browser/integration
|
||||
|
||||
- bootstrap → feature → transport → schema → mapper → Query → UI
|
||||
- AbortSignal/navigation/logout/account switch
|
||||
- CORS/cookie/CSRF/redirect/content-encoding
|
||||
- HTTP/2/proxy/CDN/Connect EndStream/gRPC-Web trailer behavior
|
||||
- offline/reconnect/focus와 stale-degraded UI
|
||||
|
||||
### Fault
|
||||
|
||||
- truncated/oversize/malformed response
|
||||
- slow credential/network/body/schema/mapper phase
|
||||
- 401 recovery, 429, retry exhaustion과 total deadline
|
||||
- GraphQL partial/error/persisted-operation drift
|
||||
- Connect missing/early/duplicate EndStream, whole-body overflow와 compression mismatch
|
||||
- gRPC missing/conflicting terminal status source, corrupt/compressed/oversize
|
||||
frame와 stream gap
|
||||
- REST Gateway route/OpenAPI/runtime rewrite drift와 abort propagation loss
|
||||
- late response, duplicate mutation, optimistic rollback과 invalidation failure
|
||||
|
||||
### Operations
|
||||
|
||||
- operation kill switch
|
||||
- contract artifact N/N-1 rollout과 rollback
|
||||
- provider incompatibility containment
|
||||
- cache scope reset와 stale-data decision
|
||||
- generated client/GraphQL/Connect/gRPC-Web/REST Gateway removal drill
|
||||
|
||||
fake와 generated compile success만으로 actual provider, browser나 operations
|
||||
evidence를 `COMPLETE`로 표시하지 않는다.
|
||||
|
||||
## 23. 설계 우선 work package
|
||||
|
||||
| package | 목표 |
|
||||
| --- | --- |
|
||||
| API-01 | REST v2 operation registry, total deadline, bounded decoder와 conditional/pagination contract |
|
||||
| API-02 | multi-protocol schema artifact/digest governance와 typed Mapper result |
|
||||
| API-03 | strict ServerStateProfile, query-key codec, pagination/revalidation와 mutation policy |
|
||||
| API-04 | persisted-operation-only GraphQL reference adapter와 conformance harness |
|
||||
| API-05 | gRPC-Web unary/server-stream reference adapter와 proxy harness |
|
||||
| API-06 | Connect-Web unary/server-stream reference adapter와 provider harness |
|
||||
| API-07 | Protobuf governance와 selected REST Gateway conformance |
|
||||
| API-08 | atomic composition, readiness, kill switch, runbook와 provider/browser evidence |
|
||||
|
||||
권장 순서는 API-01 → API-02 → API-03이다. API-04~07은 제품 선택과
|
||||
backend/provider 계약이 생긴 branch만 독립적으로 시작한다. GraphQL, Connect,
|
||||
gRPC-Web과 REST Gateway를 “미래 대비” 목적으로 모두 기본 bundle에 설치하지
|
||||
않는다.
|
||||
|
||||
## 24. 완료 기준
|
||||
|
||||
- [ ] 모든 installed operation은 protocol/schema/mapper/cache/error/deadline owner가 있다.
|
||||
- [ ] application/presentation public type에 DTO, GraphQL SDK와 generated protobuf가 없다.
|
||||
- [ ] untrusted byte부터 mapped projection까지 모든 ceiling과 trust transition이 닫혀 있다.
|
||||
- [ ] query key와 실제 request input이 동일 canonical source에서 파생된다.
|
||||
- [ ] transport retry와 Query retry가 중복되지 않는다.
|
||||
- [ ] command idempotency, optimistic patch와 conflict/invalidation owner가 명시돼 있다.
|
||||
- [ ] account/logout/release generation 뒤 late result가 cache에 들어가지 않는다.
|
||||
- [ ] actual REST/GraphQL/Connect/gRPC-Web/Gateway provider에 같은 semantic
|
||||
conformance fixture를 실행한다.
|
||||
- [ ] contract drift, kill switch, rollback과 optional dependency removal drill이 통과한다.
|
||||
- [ ] raw payload/URL/document/message/metadata/validator가 log와 cache에 없다.
|
||||
- [ ] `COMPOSED`와 production-ready/provider-conformant를 같은 의미로 쓰지 않는다.
|
||||
|
||||
## 25. 관련 문서
|
||||
|
||||
- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
|
||||
- [Client cache and browser storage](./client-cache-and-storage.md)
|
||||
- [VD-13 Client cache scope와 persistence](./decisions/VD-13-client-cache-scope-and-persistence.md)
|
||||
- [Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
|
||||
- [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md)
|
||||
- [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md)
|
||||
- [Contract compatibility](../contracts/compatibility.md)
|
||||
- [Frontend platform testing strategy](../testing/frontend-platform-testing-strategy.md)
|
||||
@@ -0,0 +1,718 @@
|
||||
# Backend API와 Server State handoff contract
|
||||
|
||||
> **정본 안내 (non-authoritative for runtime capability decisions)**
|
||||
>
|
||||
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
|
||||
> Service Worker, offline command와 Background Sync의 구현 결정은
|
||||
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
|
||||
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
|
||||
|
||||
|
||||
- 상태: frontend handoff design accepted, backend implementation/evidence pending
|
||||
- 기준일: 2026-07-28
|
||||
- 대상: Web API/BFF, application service, persistence, identity, GraphQL router,
|
||||
Connect/gRPC-Web gateway, Protobuf REST Gateway와 운영 owner
|
||||
- frontend 기준:
|
||||
[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)
|
||||
- browser Protobuf/gateway 기준:
|
||||
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
|
||||
- 파일 전송 backend 기준:
|
||||
[Server file capability infrastructure](./server-file-capability-infrastructure.md)
|
||||
- 복구 절차:
|
||||
[API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md)
|
||||
|
||||
## 1. 문서의 경계
|
||||
|
||||
이 문서는 이 frontend template이 실제 제품 backend와 연결될 때 backend가
|
||||
제공해야 하는 구조, wire contract, 상태 의미와 운영 증거를 정의한다. 특정
|
||||
언어·framework·cloud 제품을 강제하지 않는다. Spring, Nest/Fastify, Go,
|
||||
.NET 또는 다른 stack을 사용해도 아래 불변조건은 동일하다.
|
||||
|
||||
이 repository에는 backend source, database migration, identity provider,
|
||||
GraphQL schema/router, protobuf descriptor, Connect/gRPC-Web runtime/proxy,
|
||||
REST transcoder와 실제 provider evidence가 없다. 따라서 이 문서는 backend 구현
|
||||
완료 증거가 아니다.
|
||||
|
||||
파일 업로드·다운로드, object storage, presigned URL, multipart와 Image CDN은
|
||||
별도 server file 문서가 소유한다. 이 문서는 ordinary REST/GraphQL/Connect/
|
||||
gRPC-Web application API, Protobuf REST Gateway와 frontend Server State 계약만
|
||||
소유한다.
|
||||
|
||||
## 2. 권장 논리 구조
|
||||
|
||||
```text
|
||||
Browser
|
||||
-> CDN / reverse proxy / WAF
|
||||
-> Browser-facing Web API or BFF
|
||||
-> authentication + authorization
|
||||
-> exact operation registry
|
||||
-> request schema / byte / rate limit
|
||||
-> REST controller
|
||||
-> optional persisted GraphQL router
|
||||
-> optional Connect browser RPC gateway
|
||||
-> optional gRPC-Web gateway
|
||||
-> optional Protobuf REST transcoder
|
||||
-> application service
|
||||
-> command transaction
|
||||
-> query/read-model service
|
||||
-> idempotency coordinator
|
||||
-> revision/validator owner
|
||||
-> outbox/event owner
|
||||
-> primary database
|
||||
-> idempotency store
|
||||
-> read replica/read model
|
||||
-> event broker when selected
|
||||
```
|
||||
|
||||
Browser-facing contract owner와 내부 service contract owner를 분리한다.
|
||||
|
||||
- Browser API/BFF는 CORS, cookie/CSRF 또는 bearer, public DTO, envelope,
|
||||
body ceiling, status/media와 redaction을 소유한다.
|
||||
- Application service는 authorization 재검사, transaction, idempotency,
|
||||
conflict, revision과 domain invariant를 소유한다.
|
||||
- Persistence adapter는 SQL/NoSQL/Redis vendor type, row version과 cursor
|
||||
implementation을 외부 DTO에 노출하지 않는다.
|
||||
- GraphQL router, Connect/gRPC-Web gateway와 REST transcoder는 선택 adapter다.
|
||||
다른 protocol로 임의 fallback하거나 frontend에 내부 service address를
|
||||
노출하지 않는다.
|
||||
|
||||
작은 제품은 이 논리 모듈을 하나의 deployable로 구현할 수 있다. deployable을
|
||||
나누는 것보다 transaction/idempotency/authorization owner가 하나로 명확한지가
|
||||
우선이다.
|
||||
|
||||
## 3. 공통 contract artifact
|
||||
|
||||
Backend와 frontend release는 다음 bounded contract set을 공유한다.
|
||||
|
||||
```text
|
||||
ApiContractSetV1
|
||||
globalApiContractVersion
|
||||
restArtifactId + digest
|
||||
runtimeSchemaManifestId + digest
|
||||
mapperSemanticManifestId + digest
|
||||
errorVocabularyVersion
|
||||
minimumFrontendVersion
|
||||
minimumBackendVersion
|
||||
effectiveAt
|
||||
retirementEpoch | null
|
||||
|
||||
optional:
|
||||
graphqlSchemaId + digest
|
||||
persistedGraphqlManifestId + digest
|
||||
protobufDescriptorId + digest
|
||||
protobufSourceOrModuleId + digest
|
||||
protobufCodegenProfileId
|
||||
connectProviderProfileId
|
||||
grpcWebProviderProfileId
|
||||
protobufRestGatewayProfileId
|
||||
httpRuleArtifactId + digest
|
||||
protoJsonProfileId
|
||||
gatewayOpenApiArtifactId + digest
|
||||
```
|
||||
|
||||
최소 산출물:
|
||||
|
||||
- authenticated OpenAPI 또는 동등한 REST schema source
|
||||
- exact status/media/envelope fixture
|
||||
- stable error code vocabulary
|
||||
- request/response byte와 collection ceiling
|
||||
- scalar/date/null/enum 의미
|
||||
- N/N-1 compatibility 결과
|
||||
- backend build와 frontend release가 참조하는 immutable digest
|
||||
|
||||
runtime config의 version 문자열 일치만 compatibility 증거로 사용하지 않는다.
|
||||
artifact digest가 없는 동안에는 실제 staging conformance fixture와 수동 승인
|
||||
evidence가 필요하다.
|
||||
|
||||
## 4. 현재 reference REST 계약
|
||||
|
||||
현재 frontend에 실제 조립된 operation은 다음 세 개다.
|
||||
|
||||
| operation | request | success |
|
||||
| --- | --- | --- |
|
||||
| `LIST_REFERENCE_RESOURCES` | `GET /api/reference-resources?cursor&limit&tags` | `200 application/json` |
|
||||
| `GET_REFERENCE_RESOURCE` | `GET /api/reference-resources/{resourceId}` | `200 application/json` |
|
||||
| `CREATE_REFERENCE_RESOURCE` | `POST /api/reference-resources` | `200` 또는 `201 application/json` |
|
||||
|
||||
현재 list payload는 `ReferenceResource[]`다. `cursor` 입력이 존재하더라도
|
||||
`CursorPage` 출력 계약은 아직 아니다. backend가 같은 operation에서 배열을
|
||||
page object로 조용히 바꾸면 schema mismatch로 실패한다.
|
||||
|
||||
resource DTO:
|
||||
|
||||
```text
|
||||
ReferenceResourceDtoV1
|
||||
id: non-empty string, maximum 120 characters
|
||||
name: non-empty string, maximum 240 characters
|
||||
createdAt?: RFC 3339 date-time
|
||||
```
|
||||
|
||||
create command:
|
||||
|
||||
```text
|
||||
CreateReferenceResourceCommandV1
|
||||
name: trimmed string, 1..120
|
||||
note?: trimmed string, 0..500
|
||||
```
|
||||
|
||||
Backend는 frontend validation을 신뢰하지 않고 동일하거나 더 좁은 validation과
|
||||
authorization을 다시 수행한다.
|
||||
|
||||
### 4.1 JSON envelope
|
||||
|
||||
모든 현재 JSON success/failure는 다음 envelope를 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {},
|
||||
"meta": {
|
||||
"requestId": "server-request-id",
|
||||
"traceId": "server-trace-id",
|
||||
"correlationId": "client-correlation-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "STABLE_MACHINE_CODE",
|
||||
"category": "optional-safe-category",
|
||||
"message": "optional non-sensitive copy",
|
||||
"retryable": false,
|
||||
"details": {}
|
||||
},
|
||||
"meta": {
|
||||
"requestId": "server-request-id",
|
||||
"traceId": "server-trace-id",
|
||||
"correlationId": "client-correlation-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Envelope 최상위 unknown field는 현재 거절된다. ordinary resource DTO의 unknown
|
||||
field는 frontend schema에서 strip되지만 additive compatibility는 contract
|
||||
review와 fixture를 먼저 통과해야 한다.
|
||||
|
||||
`requestId`, `traceId`, `correlationId`는 각각 1..128 범위의 안전한 opaque
|
||||
identifier다. credential, user data, cursor, validator와 database key를
|
||||
identifier에 encode하지 않는다.
|
||||
|
||||
### 4.2 Status와 error
|
||||
|
||||
| HTTP status | 의미 |
|
||||
| --- | --- |
|
||||
| `400` | malformed request 또는 closed request contract 위반 |
|
||||
| `401` | 인증 없음/만료. 이미 적용된 command를 401로 반환하지 않음 |
|
||||
| `403` | authenticated principal에게 권한 없음 |
|
||||
| `404` | authorization 정책상 공개 가능한 not-found |
|
||||
| `409` | idempotency fingerprint, domain revision 또는 semantic conflict |
|
||||
| `412` | selected conditional write의 `If-Match` precondition 실패 |
|
||||
| `422` | field validation. bounded `details.issues[]`만 허용 |
|
||||
| `429` | rate limit. 유효한 `Retry-After`와 operation 정책 제공 |
|
||||
| `500/502/503/504` | server/provider failure. command effect certainty 별도 |
|
||||
|
||||
현재 frontend의 ordinary status mapper는 `412` 전용 처리를 아직 연결하지
|
||||
않았다. conditional mutation을 선택할 때 frontend failure vocabulary와
|
||||
transaction을 함께 승격해야 한다.
|
||||
|
||||
Backend `error.code`는 machine-readable stable code다. stack, SQL/vendor error,
|
||||
raw validation value, authorization reason과 내부 service address를 반환하지
|
||||
않는다.
|
||||
|
||||
## 5. 인증, CSRF와 CORS
|
||||
|
||||
현재 reference operation은 다음 profile로 조립돼 있다.
|
||||
|
||||
```text
|
||||
auth = external bearer
|
||||
Authorization: Bearer <credential>
|
||||
fetch credentials = omit
|
||||
CSRF profile = none
|
||||
redirect = error
|
||||
referrer policy = no-referrer
|
||||
```
|
||||
|
||||
Backend/BFF는 bearer의 issuer, audience, signature algorithm, time claims와
|
||||
revocation/session policy를 검증하고 operation별 authorization을 적용한다.
|
||||
401과 403을 구분하며 frontend cache를 authorization authority로 사용하지 않는다.
|
||||
|
||||
쿠키 session으로 전환할 경우 같은 profile로 간주하지 않는다. 별도
|
||||
`SAME_ORIGIN_COOKIE` profile에 다음을 함께 승인한다.
|
||||
|
||||
- `Secure`, `HttpOnly`, 명시적 `SameSite`와 host/path scope
|
||||
- unsafe method의 CSRF token/header와 Origin/Sec-Fetch-Site 검증
|
||||
- credentialed CORS에서 wildcard origin 금지
|
||||
- login/logout/session rotation과 cache generation 전환
|
||||
- session fixation, token rotation과 concurrent tab 동작
|
||||
|
||||
Cross-origin bearer provider 최소 CORS:
|
||||
|
||||
- exact allow-origin 목록과 bounded preflight cache
|
||||
- `Authorization`, `Content-Type`, `Idempotency-Key`,
|
||||
`X-Correlation-ID`, 향후 `If-None-Match`, `If-Match` 허용
|
||||
- 필요한 경우 `ETag`, `Retry-After`, request/trace header만 expose
|
||||
- redirect login page, HTML error body와 wildcard credential 금지
|
||||
|
||||
## 6. Command와 idempotency
|
||||
|
||||
`CREATE_REFERENCE_RESOURCE`는 keyed command다. frontend memory single-flight는
|
||||
backend idempotency를 대체하지 않는다.
|
||||
|
||||
idempotency identity:
|
||||
|
||||
```text
|
||||
principal/tenant
|
||||
+ semantic operation ID and contract version
|
||||
+ Idempotency-Key
|
||||
+ canonical request fingerprint
|
||||
```
|
||||
|
||||
권장 record:
|
||||
|
||||
```text
|
||||
IdempotencyRecord
|
||||
principalFingerprint
|
||||
operationId
|
||||
contractVersion
|
||||
idempotencyKeyHash
|
||||
requestFingerprint
|
||||
state = IN_PROGRESS | COMMITTED | FAILED_SAFE | EFFECT_UNKNOWN
|
||||
responseStatus
|
||||
responseEnvelopeReference
|
||||
resourceRevision | null
|
||||
leaseOwner + leaseExpiry
|
||||
retentionExpiry
|
||||
createdAt + completedAt
|
||||
```
|
||||
|
||||
불변조건:
|
||||
|
||||
- claim과 command transaction의 관계가 원자적이거나 crash reconciliation
|
||||
가능해야 한다.
|
||||
- 같은 key와 같은 fingerprint replay는 같은 authoritative receipt를 반환한다.
|
||||
- 같은 key와 다른 fingerprint는 `409 IDEMPOTENCY_KEY_REUSED`다.
|
||||
- concurrent replay는 하나만 실행하고 나머지는 같은 result를 기다리거나
|
||||
bounded `IN_PROGRESS` 결과를 받는다.
|
||||
- commit 뒤 response 유실은 새 resource를 만들지 않는다.
|
||||
- `EFFECT_UNKNOWN`은 새 key로 자동 재시도하지 않고 status/reconcile endpoint로
|
||||
확인한다.
|
||||
- retention은 frontend retry/recovery 최대 window보다 길고 quota/abuse limit이
|
||||
있다.
|
||||
- key 원문과 request body를 log/metric label에 넣지 않는다.
|
||||
|
||||
Database unique constraint 또는 durable compare-and-set이 최종 중복 방지
|
||||
authority여야 한다. process-local map/lock만 사용하지 않는다.
|
||||
|
||||
## 7. Cursor pagination과 snapshot
|
||||
|
||||
Backend가 pagination을 선택할 때 새 response schema/operation version으로 다음
|
||||
contract를 제공한다.
|
||||
|
||||
```text
|
||||
CursorPage<T>
|
||||
items: T[]
|
||||
nextCursor: opaque string | null
|
||||
hasMore: boolean
|
||||
snapshotToken: opaque string | null
|
||||
```
|
||||
|
||||
필수 불변조건:
|
||||
|
||||
- `hasMore === (nextCursor !== null)`
|
||||
- 동일 chain의 `snapshotToken`은 모든 page에서 동일
|
||||
- cursor는 principal/tenant, filter, sort, contract version과 snapshot에 binding
|
||||
- cursor는 opaque, 무결성 보호, 만료와 key rotation 정책 보유
|
||||
- offset이 아니라 stable keyset ordering 사용
|
||||
- total order의 마지막 tie-breaker는 immutable unique ID
|
||||
- deleted/inserted row가 duplicate/gap을 만드는 의미를 snapshot 정책으로 결정
|
||||
- empty page인데 `hasMore=true`인 sparse page 허용 여부를 operation profile에 고정
|
||||
- cursor 최대 encoded byte, page size와 total scan/cost ceiling을 server도 강제
|
||||
- invalid, expired, wrong-principal, wrong-filter cursor의 safe error code를 고정
|
||||
|
||||
권장 query ordering 예:
|
||||
|
||||
```text
|
||||
ORDER BY created_at DESC, resource_id DESC
|
||||
cursor payload = version + snapshot watermark + last(created_at, resource_id)
|
||||
+ filter digest + principal/tenant binding + expiry
|
||||
```
|
||||
|
||||
Cursor 원문은 log, trace, analytics와 frontend persistent storage에 넣지 않는다.
|
||||
|
||||
### 7.1 배열에서 page로의 migration
|
||||
|
||||
1. `ReferenceResourceListPagePayloadV2` schema와 새 operation/version을 추가한다.
|
||||
2. backend가 N/N-1 동안 기존 배열과 page contract를 동시에 제공한다.
|
||||
3. frontend가 cursor runtime을 새 bound query/infinite query에 연결한다.
|
||||
4. loop/snapshot/ceiling/abort conformance를 staging에서 검증한다.
|
||||
5. 새 operation을 canary한 뒤 기존 배열 operation을 retirement한다.
|
||||
|
||||
동일 media/status에서 payload shape만 바꾸는 in-place migration은 금지한다.
|
||||
|
||||
## 8. Conditional read와 revision/CAS
|
||||
|
||||
### 8.1 Read validator
|
||||
|
||||
Backend가 application-managed revalidation을 선택하면 exact mapped
|
||||
representation마다 ETag를 제공한다.
|
||||
|
||||
```text
|
||||
GET without validator
|
||||
-> 200 + JSON envelope + ETag
|
||||
|
||||
GET with If-None-Match
|
||||
-> representation unchanged: 304 + empty body
|
||||
-> changed: 200 + JSON envelope + new ETag
|
||||
```
|
||||
|
||||
불변조건:
|
||||
|
||||
- validator는 principal/tenant, authorization-visible representation,
|
||||
response schema/mapper semantics와 encoding variant에 binding
|
||||
- weak/strong 선택을 operation profile에 고정
|
||||
- user-private response를 shared CDN/public cache에 저장하지 않음
|
||||
- cross-origin이면 `ETag`를 expose하고 `If-None-Match`를 preflight 허용
|
||||
- 304에는 JSON success envelope를 넣지 않음
|
||||
- validator 원문을 log/metric/diagnostics에 넣지 않음
|
||||
- `Vary`와 `Cache-Control` owner를 명확히 하고 browser HTTP cache와
|
||||
TanStack/application revalidation이 서로 다른 value owner가 되지 않게 함
|
||||
|
||||
Frontend는 validator와 mapped cache value의 scope, query identity,
|
||||
representation version과 cache revision이 모두 일치할 때만 304를 success로
|
||||
받는다. cache value가 없으면 unconditional refetch 또는 safe failure로 닫는다.
|
||||
|
||||
### 8.2 Conditional command
|
||||
|
||||
수정/삭제 command가 선택되면 DTO에 opaque domain `revision`을 추가하고:
|
||||
|
||||
```text
|
||||
If-Match: "<revision validator>"
|
||||
```
|
||||
|
||||
를 요구한다. 일치하지 않으면 `412` 또는 승인된 `409` contract 하나만
|
||||
사용한다. frontend optimistic layer의 commit/rollback은 backend revision
|
||||
authority를 대체하지 않는다.
|
||||
|
||||
## 9. Optimistic mutation을 위한 backend 의미
|
||||
|
||||
Frontend ordered optimistic layer runtime은 구현돼 있지만 제품 operation에
|
||||
연결하려면 backend가 다음을 결정해야 한다.
|
||||
|
||||
- resource/list membership을 결정하는 canonical filter와 sort
|
||||
- command가 생성/수정/삭제하는 stable identity
|
||||
- server-assigned ID와 client correlation의 reconcile 방법
|
||||
- authoritative resource/list revision
|
||||
- conflict status와 stable error code
|
||||
- commit response가 complete resource인지 receipt인지
|
||||
- effect certainty와 idempotency status/reconcile endpoint
|
||||
- event/outbox가 있을 때 sequence/gap/snapshot reset 의미
|
||||
|
||||
Create가 server-assigned ID를 사용하는 경우 temporary UI ID를 backend ID로
|
||||
원자적으로 교체하고 관련 detail/list key를 reconcile하는 정책이 필요하다.
|
||||
이 의미 없이 generic optimistic append를 기본 활성화하지 않는다.
|
||||
|
||||
## 10. Database와 application service baseline
|
||||
|
||||
구현 예시는 다음 논리 table/constraint를 만족해야 한다.
|
||||
|
||||
```text
|
||||
reference_resource
|
||||
tenant_id
|
||||
resource_id
|
||||
display_name
|
||||
note
|
||||
revision
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at | null
|
||||
unique(tenant_id, resource_id)
|
||||
|
||||
idempotency_record
|
||||
principal/tenant fingerprint
|
||||
operation + contract version
|
||||
key hash
|
||||
request fingerprint
|
||||
state + receipt
|
||||
lease/retention timestamps
|
||||
unique(principal/tenant, operation, contract version, key hash)
|
||||
|
||||
outbox_event when selected
|
||||
aggregate identity + revision
|
||||
event type/version
|
||||
sequence
|
||||
payload reference or bounded safe projection
|
||||
publication state
|
||||
```
|
||||
|
||||
Application service transaction은 authorization scope와 tenant predicate를
|
||||
모든 read/write에 적용하고, resource mutation과 revision/outbox 기록을 같은
|
||||
transaction boundary에 둔다. cache/replica lag를 고려해 command 직후 read
|
||||
consistency와 invalidation owner를 선언한다.
|
||||
|
||||
## 11. GraphQL 선택 시 추가 구조
|
||||
|
||||
GraphQL은 제품 operation이 REST보다 aggregation 이점을 실제로 가질 때만
|
||||
선택한다.
|
||||
|
||||
```text
|
||||
Browser
|
||||
-> persisted-operation endpoint
|
||||
-> manifest allowlist
|
||||
-> auth/CSRF/rate/cost/depth/alias enforcement
|
||||
-> GraphQL router
|
||||
-> application services/loaders
|
||||
```
|
||||
|
||||
Backend handoff:
|
||||
|
||||
- authenticated immutable schema artifact와 digest
|
||||
- named operation source와 persisted ID/hash manifest
|
||||
- variables/result runtime fixtures
|
||||
- selected GraphQL-over-HTTP revision과 exact media/status profile
|
||||
- partial data policy와 safe error extension vocabulary
|
||||
- field/row authorization, cost/depth/alias/list ceiling
|
||||
- N/N-1 router/frontend manifest rollout과 retirement
|
||||
|
||||
Production endpoint는 arbitrary document와 persisted miss 후 full-document
|
||||
fallback을 받지 않는다. normalized frontend entity cache는 별도 제품 선택이다.
|
||||
|
||||
## 12. gRPC-Web 선택 시 추가 구조
|
||||
|
||||
gRPC-Web은 browser-facing gateway/proxy가 실제 선택된 unary 또는 bounded
|
||||
server-stream operation에만 사용한다.
|
||||
|
||||
```text
|
||||
Browser
|
||||
-> same-origin BFF/Envoy/gRPC-Web gateway
|
||||
-> exact service/method allowlist
|
||||
-> frame/message/deadline/status/trailer enforcement
|
||||
-> internal gRPC application service
|
||||
```
|
||||
|
||||
Backend handoff:
|
||||
|
||||
- authenticated proto source와 immutable descriptor digest
|
||||
- Buf/protoc lint/breaking 및 deterministic generation evidence
|
||||
- exact service/method/rpc-kind allowlist
|
||||
- selected gRPC-Web runtime kind, client API와 binary/JSON/text/wire revision;
|
||||
official XHR와 Connect-Web Fetch profile을 분리
|
||||
- proxy CORS, content-type, terminal status/trailer behavior
|
||||
- Envoy를 선택하면 exact version/config digest, filter order, upstream HTTP/2,
|
||||
route/idle/max-stream timeout, timeout offset와 buffering/flush
|
||||
- message/frame/count/queue/idle/total budget
|
||||
- server-stream sequence, gap, resume와 snapshot reset protocol
|
||||
- actual browser/proxy conformance
|
||||
|
||||
client streaming과 bidirectional streaming은 common gRPC-Web browser contract로
|
||||
간주하지 않는다. upload는 REST transfer, duplex는 별도 protocol을 선택한다.
|
||||
|
||||
## 13. Connect-Web/Connect 선택 시 추가 구조
|
||||
|
||||
Connect는 Protobuf-first backend의 selected unary 또는 bounded server-stream
|
||||
operation에만 사용한다. Connect protocol과 Connect-Web의 gRPC-Web transport는
|
||||
서로 다른 provider row다.
|
||||
|
||||
```text
|
||||
Browser Connect-Web adapter
|
||||
-> same-origin BFF 또는 exact cross-origin Connect endpoint
|
||||
-> auth/CSRF/CORS + service/method allowlist
|
||||
-> Connect protocol handler
|
||||
-> application service
|
||||
```
|
||||
|
||||
Backend handoff:
|
||||
|
||||
- authenticated proto/Buf source, descriptor와 generated-service digest
|
||||
- exact Connect-Web/client runtime과 server/gateway version
|
||||
- protocol revision, JSON/binary encoding, POST 또는 approved GET
|
||||
- unary HTTP/error profile 또는 stream EndStream terminal profile
|
||||
- request/response/envelope/message/count/queue byte ceiling
|
||||
- unary/stream compression capability; stock browser stream은 identity-only
|
||||
- total/idle timeout, browser abort→server context→downstream cancellation 전파
|
||||
- exact CORS allow/expose/preflight와 auth/CSRF profile
|
||||
- actual Chromium/Firefox/WebKit와 selected proxy/server conformance
|
||||
|
||||
GET은 descriptor `NO_SIDE_EFFECTS`, non-sensitive bounded input, URL/cache key,
|
||||
`Vary`와 credential policy가 모두 승인된 unary에만 허용한다. browser
|
||||
client-streaming/bidi는 Connect protocol 자체 기능과 별개로 `PLATFORM_LIMITED`다.
|
||||
|
||||
## 14. Protobuf REST Gateway 선택 시 추가 구조
|
||||
|
||||
한 route는 `CURATED_BFF | GRPC_GATEWAY | ENVOY_TRANSCODER` 중 하나만 소유한다.
|
||||
현재 reference REST의 envelope와 `200|201`, 향후 `204/304/412` 의미를 유지하는
|
||||
기본 선택은 curated BFF다.
|
||||
|
||||
Direct gateway는 ProtoJSON/HttpRule/status/error 자체를 새 public contract로
|
||||
승인한 unary operation에서만 선택한다. Backend handoff:
|
||||
|
||||
- `.proto` annotation 또는 precedence가 고정된 service config의 immutable source
|
||||
- descriptor/Buf image, canonical HttpRule route manifest와 digest
|
||||
- pinned gateway/runtime/generator/plugin과 generated OpenAPI artifact
|
||||
- ProtoJSON name/default/enum/int64/bytes/null/presence/unknown-field profile
|
||||
- exact method/path/query/body/response-body/additional-binding와 path escaping
|
||||
- safe status/error/header mapping과 raw `google.rpc.Status` detail redaction
|
||||
- CORS/auth/CSRF, body/header/query ceiling와 rate limit
|
||||
- browser abort/deadline의 upstream gRPC/application work 전파
|
||||
- N/N-1 route/OpenAPI/runtime conformance와 coherent rollback
|
||||
|
||||
Gateway는 durable idempotency, pagination snapshot, ETag/HTTP conditional,
|
||||
product envelope, authorization와 file transfer semantics를 자동 구현하지 않는다.
|
||||
필요한 operation은 application service와 BFF가 계속 소유한다. generated
|
||||
REST streaming은 별도 framing/terminal/cache ADR 없이는 `NOT_SELECTED`다.
|
||||
|
||||
## 15. Invalidation과 realtime
|
||||
|
||||
현재 frontend cross-tab invalidation은 같은 browser origin 안의 opaque
|
||||
invalidate-only hint다. backend event delivery를 의미하지 않는다.
|
||||
|
||||
Backend-driven invalidation/realtime을 선택하면:
|
||||
|
||||
- transactional outbox 또는 동등한 durable publication
|
||||
- principal/tenant authorization을 통과한 event projection
|
||||
- event type/version, aggregate revision, sequence와 dedupe identity
|
||||
- reconnect cursor, gap detection과 snapshot reset
|
||||
- retention, replay ceiling과 slow-consumer policy
|
||||
|
||||
를 제공해야 한다. event payload를 authoritative resource snapshot으로 쓸지
|
||||
query invalidate hint로만 쓸지 operation별 reducer contract가 필요하다.
|
||||
|
||||
## 16. Rate limit, deadline와 retry
|
||||
|
||||
- backend deadline은 frontend total deadline보다 짧거나 cancellation을 전파할 수
|
||||
있어야 한다.
|
||||
- disconnect/cancel 뒤 불필요한 query 작업은 중단한다.
|
||||
- keyed command는 disconnect가 transaction rollback을 보장하지 않으므로
|
||||
idempotency receipt로 effect를 판정한다.
|
||||
- `Retry-After`는 selected status에서만 bounded delta/date 형식으로 제공한다.
|
||||
- retry-safe read와 keyed command를 구분한다.
|
||||
- proxy, BFF와 service retry가 겹쳐 retry amplification을 만들지 않게 한 owner만
|
||||
재시도한다.
|
||||
- rate limit key는 principal/tenant/operation과 abuse policy에 binding하며 raw
|
||||
credential/IP를 metric label에 넣지 않는다.
|
||||
|
||||
## 17. Observability와 privacy
|
||||
|
||||
허용되는 공통 dimension:
|
||||
|
||||
```text
|
||||
operation ID
|
||||
contract/profile version
|
||||
status group / safe error code
|
||||
attempt bucket
|
||||
duration bucket
|
||||
provider/runtime health
|
||||
traffic admission stage
|
||||
```
|
||||
|
||||
금지:
|
||||
|
||||
- Authorization, cookie, CSRF와 idempotency key
|
||||
- request/response body와 validation value
|
||||
- URL query, cursor, snapshot, ETag/revision
|
||||
- GraphQL variables/path/raw error/extensions
|
||||
- protobuf bytes, metadata와 trailer 원문
|
||||
- user ID/email/file name을 metric label이나 trace attribute로 사용
|
||||
|
||||
Request ID와 trace ID는 browser에 반환할 수 있지만 credential 역할을 하지 않으며
|
||||
추측 가능한 database primary key를 포함하지 않는다.
|
||||
|
||||
필수 SLO/alert 후보:
|
||||
|
||||
- operation availability와 latency
|
||||
- 401/403/409/412/422/429 및 5xx rate
|
||||
- schema/mapper/contract mismatch
|
||||
- idempotency in-progress age, collision과 unknown effect
|
||||
- cursor invalid/expired/loop-equivalent server detection
|
||||
- conditional hit/miss와 invalid 304
|
||||
- GraphQL persisted miss/cost reject
|
||||
- Connect missing/duplicate EndStream, whole-body/queue overflow와 compression mismatch
|
||||
- gRPC-Web missing terminal status, frame/idle/queue overflow
|
||||
- REST Gateway route/OpenAPI/runtime rewrite drift와 cancel propagation loss
|
||||
|
||||
## 18. 배포, compatibility와 rollback
|
||||
|
||||
권장 순서:
|
||||
|
||||
1. contract artifact와 compatibility diff를 생성한다.
|
||||
2. backend가 N/N-1 fixture를 통과한 상태로 먼저 배포한다.
|
||||
3. frontend operation은 traffic disabled 상태에서 staging conformance를 실행한다.
|
||||
4. read-only shadow/canary 뒤 query traffic을 올린다.
|
||||
5. keyed command는 idempotency/reconcile fault injection 뒤 별도 canary한다.
|
||||
6. pagination, conditional, optimistic, GraphQL, Connect, gRPC-Web과 REST
|
||||
Gateway는 각각 독립 gate로 승격한다.
|
||||
7. provider/browser/operations evidence가 완료된 operation만 enabled한다.
|
||||
|
||||
Rollback은 frontend/backend/contract artifact를 coherent set으로 되돌린다.
|
||||
unknown-effect command를 다른 protocol이나 새 idempotency key로 replay하지 않는다.
|
||||
Backend가 old contract를 제거하는 시점은 실제 frontend support window와 cache/CDN
|
||||
retention 뒤다.
|
||||
|
||||
## 19. Conformance와 fault-injection matrix
|
||||
|
||||
Backend 완료 판정에는 unit test 외에 actual staging provider evidence가 필요하다.
|
||||
|
||||
| 범위 | 필수 증거 |
|
||||
| --- | --- |
|
||||
| REST | exact path/query/body, media/status/envelope, max body, malformed/truncated JSON |
|
||||
| Auth | missing/expired credential, 401/403, rotation, cross-origin preflight |
|
||||
| Command | concurrent same-key replay, fingerprint mismatch, commit 뒤 response loss |
|
||||
| Cursor | filter/sort binding, expiry, snapshot stability, loop/gap/duplicate 방지 |
|
||||
| Conditional | 200→304, cache-missing 304 방지, representation change, 412 |
|
||||
| Schema | additive/breaking/null/enum/time/number fixtures와 N/N-1 |
|
||||
| GraphQL | persisted hit/miss/hash mismatch, partial, cost/depth, router rollout |
|
||||
| Connect | JSON/binary unary, GET restriction, EndStream, body/message cap, compression, cancel/deadline와 CORS |
|
||||
| gRPC-Web | proxy media/status/trailer, oversized frame, cancel, idle, gap/resume |
|
||||
| REST Gateway | HttpRule path/query/body, ProtoJSON, OpenAPI/status/error rewrite, abort propagation과 N/N-1 |
|
||||
| Operations | deadline/retry amplification, rate limit, kill switch, coherent rollback |
|
||||
|
||||
## 20. Backend handoff checklist
|
||||
|
||||
- [ ] Browser-facing API/BFF owner와 on-call이 정해졌다.
|
||||
- [ ] reference REST exact endpoint/envelope/status/media fixture가 있다.
|
||||
- [ ] bearer 또는 cookie+CSRF 중 하나의 실제 profile과 CORS evidence가 있다.
|
||||
- [ ] stable error vocabulary와 redaction contract가 있다.
|
||||
- [ ] keyed command idempotency store, TTL, receipt와 reconcile이 있다.
|
||||
- [ ] CursorPage를 선택했다면 opaque cursor/snapshot contract가 있다.
|
||||
- [ ] conditional을 선택했다면 ETag/304/412와 cache owner가 있다.
|
||||
- [ ] optimistic을 선택했다면 identity/membership/revision/conflict 의미가 있다.
|
||||
- [ ] OpenAPI/runtime schema/mapper semantic artifact와 digest가 release에 binding됐다.
|
||||
- [ ] GraphQL을 선택했다면 schema/persisted manifest/router evidence가 있다.
|
||||
- [ ] Connect를 선택했다면 descriptor/runtime/server/browser evidence가 있다.
|
||||
- [ ] gRPC-Web을 선택했다면 descriptor/proxy/browser evidence가 있다.
|
||||
- [ ] REST Gateway를 선택했다면 kind/HttpRule/ProtoJSON/OpenAPI와 runtime
|
||||
conformance evidence가 있다.
|
||||
- [ ] staging conformance, fault injection, canary, kill switch와 rollback drill이
|
||||
통과했다.
|
||||
|
||||
## 21. Frontend 완료 경계
|
||||
|
||||
Backend 구현과 별개로 현재 frontend 상태를 다음처럼 해석한다.
|
||||
|
||||
| 범위 | 현재 상태 | 남은 owner |
|
||||
| --- | --- | --- |
|
||||
| REST path/provider/auth/deadline/bounded JSON | `COMPOSED` | actual provider conformance는 backend/operations |
|
||||
| runtime schema와 mapper registry | `COMPOSED` | artifact digest/source provenance는 backend contract source + frontend/platform |
|
||||
| session generation과 query identity | `COMPOSED` | account identity projection은 identity integration + frontend |
|
||||
| Cursor runtime | `AVAILABLE_NOT_COMPOSED` | CursorPage backend 계약 후 frontend query binding |
|
||||
| conditional validator store | `AVAILABLE_NOT_COMPOSED` | ETag/304/412 backend 계약 후 frontend HTTP/cache transaction |
|
||||
| ordered optimistic layer | `AVAILABLE_NOT_COMPOSED` | product membership/revision 승인 후 frontend mutation definition |
|
||||
| GraphQL adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend 선택 뒤 frontend adapter/codegen |
|
||||
| Browser RPC V3 공통 계약/coordinator | `AVAILABLE_NOT_COMPOSED` | selected descriptor/generated client와 protocol transport 확정 뒤 frontend provider adapter |
|
||||
| Protobuf schema/codegen | `DESIGNED_NOT_IMPLEMENTED` | authenticated backend contract source 선택 뒤 pinned frontend generation |
|
||||
| Connect-Web adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend/server 선택 뒤 frontend adapter/codegen |
|
||||
| gRPC-Web adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend/proxy 선택 뒤 frontend adapter/codegen |
|
||||
| Protobuf REST Gateway | `NOT_SELECTED` | gateway kind와 public HTTP contract 승인 뒤 REST adapter binding |
|
||||
| persisted query/offline command | `NOT_SELECTED` | 별도 product ADR와 backend durability 계약 |
|
||||
|
||||
따라서 “backend만 구현하면 frontend가 아무 변경 없이 모든 capability를 자동
|
||||
사용한다”는 의미는 아니다. 현재 선택된 REST reference vertical의 공통 frontend
|
||||
기반은 완료됐지만, backend contract가 확정되면 Cursor/conditional/optimistic의
|
||||
마지막 composition과 schema/mapper 변경이 frontend에 남는다. GraphQL,
|
||||
Connect/gRPC-Web과 Protobuf REST Gateway는 제품이 선택되지 않았다. 공통 Browser
|
||||
RPC operation/profile registry, application port와 lifecycle coordinator는
|
||||
구현했지만, wire별 generated client/decoder/provider binding은 아직 구현하지
|
||||
않았다. 따라서 backend contract가 정해져도 frontend provider adapter와
|
||||
composition 작업은 명시적으로 남는다.
|
||||
@@ -0,0 +1,385 @@
|
||||
# Browser data capability completion ledger
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 다음 browser data capability의 **현재 구현 상태, 목표 상태, 남은
|
||||
공통 구현, 제품 조합 책임, backend/provider 계약과 promotion 조건**을 한곳에서
|
||||
관리하는 기준 문서다.
|
||||
|
||||
- File, Blob, 파일 선택기, preview와 다운로드
|
||||
- Local/Session Storage, IndexedDB, OPFS와 Cache Storage
|
||||
- TanStack Query memory cache와 탭 간 무효화
|
||||
- Presigned URL, multipart/resumable upload와 streaming download
|
||||
- Range resumable download와 background upload/download
|
||||
- Image CDN descriptor, 검증, delivery와 presentation
|
||||
|
||||
각 상세 문서는 메커니즘과 불변조건을 설명한다. 이 ledger는 상세 문서를
|
||||
대체하지 않으며, 서로 다른 문서의 "구현됨", "사용 가능", "설계됨" 표현이
|
||||
production readiness로 잘못 합쳐지는 것을 막는 상태 단일 기준이다.
|
||||
|
||||
이 문서가 정한 상태만으로 실제 제품의 `PRODUCTION_READY`를 주장할 수 없다.
|
||||
제품 owner, backend/provider conformance와 세 browser promotion evidence가 모두
|
||||
별도 gate를 통과해야 한다.
|
||||
|
||||
## 2. 상태 체계
|
||||
|
||||
### 2.1 Primary current status
|
||||
|
||||
각 capability는 다음 다섯 상태 중 정확히 하나를 갖는다.
|
||||
|
||||
| 상태 | 의미 | 허용되는 주장 |
|
||||
| --- | --- | --- |
|
||||
| `COMPOSED` | production bootstrap 또는 설치된 feature 호출 경로에 concrete runtime이 연결돼 있다. | 저장소의 현재 제품 경로에서 실행된다. |
|
||||
| `AVAILABLE_NOT_COMPOSED` | port, policy와 reference runtime이 있으나 기본 production graph에서는 제거돼 있다. | opt-in 조합 후보가 존재한다. |
|
||||
| `DESIGNED_NOT_IMPLEMENTED` | 불변조건과 계약은 승인됐지만 해당 runtime 또는 필수 orchestration이 없다. | 설계/계약 backlog가 닫혔고 구현 backlog는 열려 있다. |
|
||||
| `NOT_SELECTED` | 가치, 비용, 보안과 운영 owner가 승인되지 않아 의도적으로 선택하지 않았다. | 누락이 아니라 미선택이다. |
|
||||
| `PLATFORM_LIMITED` | 브라우저 공통 보장이 불가능하거나 지원 범위가 제한된다. | capability probe와 fallback 안에서만 제공할 수 있다. |
|
||||
|
||||
`AVAILABLE_NOT_COMPOSED`를 `COMPOSED`로 표시하거나,
|
||||
`DESIGNED_NOT_IMPLEMENTED`를 테스트 fixture만으로 구현 완료 처리하지 않는다.
|
||||
`NOT_SELECTED` capability를 인접 runtime의 "미완성"으로 계산하지 않는다.
|
||||
|
||||
### 2.2 독립적인 canonical readiness 축
|
||||
|
||||
Primary status와 다음 네 canonical 축을 섞지 않는다. VD-15와 이 ledger를
|
||||
참조하는 운영 runbook도 축 이름과 literal을 정확히 이 표에 맞춘다.
|
||||
|
||||
| canonical 축 | 값 | 의미 |
|
||||
| --- | --- | --- |
|
||||
| `Selection` | `NOT_SELECTED`, `SELECTED`, `REMOVING` | 특정 제품이 capability를 채택했는지 여부 |
|
||||
| `TrafficAdmission` | `DISABLED`, `SHADOW`, `CANARY`, `ENABLED` | 조합된 runtime의 신규 작업 admission |
|
||||
| `RuntimeHealth` | `UNKNOWN`, `AVAILABLE`, `DEGRADED`, `UNAVAILABLE`, `INCOMPATIBLE` | 현재 runtime/provider 관측 상태 |
|
||||
| `PromotionEvidence` | `MISSING`, `PARTIAL`, `COMPLETE`, `EXPIRED` | 필요한 contract/provider/browser/operations 증거의 합성 결과 |
|
||||
|
||||
`PromotionEvidence`의 입력은 다음 component gate다. 이 값들은 새로운 readiness
|
||||
축이 아니라 합성 근거이며 evidence record와 함께 보존한다.
|
||||
|
||||
| component gate | 값 | 의미 |
|
||||
| --- | --- | --- |
|
||||
| contract | `MISSING`, `DRAFT`, `ACCEPTED` | frontend와 provider가 맞출 wire/behavior 계약 상태 |
|
||||
| provider | `NOT_REQUIRED`, `PENDING`, `CONFORMANT` | 실제 BFF, object storage, CDN 또는 hosting 증거 |
|
||||
| browser | `MISSING`, `PARTIAL`, `PROMOTABLE` | 승인 browser/device matrix의 native 증거 |
|
||||
| operations | `MISSING`, `DOCUMENTED`, `DRILLED` | 관측, kill switch, recovery와 rollback 실행 증거 |
|
||||
|
||||
projection은 다음처럼 고정한다.
|
||||
|
||||
- 필수 component artifact가 없으면 `MISSING`이다.
|
||||
- 유효한 일부 증거만 있거나 component가 terminal gate 전이면 `PARTIAL`이다.
|
||||
- contract가 `ACCEPTED`, provider가 `NOT_REQUIRED` 또는 `CONFORMANT`, browser가
|
||||
`PROMOTABLE`, operations가 `DRILLED`이고 모든 required artifact가 유효할 때만
|
||||
`COMPLETE`다.
|
||||
- 한 번 유효했던 required artifact가 정책의 freshness/expiry를 넘으면 다른
|
||||
component 값과 무관하게 `EXPIRED`다.
|
||||
|
||||
예를 들어 Image CDN reference runtime은
|
||||
`AVAILABLE_NOT_COMPOSED / Selection=NOT_SELECTED /
|
||||
TrafficAdmission=DISABLED / RuntimeHealth=UNKNOWN /
|
||||
PromotionEvidence=PARTIAL`이고 그 근거가
|
||||
`contract=ACCEPTED / provider=PENDING / browser=PARTIAL /
|
||||
operations=DOCUMENTED`일 수 있다. 이 행을 `COMPOSED`나
|
||||
`PRODUCTION_READY`로 줄여 쓰지 않는다.
|
||||
|
||||
### 2.3 가능한 구현 경로
|
||||
|
||||
다음은 제품이 아직 선택하지 않았고 reference source도 없는 capability가 거칠 수
|
||||
있는 **일반적인 경로 예시**다. 다섯 primary status를 선형 maturity로 정의하지
|
||||
않으며 모든 capability가 이 경로를 밟는 것도 아니다. 이미 reference runtime이
|
||||
있는 capability는 `AVAILABLE_NOT_COMPOSED`에서 시작할 수 있고, cross-browser
|
||||
의미가 불가능한 capability는 구현량과 무관하게 `PLATFORM_LIMITED`다.
|
||||
|
||||
```text
|
||||
NOT_SELECTED
|
||||
-> decision + owner + data classification
|
||||
-> DESIGNED_NOT_IMPLEMENTED
|
||||
-> implementation + deterministic evidence + removal evidence
|
||||
-> AVAILABLE_NOT_COMPOSED
|
||||
-> product policy + provider contract + bootstrap composition
|
||||
-> COMPOSED
|
||||
-> provider/browser/operations promotion gates
|
||||
-> product-local production approval
|
||||
```
|
||||
|
||||
`PLATFORM_LIMITED`는 위 흐름과 별도 제약이다. 지원 가능한 browser에서는
|
||||
지원 browser용 runtime 행을 별도 상태로 기록할 수 있지만, cross-browser 보장
|
||||
행의 primary status는 계속 `PLATFORM_LIMITED`다. 제품 계약은 지원 불가능한
|
||||
browser의 fallback을 동시에 선언해야 한다.
|
||||
|
||||
rollback은 상태를 거꾸로 가장하지 않는다. 신규 진입을 kill switch로 닫고,
|
||||
active operation을 drain 또는 abort하고, durable state를 정책대로 정리한 뒤
|
||||
composition과 production module을 제거한다.
|
||||
|
||||
## 3. 구현 책임 분류
|
||||
|
||||
남은 항목은 다음 네 분류 중 하나 이상을 갖는다.
|
||||
|
||||
| 분류 | owner | 설명 |
|
||||
| --- | --- | --- |
|
||||
| `COMMON_REQUIRED` | frontend platform | 제품 API 주소 없이도 구현할 수 있고 선택 capability의 안전성에 필수인 port, state machine, policy와 lifecycle |
|
||||
| `PRODUCT_COMPOSITION` | product/feature owner | dataset, account partition, UX, retention, quota priority, query/preset profile과 use-case facade |
|
||||
| `PROVIDER_CONTRACT` | backend/storage/CDN/infra owner | authorization, signing, server ledger, storage constraint, CDN preset와 conformance |
|
||||
| `OPTIONAL_CAPABILITY` | architecture + product approval | 필요성이 확인될 때 별도 threat model과 비용 승인을 거쳐 설치할 기능 |
|
||||
|
||||
`COMMON_REQUIRED`는 범용 mega-service를 뜻하지 않는다. 메커니즘은 공통이지만
|
||||
정책 값은 immutable composition snapshot으로 주입한다.
|
||||
|
||||
## 4. 현재 capability snapshot
|
||||
|
||||
### 4.1 File, Blob, picker와 download
|
||||
|
||||
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| File/Blob intake | `AVAILABLE_NOT_COMPOSED` | opaque file ref, transient vault, metadata normalization, byte/type/signature policy, bounded range read, closed-result stream | native chunk가 hard maximum을 넘지 않도록 재분할하는 ceiling과 제품 profile | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
|
||||
| native input picker | `AVAILABLE_NOT_COMPOSED` | keyboard/focus 가능한 input baseline, multiple, same-file reselection, dismissal outcome | 제품별 copy와 workflow | `PRODUCT_COMPOSITION` |
|
||||
| enhanced open picker | `AVAILABLE_NOT_COMPOSED` | user activation과 conditional enhancement | browser matrix와 native input fallback 유지 | `PRODUCT_COMPOSITION` |
|
||||
| directory selection | `NOT_SELECTED` | 없음 | bounded traversal, relative-path policy, symlink/entry ceiling | `OPTIONAL_CAPABILITY` |
|
||||
| persistent file handle | `NOT_SELECTED` | native handle은 transient vault 밖으로 나가지 않음 | permission recovery, handle registry, retention/logout | `OPTIONAL_CAPABILITY` |
|
||||
| drag/drop·paste·capture | `NOT_SELECTED` | 공통 file capture primitive 일부만 재사용 가능 | 별도 adapter와 접근 가능한 UX | `OPTIONAL_CAPABILITY` |
|
||||
| object URL preview lease | `AVAILABLE_NOT_COMPOSED` | receipt binding, active-content denylist, byte cap, lease/revoke | 제품이 preview를 선택할 때 safety probe와 함께 조합 | `PRODUCT_COMPOSITION` |
|
||||
| local preview decode-safety probe | `DESIGNED_NOT_IMPLEMENTED` | 현재 dimension/pixel/decoded-memory/animation preflight 없음 | object URL 발급 전 static header/decode budget 검증 | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
|
||||
| browser-managed download | `AVAILABLE_NOT_COMPOSED` | synchronous resolver/vault seam과 `BROWSER_HANDOFF` outcome을 saved와 구분 | concrete BFF issuer/strict response와 제품 open/share/save UX | `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` |
|
||||
| picker streaming save | `AVAILABLE_NOT_COMPOSED` | bounded stream, backpressure, integrity, close/abort truth | capability/size 기반 strategy selector | `COMMON_REQUIRED` |
|
||||
| bounded Blob download | `AVAILABLE_NOT_COMPOSED` | small generated artifact hard cap | browser별 상한과 server-generation fallback | `PRODUCT_COMPOSITION` |
|
||||
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | 현재 one-shot download와 명시적으로 분리 | Range/If-Range/206, validator, checkpoint, seek/truncate, final integrity | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` |
|
||||
| app-managed background download | `NOT_SELECTED` | browser-managed handoff만 존재 | 지원 browser의 progressive enhancement로만 평가 | `OPTIONAL_CAPABILITY` |
|
||||
| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | 장시간 worker/picker/file permission 유지가 공통 보장되지 않음 | browser-managed handoff 또는 explicit unsupported fallback | 플랫폼 제약 |
|
||||
|
||||
### 4.2 Query, Web Storage와 cross-context
|
||||
|
||||
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| TanStack Query memory cache | `COMPOSED` | concrete QueryClient, cancellation, stale UI, optimistic rollback, invalidate | session/account scope lifecycle, late-result fence, strict query policy/key codec | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
|
||||
| Local Storage registry | `COMPOSED` | 등록 key, closed codec/envelope, TTL, global hard cap, memory fallback | key별 cap, partition/logout, migration, explicit outcome, bounded sweep | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
|
||||
| Session Storage registry | `COMPOSED` | tab-scoped 등록 control record와 동일 codec | key별 cap과 explicit durability/outcome | `COMMON_REQUIRED` |
|
||||
| cross-tab invalidation | `COMPOSED` | invalidate-only, versioned envelope, duplicate/stale/gap 처리, BroadcastChannel→localStorage→local-only | account epoch, exact storage source, production coordinator browser E2E | `COMMON_REQUIRED` |
|
||||
| IndexedDB query persistence reference runtime | `DESIGNED_NOT_IMPLEMENTED` | persistence key는 disabled로 강제되고 persister source는 없음 | 승인 query만 dehydrate/hydrate하는 facade | 선택 시 `COMMON_REQUIRED` |
|
||||
| product query persistence | `NOT_SELECTED` | persist 대상 query, owner와 retention 승인이 없음 | reference runtime 구현 뒤 별도 opt-in | `OPTIONAL_CAPABILITY` |
|
||||
| durable cache namespace epoch | `DESIGNED_NOT_IMPLEMENTED` | release epoch만 존재 | persisted resurrection 방지 transaction ledger | persistence 선택 시 `COMMON_REQUIRED` |
|
||||
| offline mutation command queue | `NOT_SELECTED` | foreground optimistic mutation만 존재 | idempotent durable command/sync protocol | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` |
|
||||
| SSR hydration | `NOT_SELECTED` | 현재 client SPA | request-scoped QueryClient와 precedence | SSR 선택 시 `PRODUCT_COMPOSITION` |
|
||||
|
||||
### 4.3 IndexedDB, OPFS와 Cache Storage
|
||||
|
||||
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| generic IndexedDB runtime | `AVAILABLE_NOT_COMPOSED` | transaction-complete, CAS, idempotency, logical budget, TTL, lifecycle authority, additive DDL, resumable codec migration, blocked/versionchange | feature dataset repository/schema/codec/query와 production composition | `PRODUCT_COMPOSITION` |
|
||||
| OPFS byte runtime | `AVAILABLE_NOT_COMPOSED` | DedicatedWorker SyncAccessHandle, async fallback, Web Locks, hash tree, IDB journal saga, budget/GC/reconcile | 제품 namespace/dataset policy와 production composition | `PRODUCT_COMPOSITION` |
|
||||
| OPFS real readiness preflight | `DESIGNED_NOT_IMPLEMENTED` | 없음; API property probe와 별도 native conformance test만 존재 | worker/lock/journal/small write-read-delete-cleanup을 한 readiness operation으로 검증 | `COMMON_REQUIRED` |
|
||||
| OPFS physical/journal forward migration | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 v1 layout/journal과 reconciliation만 존재 | copy-on-write generation, checkpoint, publish authority와 N-1 rollback | `COMMON_REQUIRED` |
|
||||
| public static Cache release runtime | `AVAILABLE_NOT_COMPOSED` | same-origin public GET, exact Vary/URL/type/size/digest, stage/activate/previous rollback | 제품 release/hosting policy와 production composition | `PRODUCT_COMPOSITION` |
|
||||
| bounded Cache inspect/cleanup | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 ownership 검사는 지키지만 cache-count scan은 unbounded | policy/epoch-bound cursor, count/deadline과 partial-success resume | `COMMON_REQUIRED` |
|
||||
| Cache control/prefix forward migration | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 v1 control/prefix parser와 release primitive만 존재 | 새 schema candidate, verify/activate, N-1 retain과 bounded cleanup | `COMMON_REQUIRED` |
|
||||
| cross-store quota lifecycle | `DESIGNED_NOT_IMPLEMENTED` | store별 logical budget과 StorageManager signal은 존재 | write admission, pressure hysteresis, GC priority, one retry, scheduled maintenance | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
|
||||
| Service Worker offline fetch | `NOT_SELECTED` | Cache Storage runtime은 window에서도 독립 사용 가능 | registration, install/waiting/activation, client drain, navigation strategy | `OPTIONAL_CAPABILITY` |
|
||||
| private response cache | `NOT_SELECTED` | 현재 public cache가 명시적으로 거절 | 별도 partition/encryption 오해 방지/retention threat model | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` |
|
||||
| sparse Range cache | `NOT_SELECTED` | `Range` request와 206 response를 거절 | validator-bound sparse segment merge | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` |
|
||||
|
||||
### 4.4 Presigned transfer, upload와 Image CDN
|
||||
|
||||
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| presigned capability | `AVAILABLE_NOT_COMPOSED` | fixed endpoint provider, strict binding, in-memory single-use vault, safe data-plane fetch | explicit download wire version, browser-handoff provider, actual signer conformance | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` |
|
||||
| multipart/resumable upload | `AVAILABLE_NOT_COMPOSED` | part hash/retry, IDB checkpoint, server reconcile, cross-tab cancel, complete/abort | pause, checkpoint inventory/retention sweep, unsupported lock decision와 실제 server/session provider | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` |
|
||||
| one-shot streaming download | `AVAILABLE_NOT_COMPOSED` | bounded whole-object stream, length/media/integrity, picker/Blob/handoff delivery | strategy selector와 Range capability 분리 | `COMMON_REQUIRED` |
|
||||
| top-level transfer composition | `DESIGNED_NOT_IMPLEMENTED` | 개별 factory와 dispose는 존재 | strict config, readiness, atomic account teardown, drain, kill switch | `COMMON_REQUIRED` |
|
||||
| Image CDN verification engine | `AVAILABLE_NOT_COMPOSED` | opaque asset/preset, signed descriptor verification, responsive candidate, static metadata/decode budget | 제품 preset/presentation policy 조합과 실제 provider/private delivery E2E | `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` |
|
||||
| image descriptor HTTP provider | `DESIGNED_NOT_IMPLEMENTED` | caller가 decoded descriptor를 직접 제공 | fixed BFF endpoint, bounded schema, refresh single-flight, expiry/logout fence | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` |
|
||||
| safe image presentation primitive | `DESIGNED_NOT_IMPLEMENTED` | descriptor 결과만 제공 | URL 재조립 없는 picture/source/img projection | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
|
||||
| app-managed background upload | `NOT_SELECTED` | checkpoint 기반 foreground resume만 존재 | worker lifetime/staging/permission 모델 별도 설계 | `OPTIONAL_CAPABILITY` |
|
||||
| cross-browser app-managed background upload guarantee | `PLATFORM_LIMITED` | page/worker lifetime과 local source permission이 공통 보장되지 않음 | foreground resume 또는 explicit unsupported fallback | 플랫폼 제약 |
|
||||
|
||||
## 5. 중요한 경계
|
||||
|
||||
### 5.1 같은 이름처럼 보이지만 다른 capability
|
||||
|
||||
- streaming download는 메모리 상한을 지키며 **이번 응답을 끝까지** 저장한다.
|
||||
Range resumable download는 새로운 요청에서 validator와 destination offset을
|
||||
검증해 **이전 partial state를 이어 간다**.
|
||||
- multipart resume는 upload session protocol이다. Background upload는 page
|
||||
lifecycle이 끝난 뒤에도 실행 주체가 살아 있다는 별도 보장이다.
|
||||
- Cache Storage release runtime은 public response를 검증·활성화한다. Service
|
||||
Worker는 navigation/fetch interception과 controlled-client lifecycle을 소유한다.
|
||||
- generic IndexedDB runtime은 query persistence가 아니다. Query persistence는
|
||||
query classification, dehydration, scope epoch와 restore precedence를 추가로
|
||||
요구한다.
|
||||
- browser-managed handoff는 브라우저에 전달했다는 결과다. application이
|
||||
저장 완료, 진행률 또는 background retry를 증명한 결과가 아니다.
|
||||
- Image CDN engine은 descriptor를 검증한다. BFF descriptor 발급, 실제 CDN,
|
||||
`<picture>` UX를 자동으로 제공하지 않는다.
|
||||
|
||||
### 5.2 정책과 도메인
|
||||
|
||||
byte ceiling, retry 상한, schema version, state transition과 fail-closed fallback은
|
||||
공통 메커니즘이다. 다음 값은 도메인 코드가 아니라 **제품 composition policy**다.
|
||||
|
||||
- 어떤 file purpose와 MIME/signature profile을 허용하는가
|
||||
- 어떤 query/dataset을 어느 account partition에 얼마나 오래 저장하는가
|
||||
- quota pressure에서 무엇을 먼저 제거하는가
|
||||
- 어떤 upload purpose와 CDN preset을 설치하는가
|
||||
- save/open/share와 conflict/recovery UX를 어떻게 보여 주는가
|
||||
|
||||
업무 entity와 권한 결과는 backend/domain이 소유한다. frontend policy는 이를
|
||||
추측하거나 대체하지 않는다.
|
||||
|
||||
## 6. External authority·backend·provider 계약
|
||||
|
||||
server/session/CDN 경계를 넘는 capability는 해당되는 external 계약 없이 실제
|
||||
제품에 조합하지 않는다. local-only/reconstructable dataset에 backend를
|
||||
일괄 요구하지 않는다.
|
||||
|
||||
| 경계 | 맞춰야 하는 owner/authority/provider 계약 |
|
||||
| --- | --- |
|
||||
| File upload | Web/BFF authorization, upload session API, file server 또는 object-storage data plane, quarantine/scanner/promotion |
|
||||
| Presigned URL | BFF signer, cloud object storage, CORS/CSP, method/header/length/checksum/expiry 강제 |
|
||||
| Multipart resume | server session ledger, idempotency, authoritative part status, completion receipt와 orphan janitor |
|
||||
| Range download | immutable object generation 또는 strong validator, exact Range/If-Range semantics, full-object digest |
|
||||
| account cache scope | frontend common runtime은 scope snapshot 검증, local generation/fence/teardown을 소유한다. product composition은 account/tenant 의미를 opaque partition policy에 mapping하고, auth/session owner는 sign-in/revoke/switch 사실을 제공한다. backend-issued epoch를 선택한 경우에만 그것이 wire 계약이다. |
|
||||
| offline mutation | idempotency key, entity revision/ETag, cursor/delta, conflict/merge protocol |
|
||||
| Image CDN | BFF descriptor endpoint, asset revision/preset registry, signing key rotation, CDN cache/CORS/CSP/no-store |
|
||||
| eviction recovery | server-authoritative projection의 재구성 또는 all-marker-loss 구분이 필요한 제품에만 re-sync cursor/opaque installation epoch 계약 |
|
||||
|
||||
브라우저 native `File`, `FileSystemHandle`, IndexedDB physical store, OPFS path,
|
||||
Cache name과 local checkpoint revision은 backend wire 계약이 아니다.
|
||||
|
||||
## 7. 설계 우선 work package
|
||||
|
||||
### WP-01. Scope-safe client cache
|
||||
|
||||
- session/account/release scope snapshot과 generation
|
||||
- old QueryClient cancel, fence, clear, dispose와 remount
|
||||
- late-result rejection
|
||||
- strict query registry/key codec와 per-query ceiling
|
||||
- Web Storage per-key policy, partition/logout/migration/outcome
|
||||
- production coordinator까지 연결한 multi-page browser evidence
|
||||
|
||||
Exit: account A의 cache, storage event와 늦은 async result가 account B runtime에
|
||||
관측되거나 기록될 수 없음을 deterministic fault와 native browser test로 증명한다.
|
||||
|
||||
### WP-02. Range resumable download
|
||||
|
||||
- 별도 `ResumableDownloadPort`
|
||||
- validator-bound checkpoint와 non-authorizing persistence
|
||||
- 200/206/412/416 state machine
|
||||
- seek/truncate 또는 OPFS staging destination
|
||||
- capability renewal와 final whole-object integrity
|
||||
- browser strategy selector와 fail-closed fallback
|
||||
|
||||
Exit: crash, capability expiry, object replacement, malformed Content-Range,
|
||||
destination mismatch와 integrity failure에서 corrupt saved outcome이 0건이다.
|
||||
|
||||
### WP-03. Origin storage lifecycle
|
||||
|
||||
- StorageManager signal + actual quota failure 기반 pressure controller
|
||||
- policy-owned eviction priority와 hysteresis
|
||||
- bounded maintenance cursor/deadline
|
||||
- IDB/OPFS/Cache forward migration과 N-1 rollback
|
||||
- OPFS native preflight와 clear/eviction recovery
|
||||
- local preview bounded header parser/decode probe, pixel/decoded-byte/animation ceiling과
|
||||
object URL 발급 전 fail-closed rejection
|
||||
|
||||
Exit: quota/migration/crash fault에서 unbounded scan, destructive auto-reset 또는
|
||||
cross-scope read 없이 read-only/online-only/recovery outcome으로 닫힌다. hostile,
|
||||
oversize 또는 animated preview fixture는 object URL 발급 전에 거절되고 decode
|
||||
resource와 lease가 남지 않는다.
|
||||
|
||||
### WP-04. Transfer operational composition
|
||||
|
||||
- strict config schema와 protocol/version registry
|
||||
- file/presigned/upload/download/image runtime atomic assembly
|
||||
- readiness, kill switch, active-operation drain과 idempotent close
|
||||
- logout/account switch fence
|
||||
- checkpoint inventory/retention owner와 safe observations
|
||||
- frontend provider contract harness
|
||||
|
||||
Exit: partially configured runtime이 시작되지 않고, teardown 뒤 capability나
|
||||
late refresh가 새 scope에서 재사용되지 않는다.
|
||||
|
||||
### WP-05. Image descriptor delivery
|
||||
|
||||
- fixed BFF provider와 bounded closed decoder
|
||||
- descriptor refresh single-flight와 expiry budget
|
||||
- logout/account/runtime-generation fence
|
||||
- static safe picture projection
|
||||
- actual private/public CDN conformance and browser evidence
|
||||
|
||||
Exit: caller-provided URL/transform이 DOM에 도달하지 않고, 만료·회전·logout·decode
|
||||
failure가 placeholder 또는 closed failure로 복구된다.
|
||||
|
||||
### WP-06. Optional capability decisions
|
||||
|
||||
directory/persistent handle, Query persistence, offline mutation, Service Worker,
|
||||
private/range cache와 app-managed background upload/download는 각각 독립 ADR,
|
||||
threat model, owner, budget과 removal plan을 승인한 뒤에만 시작한다.
|
||||
|
||||
## 8. 문서 우선 gate
|
||||
|
||||
runtime 구현을 시작하기 전에 해당 work package 문서에 다음이 모두 있어야 한다.
|
||||
|
||||
- current/target status와 out-of-scope
|
||||
- application port와 adapter/provider owner
|
||||
- immutable policy/config schema와 implementation ceiling
|
||||
- state machine, concurrency와 cancellation owner
|
||||
- durable record 분류, scope, TTL, purge와 migration
|
||||
- backend/provider wire version과 compatibility
|
||||
- browser capability matrix와 fallback
|
||||
- observability allowlist와 금지 값
|
||||
- rollout, kill switch, rollback과 removal
|
||||
- deterministic, contract, native browser, fault와 operational drill
|
||||
- 완료 조건과 promotion evidence 위치
|
||||
|
||||
문서가 없는 편의 API, fallback, persistence field 또는 retry owner를 구현 중에
|
||||
추가하지 않는다. 새 요구는 ledger와 해당 ADR을 먼저 변경한다.
|
||||
|
||||
## 9. 구현 및 promotion 순서
|
||||
|
||||
```text
|
||||
ledger/ADR accepted
|
||||
-> port + closed policy/schema
|
||||
-> deterministic fake/contract harness
|
||||
-> reference runtime + negative boundary gate
|
||||
-> fault/migration/removal evidence
|
||||
-> AVAILABLE_NOT_COMPOSED
|
||||
-> product owner + 필요한 external provider/config 선택
|
||||
-> bootstrap composition behind kill switch
|
||||
-> COMPOSED + TrafficAdmission=DISABLED
|
||||
-> native Chromium/Firefox/WebKit + device drill
|
||||
-> provider/browser/operations promotion gates
|
||||
-> TrafficAdmission=CANARY/ENABLED
|
||||
-> project-local production promotion
|
||||
```
|
||||
|
||||
추천 구현 순서는 WP-01 → WP-02 → WP-03 → WP-04 → WP-05다. WP-02와 WP-03의
|
||||
seekable/staging 정책, WP-04와 WP-05의 lifecycle/config 계약은 설계 단계에서
|
||||
서로 검토하되 한 변경에서 모든 runtime을 동시에 조합하지 않는다.
|
||||
|
||||
## 10. 공통 완료 기준
|
||||
|
||||
- [ ] 모든 capability가 이 문서의 primary status 하나를 가진다.
|
||||
- [ ] `AVAILABLE_NOT_COMPOSED` source가 기본 production module inventory에 없다.
|
||||
- [ ] `COMPOSED` capability는 bootstrap부터 실제 consumer까지 호출 증거가 있다.
|
||||
- [ ] account/session 전환이 broadcast delivery나 브라우저 종료에 의존하지 않는다.
|
||||
- [ ] byte, record, queue, candidate, retry, deadline과 scan에 hard ceiling이 있다.
|
||||
- [ ] durable state는 schema/codec/scope/epoch/retention/migration을 함께 선언한다.
|
||||
- [ ] raw URL, query, signed header, file name/path, account ID, storage value,
|
||||
digest/ETag/receipt가 diagnostics나 telemetry에 노출되지 않는다.
|
||||
- [ ] backend/provider contract는 fake, emulator와 실제 provider에 재사용 가능한
|
||||
conformance suite를 가진다.
|
||||
- [ ] Chromium/Firefox/WebKit과 승인 device fallback 증거가 보존된다.
|
||||
- [ ] kill switch, N-1 rollback, recovery와 optional runtime removal drill이
|
||||
통과한다.
|
||||
- [ ] 외부 증거가 없는 항목을 `PRODUCTION_READY`로 표시하지 않는다.
|
||||
|
||||
## 11. 상세 문서
|
||||
|
||||
- [Browser file and origin storage](./browser-file-and-origin-storage.md)
|
||||
- [Client cache and storage](./client-cache-and-storage.md)
|
||||
- [Presigned transfer and Image CDN](./presigned-transfer-and-image-cdn.md)
|
||||
- [Server file capability infrastructure](./server-file-capability-infrastructure.md)
|
||||
- [VD-11 Browser file and origin-storage](./decisions/VD-11-browser-file-and-origin-storage.md)
|
||||
- [VD-12 Presigned transfer and Image CDN](./decisions/VD-12-presigned-transfer-and-image-cdn.md)
|
||||
- [VD-13 Client cache scope and persistence](./decisions/VD-13-client-cache-scope-and-persistence.md)
|
||||
- [VD-14 Resumable download and background download](./decisions/VD-14-resumable-download-and-background-transfer.md)
|
||||
- [VD-15 Origin storage lifecycle and migration](./decisions/VD-15-origin-storage-lifecycle-and-migration.md)
|
||||
- [VD-16 Browser transfer composition and image delivery](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md)
|
||||
- [Browser file/storage recovery](../operations/browser-file-storage-recovery.md)
|
||||
- [Client cache/storage recovery](../operations/client-cache-and-storage-recovery.md)
|
||||
- [Browser transfer recovery](../operations/browser-transfer-recovery.md)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
# VD-01: TypeScript 7과 ESLint 10의 점진적 전환 도구
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-typescript-tooling-foundation`
|
||||
|
||||
## 배경
|
||||
|
||||
저장소는 TypeScript `7.0.2`와 ESLint `10.8.0`을 고정하고 있다. 첫 전환
|
||||
브랜치는 compiler를 변경하거나 production source를 일괄 변환하지 않고
|
||||
JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다.
|
||||
|
||||
결정 시점의 package peer contract는 다음과 같다.
|
||||
|
||||
- `typescript-eslint@8.65.0`과 canary는 TypeScript `<6.1.0`을 요구한다.
|
||||
- `eslint-plugin-jsx-a11y@6.10.2`는 ESLint `<=9`를 요구한다.
|
||||
- `eslint-plugin-react-hooks@7.1.1`은 ESLint 10을 지원한다.
|
||||
- Babel 8 ESLint parser는 ESLint 10을 지원하고 Node `>=24.11.0`을 요구한다.
|
||||
|
||||
호환되지 않는 peer dependency를 강제 설치하면 lockfile 검증은 통과하더라도
|
||||
지원되지 않는 parser와 rule 조합을 플랫폼 계약으로 만들게 된다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. TypeScript `7.0.2`와 ESLint `10.8.0`을 유지한다.
|
||||
2. TypeScript/TSX의 ESLint syntax parsing에는
|
||||
`@babel/eslint-parser`와 TypeScript/JSX syntax plugin을 사용한다.
|
||||
3. TypeScript의 이름 해석, unused 진단과 type semantics는 `tsc`가 소유한다.
|
||||
Babel parser가 TypeScript scope manager를 제공하지 않으므로 TS 파일의
|
||||
core `no-undef`와 `no-unused-vars`는 끄고 분리된 app/node/test TypeScript
|
||||
project를 필수 게이트로 실행한다.
|
||||
4. React Hook 규칙은 호환되는 `eslint-plugin-react-hooks`로 즉시 적용한다.
|
||||
5. JSX 접근성은 현재의 semantic component contract, Testing Library,
|
||||
axe 기반 cross-browser gate와 수동 검토 계약이 계속 담당한다. 호환되지 않는
|
||||
`eslint-plugin-jsx-a11y`는 설치하지 않는다.
|
||||
6. Babel 8의 지원 범위에 맞춰 Node engine 하한을 `24.11.0`으로 명시한다.
|
||||
7. production source의 대량 rename은 이 결정에 포함하지 않는다.
|
||||
|
||||
## 적용 후 상태 (2026-07-27)
|
||||
|
||||
후속 migration에서 production source, 비-fixture tests, Node scripts와 지원되는
|
||||
tool config를 모두 TS/TSX로 전환했다. `allowJs`는 껐고 runtime/source 영역의
|
||||
JavaScript 재유입은 architecture gate가 거절한다. Node scripts는 pinned Node
|
||||
24에서 `.ts`로 직접 실행되며 NodeNext, `verbatimModuleSyntax`와
|
||||
`erasableSyntaxOnly`로 별도 typecheck한다. `tests/fixtures/**`도 TS/TSX
|
||||
architecture/security/type negative input으로 전환했다. 이는 7번 결정의 범위를
|
||||
변경한 것이 아니라 그 기반 위에서 완료한 별도 후속 작업이다.
|
||||
|
||||
## 검증
|
||||
|
||||
- `check:types`는 app, Node scripts/config, tests project를 모두 검사한다.
|
||||
- TS invalid-call, invalid port, discriminated-union fixture는 실패해야 한다.
|
||||
- ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다.
|
||||
- registry scanner는 TS registry의 required field, uniqueness와 reference를
|
||||
검증한다.
|
||||
- browser security gate는 TSX의 금지된 raw HTML fixture를 거절한다.
|
||||
|
||||
## 후속 검토와 제거
|
||||
|
||||
`typescript-eslint`가 TypeScript 7을, JSX 접근성 plugin이 ESLint 10을 공식
|
||||
지원하면 별도 dependency 브랜치에서 peer metadata와 전체 negative fixture를
|
||||
재검증한다. 교체할 때는 Babel parser package와 TS 전용 ESLint override를
|
||||
함께 제거한다. compiler downgrade나 `--force` 설치는 이 ADR의 rollback
|
||||
방법이 아니다.
|
||||
@@ -0,0 +1,58 @@
|
||||
# VD-03: React Router Data Mode와 서버 상태 소유권
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime`
|
||||
|
||||
## 배경
|
||||
|
||||
기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한
|
||||
route registry에 params/search schema, loading/error surface, access, title,
|
||||
navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이
|
||||
어긋날 수 있었다.
|
||||
|
||||
이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가
|
||||
소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지
|
||||
않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할
|
||||
필요가 있다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. 고정된 React Router `7.18.1`의 `createBrowserRouter`와 `RouterProvider`를
|
||||
사용하는 Data Mode를 기본값으로 채택한다.
|
||||
2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다.
|
||||
3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서
|
||||
route 목록을 다시 열거하지 않는다.
|
||||
4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical
|
||||
URL을 생성한다.
|
||||
5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면
|
||||
application input 또는 query adapter 한 경로를 호출한다.
|
||||
6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack
|
||||
Query가 계속 소유한다.
|
||||
7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는
|
||||
route/feature boundary가 소유한다.
|
||||
8. Framework Mode, SSR, static generation과 router version upgrade는 별도
|
||||
dependency/architecture 브랜치에서 결정한다.
|
||||
|
||||
## 검증
|
||||
|
||||
- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와
|
||||
registry gate가 모두 거절한다.
|
||||
- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry
|
||||
fixture로 검증한다.
|
||||
- params/search parse/build round-trip, canonical redirect, 최대 redirect hop,
|
||||
access rejection, title/focus와 boundary reset을 unit/component test로 검증한다.
|
||||
- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를
|
||||
build/release 검증기가 확인한다.
|
||||
- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만
|
||||
reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다.
|
||||
|
||||
## 결과와 rollback
|
||||
|
||||
Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다.
|
||||
이 구분을 지키면 React Router를 교체해도 application input과 output port는
|
||||
유지된다.
|
||||
|
||||
rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure
|
||||
surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset
|
||||
cache의 purge는 저장소 rollback 범위에 포함하지 않는다.
|
||||
@@ -0,0 +1,55 @@
|
||||
# VD-04: Native form controller와 local facade
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-form-page-platform`
|
||||
|
||||
## 배경
|
||||
|
||||
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
|
||||
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
|
||||
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
|
||||
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. RP-06은 React native form event와 controlled value를 사용하는 local
|
||||
`useAppForm` facade를 기본 엔진으로 채택한다.
|
||||
2. Zod presentation schema, application command mapper와 domain invariant는 서로
|
||||
다른 소유물로 유지한다.
|
||||
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
|
||||
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
|
||||
4. 422 details는 승인된 `path`와 `code`만 HTTP 경계에서 투영한다. backend
|
||||
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
|
||||
error로 이동한다.
|
||||
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
|
||||
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
|
||||
dirty 상태를 해제한다.
|
||||
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`는
|
||||
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
|
||||
|
||||
## React Hook Form 도입 조건
|
||||
|
||||
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
|
||||
React Hook Form과 Zod resolver를 평가한다.
|
||||
|
||||
- 동적 field array와 중첩 object를 함께 다루는 복합 form
|
||||
- field 단위 비동기 validation 취소와 의존 validation
|
||||
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
|
||||
- uncontrolled input 또는 vendor extension이 필요한 경우
|
||||
|
||||
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
|
||||
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
|
||||
|
||||
## 검증과 rollback
|
||||
|
||||
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
|
||||
reset, dirty guard와 focus를 component test로 검증한다.
|
||||
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
|
||||
- architecture gate가 template의 application/HTTP/query vendor import를
|
||||
거절한다.
|
||||
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
|
||||
|
||||
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
|
||||
application input과 outbound gateway 계약은 유지되며, form facade와 template
|
||||
commit은 독립적으로 되돌릴 수 있다.
|
||||
@@ -0,0 +1,57 @@
|
||||
# VD-05: Semantic icon facade와 native-first interaction
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-design-system-platform`
|
||||
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
|
||||
|
||||
## 배경
|
||||
|
||||
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
|
||||
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
|
||||
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
|
||||
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
|
||||
의존성과 중복 interaction 모델이 생긴다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
|
||||
고정한다.
|
||||
2. `lucide-react`의 static named import는
|
||||
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
|
||||
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
|
||||
노출한다. vendor component type, icon name, stroke API와 dynamic icon
|
||||
registry는 노출하지 않는다.
|
||||
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
|
||||
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
|
||||
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
|
||||
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
|
||||
Tabs는 manual/automatic activation, Drawer는 modal/background
|
||||
비활성화/focus restore 계약을 가진다.
|
||||
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
|
||||
collision, nested overlay, virtualized collection 또는 복합 select 요구를
|
||||
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
|
||||
평가한다.
|
||||
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
|
||||
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
|
||||
주장하지 않는다.
|
||||
|
||||
## 경계와 검증
|
||||
|
||||
- 제품 코드는 `presentation/design-system/index`만 import한다.
|
||||
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
|
||||
undefined token과 tooltip-only required information fixture를 거절한다.
|
||||
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
|
||||
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
|
||||
검증한다.
|
||||
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
|
||||
restore, gallery keyboard interaction과 axe를 검증한다.
|
||||
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
|
||||
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
|
||||
|
||||
## Rollback
|
||||
|
||||
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
|
||||
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
|
||||
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
|
||||
도입해도 public props와 interaction test를 유지한다.
|
||||
@@ -0,0 +1,96 @@
|
||||
# VD-06: Intl과 typed local message catalog
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract`
|
||||
- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때
|
||||
|
||||
## 배경
|
||||
|
||||
공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와
|
||||
JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로
|
||||
가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패
|
||||
정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차,
|
||||
복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본
|
||||
번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`,
|
||||
`ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다.
|
||||
2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke
|
||||
catalog는 `satisfies Record<MessageKey, string>`으로 compile-time parity를
|
||||
강제한다.
|
||||
3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를
|
||||
선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가
|
||||
거절한다.
|
||||
4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은
|
||||
language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은
|
||||
raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다.
|
||||
5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`는
|
||||
RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지
|
||||
않는다.
|
||||
6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지
|
||||
않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid
|
||||
date/number/timezone은 `—`를 반환하고 throw하지 않는다.
|
||||
7. locale state는 React inbound concern이다. `LocaleProvider`가 copy,
|
||||
formatter와 `<html lang/dir>`을 제공하며 application/domain은 미리 번역된
|
||||
문자열 대신 의미 값과 timestamp를 반환한다.
|
||||
8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지
|
||||
않는다. transport/application failure kind를 등록된 사용자 message key로
|
||||
매핑한 뒤 presentation이 해석한다.
|
||||
9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를
|
||||
둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만
|
||||
사용한다.
|
||||
10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면
|
||||
`presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와
|
||||
design-system public prop으로 노출하지 않는다.
|
||||
|
||||
## 실행 경계
|
||||
|
||||
```text
|
||||
route/form/failure 의미 값
|
||||
-> presentation message key
|
||||
-> LocaleProvider
|
||||
-> typed catalog / Intl formatter
|
||||
-> text node와 accessible name
|
||||
```
|
||||
|
||||
- canonical catalog: `src/presentation/i18n/catalog.ts`
|
||||
- feature contribution: `src/features/*/contracts/*-message-catalog.ts`를
|
||||
`src/features/installed-feature-messages.ts`에서 조립
|
||||
- key/보간/fallback/alias: `message-contract.ts`
|
||||
- locale-safe value formatting: `formatters.ts`
|
||||
- React composition과 document metadata: `locale-provider.tsx`
|
||||
- public entry: `src/presentation/i18n/index.ts`
|
||||
|
||||
## 검증
|
||||
|
||||
- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal,
|
||||
backend message JSX 렌더링과 raw HTML 사용을 검사한다.
|
||||
- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다.
|
||||
- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다.
|
||||
- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/
|
||||
list/plural/select의 결정성을 검증한다.
|
||||
- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination,
|
||||
Drawer semantics를 검증한다.
|
||||
- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를
|
||||
Chromium, Firefox, WebKit project에서 실행한다.
|
||||
|
||||
## 한계와 재검토 조건
|
||||
|
||||
현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합,
|
||||
메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중
|
||||
하나가 확인되면 별도 ADR로 엔진을 재평가한다.
|
||||
|
||||
- 세 개 이상의 실제 승인 locale과 번역 담당 workflow
|
||||
- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy
|
||||
- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동
|
||||
- SSR locale negotiation과 hydration 일치가 필요한 rendering mode
|
||||
|
||||
## Rollback
|
||||
|
||||
`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로
|
||||
되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`,
|
||||
`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative
|
||||
fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다.
|
||||
@@ -0,0 +1,131 @@
|
||||
# VD-07: Diagnostics와 telemetry exporter 경계
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-diagnostics-telemetry-runtime`
|
||||
- 재검토: 실제 운영 sink, consent가 필요한 analytics 또는 분산 tracing provider가
|
||||
선정될 때
|
||||
|
||||
## 배경
|
||||
|
||||
기존 telemetry registry와 best-effort HTTP queue는 있었지만 운영 진단 record와
|
||||
semantic event의 책임이 하나의 telemetry port에 섞여 있었다. boot, HTTP,
|
||||
cache, storage, route와 release failure의 선언도 실제 production producer와
|
||||
완전히 연결되지 않았다. 이 상태에서는 retry attempt마다 같은 사건을 발행하거나
|
||||
raw URL, query, request body와 오류 객체가 queue에 들어갈 위험이 있다.
|
||||
|
||||
반면 skeleton 단계에는 실제 관측 vendor, endpoint의 운영 보안 정책, analytics
|
||||
consent와 보존 기간이 결정되지 않았다. 특정 SDK를 기본 번들에 설치하는 것은
|
||||
vendor 결정 전에는 안전한 기본값이 아니다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. level 기반 운영 진단은 `DiagnosticsPort`, registry 기반 semantic event는
|
||||
`TelemetryPort`로 분리한다. application은 두 port의 concrete adapter나
|
||||
exporter SDK를 알지 못한다.
|
||||
2. diagnostics의 level, event ID와 context key는 닫힌 registry/allowlist다.
|
||||
telemetry도 event별 required/optional attribute와 value policy를 적용한다.
|
||||
등록되지 않은 event·context·고카디널리티 값은 전송하지 않는다.
|
||||
3. 기본 diagnostics adapter는 bounded in-memory evidence이고 telemetry는
|
||||
설정이 없으면 true no-op이다. endpoint가 있을 때만 bounded oldest-drop
|
||||
queue와 best-effort HTTP sink를 사용한다.
|
||||
4. raw path/URL/query/body/response/storage value, credential, cookie, email,
|
||||
stack과 오류 객체 전체는 context에 넣지 않는다. route ID, operation ID,
|
||||
correlation ID, release ID, error kind, status/attempt/duration bucket만
|
||||
허용한다.
|
||||
5. HTTP logical execution은 success, retry recovery, terminal failure 또는
|
||||
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
|
||||
`api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만
|
||||
정확히 한 번 발행한다.
|
||||
5-1. V2 client와 V3 contract executor는 각각 자신의 logical execution에 대해
|
||||
이 규칙을 만족한다. V3에서는 execution site가 `HttpExecutionObservation`
|
||||
typed record 하나만 만들고, composition root의
|
||||
`createHttpObservationProjector`가 유일한 projection authority다. observation은
|
||||
arbitrary context map이 아니며 projector는 `route_id`, `operation_id`,
|
||||
`operation`, `outcome`, `error_kind`, `http_status_group`,
|
||||
`attempt_count_bucket`, `duration_bucket`만 사용한다. raw attempt count,
|
||||
duration, status, URL, intent, key, input identity와 내부 `terminalReason`은
|
||||
sink로 나가지 않는다. effect certainty가 운영상 필요해지면 `effect_certainty`
|
||||
key와 닫힌 value policy를 contract·fixture·이 ADR에 동시에 추가한 뒤에만
|
||||
전달한다.
|
||||
5-2. caller cancellation과 scope fence는 API failure가 아니다. diagnostics는 한
|
||||
번 남기고 `api.request.failed`는 발행하지 않는다.
|
||||
5-3. `routeId`는 installed operation-executor 경계의 필수 입력이다. feature
|
||||
gateway가 소유한 low-cardinality route identity를 URL에서 재구성하지 않는다.
|
||||
6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`,
|
||||
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
|
||||
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
|
||||
7. queue full, invalid event/context, serialization과 sink failure는 제한된
|
||||
reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를
|
||||
발행하지 않는 nonrecursive 경계다.
|
||||
7-1. telemetry adapter lifecycle은 `ACTIVE | DISPOSED` 둘뿐이다. `dispose()`는
|
||||
한 번만 전이하고 `pagehide` listener 제거, queue 비우기, scheduled callback
|
||||
generation 무효화, in-flight sink `AbortController` abort를 모두 수행한다.
|
||||
dispose 뒤 `emit()`은 no-op이고 새 flush는 스케줄되지 않으며, abort를 무시한
|
||||
sink가 늦게 settle해도 post-dispose delivery state를 갱신하거나 재스케줄하지
|
||||
못한다. 종료 중 drop telemetry를 재귀적으로 발행하지 않는다.
|
||||
7-2. `flush()`는 active delivery promise를 join한다. 이미 진행 중인 flush가
|
||||
있으면 같은 promise를 반환하므로 `await flush()`는 실제 settle을 뜻한다.
|
||||
7-3. runtime `infrastructure.dispose()`는 diagnostics/state dependency를 파괴하기
|
||||
전에 `telemetry.dispose()`를 먼저 호출한다.
|
||||
7-4. queue/entry capacity는 construction-time 계약이다. `Number.isSafeInteger`가
|
||||
아니거나 1 미만이거나 문서화된 ceiling(각각 `MAX_TELEMETRY_QUEUE`,
|
||||
`MAX_DIAGNOSTIC_ENTRIES` = 10,000)을 넘으면 `TypeError`로 거절한다. NaN/Infinity가
|
||||
조용히 eviction을 비활성화하는 경로를 남기지 않는다.
|
||||
8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition,
|
||||
storage/cache fallback과 React error surface를 바꾸지 않는다.
|
||||
9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로
|
||||
만들며 untrusted error message, stack과 support 입력을 serialize하지 않는다.
|
||||
10. 실제 error reporter, RUM, analytics나 tracing SDK는 같은 port 뒤의 외부
|
||||
adapter로만 추가한다. SDK type과 event API를 application/feature/presentation
|
||||
public contract에 노출하지 않는다.
|
||||
|
||||
## 실행 경계
|
||||
|
||||
```text
|
||||
route/application/HTTP/cache/storage/bootstrap
|
||||
-> typed DiagnosticsPort 또는 TelemetryPort
|
||||
-> registry + allowlist + value policy
|
||||
-> bounded memory/no-op 또는 best-effort HTTP adapter
|
||||
-> 프로젝트가 선택한 외부 sink
|
||||
```
|
||||
|
||||
- diagnostics contract: `src/contracts/diagnostics.ts`
|
||||
- telemetry contract: `src/contracts/telemetry.ts`
|
||||
- application ports: `src/application/ports/diagnostics-port.ts`,
|
||||
`telemetry-port.ts`
|
||||
- bounded diagnostics: `src/adapters/diagnostics/bounded-diagnostics.ts`
|
||||
- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.ts`
|
||||
- composition: `src/bootstrap/runtime-adapters.ts`
|
||||
|
||||
## 검증
|
||||
|
||||
- `check:diagnostics`는 모든 registry event에 production producer가 있는지와
|
||||
source의 direct console/sensitive context 우회를 검사한다.
|
||||
- negative source fixture는 direct console, unknown event와 raw context를 실제로
|
||||
거절하며 TypeScript fixture는 잘못된 level/event ID를 거절한다.
|
||||
- unit test는 allowlist, hostile/circular error, bounded diagnostics, no-op,
|
||||
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
|
||||
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
|
||||
횟수, route/operation/correlation context와 요청 값 비노출을 검증한다.
|
||||
- `tests/integration/http-execution-v3-observability.test.ts`는 V3 terminal
|
||||
outcome이 실제로 closed allowlist를 통과하는지, terminal non-abort failure가
|
||||
`api.request.failed`를 정확히 한 번 발행하는지, cancellation/scope fence가
|
||||
발행하지 않는지, feature route ID가 executor 경계까지 보존되는지, sink 예외가
|
||||
HTTP 결과를 바꾸지 못하는지를 검증한다.
|
||||
- cache/storage/release/application/runtime test는 각 production wiring과
|
||||
diagnostics failure isolation을 검증한다.
|
||||
|
||||
## 한계와 재검토 조건
|
||||
|
||||
기본 adapter는 운영 log 검색, source map 연계, session replay, distributed span,
|
||||
analytics consent, sampling budget과 장기 보존을 제공하지 않는다. 실제 sink를
|
||||
선정할 때 데이터 처리 지역, 보존 기간, consent, CSP, source map 접근 제어,
|
||||
sampling과 비용 상한을 별도 결정해야 한다.
|
||||
|
||||
## Rollback
|
||||
|
||||
telemetry exporter는 runtime 설정을 끄거나 adapter wiring을 `noOpTelemetry`로
|
||||
바꾸어 독립적으로 제거할 수 있다. 이때도 `DiagnosticsPort`, registry,
|
||||
redaction/value policy, producer-count와 negative fixture는 유지한다. 외부 SDK
|
||||
문제로 application producer와 안전 계약을 함께 되돌리지 않는다.
|
||||
@@ -0,0 +1,71 @@
|
||||
# VD-08: 개발용 Storybook과 로컬 시각 회귀 증적
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-test-registry-evidence-hardening`
|
||||
- 재검토: 제품이 cloud visual review, 다중 OS baseline 또는 별도 디자인 시스템
|
||||
배포를 요구할 때
|
||||
|
||||
## 배경
|
||||
|
||||
`/examples/ui`와 `/examples/states`는 실제 application composition 안에서 공통
|
||||
UI와 상태 표면을 보여 주지만, primitive를 격리해 interaction과 접근성을 검증하는
|
||||
workshop은 아니었다. 실패 시 screenshot도 디버깅 증거일 뿐 의도된 UI 기준선과
|
||||
현재 렌더의 차이를 차단하지 못했다.
|
||||
|
||||
외부 visual review 서비스, 별도 Storybook 배포와 브랜드별 baseline은 아직
|
||||
선정되지 않았다. 이 결정을 기다리며 UI 회귀 검증을 비워 두거나 production
|
||||
application bundle에 workshop runtime을 포함하는 것 모두 적절하지 않다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. Storybook은 development dependency와 별도 static artifact로만 사용한다.
|
||||
production entry와 application `dist`에는 Storybook runtime, story 또는
|
||||
테스트 selector를 포함하지 않는다.
|
||||
2. story는 public design-system entry를 소비하고 실제 locale, theme, session,
|
||||
router와 query provider 계약으로 렌더한다. production component를 복제한
|
||||
story 전용 구현을 만들지 않는다.
|
||||
3. interaction과 story-level axe는 Playwright가 정적 Storybook을 대상으로
|
||||
실행한다. unexpected console, page error와 request failure는 테스트 실패다.
|
||||
4. 시각 회귀는 production `build` 후 `preview`를 대상으로 pinned Chromium,
|
||||
locale, color scheme과 viewport에서 `toHaveScreenshot()`으로 실행한다.
|
||||
5. 최초 기준선은 wide shell, compact pseudo-locale drawer, dark design-system
|
||||
gallery, loading/empty/error/access 상태 표면을 포함한다.
|
||||
6. animation과 caret만 결정적으로 비활성화한다. `html`, `body`, `main` 또는
|
||||
application 전체를 mask해 false PASS를 만드는 설정은 gate가 거절한다.
|
||||
7. snapshot 갱신은 `test:visual:update`라는 명시적 명령으로 분리하고 PNG diff를
|
||||
review한다. 일반 `test:visual`은 승인 기준선을 변경하지 않는다.
|
||||
8. local visual threshold는 작은 rasterization 차이만 허용하며 실제 layout,
|
||||
copy, theme 또는 상태 변화가 숨겨지도록 확대하지 않는다.
|
||||
9. `/examples/*`는 production composition smoke로 유지하고 Storybook story의
|
||||
대체물로 취급하지 않는다. 반대로 Storybook만 통과해 application shell
|
||||
integration을 완료 처리하지 않는다.
|
||||
10. cloud service가 선정되지 않아도 repository-local workshop, interaction,
|
||||
a11y와 visual baseline gate는 완전하게 실행 가능해야 한다.
|
||||
|
||||
## 실행과 증적
|
||||
|
||||
- workshop config: `.storybook/main.ts`, `.storybook/preview.tsx`
|
||||
- story: `src/presentation/design-system/design-system.stories.tsx`
|
||||
- interaction/a11y: `tests/storybook/workshop.spec.ts`
|
||||
- visual: `tests/visual/platform.visual.spec.ts`
|
||||
- baseline: `tests/visual/__snapshots__/`
|
||||
- production E2E: `playwright.config.ts`
|
||||
- local dev E2E: `playwright.dev.config.ts`
|
||||
- evidence policy: `scripts/check-test-evidence.ts`
|
||||
|
||||
CI는 JUnit, HTML report, failure trace/screenshot, visual baseline 존재 여부와
|
||||
금지된 full-screen mask/무소유 skip fixture를 함께 검사한다.
|
||||
|
||||
## 한계와 재검토 조건
|
||||
|
||||
로컬 기준선은 실제 iOS/Android 기기, 여러 운영체제의 font rasterization,
|
||||
디자인 승인 workflow와 다중 브랜드를 증명하지 않는다. 이를 요구하면 동일
|
||||
public component와 story를 입력으로 사용하는 외부 review adapter를 추가하되,
|
||||
provider 결과가 없을 때 임의 PASS로 대체하지 않는다.
|
||||
|
||||
## Rollback
|
||||
|
||||
Storybook dependency/config, workshop test와 visual config/baseline은 production
|
||||
runtime 변경 없이 독립적으로 제거할 수 있다. rollback 후에도 `/examples/*`,
|
||||
component behavior, automated accessibility와 built-dist E2E는 유지한다.
|
||||
@@ -0,0 +1,113 @@
|
||||
# VD-09: 공급망 inventory, license, vulnerability, SBOM과 provenance
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-supply-chain-verification`
|
||||
- 재검토: 조직 vulnerability scanner, signing/attestation provider와 dependency
|
||||
exception 승인 체계가 선정될 때
|
||||
|
||||
## 배경
|
||||
|
||||
기존 release script는 `package.json`의 직접 dependency 이름과 버전, lockfile
|
||||
전체 digest, `dist` checksum만 기록했다. 전이 dependency, 패키지별 integrity와
|
||||
license, 실제 baseline diff가 없었고 `highRiskUnreviewed: []`는 계산 결과가 아닌
|
||||
고정값이었다. secret scan도 `src`와 `dist`만 검사해 config, scripts, test와
|
||||
generated release metadata를 놓쳤다.
|
||||
|
||||
반면 저장소에는 조직이 선택한 vulnerability source, severity exception 승인자,
|
||||
signing identity와 attestation 저장소가 없다. 외부 provider가 없는 상태를 빈
|
||||
finding과 서명 성공으로 표현하면 local 검증과 release promotion을 혼동한다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. `pnpm-lock.yaml`의 모든 `packages` row와 `pnpm list --depth Infinity`의 실제
|
||||
graph를 결합해 직접/전이, production/development, required/platform-optional,
|
||||
version, SHA-512 SRI, license와 dependency edge를 기록한다.
|
||||
2. inventory row 수는 lockfile package row 수와 같아야 한다. 누락된 전이
|
||||
dependency, malformed integrity와 non-optional `NOASSERTION`은 local gate를
|
||||
실패시킨다.
|
||||
3. license는 설치된 package manifest에서 읽고 closed allow/deny policy로
|
||||
검사한다. 현재 OS에 materialize되지 않은 platform optional만
|
||||
`NOASSERTION`과 그 이유를 명시적으로 허용한다.
|
||||
4. 승인 dependency baseline과 approval digest를 보존하고 현재 lock inventory와
|
||||
actual add/remove/change/upgrade diff를 계산한다. 새 direct production
|
||||
dependency는 owner와 서로 다른 reviewer, reason과 rollback evidence가
|
||||
필요하다.
|
||||
5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest,
|
||||
SRI, license와 dependency edge가 inventory와 일치해야 한다.
|
||||
6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과
|
||||
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부
|
||||
provenance를 대신할 수 없다.
|
||||
7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와
|
||||
모든 local verification evidence를 한 번만 archive한다. Candidate manifest는
|
||||
raw lock bytes SHA-256, dependency inventory lock digest와 manifest
|
||||
`lockfileSha256`의 exact 일치를 요구한다.
|
||||
8. 두 provider job은 동일 archive를 각각 받아 외부 command를 실행한다.
|
||||
Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은
|
||||
`{name: "dist", digest.sha256}`를 포함한다. 두 문서 모두 strict schema와
|
||||
별도 trust path/key ID로 선택한 실제 Ed25519 public key 서명을 통과해야 한다.
|
||||
9. provider report나 trusted key가 없으면 local
|
||||
inventory/license/SBOM/coherence는 `PASS`, promotion은
|
||||
`FAIL_UNVERIFIED`다. 저장소 generator나 fixture가 production용 빈 finding 또는
|
||||
signed PASS를 만들지 않는다.
|
||||
10. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`와
|
||||
generated release metadata를 검사한다. allowlist는 test path에만 허용하며
|
||||
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
|
||||
rule, path, line과 fingerprint만 남긴다.
|
||||
11. `SOURCE_DATE_EPOCH`를 지원하고 supply-chain timestamp도 build manifest의
|
||||
동일 epoch에 결합한다. 같은 source/lock/config의 production build를
|
||||
두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다.
|
||||
|
||||
## 실행 경계와 증적
|
||||
|
||||
```text
|
||||
package.json + frozen pnpm-lock.yaml + installed graph
|
||||
-> deterministic dependency inventory
|
||||
-> license policy + approved actual baseline diff
|
||||
-> CycloneDX SBOM
|
||||
|
||||
source/config/lock + production dist
|
||||
-> local provenance statement
|
||||
-> immutable archive + candidate manifest + distSha256
|
||||
-> external vulnerability provider + external provenance provider
|
||||
-> read-only local revalidation + signature/digest verification
|
||||
-> promotion PASS | FAIL_UNVERIFIED
|
||||
```
|
||||
|
||||
- policy: `config/security/`
|
||||
- generator: `scripts/generate-supply-chain.ts`
|
||||
- coherence: `scripts/verify-supply-chain-artifacts.ts`
|
||||
- secret scan: `scripts/security-scan.ts`
|
||||
- reproducibility: `scripts/verify-reproducible-build.ts`
|
||||
- inventory: `artifacts/release/dependency-inventory.json`
|
||||
- SBOM/provenance: `artifacts/release/sbom.cdx.json`,
|
||||
`artifacts/release/provenance.json`
|
||||
- local/promotion status:
|
||||
`artifacts/security/supply-chain-verification.json`
|
||||
|
||||
## 검증
|
||||
|
||||
- 현재 lockfile의 561개 package row와 inventory row가 양방향 일치한다.
|
||||
- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk
|
||||
self approval, denied license, critical vulnerability와 만료 exception,
|
||||
provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다.
|
||||
- isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을
|
||||
통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를
|
||||
덮어쓰거나 generator를 provider 모드로 재실행하지 않는다.
|
||||
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
|
||||
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
|
||||
scoped test allowlist만 통과한다.
|
||||
|
||||
## 한계와 재검토 조건
|
||||
|
||||
로컬 manifest license는 법률 검토가 아니며 vulnerability report도 외부 scanner가
|
||||
제공한 데이터의 최신성 자체를 보증하지 않는다. 실제 프로젝트는 provider 버전,
|
||||
database freshness, network outage, exception 승인 조직, signing identity,
|
||||
attestation transparency/retention과 비밀 관리를 결정해야 한다.
|
||||
|
||||
## Rollback
|
||||
|
||||
외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시
|
||||
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
|
||||
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
|
||||
promotion을 PASS로 변경하지 않는다.
|
||||
@@ -0,0 +1,113 @@
|
||||
# VD-10: 선택형 frontend capability recipe
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-07-26
|
||||
- 적용 브랜치: `feature-frontend-optional-adapter-recipes`
|
||||
- 현재 선택 capability: 없음
|
||||
- 재검토: 실제 프로젝트가 realtime, offline, PWA, file, generated API,
|
||||
feature flag, worker, multi-tab, browser permission, client workflow,
|
||||
large-data UI 또는 production analytics/error provider를 요구할 때
|
||||
|
||||
## 배경
|
||||
|
||||
서버의 PostgreSQL, MongoDB, Redis, Kafka, MinIO 같은 기술을 브라우저가 직접
|
||||
소비하지는 않는다. 프론트의 변화 지점은 권한 있는 HTTP/BFF, push event,
|
||||
offline persistence, file protocol, browser runtime, 사용자 동의와 UI 성능
|
||||
경계다. 이 capability를 “언젠가 필요할 수 있다”는 이유로 모두 설치하면 초기
|
||||
bundle, 공급망, runtime config, 보안 표면과 업데이트 비용만 늘어난다.
|
||||
|
||||
반대로 문서에 이름만 적으면 실제 프로젝트에서 port 위치, cancellation,
|
||||
fallback, fake와 제거 기준을 다시 설계해야 한다. 따라서 production runtime에
|
||||
아무것도 설치하지 않되 검증 가능한 vendor-neutral recipe를 저장소 밖이 아닌
|
||||
별도 opt-in 경계에 유지한다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. `config/recipes/frontend-capability-recipes.json`이 12개 recipe의 선택 기준,
|
||||
금지 조건, port/fake, failure matrix, lifecycle cleanup, owner,
|
||||
security/privacy, gzip budget, fallback, server-state 정책과 제거 절차의
|
||||
machine-readable SSOT다.
|
||||
2. 현재 실제 소비 요구와 project owner가 없으므로 12개 상태는 모두
|
||||
`RECIPE_AVAILABLE`이며 `INSTALLED`가 아니다. production runtime dependency와
|
||||
composition registration은 0개다.
|
||||
3. `recipes/frontend-capabilities`의 TypeScript port와 fake/unavailable adapter는
|
||||
실행 가능한 설계 예시다. `src` 또는 production entry가 이 디렉터리를 import할
|
||||
수 없다.
|
||||
4. 프로젝트가 capability를 선택하면 필요한 최소 contract를
|
||||
application-owned output port 또는 presentation facade로 이동하고, concrete
|
||||
vendor adapter는 local adapter 경계에 둔다. recipe 디렉터리를 production에서
|
||||
그대로 import하지 않는다.
|
||||
5. WebSocket/SSE처럼 연결은 outbound이고 수신 event는 inbound인 양방향 기술도
|
||||
한 종류의 “adapter”로 뭉개지 않는다. 연결·credential·reconnect 정책과
|
||||
event validation·input invocation을 분리한다.
|
||||
6. Zustand/Redux Toolkit/state machine은 실제 cross-page client-only workflow가
|
||||
확인된 경우 하나만 선택한다. URL, component state, Context, TanStack Query가
|
||||
이미 소유한 상태를 복제하지 않는다.
|
||||
7. browser credential은 localStorage, URL, recipe store, telemetry 또는
|
||||
BroadcastChannel에 넣지 않는다. 브라우저가 database/object store에 직접
|
||||
접속하는 recipe도 금지한다.
|
||||
8. lifecycle이 있는 capability는 unsubscribe, close, unregister, dispose,
|
||||
cancel 또는 `AbortSignal`을 계약과 contract test에 포함해야 한다.
|
||||
9. 선택하지 않은 recipe sentinel이나 reference runtime source, vendor
|
||||
dependency가 production bundle에 들어가면 gate를 실패시킨다.
|
||||
`referenceRuntime`이 있는 recipe는 catalog `sourceRoots` 전체를 별도의
|
||||
production-mode synthetic entry로 deterministic하게 bundle/minify하되
|
||||
tree-shaking을 끄고, 모든 출력의 gzip 합계가 recipe budget을 넘으면
|
||||
production composition 여부와 무관하게 실패시킨다. 2026-07-28 최초 실측에서
|
||||
`offline-indexeddb`가 32,930 bytes였으므로 측정 없이 선언됐던 8,000 bytes를
|
||||
약 9% headroom의 36,000 bytes로 교정했으며 다른 budget은 자동 인상하지
|
||||
않는다.
|
||||
10. recipe 전체를 제거한 임시 worktree에서 base typecheck, architecture,
|
||||
unit/component/integration test와 production build가 통과해야 한다.
|
||||
|
||||
## 선택과 설치 절차
|
||||
|
||||
```text
|
||||
measured product/runtime need
|
||||
-> project owner + security/privacy classification
|
||||
-> recipe trigger/forbidden/fallback review
|
||||
-> VD-10 amendment with one selected capability
|
||||
-> application port or presentation facade copied into src
|
||||
-> one concrete adapter under local adapter boundary
|
||||
-> composition-only wiring
|
||||
-> contract/failure/cleanup/integration tests
|
||||
-> bundle + dependency baseline approval
|
||||
-> INSTALLED only after all evidence passes
|
||||
```
|
||||
|
||||
도입 커밋에는 owner, 선택 이유, 대안, gzip 차이, runtime config, browser support,
|
||||
failure UX, observability, rollback과 제거 명령을 기록한다. vendor가 필요한
|
||||
behavior를 fake만으로 확인하고 `INSTALLED`로 바꾸지 않는다.
|
||||
|
||||
## 증적
|
||||
|
||||
- catalog: `config/recipes/frontend-capability-recipes.json`
|
||||
- contracts/fakes: `recipes/frontend-capabilities`
|
||||
- 상세 runbook: `docs/architecture/optional-adapter-recipes.md`
|
||||
- file/IndexedDB/OPFS/Cache 심층 결정:
|
||||
`docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md`
|
||||
- browser data 상세 설계:
|
||||
`docs/architecture/browser-file-and-origin-storage.md`
|
||||
- realtime/Web Push/Polling 심층 설계와 결정:
|
||||
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`,
|
||||
`docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`
|
||||
- contract test: `tests/recipes/optional-capability-contracts.test.ts`
|
||||
- negative fixture:
|
||||
`tests/fixtures/optional-recipes/forbidden`
|
||||
- validation:
|
||||
`scripts/check-optional-recipes.ts`
|
||||
- removal:
|
||||
`scripts/test-optional-recipe-removal.ts`
|
||||
- evidence:
|
||||
`artifacts/quality/optional-recipes.json`,
|
||||
`artifacts/quality/optional-recipe-fixtures.json`,
|
||||
`artifacts/tests/optional-recipes.xml`,
|
||||
`artifacts/tests/optional-recipe-removal.xml`
|
||||
|
||||
## Rollback
|
||||
|
||||
현재 branch는 runtime dependency나 production composition을 바꾸지 않으므로
|
||||
recipe catalog, example과 gate를 함께 revert하면 RP-11 상태로 돌아간다. 실제
|
||||
프로젝트에서 선택한 capability는 그 capability의 port/adapter/composition/
|
||||
dependency commit만 revert한다. 여러 vendor 도입을 하나의 되돌릴 수 없는
|
||||
commit으로 묶지 않는다.
|
||||
@@ -0,0 +1,269 @@
|
||||
# VD-11: Browser file and origin-storage 경계
|
||||
|
||||
- 상태: Accepted — native reference runtime available, not composed
|
||||
- 결정일: 2026-07-27
|
||||
- reference runtime 상태: `AVAILABLE_NOT_COMPOSED`
|
||||
- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도)
|
||||
- 관련 결정: VD-10 optional capability recipes, VD-14, VD-15
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 재검토: 실제 제품이 file intake/delivery, durable offline data, large local
|
||||
binary 또는 public offline HTTP representation을 선택할 때
|
||||
|
||||
## 배경
|
||||
|
||||
File, Blob, picker, IndexedDB, OPFS와 Cache Storage는 모두 browser data를
|
||||
다루지만 같은 storage abstraction이 아니다.
|
||||
|
||||
- File/Blob은 transient byte container다.
|
||||
- picker는 user activation과 permission UX를 소유한다.
|
||||
- IndexedDB는 indexed structured record와 transaction을 제공한다.
|
||||
- OPFS는 origin-private large byte storage지만 query와 cross-API transaction이
|
||||
없다.
|
||||
- Cache Storage는 HTTP Request/Response map이며 freshness를 자동 관리하지 않는다.
|
||||
|
||||
이를 하나의 `StoragePort`나 `FileTransferPort`로 추상화하면 transaction complete,
|
||||
blocked/versionchange, object URL 수명, stream backpressure, quota, OPFS partial
|
||||
write, Cache의 인증 response 금지와 release activation이 사라진다.
|
||||
|
||||
기존 recipe는 metadata-only upload와 in-memory `Uint8Array` download를 보여 주는
|
||||
얕은 예시였다. 큰 파일과 production recovery protocol의 출발점으로는 부족했다.
|
||||
backend upload protocol을 browser file mechanism에 묶는 것 역시 선택하지 않은
|
||||
제품 capability를 암묵적으로 설치하므로 경계를 분리해야 한다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. 기존 동기식 `StoragePort`는 작은 public preference만 소유한다. IndexedDB,
|
||||
OPFS, Cache Storage를 backend enum 하나로 끼우지 않는다.
|
||||
2. native `File`, `Blob`, `FileList`, `FileSystemHandle`은 browser adapter의
|
||||
transient vault 안에 둔다. application은 opaque `LocalFileRef`, normalized
|
||||
metadata와 bounded `readRange()`만 본다.
|
||||
3. browser 파일 기능을 picker, file content, preview lease, download delivery로
|
||||
분리한다. backend upload는 `BrowserFileComposition`의 구성요소가 아니며,
|
||||
별도 선택 가능한 `ExampleQuarantinedUploadPort` 예시로만 둔다. user
|
||||
dismissal은 failure가 아닌 outcome이다.
|
||||
4. backend upload를 선택한 경우 file validation, authorization,
|
||||
malware/archive/active-content 검사와 quarantine은 client hint보다 항상
|
||||
authoritative하다.
|
||||
5. 큰 file/download/object는 stream 또는 bounded part로 처리한다. 전체
|
||||
`Uint8Array`, Blob, base64/Data URL은 승인된 hard cap 안의 small artifact에만
|
||||
사용한다. File/OPFS/Cache 및 recipe byte source는
|
||||
`AsyncIterable<Result<Uint8Array, ClosedFailure>>`로 실패를 닫고 raw native
|
||||
exception을 application으로 throw하지 않는다.
|
||||
6. download outcome은 browser handoff와 confirmed saved를 분리한다. anchor click을
|
||||
disk write 완료로 기록하지 않는다.
|
||||
7. IndexedDB는 feature-specific async repository adapter다. raw database,
|
||||
transaction callback, store/index/schema version을 application에 노출하지 않는다.
|
||||
8. DB DDL version과 record codec version을 분리한다. schema upgrade는 짧고
|
||||
additive하게, data migration은 resumable bounded batch로 수행한다.
|
||||
9. IndexedDB mutation은 request success가 아니라 transaction complete 이후에만
|
||||
성공이다. revision CAS와 idempotency key를 기본 계약으로 둔다.
|
||||
10. 모든 connection은 versionchange/forced-close를 처리하고 blocked/future-schema
|
||||
상태를 read-only 또는 online-only UX로 드러낸다. 자동 reload loop와 자동
|
||||
database deletion을 금지한다.
|
||||
11. OPFS는 큰 immutable bytes와 integrity manifest만 소유한다. logical metadata,
|
||||
query, generation과 journal commit authority는 IndexedDB가 소유한다.
|
||||
12. IndexedDB와 OPFS 사이의 비원자성은
|
||||
`PREPARING -> FILES_READY -> COMMITTED -> CLEANED` journal saga와 startup
|
||||
reconciliation으로 처리한다. `COMMITTED`만 사용자에게 보인다.
|
||||
13. OPFS sync access handle은 DedicatedWorker의 신규 staging/chunk file에만
|
||||
사용하고 항상 flush/close한다. committed file in-place overwrite와
|
||||
`readwrite-unsafe`를 금지한다.
|
||||
14. Cache Storage는 same-origin public GET representation 전용 platform-local
|
||||
facade다. auth, cookie-dependent, private, personal, no-store, opaque, 206,
|
||||
redirect response를 저장하지 않는다.
|
||||
15. Cache match는 query/Vary를 보존하고 `ignoreSearch`/`ignoreVary`를 금지한다.
|
||||
candidate 전체를 type/size/integrity 검증한 뒤에만 release를 활성화하며
|
||||
verified previous release를 rollback용으로 유지한다.
|
||||
16. Service Worker lifecycle과 Cache Storage ownership을 구분한다. unregister가
|
||||
cache 삭제를 의미하지 않으므로 owned-prefix cleanup migration을 별도로 둔다.
|
||||
17. IndexedDB, OPFS와 Cache Storage는 origin quota budget을 공유한다.
|
||||
`estimate()`는 rough signal이고 실제 `QuotaExceededError`를 authority로 둔다.
|
||||
18. credential 저장을 금지한다. same-origin client encryption을 XSS authorization
|
||||
boundary로 간주하지 않는다.
|
||||
19. fake는 계약 검증용이고 native production evidence를 대체하지 않는다.
|
||||
Chromium/Firefox/WebKit, multi-page, crash/fault, migration/rollback과 quota
|
||||
drill을 설치 capability의 promotion gate로 둔다.
|
||||
20. 공통 native adapter는 정책 주입형 reference runtime으로 제공하되 현재 제품
|
||||
owner와 dataset이 없으므로 bootstrap, installed feature, Service Worker
|
||||
registration과 runtime config에는 연결하지 않는다. catalog recipe
|
||||
availability는 `RECIPE_AVAILABLE`, reference runtime primary status는
|
||||
`AVAILABLE_NOT_COMPOSED`이며 product selection은 별도다.
|
||||
21. API lifecycle, transaction, bounded-memory, integrity와 recovery mechanism은
|
||||
공통 adapter가 소유한다. schema/codec/query, authority, classification,
|
||||
retention, quota priority와 cache/file allowlist는 dataset/use-case 정책으로
|
||||
주입한다.
|
||||
22. composition은 dataset별 opaque scope와 전체 storage policy를 검증해 깊은
|
||||
snapshot/freeze한다. 공통 runtime을 여러 dataset의 전역 mega-repository로
|
||||
구성하지 않는다.
|
||||
23. IndexedDB physical DB명은 registry-issued
|
||||
`authorityToken/namespaceToken/partitionToken`에서만 파생한다. readable
|
||||
namespace/business/account ID는 이름에 쓰지 않는다. immutable scope + full
|
||||
policy binding을 upgrade transaction, post-open과 maintenance에서 검증하고
|
||||
mismatch 또는 기존 DB의 missing binding은 fail-closed한다.
|
||||
24. IndexedDB는 codec `measureStoredBytes`, retention sidecar, dataset
|
||||
`usedBytes/receiptCount` budget을 mutation과 같은 transaction에서 갱신한다.
|
||||
TTL은 sweep 전에도 read/query에서 보이지 않으며 `UNTIL_SYNCED`는 confirmed
|
||||
record만 삭제 가능하다. lifecycle deletion은 composition authority의 opaque
|
||||
short-lived proof가 매 invocation 필요하고 proof는 검증 후 폐기한다.
|
||||
25. idempotency receipt retention은 최대 31일, receipt configured cap의 구현 절대
|
||||
상한은 1,000,000개다. codec migration은 old-writer drain proof와 revision
|
||||
fence가 필요하고 한 invocation은 최대 500 rows/30,000ms다.
|
||||
26. OPFS physical layout은
|
||||
`/ca-frontend-opfs-v1/authorities/<authority>/<namespace>/<partition>/...`이며
|
||||
세 path segment는 opaque token이다. IndexedDB journal은 logical namespace와
|
||||
physical scope를 양방향 binding하고 full policy fingerprint를 검증한다.
|
||||
27. Cache manifest는 정규화된 `expectedContentType`까지 digest에 binding한다.
|
||||
response의 정규화된 Content-Type이 정확히 일치하지 않으면 candidate activation을
|
||||
금지한다.
|
||||
28. optional 상태는 metadata만으로 주장하지 않는다. real-browser JUnit verifier,
|
||||
Vite source-module inventory, source boundary gate와 runtime removal gate를
|
||||
promotion evidence로 둔다.
|
||||
29. File selection/inspection/preview/download dataset policy는 composition-time
|
||||
registry가 소유한다. port caller는 정확히 등록된 `FilePolicyReference` 객체와
|
||||
limit reduction만 전달하며 같은 key/intention 문자열로 reference를 재구성해
|
||||
다른 profile을 선택할 수 없다. verification receipt는 exact profile과 file
|
||||
snapshot에 binding한다.
|
||||
30. `BROWSER_MANAGED_RESOURCE` download는 resource와 함께 서버 발급 capability
|
||||
receipt를 요구한다. synchronous resolver의 결과가 receipt/resource/media
|
||||
type/safe extension/server max/optional digest/expiry를 정확히 binding하지
|
||||
않으면 handoff하지 않는다. strategy와 integrity mode를 caller가 선택하지
|
||||
않는다.
|
||||
31. IndexedDB actual `StoredRecord`는
|
||||
`key/codecVersion/revision/payload`만 가지며 write time, synchronization,
|
||||
measured bytes와 eligibility는 retention sidecar에 분리한다. idempotency
|
||||
receipt와 governance binding/budget도 별도 store에 두고, full partition
|
||||
purge에는 등록된 모든 `lifecycleMetadataStores`를 포함하되 immutable
|
||||
governance identity는 유지한다. Cache cleanup retain set은 caller가 cache
|
||||
name이나 release registry ID로 제출하지 않고 verified active pointer와
|
||||
composition retention에서 계산한다. control JSON은 정확히 2 MiB
|
||||
(2,097,152 bytes) bounded stream으로만 decode한다.
|
||||
32. OPFS의 `LOGOUT`, `UNTIL_SYNCED`, `ACCOUNT_DELETION` policy maintenance는
|
||||
composition이 `requestMaintenanceAuthority` provider와
|
||||
`consumeMaintenanceAuthority` consumer를 모두 공급해야 한다. provider는
|
||||
exact reason/frozen scope/frozen policy에 묶인 최대 5분 proof를 매번 새로
|
||||
발급하고, consumer는 같은 binding과 expiry를 확인해 원자적으로 consume하여
|
||||
replay를 막는다. application caller는 proof를 전달할 수 없고 runtime은 이를
|
||||
저장·반환·관측하지 않는다.
|
||||
33. origin-wide pressure/write admission/GC, OPFS·Cache forward migration,
|
||||
OPFS real preflight, bounded Cache maintenance와 preview decode safety의
|
||||
후속 계약은 VD-15가 소유한다. 기존 store별 primitive를 그 coordinator의
|
||||
구현 증거로 사용하지 않는다.
|
||||
34. Service Worker lifecycle, directory/persistent handle과 private/sparse Range
|
||||
cache는 제품 선택 전 `NOT_SELECTED`인 별도 capability다. Range resumable
|
||||
download는 VD-14의 `DESIGNED_NOT_IMPLEMENTED` capability이며 public Cache
|
||||
runtime에 섞지 않는다.
|
||||
|
||||
## 계약과 증적
|
||||
|
||||
- 심층 계약:
|
||||
`recipes/frontend-capabilities/browser-file-storage-contracts.ts`
|
||||
- deterministic fake:
|
||||
`recipes/frontend-capabilities/browser-file-storage-fakes.ts`
|
||||
- contract test:
|
||||
`tests/recipes/browser-file-storage-contracts.test.ts`
|
||||
- selection SSOT:
|
||||
`config/recipes/frontend-capability-recipes.json`
|
||||
- 상세 설계:
|
||||
`docs/architecture/browser-file-and-origin-storage.md`
|
||||
- lifecycle/migration 결정:
|
||||
`docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md`
|
||||
- 운영 복구:
|
||||
`docs/operations/browser-file-storage-recovery.md`
|
||||
- native reference runtime:
|
||||
`src/adapters/browser-files/`, `src/adapters/storage/indexeddb/`,
|
||||
`src/adapters/storage/opfs/`, `src/adapters/cache-storage/`
|
||||
- real-browser conformance:
|
||||
`tests/browser-capabilities/`
|
||||
- browser evidence verifier:
|
||||
`scripts/verify-browser-capability-evidence.ts`
|
||||
- production module inventory:
|
||||
`artifacts/quality/vite-module-inventory.json`
|
||||
- boundary/removal evidence:
|
||||
`check:browser-file-storage-boundaries`,
|
||||
`test:browser-file-storage-removal`
|
||||
|
||||
recipe의 durable byte source도 단일 `Uint8Array` 또는 raw-throw stream 대신
|
||||
chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example은
|
||||
`ExampleBackendUploadComposition`으로 browser file composition과 분리되어 있다.
|
||||
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
|
||||
protocol에 맞게 contract를 더 좁힌다.
|
||||
|
||||
현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2,
|
||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
||||
promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두
|
||||
실행해야 한다. WebKit은 현재
|
||||
host의 필수 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는
|
||||
Chromium/Firefox 14개씩 총 28개만 통과했으므로
|
||||
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine
|
||||
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다.
|
||||
|
||||
## 선택 이후 필요한 구현
|
||||
|
||||
```text
|
||||
dataset + owner + classification + backend protocol
|
||||
-> VD-11 amendment
|
||||
-> feature-specific application ports
|
||||
-> adapter-private schema/codec/migrations
|
||||
-> native picker/file/download/IDB/OPFS/cache adapter 중 필요한 것만
|
||||
-> unavailable/read-only/online-only fallback
|
||||
-> deterministic fault + real browser contract tests
|
||||
-> diagnostics allowlist + recovery runbook drill
|
||||
-> canary + N-1 rollback evidence
|
||||
-> project catalog에서만 INSTALLED
|
||||
```
|
||||
|
||||
OPFS를 쓴다는 이유로 Service Worker를 설치하거나, Cache Storage를 쓴다는 이유로
|
||||
IndexedDB business repository를 만들지 않는다. 실제 capability 조합만 설치한다.
|
||||
|
||||
## 결과
|
||||
|
||||
장점:
|
||||
|
||||
- native API와 clean architecture 경계가 명확하다.
|
||||
- 대용량 memory blow-up과 거짓 download-complete 신호를 막는다.
|
||||
- IndexedDB migration/transaction과 OPFS crash recovery가 검증 가능하다.
|
||||
- auth/private cache poisoning을 fail-closed한다.
|
||||
- 기술별 fallback, kill switch와 제거 범위가 독립적이다.
|
||||
|
||||
비용:
|
||||
|
||||
- 하나의 generic adapter보다 port와 contract test 수가 많다.
|
||||
- native adapter 설치 시 worker, historical schema fixture, multi-page test와
|
||||
운영 drill이 필요하다.
|
||||
- offline user-authored data는 browser storage만으로 backup을 보장할 수 없어
|
||||
server sync 또는 export 제품 결정이 필요하다.
|
||||
|
||||
이 비용은 browser persistence의 실제 일관성·수명 차이를 숨기지 않기 위한
|
||||
의도적인 비용이다.
|
||||
|
||||
## Rollback
|
||||
|
||||
현재는 native reference runtime source가 있지만 production composition은 없다.
|
||||
catalog의 세 runtime은 `AVAILABLE_NOT_COMPOSED` /
|
||||
`productionComposition: false`이고 build module inventory에 runtime source가
|
||||
없어야 한다. `check:optional-recipes`가 이를 강제한다.
|
||||
완전 철회하려면 `src/application/ports/browser-file-storage`,
|
||||
`src/adapters/browser-files`, `src/adapters/browser-file-storage`,
|
||||
`src/adapters/storage/indexeddb`, `src/adapters/storage/opfs`,
|
||||
`src/adapters/cache-storage`와 전용 test를 제거하고 catalog의
|
||||
`referenceRuntime` metadata를 삭제한다.
|
||||
`test:browser-file-storage-removal`은 이 상태에서 base typecheck, architecture,
|
||||
test, build와 optional catalog가 유지되는지 검증한다.
|
||||
|
||||
제품에 composition한 이후 rollback은 다음 순서를 따른다.
|
||||
|
||||
1. 신규 write, worker activation과 cache candidate를 중지한다. 별도 upload
|
||||
workflow를 설치했다면 그 session도 독립적으로 중지한다.
|
||||
2. file ref/object URL/handle/connection/channel을 정리한다.
|
||||
3. offline read-write를 read-only 또는 online-only로 전환한다.
|
||||
4. N-1 bundle이 future schema를 destructive open 없이 감지하는지 확인한다.
|
||||
5. user-authored/unsynced data는 export/sync 확인 없이 purge하지 않는다.
|
||||
6. owned OPFS/cache namespace만 journal/manifest 기준으로 정리한다.
|
||||
7. adapter composition, runtime config와 dependency를 제거한다.
|
||||
|
||||
schema downgrade, blanket `deleteDatabase()`, `caches.keys()` 전체 삭제와 사용자
|
||||
filename 기반 OPFS 삭제는 rollback 수단으로 금지한다.
|
||||
@@ -0,0 +1,202 @@
|
||||
# VD-12: Presigned transfer, resumable upload와 Image CDN 경계
|
||||
|
||||
- 상태: Accepted — reference runtime available, not composed
|
||||
- 결정일: 2026-07-28
|
||||
- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도)
|
||||
- reference runtime 상태: `AVAILABLE_NOT_COMPOSED`
|
||||
- 관련 결정: VD-10, VD-11, VD-14, VD-16
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 재검토: 제품이 server file upload/download 또는 Image CDN delivery를 선택할 때
|
||||
|
||||
## 배경
|
||||
|
||||
Presigned URL은 URL 문자열이 아니라 짧은 수명의 bearer capability다.
|
||||
multipart/resumable upload는 단순 PUT 반복이 아니라 session, part identity,
|
||||
checksum, authoritative reconciliation, completion과 orphan cleanup protocol이다.
|
||||
streaming download는 전체 payload를 메모리에 올리지 않지만 response binding,
|
||||
truncation/overrun, destination commit과 integrity를 별도로 처리해야 한다.
|
||||
Image CDN URL도 arbitrary transform builder로 노출하면 cache poisoning, pixel/decode
|
||||
bomb, signed-query 유출과 source-fetch SSRF 경계가 사라진다.
|
||||
|
||||
이 네 capability를 범용 `HttpClient`나 `FileService` mega-port 하나로 합치면
|
||||
control plane authorization과 byte data plane, local browser lifecycle과 server
|
||||
authority가 섞인다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. BFF/Web API control plane과 object-storage/CDN data plane을 분리한다.
|
||||
2. 브라우저는 signing key, cloud 관리자 credential, bucket/container, raw object
|
||||
key 생성 규칙을 소유하지 않는다.
|
||||
3. application caller는 raw URL/query/signed headers를 전달하지 않는다.
|
||||
composition/provider가 발급한 exact capability만 adapter가 소비한다.
|
||||
4. presigned capability는 version, opaque identity, method, logical resource 또는
|
||||
session/part, exact URL, origin/path policy, byte/media/checksum 조건과 expiry를
|
||||
immutable하게 binding한다.
|
||||
5. signed URL은 bearer credential로 취급하고 persistence, checkpoint, telemetry,
|
||||
analytics, referrer와 raw exception에 넣지 않는다.
|
||||
6. data-plane fetch는 기본적으로 `credentials: omit`, `redirect: error`,
|
||||
`referrerPolicy: no-referrer`, `cache: no-store`를 사용한다. cross-origin은
|
||||
composition allowlist와 CORS/CSP 계약이 있을 때만 연다.
|
||||
7. client-side single-use 표시는 UX와 accidental replay를 줄이는 보조책이다.
|
||||
cross-tab/replay의 최종 authority는 server 또는 composition-owned atomic
|
||||
consumer다.
|
||||
8. streaming download는 response body를 closed-result stream으로 변환하고
|
||||
output chunk, total bytes, media type, encoding과 선택적 incremental integrity를
|
||||
검증한다. overrun/truncation/abort 시 native reader와 destination을 닫는다.
|
||||
9. `BROWSER_HANDOFF`와 destination close 이후의 `SAVED`를 계속 분리한다.
|
||||
10. Range resumable download는 별도 capability다. `206`, `Content-Range`,
|
||||
validator, destination seek/truncate와 final integrity 없이는 append resume를
|
||||
허용하지 않는다.
|
||||
11. upload 상위 계약은 server-authoritative session/status/part/complete/abort를
|
||||
소유하고 data-plane part executor는 capability 타입에 generic하다. 따라서
|
||||
S3-style presigned multipart와 BFF proxy part를 같은 application contract
|
||||
뒤에 둘 수 있지만 wire DTO를 공유하지 않는다.
|
||||
12. reference upload protocol literal은 `PRESIGNED_MULTIPART_V1`이다. 모든
|
||||
control-plane request/response, session과 checkpoint가 이를 exact하게
|
||||
포함하며 다른 값이나 누락을 거절한다.
|
||||
13. session은 exact source binding, total bytes, media type, part size/count,
|
||||
concurrency, checksum algorithm과 expiry를 묶는다. part number는 1부터
|
||||
연속적이고 offset/length/checksum/idempotency를 정확히 binding한다.
|
||||
14. control transport는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`의
|
||||
closed operation을 composition-owned fixed HTTPS endpoint map으로만
|
||||
실행한다. presigned 발급도 factory에 고정된 단일 BFF endpoint를 사용하며
|
||||
caller-provided URL을 받지 않는다.
|
||||
15. `requestBindingSha256`와 `uploadBindingSha256`는
|
||||
`RESUMABLE-UPLOAD-BINDING-V1` 및
|
||||
`RESUMABLE-UPLOAD-SESSION-BINDING-V1` canonical field sequence의 SHA-256이다.
|
||||
`UPLOAD_PART` capability binding은 exact
|
||||
`protocol: PRESIGNED_MULTIPART_V1`을 포함한다. BFF는 `sessionId`로 server
|
||||
session을 조회하고 snapshot으로 protocol, binding과 part plan을 재계산한다.
|
||||
client digest는 authorization이나 ownership 증명이 아니다.
|
||||
16. part memory는 `partSize × concurrency × copyFactor` hard ceiling으로 제한한다.
|
||||
retry는 같은 bytes/checksum/idempotency에만 허용한다.
|
||||
17. retryable network, 429와 모든 5xx는 bounded attempt/`Retry-After`/abortable
|
||||
backoff 안에서만 재시도한다. status의 404/410 또는
|
||||
`NOT_FOUND`/`EXPIRED`는 terminal로 보고 checkpoint를 CAS 제거한다.
|
||||
18. PUT 성공은 capability-bound status, receipt header,
|
||||
`expectedResponseByteLength`와 exact `Content-Length`를 검증하고 hard cap과
|
||||
deadline 안에서 response body를 끝까지 drain한 뒤에만 확정한다. 204는
|
||||
expected response bytes가 0일 때만 허용하며 `Content-Length` 부재를 0으로
|
||||
정규화한다.
|
||||
19. resume는 local checkpoint만 신뢰하지 않는다. server status를 다시 읽고
|
||||
완료 part의 local range digest와 server checksum/receipt를 대조한 뒤 missing
|
||||
part만 전송한다.
|
||||
20. checkpoint에는 opaque session/source binding과 reconciliation에 필요한
|
||||
protocol-defined SHA-256 file fingerprint, per-part checksum, bounded opaque
|
||||
non-authorizing part receipt token만 저장한다. 이 값도 account partition과
|
||||
retention을 적용하고 diagnostics/telemetry에는 내보내지 않는다. presigned
|
||||
URL, signed header, bearer token/capability, file name, path, account ID,
|
||||
raw provider ETag와 raw server error는 금지한다.
|
||||
21. cancel과 server abort를 분리한다. same-origin 다른 tab의 active upload는
|
||||
strict `RESUMABLE_UPLOAD_CANCEL_V1` BroadcastChannel 신호로 먼저 중단한 뒤
|
||||
per-key Web Lock 안에서 server abort/reconcile을 수행한다. 이 ephemeral
|
||||
신호는 opaque upload key만 운반하고 persistence하지 않으며 authority가
|
||||
아니다. channel이 없으면 abort caller의 bounded signal 아래 lock을 기다린다.
|
||||
complete/abort가 불명확하면 server reconcile 전까지 성공으로 기록하거나
|
||||
checkpoint를 파기하지 않는다.
|
||||
22. multipart complete는 ordered receipt 검증 뒤에도 `QUARANTINED`다. backend
|
||||
scan/CDR/promotion이 끝나기 전 available/public URL을 발급하지 않는다.
|
||||
application-facing 성공값은 state/resource/byte length/replay 여부만 노출하고
|
||||
session ID, request binding과 fingerprint를 제거한다.
|
||||
23. Image CDN application contract는 opaque asset reference와
|
||||
composition-registered named preset만 받는다. arbitrary source URL과 raw
|
||||
transform query는 금지한다.
|
||||
24. asset descriptor는 immutable revision, delivery class, safe raster media,
|
||||
natural dimensions, rendition dimensions/formats/URLs와 private expiry를 묶는다.
|
||||
25. CDN policy는 allowed HTTPS origin/path, preset width/DPR/format/quality/fit,
|
||||
output pixel/decoded-byte/encoded-byte/candidate/lifetime ceiling과
|
||||
cache/referrer policy를 소유한다.
|
||||
composition limit은 exported adapter implementation ceiling을 초과할 수
|
||||
없고 capability verification concurrency도 절대 상한 아래에서 제한한다.
|
||||
CDN origin은 composition이 명시한 application origin과 달라야 한다.
|
||||
`<img crossorigin="anonymous">`가 same-origin 요청에서는 cookie를 보낼 수
|
||||
있기 때문에 private URL의 credential omission을 probe에만 맡기지 않는다.
|
||||
26. private capability signature가 허용하는 값은 versioned preset binding ID다.
|
||||
CDN/BFF는 그 ID를 server-owned immutable preset registry에서 조회하고,
|
||||
요청의 width/height/DPR/fit/format/quality가 그 preset의 exact candidate인지
|
||||
재계산해 하나라도 다르면 거절한다. signed URL에 붙은 raw transform query나
|
||||
client 계산값은 authorization proof가 아니다.
|
||||
signing key policy는 bounded unique `acceptedKeyIds` overlap set이고 verifier
|
||||
registry가 모든 ID를 포함해야 한다. descriptor의 단일 key ID는 양쪽
|
||||
registry에 exact membership이 있어야 한다.
|
||||
27. browser probe는 native decode 전에 PNG/JPEG/WebP/AVIF header metadata와
|
||||
static-only container를 검사한다. 선언 dimensions, pixels와 decoded-byte
|
||||
budget을 넘거나 APNG/WebP animation, AVIF sequence/derived image,
|
||||
ambiguous/malformed container이면 decode 전에 거절한다.
|
||||
28. private signed delivery는 `PRIMARY_REQUIRED` probe를 강제하고
|
||||
`credentials: omit`, exact response URL과 실제 `Cache-Control: no-store`를
|
||||
검증한다. fetch/body/decode 전체에 하나의 timeout을 적용하고 abort/late
|
||||
completion에서 reader와 bitmap을 닫는다.
|
||||
29. SVG/HTML/data/blob/javascript와 unknown active media는 기본 거절한다.
|
||||
animation은 frame/decode budget이 승인된 별도 protocol 전에는 허용하지 않는다.
|
||||
30. responsive candidate는 한 source set에서 하나의 descriptor 종류만 사용하고,
|
||||
고유한 양수 width를 오름차순으로 반환한다. `sizes`는 registry-owned layout
|
||||
token에서 결정한다.
|
||||
31. public rendition은 immutable revision URL과 public immutable cache를 사용하고,
|
||||
private rendition은 short-lived capability와 필수 no-store를
|
||||
사용한다. 같은 URL의 content를 purge로 바꿔치기하지 않는다.
|
||||
32. Image CDN runtime `close()`는 terminal/idempotent다. runtime lifetime
|
||||
signal로 진행 중 verification/probe를 중단하고 accepted WeakMap을 새
|
||||
WeakMap으로 교체해 기존 reference를 즉시 revoke한다. 닫힌 runtime은
|
||||
재개하지 않고 새 composition으로 교체한다.
|
||||
33. 공통 runtime은 concrete browser mechanism과 policy validation을 제공하지만
|
||||
backend endpoint/vendor schema와 제품 asset/upload owner가 없으므로 bootstrap에
|
||||
조합하지 않는다.
|
||||
34. runtime source는 production module inventory와 removal gate로 기본 bundle에서
|
||||
제외됨을 증명한다.
|
||||
35. Range resume의 detailed state machine과 app-managed background의 플랫폼
|
||||
경계는 VD-14가 소유한다. VD-12의 whole-object streaming 구현을 그
|
||||
capability의 구현 증거로 사용하지 않는다.
|
||||
36. top-level transfer runtime, account-scoped teardown, upload pause/inventory,
|
||||
Image descriptor HTTP provider/refresh와 safe presentation projection은
|
||||
VD-16이 소유한다. 개별 runtime factory의 존재를 operational composition
|
||||
완료로 해석하지 않는다.
|
||||
|
||||
## Backend와 맞출 계약
|
||||
|
||||
- fixed BFF capability endpoint, closed session endpoint map과 runtime schema
|
||||
- `PRESIGNED_MULTIPART_V1` canonical binding, server-side session lookup,
|
||||
authorization/revocation
|
||||
- object storage CORS, allowed method/headers, exposed receipt/checksum headers
|
||||
- PUT 성공 status, receipt header, response byte length/body cap
|
||||
- session expiry, 404/410 terminal 의미, list/status pagination, idempotency와
|
||||
orphan cleanup
|
||||
- part/full-object checksum의 정확한 알고리즘·composite 의미
|
||||
- quarantine scan, promotion, status와 reject/delete lifecycle
|
||||
- CDN source registry, immutable asset revision, versioned named preset의 exact
|
||||
candidate 재계산과 query mismatch 거절
|
||||
- image signing key overlap 배포, signer 전환, capability/client drain과
|
||||
emergency revocation/forced rollout runbook
|
||||
- CDN `Content-Type`, static header metadata, dimensions/decoded-byte budget,
|
||||
private `no-store`, application과 분리된 CDN origin, cache key, `Vary`, CORS와 CSP
|
||||
|
||||
브라우저의 local file reference, native `File`/`Blob`, IndexedDB checkpoint physical
|
||||
schema, OPFS path, signed URL query와 cloud object key는 backend 공유 계약이 아니다.
|
||||
|
||||
## 선택하지 않은 대안
|
||||
|
||||
- application caller가 arbitrary presigned URL을 직접 전달
|
||||
- browser bundle에서 cloud signing
|
||||
- 범용 JSON `HttpClient`로 binary streaming/part protocol까지 처리
|
||||
- complete 응답을 scan 완료 또는 public availability로 간주
|
||||
- local checkpoint만 보고 upload complete
|
||||
- ETag를 무조건 MD5/SHA-256으로 해석
|
||||
- private signed image URL을 query cache나 persistence에 장기 저장
|
||||
- raw transform query로 CDN URL 조립
|
||||
- large download의 unbounded Blob fallback
|
||||
|
||||
## 증적
|
||||
|
||||
- application ports: `src/application/ports/browser-transfer/`
|
||||
- concrete adapters: `src/adapters/browser-transfer/`
|
||||
- unit/fault tests: `tests/unit/`
|
||||
- real browser cases: `tests/browser-capabilities/`
|
||||
- 상세 설계:
|
||||
`docs/architecture/presigned-transfer-and-image-cdn.md`
|
||||
- Range/background 결정:
|
||||
`docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md`
|
||||
- composition/Image provider 결정:
|
||||
`docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md`
|
||||
- 운영 복구:
|
||||
`docs/operations/browser-transfer-recovery.md`
|
||||
@@ -0,0 +1,880 @@
|
||||
# VD-13: Client cache scope, persistence와 탭 간 일관성 경계
|
||||
|
||||
- 상태: Accepted — staged implementation required
|
||||
- 결정일: 2026-07-28
|
||||
- 관련 결정: VD-10, VD-11
|
||||
- 상세 설계:
|
||||
`docs/architecture/client-cache-and-storage.md`
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 재검토:
|
||||
account/tenant switching, query persistence, SSR 또는 offline mutation을
|
||||
제품 capability로 선택할 때
|
||||
|
||||
## 1. 배경
|
||||
|
||||
TanStack Query memory cache, Web Storage, IndexedDB와 BroadcastChannel은 모두
|
||||
client state에 관여하지만 같은 authority, 수명과 commit point를 갖지 않는다.
|
||||
|
||||
- TanStack Query memory cache는 현재 JavaScript runtime의 server-state projection다.
|
||||
- `localStorage`와 `sessionStorage`는 작은 preference/control record를 위한
|
||||
동기식 browser storage다.
|
||||
- IndexedDB는 transaction, index와 durable structured record를 제공한다.
|
||||
- BroadcastChannel과 `storage` event는 같은 storage partition 안의 best-effort
|
||||
notification이다.
|
||||
- SSR dehydration과 browser persistence hydration은 서로 다른 source에서 생성된
|
||||
cache projection을 합치는 별도 protocol이다.
|
||||
|
||||
현재 skeleton은 memory QueryClient, 두 개의 등록 Web Storage key와
|
||||
invalidate-only cross-tab runtime을 production bootstrap에 조립한다. domain-neutral
|
||||
IndexedDB runtime은 source와 native contract test가 있지만 product dataset 없이
|
||||
bootstrap에서 제외돼 있다. IndexedDB query persister, durable namespace epoch,
|
||||
session/account-scoped QueryClient lifecycle과 SSR hydration은 아직 구현되지
|
||||
않았다.
|
||||
|
||||
이 차이를 숨긴 채 “client cache가 구현됐다”고 표현하면 다음 문제가 생긴다.
|
||||
|
||||
- logout 뒤 old account의 cache나 늦은 async result가 새 account 화면에 나타남
|
||||
- best-effort invalidation event를 authorization 또는 server commit으로 오인함
|
||||
- 여러 tab의 full cache snapshot이 서로 오래된 record를 다시 살림
|
||||
- browser persistence가 최신 SSR payload를 덮음
|
||||
- Web Storage memory fallback 성공과 durable write 성공을 구분하지 못함
|
||||
- query cache를 offline command repository처럼 사용해 unsynced user data를
|
||||
eviction으로 잃음
|
||||
|
||||
## 2. 표준 capability 상태
|
||||
|
||||
이 결정과 상세 설계는 다음 상태만 사용한다.
|
||||
|
||||
| 상태 | 의미 |
|
||||
| --- | --- |
|
||||
| `COMPOSED` | 구현·계약·test가 있고 production bootstrap이 실제 생성·소비한다. |
|
||||
| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 test가 있지만 production bootstrap에서 생성하지 않는다. |
|
||||
| `DESIGNED_NOT_IMPLEMENTED` | 경계와 invariant는 승인됐지만 실행 코드가 없다. |
|
||||
| `NOT_SELECTED` | 제품 요구·owner·policy가 승인되지 않아 설치 대상이 아니다. |
|
||||
| `PLATFORM_LIMITED` | browser/platform이 요구 의미를 cross-browser로 보장하지 못한다. |
|
||||
|
||||
`AVAILABLE_NOT_COMPOSED`와 `NOT_SELECTED`는 같은 말이 아니다. 전자는 reusable
|
||||
runtime의 구현 상태고, 후자는 제품 capability 선택 상태다. 하나의 capability에
|
||||
두 축이 필요하면 “reference runtime”과 “product selection”을 별도 행으로 쓴다.
|
||||
|
||||
### 2.1 현재 상태
|
||||
|
||||
| capability | 현재 상태 | 현재 보장 |
|
||||
| --- | --- | --- |
|
||||
| TanStack Query memory runtime | `COMPOSED` | runtime별 QueryClient, finite inactive GC, retry owner, query AbortSignal |
|
||||
| registered Web Storage | `COMPOSED` | `COLOR_SCHEME`, `CHUNK_RELOAD_GUARD`만 strict codec/envelope로 사용 |
|
||||
| invalidate-only cross-tab runtime | `COMPOSED` | versioned topic, BroadcastChannel 우선, localStorage pulse fallback |
|
||||
| generic IndexedDB repository/maintenance runtime | `AVAILABLE_NOT_COMPOSED` | CAS, idempotency, transaction complete, policy binding, bounded lifecycle/migration |
|
||||
| session/account-scoped QueryClient lifecycle | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 cache epoch는 release ID만 포함 |
|
||||
| strict query policy/key codec | `DESIGNED_NOT_IMPLEMENTED` | 현재 object key order canonicalization만 존재 |
|
||||
| IndexedDB query persistence facade | `DESIGNED_NOT_IMPLEMENTED` | persistence는 registry에서 강제로 disabled |
|
||||
| durable namespace invalidation ledger | `DESIGNED_NOT_IMPLEMENTED` | 없음 |
|
||||
| product query persistence | `NOT_SELECTED` | persist 대상 query/owner가 없음 |
|
||||
| SSR dehydration/hydration | `NOT_SELECTED` | 현재 runtime은 client SPA composition |
|
||||
| exactly-once cross-tab delivery | `PLATFORM_LIMITED` | BroadcastChannel/storage event는 acknowledgement를 제공하지 않음 |
|
||||
| browser storage non-eviction guarantee | `PLATFORM_LIMITED` | persist 요청도 user-agent eviction을 절대 금지하지 않음 |
|
||||
|
||||
## 3. 결정
|
||||
|
||||
### 3.1 하나의 cache/storage abstraction으로 합치지 않는다
|
||||
|
||||
다음 경계를 유지한다.
|
||||
|
||||
```text
|
||||
server response
|
||||
-> feature application result
|
||||
-> query inbound adapter
|
||||
-> scope-owned TanStack QueryClient
|
||||
|
||||
small approved preference/control value
|
||||
-> registered Web Storage facade
|
||||
-> exact localStorage/sessionStorage key
|
||||
|
||||
optional reconstructable query projection
|
||||
-> query persistence facade
|
||||
-> query-specific stable wire codec
|
||||
-> governance-bound IndexedDB runtime
|
||||
|
||||
committed mutation
|
||||
-> local namespace invalidation
|
||||
-> optional durable namespace epoch commit
|
||||
-> best-effort cross-tab hint
|
||||
```
|
||||
|
||||
QueryClient, native `Storage`, `IDBDatabase`, BroadcastChannel, dehydrated TanStack
|
||||
types와 physical key/store/index 이름을 application/domain에 노출하지 않는다.
|
||||
|
||||
### 3.2 source of truth와 authority
|
||||
|
||||
1. 일반 server state와 authorization의 source of truth는 서버다.
|
||||
2. memory cache와 persisted query record는 재구성 가능한 projection이다.
|
||||
3. cache hit, persisted restore와 invalidation event는 authorization proof가 아니다.
|
||||
4. 모든 protected network request는 현재 session credential과 server
|
||||
authorization을 다시 통과한다.
|
||||
5. remote invalidation event는 `invalidate`만 요청할 수 있다. `remove`, `clear`,
|
||||
logout, account deletion과 credential revocation authority를 갖지 않는다.
|
||||
6. unsynced command, local-first draft와 user-authored offline data는 query
|
||||
persistence에 저장하지 않는다. feature-specific IndexedDB repository와
|
||||
sync use case가 소유한다.
|
||||
|
||||
## 4. session/account/release scope
|
||||
|
||||
### 4.1 immutable scope snapshot
|
||||
|
||||
composition의 session authority는 다음 의미를 갖는 immutable snapshot을 발급한다.
|
||||
구현 type과 field name은 이 의미를 보존해야 한다.
|
||||
|
||||
```ts
|
||||
type CacheScopeSnapshot = Readonly<{
|
||||
protocolVersion: 1;
|
||||
authorityToken: string;
|
||||
partitionToken: string;
|
||||
sessionEpoch: string;
|
||||
accountEpoch: string;
|
||||
releaseEpoch: string;
|
||||
generation: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
- 모든 token은 registry/session authority가 발급한 충분한 entropy의 opaque
|
||||
identifier다.
|
||||
- email, account/tenant/user ID, domain ID, access token과 낮은 entropy identifier의
|
||||
단순 hash를 사용하지 않는다.
|
||||
- `generation`은 현재 page runtime에서 단조 증가하는 local lifecycle fence다.
|
||||
backend entity revision이나 wire ordering으로 사용하지 않는다.
|
||||
- `sessionEpoch`는 sign-in, re-auth, credential owner 교체 때 바뀐다.
|
||||
- `accountEpoch`는 account/tenant switch, logout, account deletion 때 바뀐다.
|
||||
- `releaseEpoch`는 query-key, mapper, codec 또는 persistence wire compatibility가
|
||||
깨질 때 바뀐다.
|
||||
- scope object와 nested policy는 construction 때 copy/freeze한다. async operation은
|
||||
시작 시 exact snapshot과 generation을 캡처한다.
|
||||
|
||||
### 4.2 profile별 scope projection
|
||||
|
||||
모든 query가 account token을 key에 넣지는 않는다. registry가 분류에 따라 다음을
|
||||
고정한다.
|
||||
|
||||
| scope | binding |
|
||||
| --- | --- |
|
||||
| `ORIGIN_SHARED` | release epoch와 origin-shared token |
|
||||
| `ACCOUNT_BOUND` | partition token, account epoch, release epoch |
|
||||
| `SESSION_BOUND` | partition token, account epoch, session epoch, release epoch |
|
||||
|
||||
- `PUBLIC`만 `ORIGIN_SHARED`를 사용할 수 있다.
|
||||
- `INTERNAL`은 제품 authority가 origin-shared public semantics를 증명하지 않는 한
|
||||
`ACCOUNT_BOUND` 이상이다.
|
||||
- `PERSONAL`은 `ACCOUNT_BOUND` 이상이고 persistence에는 explicit approval,
|
||||
bounded retention과 logout purge가 필요하다.
|
||||
- `CONFIDENTIAL`은 query persistence가 금지되고 필요한 순간의 memory
|
||||
`SESSION_BOUND`만 허용한다.
|
||||
- credential은 memory query data, persistence, query key와 invalidation wire
|
||||
모두에서 금지한다.
|
||||
|
||||
### 4.3 composite cache epoch
|
||||
|
||||
cross-tab `cacheEpoch`는 raw token을 연결한 문자열이 아니라 선택된 scope projection과
|
||||
protocol major의 opaque compatibility fingerprint다. receiver는 exact equality만
|
||||
검사하고 원래 account/session/release 의미 값을 wire에서 복원하지 않는다.
|
||||
|
||||
현재 `release.<releaseId>`만 사용하는 값은 transitional implementation이다.
|
||||
account-dependent query를 production에 설치하기 전에 composite scope fingerprint로
|
||||
교체한다.
|
||||
|
||||
## 5. QueryClient lifecycle와 late-result fence
|
||||
|
||||
### 5.1 runtime state
|
||||
|
||||
scope-owned query runtime은 다음 terminal lifecycle을 갖는다.
|
||||
|
||||
```text
|
||||
CREATING
|
||||
-> ACTIVE
|
||||
-> FENCING
|
||||
-> DISPOSING
|
||||
-> DISPOSED
|
||||
```
|
||||
|
||||
- `ACTIVE`만 신규 query/mutation/cache update를 admission한다.
|
||||
- scope transition이 시작되면 먼저 `FENCING`으로 바꾸고 generation을 올린다.
|
||||
- `DISPOSING`에서 old query를 cancel하고 provider/controller를 detach한 뒤
|
||||
QueryClient를 clear한다.
|
||||
- old cross-tab channel, persistence writer/connection, timer와 listener를 닫는다.
|
||||
- exact old Web Storage key/IndexedDB partition purge는 policy와 authority를
|
||||
통과한 bounded lifecycle operation으로 수행한다.
|
||||
- 새 scope는 새 QueryClient와 새 coordinator를 만든다. old client를 재사용해
|
||||
key prefix만 바꾸지 않는다.
|
||||
- dispose와 scope transition은 idempotent하다.
|
||||
|
||||
### 5.2 query fence
|
||||
|
||||
query execution은 TanStack의 AbortSignal과 scope generation을 모두 캡처한다.
|
||||
|
||||
1. 시작 전 runtime이 `ACTIVE`인지 확인한다.
|
||||
2. application request에 AbortSignal을 전달한다.
|
||||
3. 완료 시 captured generation과 current generation을 비교한다.
|
||||
4. mismatch면 성공/실패 모두 새 cache/UI에 적용하지 않고 `STALE_RESULT`로
|
||||
폐기한다.
|
||||
5. query cancellation 실패가 scope clear를 막지 않게 하되 safe diagnostic을
|
||||
남긴다.
|
||||
|
||||
### 5.3 mutation fence
|
||||
|
||||
frontend abort는 이미 서버에 도달한 mutation을 되돌리지 않는다.
|
||||
|
||||
- 시작 전 admission과 generation을 확인한다.
|
||||
- server commit 전 cancellation은 transport의 idempotency/cancellation 계약을
|
||||
따른다.
|
||||
- server 결과가 old generation에서 돌아오면 새 cache에 optimistic result,
|
||||
invalidation 또는 success UI를 적용하지 않는다.
|
||||
- server side effect의 authoritative 결과는 새 scope에서 정상 revalidation한다.
|
||||
- mutation success 후 local invalidation 실패나 hint publish 실패가 이미 committed
|
||||
server mutation을 실패로 바꾸지 않는다.
|
||||
- conflict resolution은 backend revision/ETag/idempotency 계약과 feature policy가
|
||||
소유한다. query cache는 business merge authority가 아니다.
|
||||
|
||||
### 5.4 session owner 연결
|
||||
|
||||
production bootstrap은 auth/session owner subscription을 query lifecycle에
|
||||
연결한다. 단순 `authenticated` boolean만으로 account identity를 추론하지 않는다.
|
||||
owner는 opaque scope snapshot 또는 이를 발급할 authority를 제공해야 한다.
|
||||
|
||||
다른 tab의 logout은 cache invalidation event에 의존하지 않는다. 각 tab의 auth
|
||||
owner가 credential/session 변화를 독립적으로 감지하고 local lifecycle을
|
||||
실행해야 한다.
|
||||
|
||||
## 6. strict query scope/persistence registry와 key codec
|
||||
|
||||
### 6.1 registry
|
||||
|
||||
모든 installed query namespace는 immutable scope/persistence profile을 갖는다.
|
||||
freshness, GC, refetch, retry, result budget, pagination과 conditional policy의
|
||||
유일한 source of truth는 VD-25 `ServerStateProfile`이다.
|
||||
|
||||
```ts
|
||||
type QueryScopePersistencePolicy = Readonly<{
|
||||
policyId: string;
|
||||
namespace: readonly [string, number];
|
||||
keySchemaVersion: number;
|
||||
classification: "PUBLIC" | "INTERNAL" | "PERSONAL" | "CONFIDENTIAL";
|
||||
scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND";
|
||||
persistence:
|
||||
| Readonly<{ kind: "MEMORY_ONLY" }>
|
||||
| Readonly<{
|
||||
kind: "INDEXEDDB";
|
||||
profileId: string;
|
||||
maxAgeMs: number;
|
||||
maxEntryBytes: number;
|
||||
}>;
|
||||
crossTab: "NONE" | "INVALIDATE";
|
||||
invalidationTopics: readonly Readonly<{
|
||||
topicId: string;
|
||||
topicVersion: number;
|
||||
}>[];
|
||||
}>;
|
||||
```
|
||||
|
||||
construction은 최소 다음을 검증한다.
|
||||
|
||||
- namespace/topic/policy ID가 closed syntax와 unique version을 가짐
|
||||
- persistence가 classification, scope, max age와 맞음
|
||||
- `NONE`은 topic 0개, `INVALIDATE`는 namespace당 unique topic 1..8개
|
||||
- `(topicId, topicVersion)` 하나는 최대 32개 namespace에 fan-out하며 global
|
||||
topic→namespace set과 namespace→topic set이 서로 exact inverse
|
||||
- profile과 nested allowlist를 deep snapshot/freeze함
|
||||
- VD-25 query definition/`ServerStateProfile`과 join했을 때 owner,
|
||||
classification, scope, namespace, persistence와 invalidation topic set/version이
|
||||
일치함
|
||||
|
||||
composition은 `QueryDefinition -> QueryScopePersistencePolicy ->
|
||||
ServerStateProfile`을 exact ID로 join한 뒤에만 TanStack option을 만든다. global
|
||||
QueryClient default는 안전 baseline일 뿐이고 installed query의 정책 증거가
|
||||
아니다.
|
||||
|
||||
### 6.2 query key wire subset
|
||||
|
||||
query key factory의 canonical input은 다음만 허용한다.
|
||||
|
||||
- `null`, boolean, finite number, bounded string
|
||||
- 위 값의 dense array
|
||||
- own enumerable data property만 가진 plain/null-prototype object
|
||||
|
||||
다음을 fail-closed로 거절한다.
|
||||
|
||||
- cycle/shared exotic graph
|
||||
- `undefined`, `BigInt`, symbol, function, accessor
|
||||
- `NaN`, infinity, negative zero를 구분하지 않는 암묵 변환
|
||||
- Date, RegExp, Map, Set, class/DOM/native object
|
||||
- File, Blob, ArrayBuffer와 typed array
|
||||
- sparse array
|
||||
- `__proto__`, `prototype`, `constructor` key
|
||||
- 허용 depth/node/part/string/serialized-byte ceiling 초과
|
||||
|
||||
구현 절대 상한:
|
||||
|
||||
| 항목 | 상한 |
|
||||
| --- | ---: |
|
||||
| installed query profile | 256 |
|
||||
| query key top-level part | 16 |
|
||||
| canonical value depth | 8 |
|
||||
| canonical value node | 256 |
|
||||
| 단일 string UTF-8 | 1,024 bytes |
|
||||
| 전체 canonical key UTF-8 | 4,096 bytes |
|
||||
|
||||
제품 profile은 더 낮출 수 있지만 이 상한을 높이려면 ADR amendment와
|
||||
memory/telemetry cardinality evidence가 필요하다.
|
||||
|
||||
normative key layout:
|
||||
|
||||
```text
|
||||
[
|
||||
"query",
|
||||
keySchemaVersion,
|
||||
scopeFingerprint,
|
||||
namespaceName,
|
||||
namespaceVersion,
|
||||
queryDefinitionVersion,
|
||||
canonicalSemanticInput
|
||||
]
|
||||
```
|
||||
|
||||
VD-25는 이 배열을 재정의하지 않고 마지막 두 field의 의미와 pagination
|
||||
projection만 소유한다. query function이 의존하는 모든 non-secret input을
|
||||
포함하되 URL 전체, bearer
|
||||
token, email, filename, human-readable personal label을 넣지 않는다. domain entity
|
||||
identity가 필요하면 backend/product contract가 발급한 opaque ID와 bounded codec을
|
||||
사용한다.
|
||||
|
||||
### 6.3 memory pressure
|
||||
|
||||
`gcTime`은 inactive retention이지 active cache hard cap이 아니다.
|
||||
|
||||
- gateway/mapper가 response count/byte ceiling을 검증한다.
|
||||
- binary, native object와 unbounded collection을 query cache에 넣지 않는다.
|
||||
- cache entry/active/inactive와 estimated payload를 safe bucket으로 관측한다.
|
||||
- hard eviction controller는 joined VD-25 profile별 정책으로만 설치한다.
|
||||
- memory pressure를 이유로 active personal data를 arbitrary global timer로
|
||||
삭제하지 않는다. scope lifecycle의 remove/clear와 일반 eviction을 구분한다.
|
||||
|
||||
## 7. Web Storage contract
|
||||
|
||||
### 7.1 registered key policy
|
||||
|
||||
Web Storage는 registered small value 전용이다.
|
||||
|
||||
```ts
|
||||
type WebStorageDefinition<Value> = Readonly<{
|
||||
logicalName: string;
|
||||
backend: "localStorage" | "sessionStorage";
|
||||
scope: "ORIGIN_SHARED" | "OPAQUE_PARTITION" | "TAB";
|
||||
classification: "PUBLIC_PREFERENCE" | "OPAQUE_CONTROL";
|
||||
schemaVersion: number;
|
||||
maxSerializedBytes: number;
|
||||
retention:
|
||||
| Readonly<{ kind: "SESSION" }>
|
||||
| Readonly<{ kind: "TTL"; maxAgeMs: number }>
|
||||
| Readonly<{ kind: "EXPLICIT_DELETE" }>;
|
||||
valueCodec: string;
|
||||
migration:
|
||||
| Readonly<{ kind: "DISCARD" }>
|
||||
| Readonly<{ kind: "ADJACENT"; migrationId: string }>;
|
||||
quotaFallback: "MEMORY" | "NO_PERSIST" | "FEATURE_DISABLE";
|
||||
logoutAction: "KEEP" | "PURGE_PARTITION";
|
||||
}>;
|
||||
```
|
||||
|
||||
구현 절대 상한:
|
||||
|
||||
| 항목 | 상한 |
|
||||
| --- | ---: |
|
||||
| registered persistent key | 64 |
|
||||
| key별 serialized value | 16,384 bytes |
|
||||
| 한 sweep에서 검사할 key | 16 |
|
||||
| 한 read에서 migration step | 2 |
|
||||
|
||||
현재 두 key는 각각 더 좁은 codec을 유지한다. `COLOR_SCHEME`은 public
|
||||
origin-shared preference이고 `CHUNK_RELOAD_GUARD`는 tab session control이다.
|
||||
server response, credential, signed URL, File/Blob, large draft와 queue를 Web
|
||||
Storage에 넣지 않는다.
|
||||
|
||||
### 7.2 physical identity와 envelope
|
||||
|
||||
physical key는 application/environment, scope kind, opaque partition 또는 tab
|
||||
instance, logical key, schema version에서 결정적으로 파생한다. account/user ID를
|
||||
포함하거나 origin 전체 key를 열거하지 않는다.
|
||||
|
||||
partition-aware 새 envelope는 기존 v1 세 필드의 의미를 변경하지 않고 새
|
||||
envelope version으로 도입한다.
|
||||
|
||||
```ts
|
||||
type BrowserStorageEnvelopeV2 = Readonly<{
|
||||
envelopeVersion: 2;
|
||||
schemaVersion: number;
|
||||
scopeFingerprint: string;
|
||||
writtenAtEpochMs: number;
|
||||
expiresAtEpochMs: number | null;
|
||||
value: unknown;
|
||||
}>;
|
||||
```
|
||||
|
||||
- exact field set, schema, scope, codec, written/expiry time 순으로 검증한다.
|
||||
- TTL expiry는 write time과 registry max age에서 계산하며 caller가 직접 주지 않는다.
|
||||
- 비정상적으로 먼 expiry, future write time과 clock skew는 fail-closed miss다.
|
||||
- corrupt/expired/future/wrong-scope record는 exact key만 best-effort 제거한다.
|
||||
- cleanup 실패는 validated miss를 raw exception으로 바꾸지 않는다.
|
||||
- memory overlay도 exact envelope와 TTL/scope validation을 공유한다.
|
||||
|
||||
### 7.3 read/write outcome
|
||||
|
||||
stored `undefined`와 miss를 암묵적으로 합치지 않는다.
|
||||
|
||||
```ts
|
||||
type WebStorageReadResult<Value> =
|
||||
| Readonly<{ ok: true; state: "HIT"; value: Value; durability: "PERSISTED" | "MEMORY_ONLY" }>
|
||||
| Readonly<{ ok: true; state: "MISS" }>
|
||||
| Readonly<{ ok: false; error: ClientStorageFailure }>;
|
||||
|
||||
type WebStorageWriteResult =
|
||||
| Readonly<{ ok: true; durability: "PERSISTED" }>
|
||||
| Readonly<{ ok: true; durability: "MEMORY_ONLY"; degraded: true }>
|
||||
| Readonly<{ ok: false; error: ClientStorageFailure }>;
|
||||
```
|
||||
|
||||
memory fallback이 current runtime에서 승인된 성공이면 `ok: true`와
|
||||
`MEMORY_ONLY`를 반환한다. durable write가 필수인 key는 fallback을 성공으로
|
||||
가장하지 않는다.
|
||||
|
||||
### 7.4 migration, quota와 sweep
|
||||
|
||||
- migration은 registry에 등록된 deterministic adjacent version만 실행한다.
|
||||
- migration callback은 network/native storage/telemetry side effect 없이 bounded
|
||||
pure codec으로 동작한다.
|
||||
- future version과 unsupported old version은 `DISCARD` policy에서 miss다.
|
||||
- `QuotaExceededError`이면 reconstructable exact key cleanup 뒤 동일 idempotent
|
||||
write를 최대 한 번 재시도한다.
|
||||
- origin 전체 `clear()`와 arbitrary LRU key enumeration을 금지한다.
|
||||
- TTL은 visibility rule이므로 boot/idle/focus 중 registry-owned bounded sweep을
|
||||
별도로 수행한다.
|
||||
- logout/account switch는 exact partition key만 purge한다. public origin-shared
|
||||
preference를 지우지 않는다.
|
||||
- `sessionStorage` opener snapshot을 authority로 사용하지 않는다. tab-local
|
||||
control에는 새 tab instance와 `noopener` policy를 적용한다.
|
||||
|
||||
## 8. optional IndexedDB query persistence
|
||||
|
||||
### 8.1 selection
|
||||
|
||||
query persistence reference facade의 현재 상태는
|
||||
`DESIGNED_NOT_IMPLEMENTED`, product selection은 `NOT_SELECTED`다. 단순 warm-start
|
||||
기대만으로 자동 설치하지 않는다.
|
||||
|
||||
다음 조건을 모두 충족한 query만 등록한다.
|
||||
|
||||
- server-authoritative이며 재구성 가능함
|
||||
- stable query-key와 payload codec이 있음
|
||||
- classification/scope/retention owner 승인
|
||||
- entry/dataset/restore byte와 count budget이 있음
|
||||
- logout/account deletion/release busting이 정의됨
|
||||
- measured offline/warm-start 가치가 있음
|
||||
- three-engine native contract와 rollback evidence가 있음
|
||||
|
||||
### 8.2 stable record, raw TanStack snapshot 금지
|
||||
|
||||
full QueryClient snapshot이나 library-private object를 그대로 저장하지 않는다.
|
||||
|
||||
```ts
|
||||
type PersistedQueryRecord = Readonly<{
|
||||
recordVersion: 1;
|
||||
queryHash: string;
|
||||
encodedQueryKey: unknown;
|
||||
policyId: string;
|
||||
scopeFingerprint: string;
|
||||
releaseEpoch: string;
|
||||
namespaceEpoch: number;
|
||||
dataUpdatedAtEpochMs: number;
|
||||
persistedAtEpochMs: number;
|
||||
expiresAtEpochMs: number;
|
||||
payloadCodecVersion: number;
|
||||
payload: unknown;
|
||||
measuredBytes: number;
|
||||
revision: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
- approved successful query data만 저장한다.
|
||||
- error, pending state, mutation, function, Promise, AbortSignal, native/binary
|
||||
object, credential와 capability를 저장하지 않는다.
|
||||
- query key와 payload를 각각 strict codec으로 검증한다.
|
||||
- generic IndexedDB runtime의 opaque scope/policy binding, transaction complete,
|
||||
CAS, byte budget, migration, lifecycle와 failure mapping을 재사용한다.
|
||||
|
||||
### 8.3 구현 상한
|
||||
|
||||
reference facade의 기본 절대 상한:
|
||||
|
||||
| 항목 | 상한 |
|
||||
| --- | ---: |
|
||||
| persisted query record | 1,024 |
|
||||
| 단일 encoded entry | 512 KiB |
|
||||
| query persistence dataset | 32 MiB |
|
||||
| 한 restore record | 256 |
|
||||
| 한 restore decoded bytes | 8 MiB |
|
||||
| boot restore deadline | 2,000 ms |
|
||||
| max age | 7 days |
|
||||
| write debounce | 250–2,000 ms |
|
||||
|
||||
제품 policy는 더 낮출 수 있다. 상한 확대는 memory/quota/startup-latency evidence와
|
||||
ADR amendment가 필요하다.
|
||||
|
||||
### 8.4 durable namespace epoch
|
||||
|
||||
full snapshot last-write-wins를 금지한다. 기본 writer model은 shared per-query
|
||||
record + monotonic namespace epoch다.
|
||||
|
||||
```ts
|
||||
type DurableCacheLedger = Readonly<{
|
||||
ledgerVersion: 1;
|
||||
scopeFingerprint: string;
|
||||
releaseEpoch: string;
|
||||
namespaces: Readonly<Record<string, number>>;
|
||||
revision: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
- mutation invalidation은 namespace epoch를 같은 IndexedDB transaction에서
|
||||
증가시킨 뒤 cross-tab hint를 publish한다.
|
||||
- persisted record의 namespace epoch가 ledger보다 작으면 hydrate하지 않는다.
|
||||
- record write는 current ledger epoch와 revision을 CAS 검증한다.
|
||||
- BroadcastChannel sequence나 wall clock을 global durable ordering으로 사용하지
|
||||
않는다.
|
||||
- localStorage read-modify-write counter와 best-effort leader election을 correctness
|
||||
fence로 쓰지 않는다.
|
||||
- ledger commit 뒤 hint를 publish한다. hint가 먼저 나가면 receiver가 commit 전
|
||||
record를 읽을 수 있다.
|
||||
|
||||
### 8.5 restore와 hydration order
|
||||
|
||||
1. bounded deadline으로 IndexedDB를 연다.
|
||||
2. immutable dataset/scope/release binding을 검증한다.
|
||||
3. ledger와 record schema/codec/TTL/byte cap을 검증한다.
|
||||
4. approved profile과 current namespace epoch만 decode한다.
|
||||
5. current memory/SSR state와 precedence를 적용한다.
|
||||
6. hydrate 뒤 normal stale/refetch policy를 실행한다.
|
||||
|
||||
wrong scope, expired, busted와 corrupt reconstructable record는 cache miss로
|
||||
격하하고 exact bounded cleanup한다. persistence unavailable/blocked/timeout은
|
||||
제품이 optional로 선택했다면 memory+network `ONLINE_ONLY`로 fail open한다.
|
||||
offline-required workflow를 query persistence로 가장하지 않는다.
|
||||
|
||||
### 8.6 writer lifecycle
|
||||
|
||||
- cache events는 bounded debounce/coalescing한다.
|
||||
- writer 하나에서 concurrent save를 serialize하고 superseded write를 버린다.
|
||||
- `pagehide`/`beforeunload` transaction 완료를 보장으로 간주하지 않는다.
|
||||
- 정상 runtime 중 주기적으로 commit하고 unload flush는 보조 수단이다.
|
||||
- dispose는 timer를 취소하고 connection/listener를 닫는다.
|
||||
- 아직 transaction complete가 아닌 write를 persisted success로 기록하지 않는다.
|
||||
|
||||
## 9. cross-tab invalidation
|
||||
|
||||
### 9.1 authority
|
||||
|
||||
cross-tab wire는 payload/query-key-free invalidate hint만 전달한다.
|
||||
|
||||
- query state/data replication 금지
|
||||
- authorization/logout/server commit 증명 금지
|
||||
- distributed lock/leader election 금지
|
||||
- exactly-once/ordered delivery 주장 금지
|
||||
- offline command 전송 금지
|
||||
|
||||
remote hint는 registry topic을 local namespace로 해석해 active query를
|
||||
invalidate/refetch한다. inactive query는 다음 mount/focus/freshness 정책에서
|
||||
revalidate한다. remote hint는 `removeQueries`, `clear()` 또는 session transition을
|
||||
직접 실행하지 않는다.
|
||||
|
||||
### 9.2 transport와 source validation
|
||||
|
||||
```text
|
||||
BroadcastChannel
|
||||
-> construction/post failure
|
||||
-> registered localStorage pulse + storage event
|
||||
-> failure/unavailable
|
||||
-> DEGRADED_LOCAL_ONLY + normal stale/focus/reconnect
|
||||
```
|
||||
|
||||
- current 2,048-byte exact wire envelope와 bounded TTL/dedupe/source tracking을
|
||||
유지한다.
|
||||
- topic registry 수에도 query profile과 같은 256개 절대 상한을 적용한다.
|
||||
- localStorage fallback key를 Web Storage control registry에 등록한다.
|
||||
- receiver는 exact key, exact `storageArea === localStorage`, exact composite
|
||||
cache epoch와 event codec을 검증한다.
|
||||
- `sessionStorage`를 cross-tab fallback으로 사용하지 않는다.
|
||||
- publisher는 local invalidation을 직접 수행한다.
|
||||
- publish success는 receiver acknowledgement가 아니다.
|
||||
- BroadcastChannel과 storage 양쪽 delivery는 event ID로 dedupe한다.
|
||||
- per-source sequence gap은 global order 증명이 아니라 “hint를 잃었을 수 있음”을
|
||||
나타낸다.
|
||||
|
||||
### 9.3 lost hint
|
||||
|
||||
query persistence가 꺼져 있으면 finite stale time, focus/reconnect와 manual refresh가
|
||||
eventual revalidation을 제공한다. persistence가 켜져 있으면 visibility/focus와
|
||||
sequence gap에서 durable namespace ledger를 bounded refresh한다.
|
||||
|
||||
즉시 global consistency가 업무 invariant라면 browser bus만으로 충족하지 않는다.
|
||||
backend revision/ETag, server push stream 또는 feature sync protocol을 추가한다.
|
||||
|
||||
## 10. SSR 선택 경계
|
||||
|
||||
현재 SSR product capability는 `NOT_SELECTED`다. browser-only code가 있다는 이유로
|
||||
SSR support가 구현됐다고 주장하지 않는다.
|
||||
|
||||
SSR을 선택하면 별도 implementation gate에서 다음을 모두 구현한다.
|
||||
|
||||
1. HTTP request마다 새 QueryClient를 생성하고 response 뒤 폐기한다.
|
||||
2. server process에서 Web Storage, IndexedDB와 BroadcastChannel에 접근하지 않는다.
|
||||
3. approved successful query만 dehydrate한다.
|
||||
4. serialized state를 HTML context에 안전하게 escape하고 byte/count cap을 적용한다.
|
||||
5. browser의 최신 SSR payload가 old persisted projection보다 우선한다.
|
||||
6. persisted state merge는 missing approved query만 복원하거나 explicit server
|
||||
revision을 비교한다.
|
||||
7. browser storage read 때문에 initial server/client markup이 달라지지 않게
|
||||
hydration-safe bootstrap 단계에서 restore한다.
|
||||
8. request A의 QueryClient/data/scope가 request B에 공유되지 않는 test를 둔다.
|
||||
|
||||
SSR support와 IndexedDB query persistence는 서로 독립 선택이다.
|
||||
|
||||
## 11. privacy와 encryption
|
||||
|
||||
- credential, token, signed URL, authorization header, password와 crypto key는
|
||||
memory query key/data, Web Storage, query persistence와 invalidation wire에
|
||||
넣지 않는다.
|
||||
- logical/physical key, query key/hash input, payload, account/user ID, URL과 native
|
||||
exception message/stack을 telemetry에 보내지 않는다.
|
||||
- 같은 origin JavaScript가 ciphertext와 key를 모두 읽을 수 있는 client-side
|
||||
encryption은 XSS authorization boundary가 아니다.
|
||||
- external/non-extractable key lifecycle과 compliance requirement가 있는 제품은
|
||||
encryption을 defense-in-depth로 별도 선택할 수 있지만, 금지 classification을
|
||||
허용하는 근거가 되지 않는다.
|
||||
- logout purge는 confidentiality의 유일한 방어가 아니다. wrong-scope binding은
|
||||
crash로 old bytes가 남아도 새 runtime이 읽지 못하게 해야 한다.
|
||||
|
||||
## 12. failure와 observability
|
||||
|
||||
failure는 최소 operation, closed code, retry owner, effect certainty와 fallback을
|
||||
표현한다.
|
||||
|
||||
- `ABORTED`와 `DEADLINE_EXCEEDED`를 구분한다.
|
||||
- IndexedDB transaction `complete`만 `APPLIED`다.
|
||||
- Broadcast publish success의 remote effect는 `UNKNOWN`이다.
|
||||
- memory fallback과 persisted success를 구분한다.
|
||||
- optional persistence failure는 `ONLINE_ONLY`로 degrade할 수 있다.
|
||||
- scope mismatch/corruption/future version은 raw record를 반환하지 않는다.
|
||||
- diagnostics failure가 query, storage, lifecycle와 cleanup을 실패시키지 않는다.
|
||||
|
||||
safe metric:
|
||||
|
||||
- memory active/inactive/estimated-byte bucket
|
||||
- Web Storage hit/miss/degraded/quota bucket
|
||||
- persistence restore success/miss/busted/corrupt/deadline bucket
|
||||
- scope reset duration/cleanup-incomplete
|
||||
- invalidation publish/receive/drop/duplicate/gap/coalesced bucket
|
||||
- listener/channel/connection leak count
|
||||
|
||||
## 13. implementation gate
|
||||
|
||||
### Gate 0 — 상태와 문서
|
||||
|
||||
- 이 ADR과 상세 설계가 current/target 상태를 분리한다.
|
||||
- capability catalog, runbook과 test evidence의 상태가 같은 taxonomy를 사용한다.
|
||||
- 구현되지 않은 target type을 current API처럼 문서화하지 않는다.
|
||||
|
||||
### Gate 1 — strict registry와 codec
|
||||
|
||||
- query policy registry와 query key closed codec 구현
|
||||
- profile/key absolute ceiling 구현
|
||||
- Web Storage per-key cap, HIT/MISS/durability result 구현
|
||||
- current v1 key의 discard/upgrade 전략 확정
|
||||
- hostile/cyclic/oversize/property-accessor test 통과
|
||||
|
||||
이 gate는 scope lifecycle을 자동 활성화하지 않는다.
|
||||
|
||||
### Gate 2 — scope-owned QueryClient lifecycle
|
||||
|
||||
- session authority scope snapshot contract 구현
|
||||
- auth owner subscription과 local generation fence 구현
|
||||
- old query cancel/provider detach/client clear/dispose 구현
|
||||
- late query/mutation result 폐기 구현
|
||||
- account switch/logout exact partition cleanup 구현
|
||||
- two-account and lost-event tests 통과
|
||||
|
||||
account-dependent query promotion은 이 gate 전 금지한다.
|
||||
|
||||
### Gate 3 — cross-tab scope hardening
|
||||
|
||||
- composite cache epoch 구현
|
||||
- registered localStorage pulse와 storageArea 검증 구현
|
||||
- browser production coordinator E2E와 bfcache/StrictMode leak test
|
||||
- Chromium/Firefox/WebKit 동일 case evidence
|
||||
|
||||
### Gate 4 — optional query persistence reference runtime
|
||||
|
||||
- stable query record codec와 IndexedDB facade 구현
|
||||
- durable namespace ledger/CAS/commit-before-hint 구현
|
||||
- bounded restore/write/dispose 구현
|
||||
- wrong-scope/TTL/release/migration/quota/blocked test
|
||||
- production bootstrap import와 DB open이 없는 module-inventory/removal gate
|
||||
|
||||
완료 뒤에도 product selection은 `NOT_SELECTED`이고 reference 상태만
|
||||
`AVAILABLE_NOT_COMPOSED`로 바뀐다.
|
||||
|
||||
### Gate 5 — product composition
|
||||
|
||||
- measured requirement와 owner 승인
|
||||
- exact query profile/persistence allowlist/retention/budget 등록
|
||||
- account/logout/backend conflict contract 승인
|
||||
- disabled → canary → enabled traffic admission
|
||||
- rollback, cleanup-only release와 operational drill
|
||||
|
||||
### Gate 6 — SSR 또는 offline workflow
|
||||
|
||||
각 capability를 별도 선택하고 별도 gate를 통과한다.
|
||||
|
||||
- SSR: request isolation, safe dehydration, precedence와 hydration test
|
||||
- offline mutation: feature repository, server idempotency/revision/sync protocol,
|
||||
conflict/export/recovery UX
|
||||
|
||||
query persistence gate 통과가 SSR/offline workflow 통과를 의미하지 않는다.
|
||||
|
||||
## 14. test와 promotion evidence
|
||||
|
||||
### 14.1 deterministic
|
||||
|
||||
- independent QueryClient per runtime/scope
|
||||
- session/account/release transition과 late result
|
||||
- query key hostile value/ceiling/canonical equality
|
||||
- Web Storage HIT/MISS/durability, TTL, migration, quota, cleanup, partition
|
||||
- IndexedDB transaction complete, CAS, ledger epoch와 concurrent writer
|
||||
- hint commit ordering, duplicate/self/stale/gap/coalescing
|
||||
- diagnostics redaction와 dispose leak 0
|
||||
|
||||
### 14.2 real browser
|
||||
|
||||
Chromium, Firefox와 WebKit에서 같은 case set을 실행한다.
|
||||
|
||||
- native BroadcastChannel two-page delivery
|
||||
- localStorage fallback과 exact storageArea
|
||||
- account switch 중 in-flight query
|
||||
- event loss 뒤 local auth lifecycle
|
||||
- IndexedDB concurrent writer/blocked/versionchange/restore deadline
|
||||
- bfcache/pagehide/StrictMode listener·connection cleanup
|
||||
- sessionStorage tab/opener semantics
|
||||
- N-1 release reader와 incompatible buster
|
||||
|
||||
현재 native transport spec이 존재해도 production QueryClient lifecycle 전체와
|
||||
세 engine promotion artifact가 없으면 Gate 3 완료로 보지 않는다.
|
||||
|
||||
### 14.3 promotion artifact
|
||||
|
||||
artifact는 engine/browser version/OS image/build/release ID/contract suite
|
||||
version/pass/fail/skip/실행 시각을 보존한다. fake/jsdom 통과를 native provider
|
||||
통과로 보고하지 않는다. WebKit system dependency 부족은 capability skip이 아니라
|
||||
promotion evidence 미충족이다.
|
||||
|
||||
## 15. rollout과 rollback
|
||||
|
||||
### 15.1 rollout
|
||||
|
||||
1. strict registry/codec을 기존 behavior 뒤 shadow validation으로 배포한다.
|
||||
2. scope lifecycle을 single-account environment에서 먼저 관측한다.
|
||||
3. account switch/logout fault test 뒤 account-dependent query를 허용한다.
|
||||
4. optional persistence는 source와 test만 추가하고 production composition은
|
||||
계속 끈다.
|
||||
5. product selection 뒤 read-only restore/shadow write를 먼저 검증한다.
|
||||
6. 작은 cohort에서 write/restore/quota/blocked/rollback drill을 수행한다.
|
||||
7. error budget과 N-1 compatibility를 확인한 뒤 확대한다.
|
||||
|
||||
### 15.2 kill switch
|
||||
|
||||
서로 독립적으로 끌 수 있어야 한다.
|
||||
|
||||
- persistence restore off
|
||||
- persistence write off
|
||||
- cross-tab publish off
|
||||
- cross-tab receive off
|
||||
- offline mutation admission off
|
||||
|
||||
memory QueryClient와 정상 server fetch는 유지한다. account/session local lifecycle은
|
||||
security boundary이므로 best-effort invalidation kill switch와 함께 끄지 않는다.
|
||||
|
||||
### 15.3 rollback
|
||||
|
||||
1. 신규 persistence write/restore admission을 중지한다.
|
||||
2. writer/timer/channel/listener/DB connection을 dispose한다.
|
||||
3. scope fence와 memory QueryClient clear는 유지한다.
|
||||
4. rollback bundle이 future record/schema를 miss/online-only로 처리하게 한다.
|
||||
5. cleanup-only compatible release에서 exact owned partition을 bounded purge한다.
|
||||
6. retention/rollback window 뒤 registry/adapter/dependency를 제거한다.
|
||||
|
||||
schema version을 내리거나 origin 전체 `localStorage.clear()`/
|
||||
`indexedDB.deleteDatabase()`를 자동 실행하지 않는다. unsynced user-authored data는
|
||||
export/sync 확인 없이 query-cache cleanup으로 삭제하지 않는다.
|
||||
|
||||
## 16. 완료 기준
|
||||
|
||||
- [ ] session/account/release scope가 QueryClient, key와 event에 binding된다.
|
||||
- [ ] scope transition은 admission fence, cancel, detach, clear, dispose와 새
|
||||
QueryClient 생성으로 완료된다.
|
||||
- [ ] old generation query/mutation result가 새 scope UI/cache를 변경하지 않는다.
|
||||
- [ ] strict query scope/persistence registry와 closed key codec이 모든 absolute
|
||||
ceiling을 강제하고 VD-25 profile과 exact join된다.
|
||||
- [ ] Web Storage가 per-key cap, HIT/MISS, durability, partition, logout,
|
||||
migration과 bounded sweep을 구현한다.
|
||||
- [ ] localStorage invalidation fallback이 registered key와 exact storageArea를
|
||||
검증한다.
|
||||
- [ ] Chromium/Firefox/WebKit production coordinator/account lifecycle evidence가
|
||||
있다.
|
||||
- [ ] optional query persistence reference facade는 stable record와 durable
|
||||
namespace ledger를 사용하고 production 미선택 시 zero side effect다.
|
||||
- [ ] persisted mutation/error/native object/credential이 없음을 negative test가
|
||||
증명한다.
|
||||
- [ ] SSR을 선택한 경우 request isolation과 hydration precedence가 증명된다.
|
||||
- [ ] offline mutation을 선택한 경우 backend idempotency/revision/sync와
|
||||
conflict/recovery UX가 별도 계약으로 증명된다.
|
||||
- [ ] rollout/kill-switch/rollback/removal artifact가 보존된다.
|
||||
|
||||
현재 이 체크리스트는 완료 선언이 아니라 implementation gate다. 각 행을 실제
|
||||
source, deterministic test, native evidence와 composition inventory로 증명하기
|
||||
전에는 완료로 바꾸지 않는다.
|
||||
|
||||
## 17. 선택하지 않은 대안
|
||||
|
||||
- module singleton QueryClient
|
||||
- account switch에서 query key prefix만 교체
|
||||
- BroadcastChannel logout event를 lifecycle authority로 사용
|
||||
- arbitrary query key와 raw TanStack cache snapshot persistence
|
||||
- localStorage full-cache snapshot 또는 monotonic counter
|
||||
- browser persistence를 offline command queue로 사용
|
||||
- same-origin client encryption을 credential authorization boundary로 사용
|
||||
- origin 전체 storage clear를 quota/logout/rollback 복구로 사용
|
||||
- fake browser test만으로 production promotion
|
||||
|
||||
## 18. 결과
|
||||
|
||||
장점:
|
||||
|
||||
- account/session boundary와 best-effort invalidation의 권한 차이가 명확하다.
|
||||
- persistence를 선택하지 않은 제품에는 DB open/listener/bundle side effect가 없다.
|
||||
- query key, Web Storage와 IndexedDB의 migration/retention을 독립적으로 검증한다.
|
||||
- old tab/snapshot이 invalidated data를 되살리는 경로를 durable epoch로 닫는다.
|
||||
- SSR, offline workflow와 query warm-start를 서로 독립 선택할 수 있다.
|
||||
|
||||
비용:
|
||||
|
||||
- scope authority와 QueryClient remount lifecycle이 필요하다.
|
||||
- registry/codec/historical fixture와 multi-page browser test가 늘어난다.
|
||||
- optional persistence를 설치하는 제품은 IndexedDB migration, quota와 cleanup
|
||||
runbook을 운영해야 한다.
|
||||
|
||||
이 비용은 cache hit, durable restore, cross-tab hint와 server truth를 하나의
|
||||
“cached” 상태로 잘못 합치지 않기 위한 의도적인 비용이다.
|
||||
@@ -0,0 +1,898 @@
|
||||
# VD-14: Resumable download와 background download 경계
|
||||
|
||||
- 상태: Accepted — production design complete, implementation pending
|
||||
- 결정일: 2026-07-28
|
||||
- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도)
|
||||
- Range resumable download primary status: `DESIGNED_NOT_IMPLEMENTED`
|
||||
- app-managed background download primary status: `NOT_SELECTED`
|
||||
- cross-browser app-managed background download guarantee: `PLATFORM_LIMITED`
|
||||
- 관련 결정: VD-10, VD-11, VD-12
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 재검토: 제품이 Range 재개, 탭 종료 뒤 전달 또는 대용량 Safari fallback을
|
||||
선택할 때
|
||||
|
||||
## 1. 배경과 현재 사실
|
||||
|
||||
현재 reference runtime은 whole-object `200` response를 bounded stream으로 읽어
|
||||
foreground destination에 저장하거나 browser download manager에 handoff한다. 이
|
||||
경로는 전체 payload를 하나의 `Blob`으로 만들지 않고 byte length와 SHA-256을
|
||||
검증하지만, 네트워크나 탭이 중단되면 다음 실행은 byte 0부터 다시 시작한다.
|
||||
|
||||
Range resume는 기존 stream에 `Range` header 하나를 추가하는 기능이 아니다.
|
||||
representation identity, exact `206 Content-Range`, durable partial destination,
|
||||
checkpoint CAS, `200/412/416` reconciliation과 마지막 whole-object integrity가
|
||||
하나의 protocol이어야 한다. background download도 Range resume와 동일하지 않다.
|
||||
브라우저 download manager에 넘기는 것과 애플리케이션이 Service Worker에서
|
||||
전송을 계속 관리하는 것은 완료 증거와 상호운용성이 전혀 다르다.
|
||||
|
||||
이 ADR은 목표 계약을 정의한다. 이 문서가 존재한다는 사실은 runtime, endpoint,
|
||||
worker 또는 제품 UX가 구현·조합되었다는 뜻이 아니다.
|
||||
|
||||
## 2. 표준 capability 상태
|
||||
|
||||
설계, source 존재, 제품 조합과 플랫폼 한계를 하나의 `enabled` boolean으로 합치지
|
||||
않는다. primary current status는 다음 다섯 값 중 정확히 하나다. 이 taxonomy는
|
||||
선형 maturity model이 아니며 상태 이름만으로 rollout 또는 production readiness를
|
||||
추론하지 않는다.
|
||||
|
||||
| primary status | 의미 |
|
||||
| --- | --- |
|
||||
| `NOT_SELECTED` | 제품 요구, owner, policy 또는 구현 범위가 아직 선택되지 않음 |
|
||||
| `DESIGNED_NOT_IMPLEMENTED` | versioned contract와 불변조건은 승인됐지만 reference source가 없음 |
|
||||
| `AVAILABLE_NOT_COMPOSED` | 검증 가능한 reference source가 있지만 제품 bootstrap/endpoint에는 연결되지 않음 |
|
||||
| `COMPOSED` | 특정 제품 facade, config와 dependency에 실제로 조합됨 |
|
||||
| `PLATFORM_LIMITED` | 요구 semantics를 target browser/platform 전체에서 보장할 수 없음 |
|
||||
|
||||
production readiness와 traffic admission은 primary status와 독립된 축이다.
|
||||
운영 상태는 completion ledger가 정의한 네 canonical 축만 사용한다.
|
||||
|
||||
```text
|
||||
Selection =
|
||||
NOT_SELECTED | SELECTED | REMOVING
|
||||
TrafficAdmission =
|
||||
DISABLED | SHADOW | CANARY | ENABLED
|
||||
RuntimeHealth =
|
||||
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
|
||||
PromotionEvidence =
|
||||
MISSING | PARTIAL | COMPLETE | EXPIRED
|
||||
```
|
||||
|
||||
아래 `source evidence`는 ADR과 reference source의 존재를 설명하는 문서 표기일
|
||||
뿐 canonical readiness 축이 아니다. `DESIGN_REVIEWED`는 native browser나
|
||||
provider 증거가 아니고, `REFERENCE_TESTED`도 ledger의 browser component를
|
||||
`PROMOTABLE` 또는 `PromotionEvidence=COMPLETE`로 만들지 않는다.
|
||||
|
||||
현재 capability별 판정:
|
||||
|
||||
| capability | primary status | source evidence | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| whole-object foreground streaming | `AVAILABLE_NOT_COMPOSED` | `REFERENCE_TESTED` | 기존 VD-12 범위 |
|
||||
| browser-managed handoff mechanism | `AVAILABLE_NOT_COMPOSED` | `REFERENCE_TESTED` | 실제 capability issuer는 제품 연결 시 필요 |
|
||||
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | `DESIGN_REVIEWED` | 이 ADR의 구현 대상 |
|
||||
| app-managed background download | `NOT_SELECTED` | `DESIGN_REVIEWED` | 제품 요구가 선택될 때만 별도 구현 |
|
||||
| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | `DESIGN_REVIEWED` | 공통 baseline으로 promotion 불가 |
|
||||
|
||||
기존 foreground stream과 browser-managed handoff의 source/evidence를 Range나
|
||||
app-managed background download 구현 증거로 재사용하지 않는다.
|
||||
|
||||
## 3. 결정 요약
|
||||
|
||||
1. whole-object foreground streaming, Range resumable download,
|
||||
browser-managed handoff와 app-managed background download를 서로 다른
|
||||
capability와 결과 타입으로 유지한다.
|
||||
2. Range protocol literal은 `RANGE_RESUMABLE_DOWNLOAD_V1`로 고정한다. 기존
|
||||
whole-object presigned contract에 암묵적으로 섞지 않는다.
|
||||
3. resume의 authority는 server-owned immutable generation과 strong validator다.
|
||||
local offset, file name, timestamp 또는 partial byte 존재는 authority가 아니다.
|
||||
4. 각 data-plane capability는 exact representation, start/end range, method,
|
||||
response status/header/length와 expiry를 묶고 한 번만 사용한다.
|
||||
5. checkpoint는 비권한성 recovery metadata만 account-partitioned storage에
|
||||
보관한다. URL, signed query/header, raw ETag, bearer token과 file path는
|
||||
저장하지 않는다.
|
||||
6. destination은 seek/truncate 가능한 명시적 port 또는 owned OPFS staging이다.
|
||||
순차 writable에 검증되지 않은 partial bytes를 append하지 않는다.
|
||||
7. checkpoint offset은 destination segment가 durable하게 commit되고 exact length가
|
||||
재확인된 뒤에만 CAS로 전진한다.
|
||||
8. final success는 destination 전체를 처음부터 다시 읽어 whole-object SHA-256을
|
||||
검증하고 final commit을 마친 경우만 `SAVED_VERIFIED`다.
|
||||
9. browser-managed handoff는 탭 종료 뒤 계속될 수 있는 기본 server-file
|
||||
fallback이지만 결과는 계속 `BROWSER_HANDOFF`다.
|
||||
10. app-managed background download는 cross-browser baseline이 아니다. 별도
|
||||
optional protocol, platform probe, worker control plane과 owned staging이 모두
|
||||
승인된 환경에서만 progressive enhancement로 조합한다.
|
||||
11. browser 차이는 user-agent 문자열이 아니라 capability probe와 정책으로
|
||||
결정한다.
|
||||
|
||||
## 4. Topology와 책임
|
||||
|
||||
```text
|
||||
product download use case
|
||||
-> product-owned download facade
|
||||
-> DownloadStrategySelector
|
||||
-> WHOLE_OBJECT_PICKER_STREAM
|
||||
-> RANGE_RESUMABLE_FOREGROUND
|
||||
-> BROWSER_MANAGED_HANDOFF
|
||||
-> BOUNDED_OBJECT_URL
|
||||
-> APP_MANAGED_BACKGROUND_DOWNLOAD (optional)
|
||||
|
||||
RANGE_RESUMABLE_FOREGROUND
|
||||
-> BFF control plane
|
||||
authorization
|
||||
immutable representation lookup
|
||||
range capability issuance/reissue
|
||||
-> browser RangeDownloadRuntime
|
||||
checkpoint + mutation lock
|
||||
exact HTTP state machine
|
||||
seekable destination or OPFS staging
|
||||
whole-object verification
|
||||
-> object store/BFF byte plane
|
||||
|
||||
APP_MANAGED_BACKGROUND_DOWNLOAD
|
||||
-> window-owned admission and user intent
|
||||
-> worker-specific control plane
|
||||
-> owned OPFS staging
|
||||
-> later foreground export
|
||||
```
|
||||
|
||||
브라우저는 bucket, object key, provider generation locator, signing key 또는 cloud
|
||||
credential을 소유하지 않는다. BFF가 logical resource를 exact immutable
|
||||
representation에 binding한다. direct object-store Range가 해당 binding과
|
||||
capability의 `preconditionMode`가 선택한 exact `If-Range` 또는
|
||||
immutable-generation precondition을 실제로 강제하지 못하면 BFF proxy/relay를
|
||||
사용한다.
|
||||
|
||||
## 5. Versioned Range capability
|
||||
|
||||
### 5.1 Application-visible handle
|
||||
|
||||
application에는 raw URL이나 validator를 노출하지 않는다.
|
||||
|
||||
```text
|
||||
RangeDownloadCapability
|
||||
protocol = RANGE_RESUMABLE_DOWNLOAD_V1
|
||||
opaque identity
|
||||
safe receipt
|
||||
resourceId
|
||||
representationBindingSha256
|
||||
totalByteLength
|
||||
mediaType
|
||||
wholeObjectSha256
|
||||
requestedStart
|
||||
requestedEndExclusive
|
||||
preconditionMode = STRONG_IF_RANGE | IMMUTABLE_GENERATION_PRECONDITION
|
||||
allowWholeObjectFallback
|
||||
expiresAtEpochMs
|
||||
```
|
||||
|
||||
adapter-owned identity vault에는 다음 data-plane binding을 함께 둔다.
|
||||
|
||||
```text
|
||||
exact HTTPS URL/query
|
||||
exact GET method
|
||||
exact origin/path
|
||||
exact Range header
|
||||
exact precondition header/value selected by preconditionMode
|
||||
required response headers
|
||||
allowed statuses = policy-derived exact subset of 200 | 206 | 412 | 416
|
||||
expected representation binding
|
||||
maximum response bytes
|
||||
single-use receipt
|
||||
```
|
||||
|
||||
`representationBindingSha256`는 protocol/version, logical resource, immutable
|
||||
generation, precondition mode별 normalized strong validator 또는 generation
|
||||
binding, exact total length, media type와 expected whole-object digest의 canonical
|
||||
binding이다. 이것은 authorization proof가 아니다. BFF는 client 값을 echo하지
|
||||
않고 registry snapshot에서 직접 재계산한다.
|
||||
|
||||
### 5.2 Strong validator
|
||||
|
||||
resume에는 다음 중 하나가 필요하다.
|
||||
|
||||
- server registry가 소유하는 immutable object generation과 그 generation에 pin된
|
||||
proxy/direct request
|
||||
- RFC semantics를 만족하는 strong ETag와 exact `If-Range`
|
||||
|
||||
weak ETag(`W/`), `Last-Modified`만 있는 representation, multipart ETag를 whole
|
||||
digest로 해석한 값과 CDN이 임의로 다시 쓴 validator는 resume authority로
|
||||
사용하지 않는다. provider가 strong validator를 제공하지 못하면 BFF가 immutable
|
||||
generation을 pin하거나 Range resume를 `UNSUPPORTED`로 닫는다.
|
||||
|
||||
raw ETag와 provider generation locator는 application, checkpoint, diagnostics와
|
||||
telemetry에 노출하지 않는다. reload 뒤에는 BFF가 새 capability를 발급하고,
|
||||
runtime은 새 capability의 `representationBindingSha256`가 checkpoint와 같은지
|
||||
확인한 뒤 vault 안의 exact precondition만 사용한다.
|
||||
|
||||
`STRONG_IF_RANGE` mode는 exact `If-Range`를 보내고 `206`, Range-ignore 또는
|
||||
validator mismatch의 full `200`과 해당 `416`만 계약한다.
|
||||
`IMMUTABLE_GENERATION_PRECONDITION` mode는 BFF/provider가 정한 exact `If-Match`
|
||||
또는 generation precondition을 보내며 `412`를 계약할 수 있다.
|
||||
`allowWholeObjectFallback`과 `allowedStatuses`는 mode, requested start와 provider
|
||||
topology에서 capability 발급 시 닫히며 executor가 임의로 넓히지 않는다.
|
||||
|
||||
### 5.3 Capability 재발급
|
||||
|
||||
capability expiry, data-plane `401/403/410` 또는 최소 잔여 lifetime 부족은 같은
|
||||
URL의 무조건 retry가 아니다.
|
||||
|
||||
1. 현재 response reader를 cancel하고 capability를 consume한다.
|
||||
2. control plane에 `downloadKey`, resource와 expected representation binding,
|
||||
exact next range를 전달한다.
|
||||
3. BFF가 authorization와 current generation을 다시 읽는다.
|
||||
4. binding이 같을 때만 새 capability로 같은 range를 재시도한다.
|
||||
5. binding이 바뀌었으면 partial destination을 append하지 않고
|
||||
`REPRESENTATION_CHANGED/RESTART`로 닫는다.
|
||||
|
||||
재발급 횟수, 전체 operation deadline과 retry backoff는 composition hard ceiling
|
||||
안에 둔다. capability를 durable queue나 worker message에 저장하지 않는다.
|
||||
|
||||
## 6. Durable checkpoint
|
||||
|
||||
### 6.1 Schema
|
||||
|
||||
```text
|
||||
RangeDownloadCheckpointV1
|
||||
schemaVersion = 1
|
||||
protocol = RANGE_RESUMABLE_DOWNLOAD_V1
|
||||
revision
|
||||
state = ACTIVE | PAUSED | FINALIZING | CLEANUP_PENDING
|
||||
downloadKey
|
||||
resourceBindingSha256
|
||||
representationBindingSha256
|
||||
totalByteLength
|
||||
nextOffset
|
||||
committedSegmentCount
|
||||
destination
|
||||
kind = OPFS_STAGING | SEEKABLE_FILE
|
||||
opaqueDestinationBinding
|
||||
createdAtEpochMs
|
||||
updatedAtEpochMs
|
||||
retentionExpiresAtEpochMs
|
||||
```
|
||||
|
||||
`downloadKey`, destination binding과 physical database/OPFS namespace는
|
||||
composition-issued opaque token이다. 사용자 file name, resource ID, account ID,
|
||||
tenant ID 또는 local path를 넣지 않는다.
|
||||
|
||||
checkpoint에 금지하는 값:
|
||||
|
||||
- presigned URL, query와 signed request/response header
|
||||
- bearer/session/auth/CSRF token
|
||||
- raw ETag, provider object key/generation locator
|
||||
- file name, user path와 native exception
|
||||
- incremental hash 내부 state
|
||||
- raw backend response나 retry body
|
||||
|
||||
허용된 digest binding과 offset은 비권한성 recovery metadata다. account partition,
|
||||
retention, count/byte budget과 logout deletion을 적용하며 log/analytics/ticket에는
|
||||
내보내지 않는다.
|
||||
|
||||
### 6.2 CAS와 durable offset
|
||||
|
||||
한 `downloadKey`는 cross-context exclusive mutation lock으로 직렬화한다. lock은
|
||||
correctness의 유일한 authority가 아니며 checkpoint revision CAS와 exact
|
||||
destination binding이 최종 local authority다.
|
||||
|
||||
`nextOffset`은 다음 순서가 모두 성공한 뒤에만 전진한다.
|
||||
|
||||
1. exact `206` range를 bounded stream으로 읽는다.
|
||||
2. expected start 위치에만 쓴다.
|
||||
3. writer close/segment commit을 완료한다.
|
||||
4. destination의 committed length가 expected end 이상인지 확인한다.
|
||||
5. unexpected tail이 있으면 authorized `truncate(expectedEnd)`를 완료한다.
|
||||
6. checkpoint를 `revision + 1`, `nextOffset = expectedEnd`로 CAS한다.
|
||||
|
||||
response가 성공했지만 destination commit 전에 crash하면 checkpoint는 이전
|
||||
offset에 머문다. 재시작은 destination을 checkpoint offset으로 truncate하고 같은
|
||||
range를 다시 요청한다. destination commit 뒤 checkpoint CAS가 유실된 경우도
|
||||
동일하게 checkpoint offset까지 truncate한 뒤 재전송한다. 따라서 중복 byte를
|
||||
append하지 않는다.
|
||||
|
||||
### 6.3 Inventory와 retention
|
||||
|
||||
checkpoint store는 단일 key read 외에 bounded admin operation을 제공해야 한다.
|
||||
|
||||
- account partition 안의 safe summary를 cursor page로 list
|
||||
- expired/terminal checkpoint를 bounded batch로 classify
|
||||
- destination binding과 함께 exact owned staging을 cleanup
|
||||
- active lock/lease가 있는 항목은 건너뜀
|
||||
- count, logical bytes, maximum age와 cleanup retry budget 강제
|
||||
- cleanup receipt를 durable하게 남기고 response 유실을 reconcile
|
||||
|
||||
inventory에는 resource ID, file name, digest, raw validator와 path를 반환하지
|
||||
않는다. 제품 resume UI가 필요한 경우 제품 database/query가 별도 safe display
|
||||
metadata를 소유하고 opaque `downloadKey`로만 연결한다.
|
||||
|
||||
## 7. Destination 계약
|
||||
|
||||
### 7.1 공통 port
|
||||
|
||||
```text
|
||||
ResumableDownloadDestinationPort
|
||||
inspect(binding) -> committedLength, readable, writable, permissionState
|
||||
openWriter(binding, keepExistingData=true)
|
||||
seek(offset)
|
||||
write(chunk)
|
||||
truncate(length)
|
||||
commitSegment()
|
||||
openReader(start=0)
|
||||
finalize()
|
||||
abortAttempt()
|
||||
cleanup(authority)
|
||||
```
|
||||
|
||||
native handle, OPFS handle와 path는 adapter 밖으로 노출하지 않는다. 모든 method는
|
||||
bounded deadline, AbortSignal과 closed failure를 사용한다.
|
||||
|
||||
### 7.2 Seekable external file
|
||||
|
||||
직접 외부 파일에 resume하려면 browser가 기존 data 보존, seek, truncate,
|
||||
재읽기와 permission 재확인을 실제로 지원해야 한다.
|
||||
|
||||
- picker와 permission request는 Window의 명시적 user activation에서만 실행한다.
|
||||
- structured-cloned handle을 보존하는 경우 별도 privacy/retention 승인이 필요하다.
|
||||
- reopen 뒤 `queryPermission`/`requestPermission`을 거치며 denied면
|
||||
`PERMISSION_DENIED/RESELECT`다.
|
||||
- writer가 temporary-file commit semantics를 쓰면 segment마다 close한 뒤
|
||||
committed file size를 다시 확인한다.
|
||||
- checkpoint보다 큰 tail은 검증하지 않고 사용하지 않으며 exact checkpoint
|
||||
offset으로 truncate한다.
|
||||
- checkpoint보다 파일이 작거나 다른 handle이면 `CONFLICT/RESTART`다.
|
||||
|
||||
브라우저가 이 계약을 만족하지 못하면 external-file resume를 흉내 내지 않고 OPFS
|
||||
staging 또는 browser-managed handoff로 전환한다.
|
||||
|
||||
### 7.3 OPFS staging
|
||||
|
||||
cross-browser app-controlled resume의 우선 destination은 policy-owned OPFS
|
||||
staging이다.
|
||||
|
||||
- physical path는 기존 OPFS authority/namespace/partition registry가 발급한다.
|
||||
- checkpoint와 OPFS object는 immutable binding과 generation journal로 연결한다.
|
||||
- quota estimate는 admission hint일 뿐이며 write 중 quota failure도 처리한다.
|
||||
- download 완료 뒤 staging 전체를 다시 읽어 SHA-256을 검증한다.
|
||||
- foreground user activation에서 새 외부 destination을 열고 staging을 stream
|
||||
export한다.
|
||||
- 외부 export close가 성공하기 전 staging을 삭제하지 않는다.
|
||||
- export 결과가 유실되면 staging을 유지하고 user에게 retry 가능한 상태를
|
||||
반환한다.
|
||||
|
||||
OPFS 저장 성공은 사용자가 접근 가능한 파일 저장 완료가 아니다. 결과를
|
||||
`STAGED_VERIFIED`와 `SAVED_VERIFIED`로 구분한다. OPFS는 큰 파일에서 storage와
|
||||
I/O를 한 번 더 요구하므로 quota/retention owner 없는 기본 fallback이 아니다.
|
||||
|
||||
## 8. HTTP 상태 머신
|
||||
|
||||
### 8.1 요청 전
|
||||
|
||||
1. checkpoint와 destination binding을 exact하게 읽는다.
|
||||
2. destination length를 검사하고 checkpoint보다 큰 tail을 truncate한다.
|
||||
3. checkpoint보다 작으면 partial을 신뢰하지 않고 restart/cleanup으로 닫는다.
|
||||
4. 새 capability의 representation binding과 exact range를 검증한다.
|
||||
5. `Range: bytes=S-E`와 capability의 `preconditionMode`가 정한 exact
|
||||
`If-Range` 또는 immutable-generation precondition을 vault binding 그대로
|
||||
보낸다.
|
||||
6. `credentials: omit`, `redirect: error`, `no-referrer`, `no-store`,
|
||||
identity content encoding을 강제한다.
|
||||
|
||||
한 request의 range 크기와 exact `S/E`는 capability 발급 **전에** composition
|
||||
maximum 안에서 계산한다. executor는 capability의 `requestedStart`,
|
||||
`requestedEndExclusive`와 exact Range header가 일치하는지 검증하고 그대로
|
||||
전송하며 다시 줄이거나 늘리지 않는다. ceiling을 넘는 capability는 사용 전에
|
||||
거절한다. 기본 protocol은 sequential range만 허용한다. parallel range와 sparse
|
||||
destination은 별도 protocol/version 없이는 사용하지 않는다.
|
||||
zero-byte representation은 유효하지 않은 byte range를 만들지 않는다. exact
|
||||
length가 0이고 empty-object SHA-256 binding이 일치하는 whole-object `200` 경로로
|
||||
body/length를 확인한 뒤 바로 final verification으로 이동한다.
|
||||
|
||||
response body를 읽거나 destination writer를 열기 전에 `200`, `206`, `412`,
|
||||
`416` 중 수신한 status가 capability vault의 exact `allowedStatuses` member인지
|
||||
검사한다. 해당 네 값 중 허용되지 않은 status는 body를 cancel하고 capability를
|
||||
consume하며 destination과 checkpoint를 변경하지 않은 채
|
||||
`CONTRACT_MISMATCH`로 fail-closed한다. 아래 네 분기는 이 공통 admission gate를
|
||||
통과한 경우에만 실행한다. 그 밖의 status는 §8.6의 별도 failure/reissue 규칙으로
|
||||
처리한다.
|
||||
|
||||
### 8.2 `206 Partial Content`
|
||||
|
||||
성공 조건:
|
||||
|
||||
- `206`이 capability의 `allowedStatuses` member
|
||||
- final response URL이 capability URL과 exact match
|
||||
- `Content-Range: bytes S-E/T`가 하나만 존재하고 parse가 엄격함
|
||||
- `S`가 requested start, `E + 1`이 requested end exclusive
|
||||
- `T`가 checkpoint total과 같음
|
||||
- `Content-Length = E - S + 1`
|
||||
- strong validator/immutable generation binding 일치
|
||||
- media type과 identity encoding 일치
|
||||
- body 실제 bytes가 exact content length
|
||||
|
||||
하나라도 다르면 reader와 current destination attempt를 abort하고 checkpoint를
|
||||
전진시키지 않는다. 정상인 경우에만 앞 절의 durable offset 순서로 commit한다.
|
||||
|
||||
### 8.3 `200 OK`
|
||||
|
||||
`200`은 capability의 `allowedStatuses` member인 경우에만 이 분기로 들어온다.
|
||||
body를 destination에 쓰기 전에 final response URL, required response header,
|
||||
media type, identity encoding과 mode별 strong validator 또는 immutable-generation
|
||||
evidence가 capability의 exact representation binding과 일치하는지 검증한다.
|
||||
|
||||
다음 순서로 배타적으로 처리한다.
|
||||
|
||||
1. validator/generation evidence가 없거나 binding이 다르면 body를 cancel하고
|
||||
capability를 consume한다. 기존 checkpoint와 partial은 append하지 않고
|
||||
quarantine/retention policy로 전환한 뒤 control plane에서 current
|
||||
representation을 다시 확인한다. 결과는
|
||||
`REPRESENTATION_CHANGED/RESTART`이며 byte 0의 새 operation만 허용한다.
|
||||
2. binding은 같지만 requested start가 `0`이고
|
||||
`allowWholeObjectFallback=true`이면 fresh whole-object destination에서 기존
|
||||
whole-object stream 계약으로 처리한다. exact total length와 final
|
||||
whole-object digest를 검증하기 전에는 success나 final commit을 반환하지 않는다.
|
||||
3. binding은 같고 requested start가 `0`이지만
|
||||
`allowWholeObjectFallback=false`이면 body를 한 byte도 쓰지 않고 cancel한다.
|
||||
결과는 `WHOLE_OBJECT_FALLBACK_NOT_ALLOWED`이며 policy가 허용한 새 Range
|
||||
capability, browser handoff 또는 explicit unsupported만 선택한다.
|
||||
4. binding은 같고 requested start가 `0`보다 크면 server가 Range를 무시한
|
||||
것이다. body를 한 byte도 쓰지 않고 cancel하며 기존 partial을 같은 writer에서
|
||||
덮어쓰지 않는다. control plane reconcile 뒤 같은 representation의 byte 0
|
||||
restart operation, browser handoff 또는 explicit unsupported만 선택한다.
|
||||
|
||||
### 8.4 `412 Precondition Failed`
|
||||
|
||||
`412`가 capability의 `allowedStatuses` member이고
|
||||
`preconditionMode=IMMUTABLE_GENERATION_PRECONDITION`인 경우에만 이 분기로 들어온다.
|
||||
representation precondition 실패다. body를 cancel하고 checkpoint를 유지한 채
|
||||
control plane에서 current generation을 확인한다. 같은 binding을 다시 발급하지
|
||||
못하면 partial은 cleanup policy에 따라 폐기하고 byte 0부터 새 operation을
|
||||
시작한다.
|
||||
|
||||
RFC `If-Range` validator mismatch 자체의 정상 응답은 `200`이다. `412`는 BFF나
|
||||
provider가 immutable generation을 pin하기 위해 별도 `If-Match` 계열 precondition을
|
||||
함께 강제하는 topology에서만 이 상태 머신에 들어온다. topology가 `412`를 계약하지
|
||||
않았다면 unknown status로 fail-closed한다.
|
||||
|
||||
### 8.5 `416 Range Not Satisfiable`
|
||||
|
||||
`416`이 capability의 `allowedStatuses` member인 경우에만 이 분기로 들어온다.
|
||||
response body는 download data로 소비하지 않고 cancel한다.
|
||||
`Content-Range: bytes */T`를 strict하게 검사하며 final response URL, required
|
||||
headers와 mode별 validator/generation binding도 확인한다. provider의 `416`이
|
||||
binding evidence를 반환할 수 없는 topology라면 BFF control plane reconcile이
|
||||
exact immutable generation을 다시 증명하기 전에는 EOF나 missing-range 분기로
|
||||
진행하지 않는다.
|
||||
|
||||
다음 순서를 사용하며 한 분기를 처리한 뒤 아래 분기로 fall through하지 않는다.
|
||||
|
||||
1. malformed/missing `T`, final URL/header mismatch 또는 증명되지 않은
|
||||
representation binding은 `CONTRACT_MISMATCH`로 fail-closed한다.
|
||||
2. `T != expected total`이면 representation changed다. local bytes를 `T`에 맞춰
|
||||
자동 truncate하거나 append하지 않고 capability를 consume한 뒤 partial을
|
||||
quarantine/restart한다.
|
||||
3. `nextOffset > T`이면 checkpoint 자체가 corrupt/stale이다. 잘못된 offset으로
|
||||
truncate하지 않고 checkpoint와 partial을 quarantine한 뒤 restart/recovery로
|
||||
닫는다.
|
||||
4. `nextOffset <= T`이지만 `local committed length != nextOffset`이면 먼저 local
|
||||
state를 reconcile한다.
|
||||
- local length가 더 크면 exact `nextOffset`까지만 uncommitted tail을
|
||||
authorized truncate하고 durable length를 다시 확인한다.
|
||||
- local length가 더 작으면 journal이 증명하는 마지막 confirmed segment로
|
||||
destination과 checkpoint를 함께 CAS rollback할 수 있을 때만 복구한다.
|
||||
그렇지 않으면 quarantine/restart한다.
|
||||
이 분기는 reconcile 결과를 새 state-machine invocation에서 다시 평가하며 바로
|
||||
finalization이나 missing-range request로 진행하지 않는다.
|
||||
5. `local committed length == nextOffset == T`이면 data transfer가 끝난 후보로
|
||||
보고 `FINALIZING` whole-object verification으로 이동한다.
|
||||
6. `local committed length == nextOffset < T`이면 local missing range가 남아 있다.
|
||||
새 capability로 exact `nextOffset` range를 재발급한다. 같은 total에 대해
|
||||
satisfiable range가 다시 `416`이면 bounded retry하지 않고
|
||||
`CONTRACT_MISMATCH`로 fail-closed한다.
|
||||
|
||||
`416` 자체를 다운로드 성공으로 간주하지 않는다.
|
||||
|
||||
### 8.6 나머지 상태와 network failure
|
||||
|
||||
| 조건 | 처리 |
|
||||
| --- | --- |
|
||||
| `401/403/410` | bounded capability reissue; binding mismatch면 restart |
|
||||
| `404` | existence-hiding policy에 따라 unavailable/not-found, partial cleanup 예약 |
|
||||
| `409` | server representation/session reconcile |
|
||||
| `429`/모든 `5xx`/network | 동일 exact range만 bounded retry |
|
||||
| redirect/opaque response | policy rejection |
|
||||
| timeout/cancel | reader와 writer attempt abort, checkpoint 유지 |
|
||||
| overrun/truncation | integrity failure, checkpoint 유지 |
|
||||
|
||||
retry는 destination commit 여부를 먼저 판단한다. effect가 ambiguous하면
|
||||
checkpoint와 destination length를 reconcile하기 전 새 offset으로 이동하지 않는다.
|
||||
|
||||
## 9. Whole-object integrity와 final commit
|
||||
|
||||
Range별 transport 검증은 whole-object 무결성 증거가 아니다. 모든 bytes가
|
||||
수신되면 checkpoint를 `FINALIZING`으로 CAS하고 다음을 수행한다.
|
||||
|
||||
1. destination length가 exact total과 같은지 확인한다.
|
||||
2. destination을 byte 0부터 bounded chunk로 다시 읽는다.
|
||||
3. vetted incremental SHA-256으로 whole-object digest를 계산한다.
|
||||
4. capability/representation binding의 expected digest와 constant-time 비교한다.
|
||||
5. mismatch면 사용자 destination을 성공으로 표시하지 않고 staging을 격리하거나
|
||||
authorized cleanup한다.
|
||||
6. OPFS staging이면 foreground external export와 destination close를 완료한다.
|
||||
7. final destination commit truth를 확인한 뒤만 `SAVED_VERIFIED`를 반환한다.
|
||||
8. checkpoint와 staging cleanup을 exact revision/receipt로 완료한다.
|
||||
|
||||
portable하지 않은 incremental hash 내부 state를 checkpoint에 serialize하지 않는다.
|
||||
마지막 full reread 비용을 피하려면 chunk digest/Merkle manifest를 별도 protocol로
|
||||
설계하고 server가 exact proof를 제공해야 한다.
|
||||
|
||||
## 10. Pause, cancel, crash와 account lifecycle
|
||||
|
||||
### 10.1 Pause
|
||||
|
||||
`pause(downloadKey)`는 browser work 중단이며 server resource/capability revoke가
|
||||
아니다.
|
||||
|
||||
- 같은 runtime의 read/write/backoff를 AbortSignal로 중단한다.
|
||||
- same-origin context에는 opaque key만 담은 versioned ephemeral pause event를
|
||||
보낸다.
|
||||
- mutation lock 안에서 checkpoint를 `PAUSED`로 CAS한다.
|
||||
- in-memory URL/header/capability는 즉시 retire한다.
|
||||
- committed segment는 유지하고 ambiguous writer attempt는 checkpoint offset으로
|
||||
reconcile한다.
|
||||
|
||||
### 10.2 Cancel과 discard
|
||||
|
||||
cancel은 transfer 중단만 의미할 수 있고, discard는 local partial 삭제다. 제품
|
||||
facade가 두 의도를 구분해야 한다. discard는 exact partition/destination binding과
|
||||
short-lived maintenance authority를 요구하며 checkpoint와 OPFS staging을 하나의
|
||||
cleanup journal로 처리한다.
|
||||
|
||||
### 10.3 Crash/reload
|
||||
|
||||
reload 후 runtime은:
|
||||
|
||||
1. account partition과 governance binding을 검증한다.
|
||||
2. checkpoint schema/protocol/revision을 검증한다.
|
||||
3. destination을 reopen하고 permission/length를 검사한다.
|
||||
4. server에서 새 capability를 발급받아 representation binding을 대조한다.
|
||||
5. exact checkpoint offset부터 resume한다.
|
||||
|
||||
source가 같은지 사용자에게 묻는 file-name 기반 확인은 사용하지 않는다.
|
||||
|
||||
### 10.4 Logout/account/tenant switch
|
||||
|
||||
- 신규 capability 발급과 resume admission을 먼저 닫는다.
|
||||
- active foreground operation을 abort하고 writer를 정리한다.
|
||||
- vault와 worker channel을 close한다.
|
||||
- account partition의 checkpoint와 owned staging을 maintenance-authorized bounded
|
||||
cleanup으로 제거한다.
|
||||
- blocked deletion을 성공으로 보고하지 않는다.
|
||||
- 이전 account handle/reference를 새 runtime에서 resolve하지 않는다.
|
||||
|
||||
retention/legal-hold 정책이 local partial 보존을 요구하는 특별한 제품이 아니라면
|
||||
logout에서 partial을 제거하는 것이 기본이다.
|
||||
|
||||
## 11. Download strategy selector
|
||||
|
||||
selector는 presentation의 임의 조건문이 아니라 composition-owned immutable
|
||||
policy와 runtime probe를 받는 공통 application service다.
|
||||
|
||||
입력:
|
||||
|
||||
- source가 server resource인지 client-generated artifact인지
|
||||
- exact 또는 maximum byte length
|
||||
- verified integrity 필요 여부
|
||||
- resume/background 요구
|
||||
- system save picker, seek/truncate, OPFS와 worker capability
|
||||
- user activation
|
||||
- storage quota admission
|
||||
- browser-managed capability availability
|
||||
- data classification와 retention policy
|
||||
|
||||
결과:
|
||||
|
||||
| 조건 | 선택 |
|
||||
| --- | --- |
|
||||
| server file, 탭 종료 뒤 계속 필요 | `BROWSER_MANAGED_HANDOFF` |
|
||||
| server file, verified foreground save, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` |
|
||||
| server file, resume 필수, destination 계약 충족 | `RANGE_RESUMABLE_FOREGROUND` |
|
||||
| 작은 generated artifact | `BOUNDED_OBJECT_URL` |
|
||||
| 큰 generated artifact, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` |
|
||||
| 큰 generated artifact, picker 미지원 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` |
|
||||
| app background download가 승인·지원되고 OPFS quota 확보 | `APP_MANAGED_BACKGROUND_DOWNLOAD` |
|
||||
|
||||
selector는 fallback으로 byte/memory/security ceiling을 올리지 않는다. integrity가
|
||||
필수인데 browser handoff만 가능하면 “검증된 저장”으로 downgrade하지 않고 제품이
|
||||
handoff 또는 unsupported 중 하나를 명시적으로 선택한다.
|
||||
|
||||
### Safari와 picker 미지원 환경
|
||||
|
||||
user-agent 문자열로 Safari를 판별하지 않는다. 필요한 API와 실제 semantics를
|
||||
capability probe로 확인한다.
|
||||
|
||||
- 대용량 server file: authorized `Content-Disposition` browser handoff
|
||||
- 작은 generated file: bounded Blob/object URL
|
||||
- 대용량 generated file: server-side generation 또는 unsupported
|
||||
- OPFS: app-private staging일 뿐 Finder/Files 저장 완료로 표시하지 않음
|
||||
- system save picker 미지원: unbounded Blob으로 자동 전환하지 않음
|
||||
- seek/truncate/permission semantics 미충족: external Range resume 비활성화
|
||||
|
||||
browser-managed handoff endpoint는 cross-origin `download` attribute에 의존하지
|
||||
않고 server가 safe `Content-Disposition`, media type, byte/generation policy를
|
||||
실제 response에서 강제한다.
|
||||
|
||||
## 12. Background download 전달의 세 의미
|
||||
|
||||
### 12.1 Foreground app-managed
|
||||
|
||||
page가 열린 동안 runtime이 fetch, progress, integrity와 destination을 모두
|
||||
관리한다. 현재 whole-object stream과 목표 Range resume가 이 범주다. page lifecycle
|
||||
종료 뒤 지속을 보장하지 않는다.
|
||||
|
||||
### 12.2 Browser-managed handoff
|
||||
|
||||
navigation/download manager에 authorized endpoint를 넘긴다.
|
||||
|
||||
- page 종료 뒤 계속될 수 있는 가장 넓은 fallback
|
||||
- application은 실제 disk write, 저장 위치와 final digest를 관찰하지 못함
|
||||
- 결과는 `BROWSER_HANDOFF`, `SAVED`나 `VERIFIED`가 아님
|
||||
- pause/resume UI와 retry semantics는 browser가 소유
|
||||
|
||||
### 12.3 App-managed background download
|
||||
|
||||
Service Worker/Background Fetch 등에서 application이 progress/retry/staging을
|
||||
관리하려는 별도 optional capability다.
|
||||
|
||||
필수 조건:
|
||||
|
||||
- target browser/deployment의 explicit support matrix
|
||||
- worker-safe authenticated control plane
|
||||
- worker가 매 range마다 새 short-lived capability를 발급받는 계약
|
||||
- capability/URL/header를 IDB, OPFS, Cache Storage와 message에 저장하지 않음
|
||||
- private bytes는 Cache Storage가 아니라 policy-owned OPFS staging 사용
|
||||
- worker termination을 정상 상태로 보고 checkpoint에서 재개
|
||||
- concurrency, battery/network, quota와 retention ceiling
|
||||
- logout/revocation event와 worker admission fence
|
||||
- client/worker version compatibility와 upgrade drain
|
||||
- notification/foreground export UX
|
||||
|
||||
일반 Service Worker의 수명이나 background execution 시간을 correctness 근거로
|
||||
삼지 않는다. Background Fetch가 없는 환경에서 timer/keepalive로 장기 download를
|
||||
흉내 내지 않는다. user-visible external save picker는 worker에서 호출하지 않고
|
||||
완료된 OPFS staging을 다음 foreground user gesture에서 export한다.
|
||||
|
||||
따라서 app-managed background download가 향후 `AVAILABLE_NOT_COMPOSED` 또는 `COMPOSED`가
|
||||
되더라도 지원 browser의 progressive enhancement일 뿐이다. cross-browser 보장
|
||||
자체의 primary status는 계속 `PLATFORM_LIMITED`다.
|
||||
|
||||
## 13. Security, privacy와 observability
|
||||
|
||||
- URL/query/header, validator와 capability는 bearer 또는 sensitive metadata로
|
||||
취급한다.
|
||||
- `Range`와 `preconditionMode`가 선택한 exact `If-Range` 또는
|
||||
immutable-generation precondition은 adapter vault가 binding 그대로 생성한다.
|
||||
- caller는 offset을 늘리거나 arbitrary range를 요청하지 못한다.
|
||||
- account partition과 resource authorization을 매 capability reissue에서 검사한다.
|
||||
- partial bytes는 원본과 같은 data classification, retention, encryption-at-rest와
|
||||
deletion policy를 적용한다.
|
||||
- OPFS quota pressure가 다른 account partial을 제거할 권한을 주지 않는다.
|
||||
- preview, execution 또는 Cache Storage promotion은 final verification 전 금지한다.
|
||||
- high-cardinality ID, file name, path, URL, raw ETag와 digest를 metric label/log에
|
||||
넣지 않는다.
|
||||
|
||||
허용된 aggregate observation:
|
||||
|
||||
- strategy와 destination kind
|
||||
- response state bucket
|
||||
- expected/committed byte bucket
|
||||
- retry/reissue/resume count bucket
|
||||
- duration, pause, restart, integrity와 cleanup outcome
|
||||
- browser capability support reason code
|
||||
|
||||
## 14. Composition과 operational admission
|
||||
|
||||
`createBrowserTransferRuntime`에 해당하는 미래 composition owner만 다음을 조합한다.
|
||||
|
||||
- versioned wire codecs와 fixed BFF endpoint
|
||||
- capability vault/provider/executor
|
||||
- Range checkpoint store, destination registry와 mutation lock
|
||||
- selector policy와 browser capability probe
|
||||
- presigned, upload, image와 Range lifecycle
|
||||
- account/logout cleanup authority
|
||||
- safe observer
|
||||
- traffic admission과 kill switch
|
||||
|
||||
독립적인 canonical readiness 상태:
|
||||
|
||||
```text
|
||||
Selection = NOT_SELECTED | SELECTED | REMOVING
|
||||
TrafficAdmission = DISABLED | SHADOW | CANARY | ENABLED
|
||||
RuntimeHealth = UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
|
||||
PromotionEvidence = MISSING | PARTIAL | COMPLETE | EXPIRED
|
||||
```
|
||||
|
||||
primary status가 `COMPOSED`여도 `TrafficAdmission` 기본값은 `DISABLED`다.
|
||||
필수 config, strong validator/provider conformance, destination semantics, cleanup
|
||||
owner 또는 valid evidence가 없으면 `TrafficAdmission=DISABLED`,
|
||||
`RuntimeHealth=UNKNOWN | UNAVAILABLE`,
|
||||
`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 readiness를 fail-closed한다.
|
||||
이미 승인된 product selection 자체를 provider evidence 부족만으로 되돌리지
|
||||
않는다.
|
||||
|
||||
Kill switch:
|
||||
|
||||
- 신규 Range capability issuance off
|
||||
- Range resume off → whole-object restart 또는 browser handoff
|
||||
- direct object-store Range off → BFF proxy
|
||||
- external seek destination off → OPFS staging 또는 handoff
|
||||
- app-managed background download off → foreground/browser handoff
|
||||
- final export off → verified staging 유지
|
||||
|
||||
kill switch는 partial을 자동 삭제하거나 handoff를 saved/verified로 바꾸지 않는다.
|
||||
|
||||
## 15. Test와 conformance matrix
|
||||
|
||||
### 15.1 Deterministic runtime
|
||||
|
||||
- checkpoint CAS conflict와 corrupt/unknown field
|
||||
- exact segment commit 전/후 crash
|
||||
- destination larger/smaller/different binding
|
||||
- pause/resume/cancel/discard races
|
||||
- capability expiry/reissue와 representation change
|
||||
- `200/206/412/416` 모든 분기
|
||||
- malformed/multiple/overflow `Content-Range`
|
||||
- weak/missing/mismatched validator
|
||||
- overrun, truncation, stalled body와 abort
|
||||
- final whole-object digest mismatch
|
||||
- cleanup response loss와 replay
|
||||
- count/byte/age retention sweep
|
||||
|
||||
### 15.2 Browser matrix
|
||||
|
||||
- system picker 지원/미지원
|
||||
- seek/truncate/keep-existing-data semantics
|
||||
- OPFS quota, eviction, reload와 worker termination
|
||||
- cross-tab lock/pause delivery
|
||||
- user activation과 permission denied/revoked
|
||||
- large server handoff
|
||||
- foreground export close/abort
|
||||
- Chromium, Firefox와 WebKit 동일 필수 case set
|
||||
|
||||
지원하지 않는 API는 skip이 아니라 selector의 expected fallback/`UNSUPPORTED` 결과로
|
||||
검증한다.
|
||||
|
||||
### 15.3 BFF/object provider contract
|
||||
|
||||
- immutable generation pin
|
||||
- capability `preconditionMode`에 따른 strong `If-Range` 또는 immutable
|
||||
generation precondition
|
||||
- beginning/middle/end/empty/invalid range
|
||||
- exact `206 Content-Range`와 length
|
||||
- deliberate Range ignore `200`
|
||||
- mode가 계약한 경우의 precondition `412`, EOF/invalid `416`
|
||||
- mid-transfer capability expiry/revocation
|
||||
- redirect/CORS/exposed-header/identity-encoding
|
||||
- object replacement race
|
||||
- direct provider와 proxy 결과 동등성
|
||||
- URL/header/log redaction
|
||||
|
||||
fake와 route interception은 actual provider conformance를 대체하지 않는다.
|
||||
|
||||
### 15.4 Background-download-specific fault
|
||||
|
||||
- worker가 range commit 전/후 종료
|
||||
- worker/client version 교체
|
||||
- logout과 capability revocation
|
||||
- offline/online 반복, quota exhaustion과 battery/network policy
|
||||
- notification 유실과 foreground export replay
|
||||
- unsupported browser가 foreground/handoff로 정확히 fallback
|
||||
|
||||
## 16. Rollout과 promotion
|
||||
|
||||
Primary status 변경과 readiness/traffic promotion은 별도로 승인한다.
|
||||
|
||||
1. Range의 `DESIGNED_NOT_IMPLEMENTED`와 ADR-local
|
||||
`sourceEvidence=DESIGN_REVIEWED`를 확인한다.
|
||||
2. provider-neutral ports/runtime, deterministic fake, negative fixture와 browser
|
||||
test를 완성한 경우에만 Range primary status를
|
||||
`AVAILABLE_NOT_COMPOSED`, source evidence를 `REFERENCE_TESTED`로 변경한다.
|
||||
deterministic/reference test만으로 canonical `PromotionEvidence`를
|
||||
`COMPLETE`로 바꾸지 않는다.
|
||||
3. 제품 요구, owner, data class와 fallback이 선택되지 않은 app-managed
|
||||
background download는 계속 `NOT_SELECTED`로 둔다. cross-browser 보장은
|
||||
`PLATFORM_LIMITED`다.
|
||||
4. fixed staging BFF/provider, actual config, account lifecycle와 runbook을 설치한
|
||||
capability만 `COMPOSED`로 기록한다. 이때도
|
||||
`TrafficAdmission=DISABLED`, `RuntimeHealth=UNKNOWN`,
|
||||
`PromotionEvidence=PARTIAL`이다.
|
||||
5. operator probe와 shadow에서 contract evidence를 수집한다.
|
||||
6. internal cohort에서 BFF proxy Range를 먼저 canary한다.
|
||||
7. direct provider Range와 external seek destination은 각각 별도 canary한다.
|
||||
8. app-managed background download를 실제로 선택했다면 지원 browser cohort에서만 별도
|
||||
opt-in canary한다.
|
||||
9. contract/provider/browser/operations component gate, SLO, cleanup drill,
|
||||
rollback과 evidence freshness가 모두 충족된 승인 범위만
|
||||
`PromotionEvidence=COMPLETE`, `RuntimeHealth=AVAILABLE`,
|
||||
`TrafficAdmission=ENABLED`로 promotion한다.
|
||||
|
||||
provider, endpoint, validator semantics, browser major behavior, destination adapter,
|
||||
wire protocol 또는 security policy가 바뀌면 relevant evidence를 만료시키고
|
||||
재승인한다.
|
||||
|
||||
## 17. Rollback과 제거
|
||||
|
||||
운영 rollback 순서:
|
||||
|
||||
1. 신규 Range/background-download admission과 capability 발급을 중지한다.
|
||||
2. active writer/worker를 abort하고 checkpoint offset으로 reconcile한다.
|
||||
3. app background download를 foreground/browser handoff로 낮춘다.
|
||||
4. direct Range를 BFF proxy 또는 whole-object restart로 낮춘다.
|
||||
5. verified OPFS staging은 retention window 안에서 foreground export 가능 상태로
|
||||
유지한다.
|
||||
6. ambiguous partial은 성공으로 표시하지 않고 cleanup queue로 넘긴다.
|
||||
7. provider/signing credential 노출이 원인이면 backend revoke와 key rotation을
|
||||
수행한다.
|
||||
|
||||
완전 제거:
|
||||
|
||||
1. pending checkpoint/staging inventory를 bounded하게 drain, export 또는 discard한다.
|
||||
2. worker, channel, lock과 runtime을 close한다.
|
||||
3. account-partition checkpoint/OPFS namespace를 maintenance-authorized cleanup한다.
|
||||
4. endpoint, worker registration, config, policy와 feature facade를 제거한다.
|
||||
5. production bundle/module inventory와 removal test로 source 부재를 증명한다.
|
||||
|
||||
rollback은 unbounded Blob fallback, validator 완화, digest 생략 또는 partial 자동
|
||||
append를 허용하지 않는다.
|
||||
|
||||
## 18. 완료 기준
|
||||
|
||||
Range resumable download는 다음이 모두 참일 때만 구현 완료다.
|
||||
|
||||
- `RANGE_RESUMABLE_DOWNLOAD_V1` port와 strict wire codec이 있음
|
||||
- capability mode별 exact allowed-status subset과 `200/206/412/416` 처리
|
||||
상태 머신이 실행 가능하게 검증됨
|
||||
- strong validator/immutable generation이 실제 provider에서 강제됨
|
||||
- checkpoint CAS, inventory, retention과 account cleanup이 구현됨
|
||||
- seek/truncate 또는 OPFS staging destination이 crash fault를 통과함
|
||||
- capability reissue가 representation mismatch를 fail-closed함
|
||||
- final whole-object SHA-256 뒤에만 verified success를 반환함
|
||||
- selector가 picker/seek 미지원과 대용량 fallback을 안전하게 결정함
|
||||
- actual BFF/provider와 Chromium/Firefox/WebKit evidence가 유효함
|
||||
- SLO, alert, runbook, kill switch, rollback과 cleanup drill이 승인됨
|
||||
|
||||
app-managed background download는 위 항목에 더해 다음이 필요하다.
|
||||
|
||||
- 지원 browser/deployment 범위가 명시됨
|
||||
- worker lifecycle 종료를 checkpoint로 복구함
|
||||
- worker control plane이 durable capability 저장 없이 동작함
|
||||
- logout/revocation/version upgrade fault가 통과함
|
||||
- 미지원 browser fallback이 동일 제품 요구를 안전하게 만족하거나 명시적
|
||||
unsupported UX를 가짐
|
||||
|
||||
이 기준 전에는 기존 foreground streaming 또는 browser handoff의 성공을 Range나
|
||||
background download 구현 완료 증거로 사용하지 않는다.
|
||||
|
||||
## 19. 선택하지 않은 대안
|
||||
|
||||
- `Range` header만 추가하고 기존 sequential writable에 append
|
||||
- weak ETag나 file name/lastModified를 representation identity로 사용
|
||||
- serialized incremental hash state를 검증 없이 checkpoint
|
||||
- `200` response를 기존 partial 뒤에 append
|
||||
- `416`을 곧바로 success로 해석
|
||||
- Service Worker keepalive를 cross-browser background 보장으로 간주
|
||||
- picker 미지원 대용량 파일을 unbounded Blob으로 fallback
|
||||
- OPFS staging을 사용자 파일 저장 완료로 표시
|
||||
- browser-managed handoff를 application-verified save로 표시
|
||||
- user-agent 문자열 기반 Safari 분기
|
||||
|
||||
## 20. 참고
|
||||
|
||||
- [Browser data capability completion ledger](../browser-data-capability-completion-ledger.md)
|
||||
- [VD-16 Browser transfer composition과 Image delivery](./VD-16-browser-transfer-composition-and-image-delivery.md)
|
||||
- [Browser transfer recovery](../../operations/browser-transfer-recovery.md)
|
||||
- [RFC 9110 HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html)
|
||||
- [Fetch Standard](https://fetch.spec.whatwg.org/)
|
||||
- [File System Standard](https://fs.spec.whatwg.org/)
|
||||
- [Service Workers](https://w3c.github.io/ServiceWorker/)
|
||||
- [Background Fetch draft](https://wicg.github.io/background-fetch/)
|
||||
- [기존 transfer 설계](../presigned-transfer-and-image-cdn.md)
|
||||
- [browser file/origin storage 설계](../browser-file-and-origin-storage.md)
|
||||
@@ -0,0 +1,858 @@
|
||||
# VD-15: Origin storage lifecycle, migration, and optional file capabilities
|
||||
|
||||
- 상태: Accepted design — implementation pending
|
||||
- 결정일: 2026-07-28
|
||||
- 현재 구현 상태: capability별로 아래 표에 명시
|
||||
- 이 ADR이 선택한 common delta의 목표 reference 상태:
|
||||
`AVAILABLE_NOT_COMPOSED`
|
||||
- 관련 결정: VD-10, VD-11, VD-12, VD-14
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 적용 범위: File/Blob preview, file/directory selection, IndexedDB, OPFS,
|
||||
Cache Storage, StorageManager, optional Service Worker lifecycle
|
||||
|
||||
이 결정은 VD-11의 reference runtime을 실제 제품에 조립하기 전에 남아 있는
|
||||
origin-wide lifecycle과 migration 경계를 고정한다. 문서가 추가됐다는 사실은
|
||||
runtime 구현, bootstrap composition 또는 production traffic 승격을 의미하지
|
||||
않는다.
|
||||
|
||||
현재 구현돼 있는 것은 transient file vault, file picker, bounded download,
|
||||
IndexedDB repository/migration mechanism, OPFS object/journal runtime, public static
|
||||
Cache Storage release runtime과 StorageManager inspection primitive다. 다음은 아직
|
||||
구현되지 않았다.
|
||||
|
||||
- IndexedDB, OPFS, Cache Storage를 함께 조정하는 pressure/write-admission/GC
|
||||
coordinator
|
||||
- origin 전체 eviction을 완전하게 판별하는 mechanism
|
||||
- OPFS physical layout/journal과 Cache control schema의 forward migration runtime
|
||||
- 실제 OPFS write/read/delete readiness probe
|
||||
- cursor/deadline이 있는 bounded Cache Storage inspection/cleanup
|
||||
- Service Worker update/client-drain controller
|
||||
- local preview의 pixel/decode/frame safety probe
|
||||
- directory/persistent handle/drag-and-drop capability
|
||||
- Range/206 download 또는 private/range response cache
|
||||
|
||||
## 1. 상태 모델과 현재/목표
|
||||
|
||||
### 1.1 다섯 primary current-status literal
|
||||
|
||||
capability의 source 구현, 제품 선택, composition, traffic과 evidence를 하나의
|
||||
`enabled` boolean으로 합치지 않는다. 이 결정에서 capability의 **primary current
|
||||
status**로 허용하는 literal은 정확히 다음 다섯 가지다.
|
||||
|
||||
| primary status | 의미 |
|
||||
| --- | --- |
|
||||
| `COMPOSED` | 실제 owner/policy/provider가 production composition root에 연결돼 있다. traffic이 disabled/canary/enabled인지는 이 상태가 아니라 별도 admission 축이다. |
|
||||
| `AVAILABLE_NOT_COMPOSED` | 실행 가능한 runtime과 test가 source에 있지만 production bootstrap과 제품 dataset에는 연결하지 않았다. |
|
||||
| `DESIGNED_NOT_IMPLEMENTED` | port, invariant, failure/recovery와 promotion 기준은 결정됐지만 실행 가능한 runtime이 없다. |
|
||||
| `NOT_SELECTED` | 제품 요구와 owner가 capability를 선택하지 않았다. source 설계나 일부 primitive가 있더라도 runtime, DB, worker, listener를 만들지 않는다. |
|
||||
| `PLATFORM_LIMITED` | 요구한 의미를 대상 browser 전체에서 application-controlled capability로 보장할 수 없다. 지원 engine의 progressive enhancement와 명시적 fallback만 허용한다. |
|
||||
|
||||
이 다섯 값은 선형 maturity 단계가 아니다. 예를 들어 구현이 존재해도 제품이
|
||||
선택하지 않은 별도 capability의 primary status는 `NOT_SELECTED`일 수 있고,
|
||||
cross-browser 보장이 불가능하면 구현량과 무관하게 `PLATFORM_LIMITED`다.
|
||||
`COMPOSED`도 traffic enablement나 runtime health를 암묵적으로 뜻하지 않는다.
|
||||
|
||||
primary status와 별도로 다음 축을 기록한다.
|
||||
|
||||
```text
|
||||
Selection
|
||||
NOT_SELECTED | SELECTED | REMOVING
|
||||
|
||||
TrafficAdmission
|
||||
DISABLED | SHADOW | CANARY | ENABLED
|
||||
|
||||
RuntimeHealth
|
||||
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
|
||||
|
||||
PromotionEvidence
|
||||
MISSING | PARTIAL | COMPLETE | EXPIRED
|
||||
```
|
||||
|
||||
이 네 이름과 literal은 completion ledger의 canonical readiness 축이다.
|
||||
`PromotionEvidence`는 별도 임의 enum이 아니라 ledger의 contract, provider,
|
||||
browser, operations component gate를 합성한 값이다. required artifact가 없으면
|
||||
`MISSING`, 일부만 terminal이면 `PARTIAL`, 모든 required gate와 freshness가
|
||||
충족될 때만 `COMPLETE`, 한 번 유효했던 required artifact가 만료되면
|
||||
`EXPIRED`로 기록한다.
|
||||
|
||||
예를 들어 실제 dependency를 조립한 첫 배포는
|
||||
`primaryStatus=COMPOSED`, `TrafficAdmission=DISABLED`,
|
||||
`RuntimeHealth=UNKNOWN`, `PromotionEvidence=PARTIAL`일 수 있다. probe와 canary
|
||||
승격은 primary status를 새 literal로 바꾸지 않고 별도 축만 변경한다.
|
||||
|
||||
rollback은 `TrafficAdmission`을 먼저 `DISABLED`로 내린다. schema version을
|
||||
내리거나, user-authored data를 자동 삭제하거나, unavailable runtime을 in-memory
|
||||
fake로 교체하지 않는다.
|
||||
|
||||
### 1.2 current vs target
|
||||
|
||||
| capability | 현재 | 이 결정의 목표 | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| transient File/Blob vault와 picker | `AVAILABLE_NOT_COMPOSED` | 유지 | 제품 policy가 없으므로 조립하지 않음 |
|
||||
| foreground streaming/save와 browser handoff | `AVAILABLE_NOT_COMPOSED` | 유지 | Range resume는 포함하지 않음 |
|
||||
| IndexedDB repository/codec migration | `AVAILABLE_NOT_COMPOSED` | 유지, coordinator hook 추가 대상 | 제품 dataset/schema는 없음 |
|
||||
| OPFS object/journal v1 | `AVAILABLE_NOT_COMPOSED` | 유지 | 현재 byte runtime과 v1 reconciliation 범위 |
|
||||
| OPFS real readiness preflight | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | property probe/native test를 composition readiness로 오인하지 않음 |
|
||||
| public static Cache release v1 | `AVAILABLE_NOT_COMPOSED` | 유지 | 현재 stage/activate/previous retain은 구현 |
|
||||
| bounded Cache inspect/cleanup | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 현재 ownership은 검증하지만 cache-count scan은 unbounded |
|
||||
| StorageManager estimate/persist primitive | `AVAILABLE_NOT_COMPOSED` | 유지 | origin coordinator는 없음 |
|
||||
| origin storage lifecycle coordinator | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 이 ADR이 계약을 확정 |
|
||||
| OPFS physical/journal forward migration | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 기존 v1 layout/journal을 migrator 구현으로 오인하지 않음 |
|
||||
| Cache control/prefix forward migration | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 기존 v1 parser/release primitive를 migrator 구현으로 오인하지 않음 |
|
||||
| local preview safety probe | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 현재 byte/signature check만 있음 |
|
||||
| Service Worker update lifecycle | `NOT_SELECTED` | 제품이 PWA를 선택할 때 별도 승격 | Cache Storage 사용만으로 자동 선택하지 않음 |
|
||||
| directory selection/persistent handles/drop | `NOT_SELECTED` | 제품 workspace 요구가 있을 때 별도 승격 | consent/retention 결정 필요 |
|
||||
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | VD-14의 별도 capability | backend validator/range 계약과 별도 runtime 필요 |
|
||||
| sparse Range response cache | `NOT_SELECTED` | Range download와도 분리된 별도 capability | segment merge/cache threat model 필요 |
|
||||
| private response cache | `NOT_SELECTED` | 별도 security review 전 금지 | public cache를 확장해 암묵 설치하지 않음 |
|
||||
| app-managed background download | `NOT_SELECTED` | 지원 browser용 별도 optional capability | 별도 owner/staging/worker protocol 필요 |
|
||||
| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | browser-managed handoff를 기본 fallback으로 유지 | Service Worker가 장시간 transfer 지속을 보장하지 않음 |
|
||||
|
||||
## 2. 변경할 수 없는 불변조건
|
||||
|
||||
1. `navigator.storage.estimate()`는 rough origin signal이지 free-space reservation,
|
||||
per-store usage 또는 eviction guarantee가 아니다.
|
||||
2. 실제 `QuotaExceededError`가 write failure의 authority다. estimate가 정상이어도
|
||||
write는 실패할 수 있다.
|
||||
3. IndexedDB, OPFS와 Cache Storage 사이에는 atomic transaction이 없다. coordinator는
|
||||
saga와 idempotency를 제공할 뿐 cross-API ACID를 주장하지 않는다.
|
||||
4. user-authored/unsynced data는 pressure 또는 migration convenience를 이유로 자동
|
||||
삭제하지 않는다.
|
||||
5. credential, session token, raw authorization header, presigned URL과 signing key는
|
||||
어느 origin store에도 저장하지 않는다.
|
||||
6. migration은 expand/migrate/contract 순서다. committed OPFS file을 in-place로
|
||||
변환하지 않고, Cache Storage의 검증되지 않은 candidate를 active로 만들지 않는다.
|
||||
7. future schema를 이전 bundle이 발견하면 destructive open/delete 대신 read-only,
|
||||
online-only 또는 export-required로 전환한다.
|
||||
8. capability probe failure를 fake success로 바꾸지 않는다.
|
||||
9. Service Worker 등록·활성화와 Cache Storage ownership은 서로 다른 capability다.
|
||||
10. local image preview는 encoded byte cap만으로 decode safety를 주장하지 않는다.
|
||||
11. directory handle과 persistent file handle은 transient file selection의 자연스러운
|
||||
연장이 아니라 별도 consent/persistence capability다.
|
||||
12. public Cache Storage는 private/auth/range representation의 repository가 아니다.
|
||||
|
||||
## 3. composition과 owner/policy injection
|
||||
|
||||
### 3.1 composition root
|
||||
|
||||
제품이 선택하면 한 composition root가 다음 dependency를 immutable snapshot으로
|
||||
고정한다.
|
||||
|
||||
```text
|
||||
OriginStorageLifecycleComposition
|
||||
originScope
|
||||
releaseId
|
||||
datasetPolicyRegistry
|
||||
storageDurability
|
||||
indexedDbMaintenance[]
|
||||
opfsMaintenance[]
|
||||
publicCacheMaintenance[]
|
||||
mutationLock
|
||||
clock
|
||||
scheduler
|
||||
lifecycleAuthority
|
||||
safeObserver
|
||||
killSwitches
|
||||
```
|
||||
|
||||
page, hook 또는 domain use case는 native manager와 maintenance adapter를 직접
|
||||
조합하지 않는다. coordinator에 등록되는 각 dataset profile은 다음을 필수로
|
||||
소유한다.
|
||||
|
||||
| 필드 | owner가 결정할 내용 |
|
||||
| --- | --- |
|
||||
| `datasetRegistryId` | readable user/account 값이 아닌 고정된 registry ID |
|
||||
| `owner` | product owner와 operational owner |
|
||||
| `technology` | IndexedDB, OPFS, public Cache 중 정확한 storage |
|
||||
| `authority` | server, local-first, reconstructable |
|
||||
| `classification` | public, internal, personal, confidential |
|
||||
| `accountScope` | origin-shared 또는 opaque partition |
|
||||
| `retention` | session, TTL, until-synced, explicit delete |
|
||||
| `soft/hardBudget` | logical dataset budget; native free-space claim이 아님 |
|
||||
| `pressurePriority` | expired/reconstructable, synced-copy, user-authored 순서 |
|
||||
| `writeCriticality` | essential user write, sync receipt, reconstructable cache |
|
||||
| `fallback` | read-only, online-only, export-required |
|
||||
| `migrationOwner` | schema/codec/layout migration과 rollback owner |
|
||||
| `recoveryOwner` | rehydrate, export, backend sync와 incident owner |
|
||||
|
||||
registry는 composition 시 deep snapshot/freeze하고 같은 문자열을 가진 caller-created
|
||||
profile을 identity로 인정하지 않는다. presentation은 priority, retention 또는
|
||||
eviction eligibility를 요청별로 고를 수 없다.
|
||||
|
||||
### 3.2 authority가 필요한 동작
|
||||
|
||||
logout, account deletion, until-synced deletion과 user-authored export/purge는 제품
|
||||
authority를 요구한다. 기존 OPFS maintenance proof와 동일하게 provider가 exact
|
||||
reason/scope/policy에 묶인 짧은 proof를 발급하고 consumer가 원자적으로
|
||||
consume한다.
|
||||
|
||||
pressure에 따른 expired/reconstructable GC는 product proof가 없어도 실행할 수
|
||||
있지만 등록 policy와 bounded budget을 벗어나면 안 된다. `SYNCED_COPY` 삭제는
|
||||
authoritative server revision 또는 별도 sync receipt가 확인된 항목에만 허용한다.
|
||||
|
||||
## 4. origin-wide pressure, write admission, and GC
|
||||
|
||||
### 4.1 coordinator port
|
||||
|
||||
목표 runtime은 native type을 노출하지 않는 다음 의미의 port를 제공한다.
|
||||
|
||||
```ts
|
||||
type PressureState =
|
||||
| "UNKNOWN"
|
||||
| "NORMAL"
|
||||
| "PRESSURE"
|
||||
| "CRITICAL"
|
||||
| "QUOTA_FAILURE";
|
||||
|
||||
type WriteAdmission =
|
||||
| { kind: "ADMITTED"; admissionId: string; attempt: 1 | 2 }
|
||||
| { kind: "DEFERRED"; recovery: "RETRY" | "ONLINE_ONLY" }
|
||||
| { kind: "DENIED"; recovery: "READ_ONLY" | "EXPORT_REQUIRED" };
|
||||
```
|
||||
|
||||
실제 API는 repository write를 대신하지 않는다. 각 adapter가 write 직전 admission을
|
||||
얻고, commit/rollback 이후 exact admission을 완료하도록 좁은 hook을 받는다.
|
||||
admission ID는 diagnostic 또는 persistence에 남기지 않는 runtime-local fencing
|
||||
token이다.
|
||||
|
||||
### 4.2 pressure state와 hysteresis
|
||||
|
||||
기본 threshold 시작점은 VD-11과 일치한다.
|
||||
|
||||
| 진입 조건 | 상태 |
|
||||
| --- | --- |
|
||||
| usage/quota를 모름 | `UNKNOWN` |
|
||||
| `< 70%` | `NORMAL` |
|
||||
| `>= 70%` | `PRESSURE` |
|
||||
| `>= 85%` | `CRITICAL` |
|
||||
| 실제 write의 quota exception | `QUOTA_FAILURE` |
|
||||
|
||||
flapping을 막기 위해 하향 전이는 더 낮은 threshold를 사용한다.
|
||||
|
||||
- `CRITICAL -> PRESSURE`: 두 번 연속 inspection에서 `< 80%`
|
||||
- `PRESSURE -> NORMAL`: 두 번 연속 inspection에서 `< 65%`
|
||||
- inspection 간격은 composition policy가 정하되 boot polling loop를 만들지 않는다.
|
||||
- tab마다 독립 GC하지 않는다. 고정된 origin Web Lock 아래 leader 하나만 maintenance를
|
||||
수행하고, lock이 없으면 destructive maintenance를 하지 않는다.
|
||||
|
||||
두 번 연속 규칙은 in-memory observation일 뿐 영구 storage truth가 아니다. 새
|
||||
runtime은 persisted pressure state를 맹신하지 않고 `UNKNOWN`에서 시작한다.
|
||||
|
||||
### 4.3 admission matrix
|
||||
|
||||
| pressure | essential user-authored | sync/export receipt | reconstructable/cache |
|
||||
| --- | --- | --- | --- |
|
||||
| `UNKNOWN` | policy hard budget 안에서 허용, failure 대비 | 허용 | 보수적으로 defer 가능 |
|
||||
| `NORMAL` | 허용 | 허용 | 허용 |
|
||||
| `PRESSURE` | 허용 | 허용 | 먼저 bounded GC, 신규 speculative write 제한 |
|
||||
| `CRITICAL` | hard budget과 recovery path가 있을 때만 허용 | 허용 우선 | 거절/online-only |
|
||||
| `QUOTA_FAILURE` | rollback 후 export/sync UX | rollback 후 retry 조건 평가 | rollback, GC, 최대 1회 retry |
|
||||
|
||||
estimate만으로 “N bytes를 예약했다”고 기록하지 않는다. IndexedDB/OPFS의 logical
|
||||
budget reservation은 동시 writer 간 policy ceiling을 강제하기 위한 값이며 origin
|
||||
free space가 아니다.
|
||||
|
||||
### 4.4 GC 순서와 bounded execution
|
||||
|
||||
GC 순서는 모든 기술에서 다음 우선순위를 유지한다.
|
||||
|
||||
```text
|
||||
incomplete candidate / stale staging
|
||||
-> expired reconstructable
|
||||
-> unreferenced immutable chunk with grace
|
||||
-> inactive public cache release
|
||||
-> confirmed synced copy
|
||||
-> stop
|
||||
```
|
||||
|
||||
user-authored/unsynced는 자동 GC 목록에 들어가지 않는다. 각 invocation은 다음
|
||||
두 예산을 모두 가진다.
|
||||
|
||||
- 기본 최대 100 items 또는 5초
|
||||
- 구현 절대 상한 500 items 또는 30초
|
||||
|
||||
각 native operation 사이에 deadline과 AbortSignal을 다시 확인한다. 결과는
|
||||
`inspected`, `removed`, `releasedLogicalBytes`, `moreAvailable`,
|
||||
`deadlineReached`와 opaque cursor를 반환한다. cursor는 dataset/policy/release
|
||||
epoch에 묶고 다른 owner에서 replay하면 `STALE_RESULT`다.
|
||||
|
||||
### 4.5 quota failure 뒤 단 한 번의 retry
|
||||
|
||||
자동 retry는 다음 조건을 전부 만족할 때만 허용한다.
|
||||
|
||||
1. 첫 attempt가 실제 `QuotaExceededError`로 rollback됐다.
|
||||
2. operation이 같은 idempotency key, revision fence와 payload digest를 가진다.
|
||||
3. 외부 side effect 또는 cross-store publish가 commit되지 않았다.
|
||||
4. bounded GC가 실제로 candidate를 제거했거나 pressure가 하향됐다.
|
||||
5. retry가 같은 operation lifecycle에서 정확히 한 번뿐이다.
|
||||
6. 새 admission token을 발급하고 현재 revision/generation을 다시 읽는다.
|
||||
|
||||
두 번째 quota failure, partial external commit, user-authored destructive overwrite,
|
||||
unknown idempotency는 retry하지 않는다. recovery는 policy에 따라 `READ_ONLY`,
|
||||
`ONLINE_ONLY` 또는 `EXPORT_REQUIRED`다.
|
||||
|
||||
## 5. eviction detection의 범위와 한계
|
||||
|
||||
### 5.1 감지할 수 있는 것
|
||||
|
||||
각 조립된 dataset은 opaque scope에 다음 binding을 둔다.
|
||||
|
||||
- IndexedDB governance row와 dataset epoch
|
||||
- OPFS journal logical object와 physical manifest/digest
|
||||
- public Cache active pointer와 verified release marker
|
||||
- 선택적으로 backend가 알고 있는 opaque dataset installation epoch
|
||||
|
||||
다음 partial mismatch는 `STORAGE_EVICTED` 또는 `CORRUPT_DATA`로 구분할 수 있다.
|
||||
|
||||
- logical OPFS object는 있는데 physical manifest/chunk가 없음
|
||||
- Cache active pointer는 있는데 candidate cache/marker가 없음
|
||||
- migration checkpoint는 있는데 target generation이 없음
|
||||
- expected dataset epoch와 local governance binding이 다름
|
||||
|
||||
reconstructable data는 rehydrate하고, local-first/user-authored data는 자동 empty
|
||||
state로 초기화하지 않고 read-only/export-required incident로 올린다.
|
||||
|
||||
### 5.2 감지할 수 없는 것
|
||||
|
||||
browser가 origin의 IndexedDB, OPFS와 Cache Storage를 모두 함께 지우면 local
|
||||
sentinel도 함께 사라진다. local state만으로 다음 두 상황을 완전하게 구분할 수
|
||||
없다.
|
||||
|
||||
```text
|
||||
이 browser의 첫 설치
|
||||
origin storage 전체 eviction/user clear
|
||||
```
|
||||
|
||||
따라서 “sentinel이 없으므로 첫 설치”라고 단정하지 않는다. 제품이 구분을 요구하면
|
||||
현재 인증 session의 backend에 opaque installation/dataset epoch를 보관하고
|
||||
authorization 후 비교해야 한다. backend marker도 browser byte backup이 아니며,
|
||||
local-only data 복구를 보장하지 않는다.
|
||||
|
||||
backend epoch가 없으면 UI는 empty/new와 storage-reset-possible 상태를 제품 정책에
|
||||
맞게 합쳐 표현해야 한다. raw account ID, filename, object ID 또는 digest를
|
||||
sentinel/log에 넣지 않는다.
|
||||
|
||||
## 6. schema, codec, physical migration
|
||||
|
||||
### 6.1 독립 version 축
|
||||
|
||||
다음 version을 하나의 숫자로 합치지 않는다.
|
||||
|
||||
| 축 | 의미 | 현재 |
|
||||
| --- | --- | --- |
|
||||
| IndexedDB DDL | store/index/governance shape | reference runtime에 additive planner 있음 |
|
||||
| IndexedDB record codec | payload decode/encode | resumable maintenance mechanism 있음 |
|
||||
| OPFS journal DDL | logical object/journal/budget/refcount schema | v1 고정 |
|
||||
| OPFS physical layout | root/path/manifest/chunk-tree algorithm | v1 고정 |
|
||||
| Cache control schema | marker/active pointer JSON | v1 고정 |
|
||||
| Cache release manifest | URL/header/type/length/digest binding | current static release contract |
|
||||
| lifecycle registry schema | owner/policy/admission binding | 이 결정에서 설계, 구현 없음 |
|
||||
|
||||
IndexedDB mechanism이 존재한다고 해서 제품 codec/migration이 자동으로 존재하는
|
||||
것은 아니다. OPFS와 Cache v1 parser가 있다는 사실도 forward migration 구현을
|
||||
뜻하지 않는다.
|
||||
|
||||
### 6.2 공통 expand/migrate/contract
|
||||
|
||||
1. **expand:** 새 reader가 N과 N-1을 읽고 새 metadata/checkpoint를 additive하게
|
||||
추가한다.
|
||||
2. **drain:** old writer가 더는 N-1 shape를 쓰지 않는다는 release/lease evidence를
|
||||
확인한다.
|
||||
3. **migrate:** bounded batch와 keyset/opaque cursor로 copy/verify한다.
|
||||
4. **publish:** row, checkpoint, logical budget과 generation fence를 가능한 한 같은
|
||||
native transaction에서 commit한다.
|
||||
5. **observe:** canary와 rollback window 동안 N-1 reader compatibility를 확인한다.
|
||||
6. **contract:** 모든 active/rollback release가 지난 별도 release에서만 old shape를
|
||||
정리한다.
|
||||
|
||||
schema downgrade, blanket database/cache/root deletion과 read-time unbounded rewrite는
|
||||
금지한다.
|
||||
|
||||
### 6.3 OPFS migration
|
||||
|
||||
OPFS physical migration은 copy-on-write다.
|
||||
|
||||
```text
|
||||
v1 committed object
|
||||
-> v2 staging transaction
|
||||
-> bounded chunk copy/read
|
||||
-> v2 manifest + tree digest verify
|
||||
-> IDB journal generation/fencing CAS
|
||||
-> v2 logical publish
|
||||
-> rollback window 동안 v1 retain
|
||||
-> authority 확인 후 v1 bounded cleanup
|
||||
```
|
||||
|
||||
- committed v1 file/chunk를 in-place로 수정하지 않는다.
|
||||
- checkpoint는 last logical object key와 source/target generation을 저장한다.
|
||||
- source digest, target digest, bytes와 policy binding이 맞지 않으면 quarantine하고
|
||||
다음 object로 성공 처리하지 않는다.
|
||||
- crash가 v2 publish 전이면 v1이 authority다.
|
||||
- publish 후 cleanup crash는 v2가 authority이고 cleanup을 재개한다.
|
||||
- N-1 bundle은 v2를 쓰지 않고 read-only/online-only로 degrade한다.
|
||||
- local-first bytes를 contract하려면 export/sync 또는 승인된 rollback-window
|
||||
evidence가 필요하다.
|
||||
|
||||
### 6.4 Cache migration과 rollback
|
||||
|
||||
public Cache data는 reconstructable이므로 byte-by-byte schema rewrite보다 새
|
||||
release를 다시 stage/verify/activate한다. Service Worker를 선택하지 않은 static
|
||||
Cache-only 조합의 migration은 다음 흐름이다.
|
||||
|
||||
```text
|
||||
old active verified release
|
||||
-> new prefix/control schema candidate
|
||||
-> exact network fetch + integrity verify
|
||||
-> explicit activation
|
||||
-> old + previous retain
|
||||
-> composition-owned rollback/grace window 확인
|
||||
-> bounded owned-prefix cleanup
|
||||
```
|
||||
|
||||
이 흐름에는 waiting worker, `controllerchange` 또는 controlled-client drain을
|
||||
성공 조건으로 넣지 않는다. Service Worker를 별도 선택한 조합만 section 9.2의
|
||||
waiting/activation protocol을 실행하고, old controlled client가 drain된 뒤 해당
|
||||
release를 cleanup eligible로 만든다.
|
||||
|
||||
rollback은 검증된 previous release의 ID와 manifest digest로 같은 activation
|
||||
protocol을 다시 실행한다. caller가 raw cache name이나 retain list를 전달하지
|
||||
않는다. new control schema가 unreadable하면 old pointer를 덮어쓰지 않고
|
||||
network-only로 degrade한다.
|
||||
|
||||
Cache control/release cleanup도 section 4의 cursor/deadline 예산을 적용한다.
|
||||
unregister 또는 새 Service Worker install만으로 cache migration이 완료됐다고
|
||||
보지 않는다.
|
||||
|
||||
### 6.5 N-1 rollback contract
|
||||
|
||||
모든 durable migration은 최소 다음 fixture를 보유한다.
|
||||
|
||||
- N-1 fresh -> N open
|
||||
- N-1 populated -> N partial migration crash -> N resume
|
||||
- N migration 완료 -> N-1 open: destructive write 없이 read-only/online-only
|
||||
- N canary rollback -> N-1 server path로 정상 동작
|
||||
- N rollback window 종료 뒤 별도 contract release
|
||||
|
||||
rollback bundle은 schema number를 낮추지 않는다. 새로운 writer를 끄고 compatible
|
||||
reader/fallback을 사용한다.
|
||||
|
||||
## 7. OPFS real readiness preflight
|
||||
|
||||
현재 `inspectBrowserOpfsSupport()`는 API property를 확인한다. 목표 preflight는
|
||||
실제 작은 operation을 검증한다.
|
||||
|
||||
### 7.1 probe protocol
|
||||
|
||||
probe는 `primaryStatus=COMPOSED`, `Selection=SELECTED`이고 readiness 확인이
|
||||
필요할 때 실행한다. 최초 composition에서는 `TrafficAdmission=DISABLED` 또는
|
||||
`SHADOW`로 probe하며, 선택하지 않은 skeleton boot에서 OPFS root/DB/worker를
|
||||
만들지 않는다.
|
||||
|
||||
```text
|
||||
secure context/API check
|
||||
-> DedicatedWorker boot + protocol handshake
|
||||
-> origin Web Lock acquire
|
||||
-> owned opaque probe scope의 IDB journal transaction
|
||||
-> random staging file create
|
||||
-> bounded bytes write + flush/close
|
||||
-> read + length/digest verify
|
||||
-> file/journal cleanup
|
||||
-> lock/worker/connection close
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
- main thread에서 SyncAccessHandle을 만들지 않는다.
|
||||
- synchronous path와 configured async writable fallback을 각각 capability로
|
||||
보고한다.
|
||||
- probe object ID/path는 secure random opaque value이고 log에 기록하지 않는다.
|
||||
- 기본 deadline 5초, 절대 상한 30초다.
|
||||
- timeout/crash 뒤 stale probe는 reconciliation owner가 grace 후 bounded cleanup한다.
|
||||
- 결과는 runtime memory에 짧게 cache할 수 있지만 browser update, visibility가 긴
|
||||
sleep에서 복귀, quota/permission failure 뒤 다시 `UNKNOWN`으로 돌린다.
|
||||
- probe 성공은 future write 또는 persistence guarantee가 아니다.
|
||||
|
||||
### 7.2 readiness mapping
|
||||
|
||||
| 결과 | runtime health | admission |
|
||||
| --- | --- | --- |
|
||||
| full worker/lock/journal/write/read/delete 성공 | `AVAILABLE` | policy에 따라 가능 |
|
||||
| sync handle 없음, 승인된 async fallback 성공 | `DEGRADED` | size/concurrency ceiling 하향 |
|
||||
| API 없음/secure context 아님 | `UNAVAILABLE` | online-only |
|
||||
| protocol/schema mismatch | `INCOMPATIBLE` | read/write 금지 |
|
||||
| timeout/quota/permission | `DEGRADED` 또는 `UNAVAILABLE` | 신규 write 금지, recovery 실행 |
|
||||
|
||||
## 8. bounded Cache Storage maintenance
|
||||
|
||||
현재 static public cache는 release stage/verify/activate, previous retain과
|
||||
owned-prefix cleanup을 구현한다. 현재 `cleanupOwned()`와 `inspect()`에는
|
||||
max-count/deadline/cursor가 없다. 목표 contract는 이를 bounded operation으로
|
||||
바꾼다.
|
||||
|
||||
```ts
|
||||
type CacheMaintenancePage = Readonly<{
|
||||
inspectedCaches: number;
|
||||
deletedCaches: number;
|
||||
retainedCaches: number;
|
||||
unreadableCaches: number;
|
||||
nextCursor: string | null;
|
||||
moreAvailable: boolean;
|
||||
deadlineReached: boolean;
|
||||
}>;
|
||||
```
|
||||
|
||||
- default 100 caches/5초, absolute 500 caches/30초
|
||||
- cursor는 owned prefix, active pointer epoch와 policy fingerprint에 binding
|
||||
- caller는 raw cache name, retain list 또는 prefix를 제출할 수 없음
|
||||
- mutation Web Lock 아래 active pointer를 다시 읽은 뒤 한 cache씩 처리
|
||||
- abort/deadline 뒤 이미 완료한 delete truth는 되돌리지 않고 cursor부터 재개
|
||||
- corrupt active pointer면 destructive cleanup을 중지하고 network-only
|
||||
- unreadable inactive candidate는 grace와 current/previous binding 확인 뒤 삭제
|
||||
- `QuotaExceededError`를 이유로 다른 origin cache나 user data를 삭제하지 않음
|
||||
|
||||
inspection도 동일한 page contract를 써서 cache 수에 비례한 unbounded boot work를
|
||||
금지한다.
|
||||
|
||||
## 9. static Cache release와 optional Service Worker lifecycle
|
||||
|
||||
### 9.1 현재 static release capability
|
||||
|
||||
현재 adapter가 소유하는 범위:
|
||||
|
||||
- same-origin anonymous public GET
|
||||
- exact query/request header/Vary
|
||||
- type, declared/actual length와 SHA-256
|
||||
- candidate 전체 성공 뒤 explicit activation
|
||||
- failed candidate 삭제와 기존 active 유지
|
||||
- active + verified previous release retain
|
||||
- private/no-store/auth/opaque/redirect/206 거부
|
||||
|
||||
Window 또는 Worker에서 Cache Storage를 쓸 수 있으므로 이 기능은 Service Worker
|
||||
설치를 의미하지 않는다.
|
||||
|
||||
### 9.2 Service Worker를 선택할 때의 별도 protocol
|
||||
|
||||
PWA/offline interception을 제품이 선택하면 별도 owner가 다음 lifecycle을
|
||||
composition한다.
|
||||
|
||||
```text
|
||||
installing worker
|
||||
-> candidate static release stage/verify
|
||||
-> waiting
|
||||
-> page update controller:
|
||||
dirty form / active transfer / compatibility 확인
|
||||
-> explicit ACTIVATE(version, manifest)
|
||||
-> pointer flip
|
||||
-> skipWaiting opt-in
|
||||
-> controllerchange acknowledgement
|
||||
-> old clients drain
|
||||
-> clients.claim opt-in
|
||||
-> previous release grace retain
|
||||
-> bounded cleanup
|
||||
```
|
||||
|
||||
`skipWaiting()`과 `clients.claim()`을 install handler에서 자동 호출하지 않는다.
|
||||
message는 protocol version, release ID, nonce와 exact target worker에 binding하고
|
||||
unknown message를 drop한다.
|
||||
|
||||
fetch 전략은 route registry에 고정한다.
|
||||
|
||||
| route class | 허용 전략 |
|
||||
| --- | --- |
|
||||
| content-hashed static asset | exact active cache-first |
|
||||
| navigation | network-first + 별도 검증된 static offline page |
|
||||
| runtime config/release manifest/auth/API | network-only |
|
||||
| approved public runtime media | 별도 TTL metadata owner가 있을 때만 bounded SWR |
|
||||
|
||||
runtime TTL/SWR은 static release adapter의 묵시적 기능이 아니다. 별도 entry/count/
|
||||
byte/TTL budget, revalidation owner와 prune cursor가 있어야 한다.
|
||||
|
||||
Service Worker는 application-controlled long-running background download를
|
||||
cross-browser로 보장하지 않는다. download lifecycle은 VD-14의 별도 capability다.
|
||||
|
||||
## 10. local preview decode safety
|
||||
|
||||
### 10.1 현재와 목표
|
||||
|
||||
현재 preview path는 selection byte cap, signature receipt, media allowlist,
|
||||
active-content denylist와 object URL lease를 제공한다. static raster의 intrinsic
|
||||
dimensions, decoded surface와 animation frame 수를 검사하지 않는다.
|
||||
|
||||
목표 runtime은 object URL을 발급하기 전에 exact registered preview policy에
|
||||
묶인 `PreviewSafetyProbePort`를 호출한다.
|
||||
|
||||
### 10.2 policy와 검사 순서
|
||||
|
||||
owner가 최소 다음을 결정한다.
|
||||
|
||||
- 허용 static format과 signature parser version
|
||||
- max encoded bytes
|
||||
- max width/height
|
||||
- max total pixels
|
||||
- max decoded bytes
|
||||
- animation 허용 여부와 max frames/total pixels
|
||||
- decode concurrency와 deadline
|
||||
- malformed/unsupported metadata 동작
|
||||
|
||||
기본은 JPEG, PNG, WebP, AVIF 중 검토된 static parser만 허용하고 animation,
|
||||
SVG, HTML, XML, PDF는 preview에서 거절한다. animation이 제품 요구면 별도
|
||||
frame/time/memory capability로 승격한다.
|
||||
|
||||
```text
|
||||
bounded header read
|
||||
-> container/signature parse
|
||||
-> width/height/frame/static 여부
|
||||
-> overflow-safe pixel/decoded-byte 계산
|
||||
-> optional real bitmap decode
|
||||
-> decoded dimensions exact match
|
||||
-> bitmap close
|
||||
-> object URL lease 발급
|
||||
```
|
||||
|
||||
`width * height * 4` 계산은 safe integer overflow를 검사한다. parser header만
|
||||
신뢰하지 않고 지원 browser에서는 `createImageBitmap` 등 실제 decode를 bounded
|
||||
concurrency/deadline 아래 확인하고 즉시 `close()`한다. decode failure 뒤 object
|
||||
URL을 발급하지 않는다.
|
||||
|
||||
runtime absolute ceiling은 product policy보다 크거나 같고 caller는 낮출 수만 있다.
|
||||
원본 filename, digest와 dimensions를 telemetry에 기록하지 않고 bucket만 남긴다.
|
||||
|
||||
## 11. directory, persistent handles, and drag-and-drop
|
||||
|
||||
세 기능은 현재 transient picker port에 추가하지 않는다.
|
||||
|
||||
### 11.1 directory selection
|
||||
|
||||
제품이 folder import/workspace를 선택하면 별도 `DirectorySelectionPort`를 만든다.
|
||||
|
||||
- `showDirectoryPicker`는 progressive enhancement
|
||||
- `<input webkitdirectory>`는 검증된 baseline으로만 사용
|
||||
- depth, entry count, per-file/total bytes, traversal time의 hard cap
|
||||
- relative path segment NFC 정규화, `.`/`..`, separator, control/bidi 거부
|
||||
- 파일이 아닌 entry, traversal 중 permission loss와 mutation을 closed failure로
|
||||
처리
|
||||
- traversal 결과는 opaque file refs와 sanitized relative metadata만 반환
|
||||
- directory name/path를 domain ID 또는 log로 사용하지 않음
|
||||
|
||||
directory upload가 필요하면 backend도 archive/path/symlink/traversal과 total
|
||||
expanded budget을 다시 검증한다.
|
||||
|
||||
### 11.2 persistent handles and permission
|
||||
|
||||
persistent handle은 별도 registry와 consent가 필요하다.
|
||||
|
||||
- IDB structured-clone support를 실제 probe
|
||||
- handle 자체를 application/domain/query cache에 노출하지 않음
|
||||
- opaque handle ref, account partition, purpose, retention과 last-used bucket만 보관
|
||||
- boot/background에서 `requestPermission()` 금지
|
||||
- explicit user action에서 `queryPermission()` 후 필요한 경우에만 request
|
||||
- denied/revoked/stale handle은 `RESELECT`, silent empty file로 처리하지 않음
|
||||
- logout/account deletion과 handle registry purge는 authority를 요구
|
||||
- browser가 OS 권한 철회를 지원하지 않을 수 있음을 UX에 명시
|
||||
|
||||
handle persistence는 local bytes backup이 아니며 파일이 외부에서 바뀔 수 있다.
|
||||
매 open마다 size/lastModified와 제품이 요구하는 content identity를 다시 검사한다.
|
||||
|
||||
### 11.3 drag-and-drop
|
||||
|
||||
현재 `DROP` source enum은 full adapter를 의미하지 않는다. 선택 시 별도 inbound
|
||||
adapter가 `DataTransfer`를 event 안에서 snapshot하고, file-only drop과 directory
|
||||
traversal을 구분한다. pasted/dropped HTML, URL과 string item을 file capability로
|
||||
승격하지 않는다. same count/byte/type/path policy를 picker와 공유하되 UI event
|
||||
type을 permission으로 사용하지 않는다.
|
||||
|
||||
## 12. Range와 private cache는 별도 capability
|
||||
|
||||
### 12.1 Range/206
|
||||
|
||||
public static cache는 `Range` request와 206 response를 계속 거절한다. resumable
|
||||
download에는 별도 계약이 필요하다.
|
||||
|
||||
- immutable object version 또는 strong validator
|
||||
- `Range`/`If-Range`
|
||||
- exact `206 Content-Range`
|
||||
- `200`, `206`, `412`, `416` state transition
|
||||
- destination offset/seek/truncate와 partial checkpoint
|
||||
- overlap/gap 방지
|
||||
- capability 재발급 시 같은 representation binding
|
||||
- 전체 완료 뒤 whole-object integrity
|
||||
|
||||
sparse range를 Cache Storage entry로 합치는 것은 현재 public release port의 역할이
|
||||
아니다. 필요하면 OPFS staging 또는 별도 range store를 선택하고 backend
|
||||
File/Object Server와 validator/range 계약을 맞춘다.
|
||||
|
||||
### 12.2 private response cache
|
||||
|
||||
private/auth/account representation은 public Cache Storage adapter에서 계속
|
||||
fail-closed한다. offline private data가 제품 요구면 별도 설계가 최소 다음을
|
||||
소유해야 한다.
|
||||
|
||||
- current authorization과 server source-of-truth
|
||||
- opaque account partition
|
||||
- logout/account-deletion purge authority
|
||||
- TTL/revalidation/revocation
|
||||
- offline disclosure threat model
|
||||
- export/recovery
|
||||
- XSS가 same-origin key를 사용할 수 있다는 한계
|
||||
|
||||
client-side encryption만으로 authorization boundary를 만들었다고 주장하지 않는다.
|
||||
security/privacy 승인이 없으면 network-only다.
|
||||
|
||||
## 13. fault and recovery matrix
|
||||
|
||||
| fault | fail-closed 결과 | recovery |
|
||||
| --- | --- | --- |
|
||||
| estimate unavailable | `UNKNOWN` | essential만 policy budget 내 허용, speculative cache defer |
|
||||
| pressure/critical | admission 제한 | bounded GC, sync/export 안내 |
|
||||
| first quota failure | transaction/candidate rollback | eligible GC 후 exact operation 최대 1회 retry |
|
||||
| second quota failure | 신규 write 중지 | read-only/online-only/export-required |
|
||||
| partial sentinel mismatch | `STORAGE_EVICTED`/`CORRUPT_DATA` | reconstructable rehydrate, local-first quarantine |
|
||||
| 모든 local marker 소실 | first install과 구분 불가 | backend epoch가 있으면 비교, 없으면 정직한 degraded UX |
|
||||
| migration crash | old committed generation 유지 | checkpoint부터 resume/reconcile |
|
||||
| future schema | `INCOMPATIBLE` | N-1 destructive write 금지, online-only/read-only |
|
||||
| OPFS real probe fail | `DEGRADED/UNAVAILABLE` | async fallback probe 또는 online-only |
|
||||
| Cache cleanup deadline | partial success + cursor | 다음 bounded invocation |
|
||||
| corrupt active pointer | cleanup/interception 중지 | network-only, verified recovery tool |
|
||||
| preview pixel/decode limit | `LIMIT_EXCEEDED/POLICY_REJECTED` | attachment-only 또는 reselect |
|
||||
| persistent permission revoked | `PERMISSION_DENIED` | 명시적 reselect/re-authorize |
|
||||
| SW old/new incompatibility | activation 중지 | old active 유지 또는 verified previous 재활성화 |
|
||||
|
||||
## 14. observability
|
||||
|
||||
허용:
|
||||
|
||||
- capability/lifecycle/runtime-health 상태
|
||||
- operation과 closed failure code
|
||||
- pressure/byte/count/duration bucket
|
||||
- migration version ID와 processed/remaining bucket
|
||||
- GC deadline/more-available 여부
|
||||
- probe phase와 capability boolean
|
||||
- release registry ID처럼 registry-owned non-user identifier
|
||||
|
||||
금지:
|
||||
|
||||
- filename, directory path, object ID, account/tenant ID
|
||||
- URL/query/request/response body
|
||||
- exact digest, ETag, raw cache/DB/path name
|
||||
- handle, capability receipt, authority proof
|
||||
- native exception message/stack
|
||||
- exact usage/quota로 사용자의 device storage를 fingerprint하는 event
|
||||
|
||||
## 15. rollout and rollback
|
||||
|
||||
### 15.1 구현 순서
|
||||
|
||||
1. lifecycle policy/registry와 deterministic state machine
|
||||
2. bounded maintenance page/cursor 계약
|
||||
3. cross-store admission + injected fault adapters
|
||||
4. OPFS real preflight
|
||||
5. OPFS/Cache historical migration fixtures와 runtime
|
||||
6. preview safety probe
|
||||
7. optional capability는 제품 선택 후 별도 branch에서 구현
|
||||
|
||||
새 runtime은 구현과 evidence가 끝나도 skeleton에서는
|
||||
`AVAILABLE_NOT_COMPOSED`로 종료한다.
|
||||
|
||||
### 15.2 제품 승격
|
||||
|
||||
```text
|
||||
owner/policy와 필요한 경우 backend authority/re-sync 결정
|
||||
-> registry 및 immutable composition
|
||||
-> primaryStatus=COMPOSED, TrafficAdmission=DISABLED
|
||||
-> real browser readiness/shadow inspection
|
||||
-> RuntimeHealth=AVAILABLE 또는 승인된 DEGRADED
|
||||
-> PromotionEvidence=COMPLETE
|
||||
-> TrafficAdmission=CANARY (reconstructable dataset)
|
||||
-> TrafficAdmission=CANARY (user-authored write)
|
||||
-> migration/rollback drill
|
||||
-> TrafficAdmission=ENABLED
|
||||
```
|
||||
|
||||
user-authored/local-first를 reconstructable cache보다 먼저 canary하지 않는다.
|
||||
|
||||
### 15.3 rollback
|
||||
|
||||
1. 신규 write, migration, cache activation과 SW update를 disable한다.
|
||||
2. in-flight operation을 abort/drain하고 native truth를 reconcile한다.
|
||||
3. current schema를 읽을 수 있는 bundle은 read-only로 유지한다.
|
||||
4. N-1이 future schema면 online-only/export-required로 전환한다.
|
||||
5. previous verified static cache가 있으면 explicit activation으로 rollback한다.
|
||||
6. user-authored data는 sync/export 확인 없이 purge하지 않는다.
|
||||
7. old physical/cache generation은 rollback window와 client drain 뒤 bounded
|
||||
maintenance로 정리한다.
|
||||
|
||||
## 16. test and promotion evidence
|
||||
|
||||
### 16.1 deterministic tests
|
||||
|
||||
- threshold/hysteresis와 concurrent admission
|
||||
- pressure leader lock loss, abort, timeout
|
||||
- first quota failure -> GC -> exact one retry
|
||||
- retry가 non-idempotent/partial commit/second failure에서 차단됨
|
||||
- GC ordering과 user-authored non-eviction
|
||||
- sentinel partial mismatch와 all-marker-loss ambiguity
|
||||
- migration batch crash/resume/replay/fencing
|
||||
- OPFS v1->v2 copy/verify/publish/cleanup fault
|
||||
- Cache candidate failure, pointer corruption, rollback과 cursor expiry
|
||||
- cleanup/inspect count/deadline absolute ceiling
|
||||
- preview hostile dimensions, integer overflow, animation, truncated container와 decode
|
||||
- permission denied/revoked/stale persistent handle
|
||||
- redaction과 dependency snapshot mutation
|
||||
|
||||
### 16.2 real browser tests
|
||||
|
||||
Chromium, Firefox와 WebKit에서 지원 범위를 명시하고 skip을 success로 세지 않는다.
|
||||
|
||||
- StorageManager estimate/persist denial
|
||||
- native IndexedDB/OPFS/Cache quota exception mapping
|
||||
- DedicatedWorker + Web Lock + OPFS write/read/delete probe
|
||||
- two-tab migration/maintenance serialization
|
||||
- versionchange/future schema and N-1 read-only
|
||||
- actual Cache stage/activate/previous rollback/controlled client drain
|
||||
- storage clear 뒤 explicit degraded behavior
|
||||
- file preview real static decode/cleanup
|
||||
- directory/handle은 지원 engine + OS manual evidence
|
||||
|
||||
quota를 실제로 완전히 채우는 flaky test는 유일한 gate로 쓰지 않는다. deterministic
|
||||
fault injection과 실제 small-operation smoke를 함께 보존한다.
|
||||
|
||||
### 16.3 promotion artifact
|
||||
|
||||
artifact는 다음을 포함한다.
|
||||
|
||||
- release/commit, browser/OS/image
|
||||
- policy/registry/migration suite version과 hash
|
||||
- runtime lifecycle/health/admission
|
||||
- deterministic + native pass/fail/skip
|
||||
- historical fixture N-1/N/N+1 결과
|
||||
- rollback drill과 recovery runbook link
|
||||
- evidence expiry와 waiver
|
||||
|
||||
필수 engine skip, expired evidence, migration fixture 누락, quota retry invariant 위반,
|
||||
preview decode safety 누락 또는 user-authored auto-delete가 있으면 promotion을
|
||||
차단한다.
|
||||
|
||||
## 17. 완료 기준
|
||||
|
||||
이 결정의 공통 runtime 구현은 다음을 모두 만족해야
|
||||
`AVAILABLE_NOT_COMPOSED`로 완료된다.
|
||||
|
||||
- origin coordinator가 immutable registry, 다섯 primary status literal과 독립된
|
||||
selection/admission/health/evidence 축을 강제
|
||||
- pressure hysteresis, bounded GC와 exact one-retry가 executable test로 검증
|
||||
- all-marker-loss ambiguity를 API/result/문서에서 숨기지 않음
|
||||
- OPFS real preflight가 worker/lock/journal/write/read/delete/cleanup을 검증
|
||||
- OPFS/Cache forward migration과 N-1 rollback historical fixture 통과
|
||||
- Cache inspect/cleanup이 cursor/count/deadline 상한을 강제
|
||||
- static Cache와 optional Service Worker composition이 import/bundle 경계로 분리
|
||||
- preview가 pixel/decoded-byte/animation/decode limit을 object URL 전에 강제
|
||||
- directory/persistent/drop이 transient picker에 암묵적으로 추가되지 않음
|
||||
- Range download와 private/sparse Range cache가 서로도 별도 capability로
|
||||
남고 public cache가 둘을 계속 거부
|
||||
- Chromium/Firefox/WebKit의 required evidence와 recovery/rollback drill 완성
|
||||
- default production build에는 선택되지 않은 runtime, worker, DB open, listener,
|
||||
timer가 없음
|
||||
|
||||
이 기준 전에는 기존 `AVAILABLE_NOT_COMPOSED` runtime 일부가 존재하더라도 origin
|
||||
storage lifecycle 전체를 production-ready 또는 `COMPOSED`라고 부르지 않는다.
|
||||
@@ -0,0 +1,638 @@
|
||||
# VD-16: Browser transfer composition과 Image delivery
|
||||
|
||||
- 상태: Accepted — design complete, implementation pending
|
||||
- 결정일: 2026-07-28
|
||||
- 이 ADR이 선택한 common delta의 current status:
|
||||
`DESIGNED_NOT_IMPLEMENTED`
|
||||
- common delta의 목표 reference status:
|
||||
`AVAILABLE_NOT_COMPOSED`
|
||||
- 관련 결정: VD-10, VD-11, VD-12, VD-13, VD-14, VD-15
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 재검토: 첫 product upload/download/Image CDN capability를 조합하기 전
|
||||
|
||||
## 배경
|
||||
|
||||
현재 저장소에는 File runtime, presigned capability provider/vault/executor,
|
||||
multipart/resumable upload, one-shot streaming download와 Image CDN verification
|
||||
runtime의 개별 factory가 있다. 이 구현들은 production bootstrap에서 제거돼
|
||||
있고 각각의 local `close()` 또는 `dispose()`만 제공한다.
|
||||
|
||||
제품에서 이들을 직접 조합하면 다음 문제가 생긴다.
|
||||
|
||||
- account/session이 바뀌어도 이전 capability, checkpoint, refresh 또는 async
|
||||
completion이 살아남을 수 있다.
|
||||
- 서로 다른 config가 같은 byte/resource/preset을 다르게 해석할 수 있다.
|
||||
- 일부 provider만 생성된 partial runtime이 요청을 받기 시작할 수 있다.
|
||||
- upload, download와 image에 retry, kill switch, deadline과 observation owner가
|
||||
중복될 수 있다.
|
||||
- Image engine은 이미 decode된 server descriptor를 받으므로 BFF transport,
|
||||
expiry refresh와 presentation handoff의 책임이 비어 있다.
|
||||
- 실제 provider가 frontend mock과 같은 계약을 지키는지 재사용 가능한
|
||||
conformance harness가 없다.
|
||||
|
||||
이 결정은 도메인별 upload 화면이나 cloud vendor를 공통 플랫폼에 넣지 않는다.
|
||||
선택된 capability를 안전하게 조립·폐기하는 composition protocol과 Image
|
||||
descriptor acquisition 경계를 정한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| 개별 presigned/upload/image runtime | `AVAILABLE_NOT_COMPOSED` | factory와 deterministic test가 있으나 제품 graph에는 없음 |
|
||||
| one-shot streaming download | `AVAILABLE_NOT_COMPOSED` | Range resume가 아닌 전체 객체 스트림 |
|
||||
| top-level transfer runtime | `DESIGNED_NOT_IMPLEMENTED` | export index만 있고 atomic factory/readiness/lifecycle 없음 |
|
||||
| Image descriptor HTTP provider | `DESIGNED_NOT_IMPLEMENTED` | caller가 `BackendIssuedImageAsset`을 직접 전달 |
|
||||
| safe image DOM projection | `DESIGNED_NOT_IMPLEMENTED` | presentation descriptor는 있으나 renderer boundary 없음 |
|
||||
| app-managed background download | `NOT_SELECTED` | VD-14의 별도 optional capability |
|
||||
| cross-browser background-download guarantee | `PLATFORM_LIMITED` | browser-managed handoff가 기본 fallback |
|
||||
| app-managed background upload | `NOT_SELECTED` | durable source staging/worker auth protocol이 별도로 필요 |
|
||||
| cross-browser background-upload guarantee | `PLATFORM_LIMITED` | worker lifetime/local source permission을 공통 보장할 수 없음 |
|
||||
|
||||
이 ADR을 추가해도 위 상태는 자동으로 바뀌지 않는다. port, runtime, test와
|
||||
removal evidence가 구현된 뒤에만 reference 상태를 올린다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. 하나의 account-scoped composition owner
|
||||
|
||||
선택된 file/transfer/image capability는
|
||||
`BrowserTransferRuntimeComposition` 역할의 단일 owner가 다음 순서로 생성한다.
|
||||
|
||||
```text
|
||||
parse immutable config
|
||||
-> validate implementation ceilings
|
||||
-> obtain immutable session/account scope
|
||||
-> create policy registries
|
||||
-> create provider transports
|
||||
-> create capability vaults
|
||||
-> create checkpoint/lock/channel owners
|
||||
-> create file/upload/download/image runtimes
|
||||
-> run required compatibility probes
|
||||
-> publish READY facade atomically
|
||||
```
|
||||
|
||||
factory가 중간에 실패하면 생성된 owner를 역순으로 닫고 facade를 반환하지 않는다.
|
||||
partial runtime, degraded provider 또는 mutable config를 application에 노출하지
|
||||
않는다.
|
||||
|
||||
composition은 다음 두 종류를 반환하는 union이어야 한다.
|
||||
|
||||
```text
|
||||
READY {
|
||||
generation,
|
||||
capabilities,
|
||||
application facades,
|
||||
readiness,
|
||||
close()
|
||||
}
|
||||
|
||||
UNAVAILABLE {
|
||||
safe reason,
|
||||
retryability,
|
||||
fallback capability,
|
||||
disposePartial()
|
||||
}
|
||||
```
|
||||
|
||||
`UNAVAILABLE`에 provider URL, raw browser exception, account/tenant ID 또는
|
||||
credential을 넣지 않는다.
|
||||
|
||||
### 2. Runtime config는 closed schema다
|
||||
|
||||
config는 composition root만 읽고 깊은 snapshot/freeze한다. 최소한 다음
|
||||
registry-owned reference를 갖는다.
|
||||
|
||||
- config schema version과 runtime compatibility version
|
||||
- opaque session/account scope와 generation
|
||||
- application origin과 fixed BFF endpoint IDs
|
||||
- exact `PRESIGNED_TRANSFER_V1`, `PRESIGNED_MULTIPART_V1`,
|
||||
`RANGE_RESUMABLE_DOWNLOAD_V1`, `IMAGE_CDN_DESCRIPTOR_V1` 중 선택한 protocol
|
||||
registry와 fixed endpoint map
|
||||
- upload purpose/profile, part/concurrency/retry/deadline hard ceiling
|
||||
- download profile, size/integrity/strategy와 Range capability selection
|
||||
- checkpoint namespace, retention, inventory와 maintenance budget
|
||||
- lock/cancel transport selection과 unsupported outcome
|
||||
- Image issuer/origin/preset/key/probe/decode policy
|
||||
- descriptor refresh lead time, request/decode deadline와 concurrency
|
||||
- capability별 traffic admission과 kill switch
|
||||
- observation sink와 redaction policy
|
||||
- active-operation drain deadline
|
||||
|
||||
caller는 raw endpoint, URL, header, object key, transform, retry count, byte ceiling,
|
||||
cache policy 또는 account partition을 request마다 override할 수 없다.
|
||||
|
||||
config는 구현 절대 상한을 높일 수 없다. 구현 상한보다 큰 값, 중복 registry ID,
|
||||
same-origin private Image CDN, 모순되는 fallback 또는 provider 누락은 startup에서
|
||||
fail-closed한다.
|
||||
|
||||
### 3. Lifecycle state machine
|
||||
|
||||
top-level runtime은 다음 상태만 가진다.
|
||||
|
||||
```text
|
||||
CREATING
|
||||
-> PROBING
|
||||
-> READY
|
||||
-> DRAINING
|
||||
-> CLOSED
|
||||
|
||||
CREATING | PROBING
|
||||
-> FAILED
|
||||
-> CLOSED
|
||||
```
|
||||
|
||||
- `READY`만 새 operation을 받는다.
|
||||
- `DRAINING`은 새 operation을 거절하고 진행 중 operation에 bounded deadline을
|
||||
제공한다.
|
||||
- deadline 뒤 남은 operation은 runtime lifetime signal로 abort한다.
|
||||
- `close()`는 terminal/idempotent이며 `DRAINING/CLOSED`에서 반복 호출해도
|
||||
새 side effect를 만들지 않는다.
|
||||
- 닫힌 runtime은 reopen하지 않는다. 새 config/scope에는 새 generation을 만든다.
|
||||
- operation은 시작할 때 runtime generation과 account scope snapshot을 얻고
|
||||
모든 async boundary와 terminal commit 전에 다시 확인한다.
|
||||
- 늦게 끝난 fetch, hash, IndexedDB transaction, image verification 또는 decode가
|
||||
old generation이면 결과를 폐기하고 native resource를 닫는다.
|
||||
|
||||
### 4. Logout과 account/tenant switch
|
||||
|
||||
session owner notification이 authority다. BroadcastChannel, storage event,
|
||||
capability expiry 또는 page unload를 logout authority로 사용하지 않는다.
|
||||
|
||||
```text
|
||||
session owner announces local revoke
|
||||
-> traffic admission CLOSED
|
||||
-> runtime generation FENCED
|
||||
-> reject new operations
|
||||
-> signal active reads/fetch/backoff/probes
|
||||
-> bounded drain
|
||||
-> close capability and image vaults
|
||||
-> close cancel channels and release locks/connections
|
||||
-> apply checkpoint retention/purge policy with exact old scope
|
||||
-> dispose observations
|
||||
-> CLOSED
|
||||
-> construct new scope/runtime independently
|
||||
```
|
||||
|
||||
- old-scope purge와 new-scope open을 같은 transaction이나 facade에 섞지 않는다.
|
||||
- checkpoint가 crash 때문에 남아도 exact scope/policy binding이 다르면 새
|
||||
runtime이 읽지 못해야 한다.
|
||||
- presigned URL, signed headers와 Image private URL은 어떤 teardown record에도
|
||||
저장하지 않는다.
|
||||
- old account의 descriptor refresh, part completion과 download destination
|
||||
commit은 generation fence 뒤 성공으로 보고하지 않는다.
|
||||
- logout이 backend capability의 즉시 revoke를 보장하지 않는다. 강한 회수가
|
||||
필요하면 BFF가 revoke authority 또는 proxy/relay를 제공해야 한다.
|
||||
|
||||
### 5. Capability별 facade
|
||||
|
||||
application에는 top-level runtime 자체나 native adapter를 반환하지 않는다.
|
||||
composition은 설치된 feature에 필요한 좁은 facade만 주입한다.
|
||||
|
||||
```text
|
||||
FeatureUploadFacade
|
||||
-> select local file profile
|
||||
-> create/resume/pause/abort approved purpose
|
||||
|
||||
FeatureDownloadFacade
|
||||
-> request approved resource
|
||||
-> receive policy-selected handoff/save outcome
|
||||
|
||||
FeatureImageFacade
|
||||
-> request opaque asset/preset
|
||||
-> receive safe presentation descriptor
|
||||
```
|
||||
|
||||
feature는 `File`, `Blob`, `Response`, `ReadableStream`, `FileSystemHandle`,
|
||||
presigned URL, Image signature DTO, checkpoint store 또는 QueryClient를 받지 않는다.
|
||||
progress UI를 위한 observation도 bounded aggregate snapshot이며 transfer
|
||||
authority가 아니다.
|
||||
|
||||
## Upload lifecycle integration
|
||||
|
||||
### 6. Pause와 abort는 다르다
|
||||
|
||||
top-level upload facade가 향후 제공할 상태는 다음과 같다.
|
||||
|
||||
```text
|
||||
ACTIVE
|
||||
-> PAUSE_REQUESTED
|
||||
-> PAUSED
|
||||
-> RESUMING
|
||||
-> ACTIVE
|
||||
|
||||
ACTIVE | PAUSED
|
||||
-> ABORT_REQUESTED
|
||||
-> ABORT_PENDING
|
||||
-> ABORTED
|
||||
|
||||
ACTIVE
|
||||
-> COMPLETING
|
||||
-> QUARANTINED
|
||||
```
|
||||
|
||||
- pause는 새 part와 retry를 중지하고 현재 bounded native operation을 abort한 뒤
|
||||
non-authorizing checkpoint를 유지한다.
|
||||
- cross-context pause wire literal은 `RESUMABLE_UPLOAD_PAUSE_V1`이며
|
||||
`uploadKey`, exact scope/generation과 bounded message metadata만 운반한다.
|
||||
- durable `PAUSED`를 추가하는 checkpoint는 `schemaVersion: 2`다. v1
|
||||
`ACTIVE | ABORT_PENDING` reader/writer와 섞지 않고 old-writer drain,
|
||||
historical migration과 N-1 fail-closed를 증명한다.
|
||||
- abort는 server session authority와 reconcile한 뒤 terminal checkpoint를
|
||||
제거한다.
|
||||
- pause signal은 authority가 아니라 best-effort same-scope hint다. 수신자는
|
||||
exact upload key/scope/generation을 검증한다.
|
||||
- checkpoint inventory는 presigned URL이나 raw file path 없이 opaque upload
|
||||
key, safe state, age/byte/part bucket과 expiry만 반환한다.
|
||||
- inventory/list와 retention sweep은 count, cursor, deadline을 갖는다.
|
||||
- abandoned/expired checkpoint는 server status 또는 expiry policy와 CAS를
|
||||
확인한 뒤 bounded batch로 제거한다.
|
||||
- file 재선택 뒤 source fingerprint/size/media/part layout이 exact하게 맞지
|
||||
않으면 resume하지 않는다.
|
||||
|
||||
app-managed background upload는 이 state machine을 재사용할 수 있지만 page
|
||||
runtime의 pause/resume를 background 보장으로 표현하지 않는다.
|
||||
|
||||
## Download integration
|
||||
|
||||
### 7. Strategy selector
|
||||
|
||||
download strategy는 application caller가 지정하지 않고 immutable profile,
|
||||
resource delivery class, expected bytes, browser capability와 user activation을
|
||||
입력으로 하는 headless selector가 결정한다.
|
||||
|
||||
| 조건 | 결과 |
|
||||
| --- | --- |
|
||||
| save picker 지원, user activation 있음, large stream | `WHOLE_OBJECT_PICKER_STREAM` |
|
||||
| server-managed resource, picker 없음 또는 handoff가 제품 정책 | `BROWSER_MANAGED_HANDOFF` |
|
||||
| generated artifact가 approved Blob cap 이하 | `BOUNDED_OBJECT_URL` |
|
||||
| large generated artifact, picker 없음 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` |
|
||||
| Range profile + seekable destination + provider contract | `RANGE_RESUMABLE_FOREGROUND` |
|
||||
| background download가 선택되고 지원되는 browser + owned staging | `APP_MANAGED_BACKGROUND_DOWNLOAD` |
|
||||
|
||||
selector는 capability probe와 actual invocation failure를 구분한다.
|
||||
`WHOLE_OBJECT_PICKER_STREAM`인데 picker가 없으면 request validation 오류가 아니라
|
||||
`UNSUPPORTED` 또는 승인 fallback이어야 한다. Safari/WebView/private mode의
|
||||
fallback도 동일 표에서 결정하며 user agent 문자열만으로 기능을 가정하지 않는다.
|
||||
|
||||
selector의 위 값은 application-level `DownloadExecutionPlan`이다. 현재
|
||||
file-delivery primitive와의 mapping은 다음처럼 닫는다.
|
||||
|
||||
| execution plan | 현재 adapter mapping |
|
||||
| --- | --- |
|
||||
| `WHOLE_OBJECT_PICKER_STREAM` | `DownloadStrategy=PROMPT_AND_STREAM` |
|
||||
| `BROWSER_MANAGED_HANDOFF` | source kind `BROWSER_MANAGED_RESOURCE` + `DownloadStrategy=BROWSER_MANAGED` |
|
||||
| `BOUNDED_OBJECT_URL` | `DownloadStrategy=BOUNDED_OBJECT_URL` |
|
||||
| `RANGE_RESUMABLE_FOREGROUND` | VD-14의 별도 future port; 현재 adapter에 mapping 금지 |
|
||||
| `APP_MANAGED_BACKGROUND_DOWNLOAD` | 별도 optional worker/staging port |
|
||||
| `SERVER_GENERATION_REQUIRED` / `UNSUPPORTED` | browser delivery를 시작하지 않는 closed outcome |
|
||||
|
||||
Range resume의 checkpoint, validator와 seek/truncate 결정은 VD-14를 따른다.
|
||||
app-managed background download는 기본 selector 결과가 아니다.
|
||||
|
||||
## Image descriptor acquisition과 delivery
|
||||
|
||||
### 8. Wire protocol
|
||||
|
||||
private Image descriptor BFF 계약은 다음 literal을 사용한다.
|
||||
|
||||
```text
|
||||
IMAGE_CDN_DESCRIPTOR_V1
|
||||
```
|
||||
|
||||
request는 composition-owned fixed HTTPS endpoint를 호출하며 최소한 다음
|
||||
application-safe 입력만 허용한다.
|
||||
|
||||
- protocol
|
||||
- opaque asset reference
|
||||
- named preset reference 또는 preset family
|
||||
- intended presentation class
|
||||
- current runtime generation에 묶인 CSRF/session transport
|
||||
|
||||
caller는 CDN URL, source URL, origin, object key, width, height, DPR, quality, fit,
|
||||
format, cache header, signing key ID나 expiry를 제출하지 않는다.
|
||||
|
||||
response decoder는 content type, status, header/body byte cap, total deadline와
|
||||
closed JSON shape를 검증한다. response에는 최소한 다음이 binding된다.
|
||||
|
||||
- exact protocol과 issuer
|
||||
- opaque asset ID와 immutable revision
|
||||
- origin/preset binding IDs
|
||||
- static raster media와 intrinsic dimensions
|
||||
- allowed preset binding ID set
|
||||
- issued/expiry time
|
||||
- signature algorithm, key ID, canonical binding digest와 signature
|
||||
|
||||
unknown field 정책은 protocol version에서 고정한다. credential, backend stack,
|
||||
raw provider key 또는 arbitrary transform은 descriptor에 포함하지 않는다.
|
||||
|
||||
HTTP `200`만 descriptor success다. `401/403/404`의 외부 mapping은 existence
|
||||
hiding 정책에 따라 closed failure로 정규화하며 raw backend message를 버린다.
|
||||
redirect, opaque response, wrong content type, oversize, timeout와 malformed
|
||||
descriptor는 capability를 생성하지 않는다.
|
||||
|
||||
### 9. Provider와 verifier 경계
|
||||
|
||||
- BFF는 authorization, asset existence, quarantine/promotion state와 descriptor
|
||||
발급 authority를 소유한다.
|
||||
- verifier registry는 composition이 승인한 bounded old/new public key set만
|
||||
가진다.
|
||||
- client signature 검증은 BFF authorization의 대체가 아니라 response tamper와
|
||||
registry mismatch를 fail-closed하는 보조 경계다.
|
||||
- CDN은 asset revision과 preset binding ID로 exact transform candidate를
|
||||
재계산한다. signed query나 client 계산 width가 authority가 아니다.
|
||||
- private asset의 emergency revocation은 backend/CDN/BFF가 소유한다. client는
|
||||
runtime close와 short expiry로 exposure를 줄인다.
|
||||
|
||||
### 10. Refresh state machine
|
||||
|
||||
descriptor provider는 asset/preset/scope/generation별 bounded single-flight만
|
||||
허용한다.
|
||||
|
||||
```text
|
||||
ABSENT
|
||||
-> FETCHING
|
||||
-> VERIFIED
|
||||
-> FRESH
|
||||
-> REFRESH_DUE
|
||||
-> REFRESHING
|
||||
-> FRESH
|
||||
|
||||
FETCHING | REFRESHING
|
||||
-> TERMINAL_POLICY_FAILURE
|
||||
-> PLACEHOLDER
|
||||
|
||||
FETCHING | REFRESHING
|
||||
-> RETRYABLE_FAILURE
|
||||
-> EXISTING_FRESH_UNTIL_EXPIRY | PLACEHOLDER
|
||||
|
||||
any state + scope/runtime revoke
|
||||
-> REVOKED
|
||||
```
|
||||
|
||||
- refresh lead time은 config가 정하되 expiry hard ceiling을 넘지 않는다.
|
||||
- private descriptor를 generic Query cache, Web Storage 또는 IndexedDB에
|
||||
persistence하지 않는다.
|
||||
- concurrent callers는 같은 verified result를 받을 수 있지만 URL/string을
|
||||
application state에 장기 복사하지 않는다.
|
||||
- 기존 descriptor가 아직 fresh하고 refresh가 일시 실패하면 expiry까지만
|
||||
사용할 수 있다. expiry 뒤 stale-while-error를 금지한다.
|
||||
- `lazy` load로 실제 fetch가 expiry 뒤 시작될 가능성이 있으면 eager/priority로
|
||||
바꾸거나 load 직전에 새 descriptor를 발급한다.
|
||||
- logout, key registry replacement와 runtime generation 변경은 in-flight
|
||||
transport, verification과 probe를 abort하고 늦은 결과를 폐기한다.
|
||||
|
||||
### 11. Safe presentation projection
|
||||
|
||||
공통 presentation primitive는 검증된 `ImagePresentationDescriptor`를 다음
|
||||
정적 속성으로만 투영한다.
|
||||
|
||||
- fallback `src`
|
||||
- ordered `<source type srcset>`
|
||||
- registry-owned `sizes`
|
||||
- intrinsic `width`와 `height`
|
||||
- `loading`, `decoding`, `fetchpriority`
|
||||
- `referrerpolicy`
|
||||
- `crossorigin="anonymous"`
|
||||
|
||||
primitive는 URL을 parse·조립·append하거나 transform query를 생성하지 않는다.
|
||||
descriptor가 가진 string을 React property로 전달하기 전에 closed allowed
|
||||
protocol/origin과 runtime generation을 다시 확인한다. raw HTML 주입과 CSS URL
|
||||
조립을 금지한다.
|
||||
|
||||
다음은 제품 presentation owner가 결정한다.
|
||||
|
||||
- 의미 있는 `alt`
|
||||
- placeholder와 오류 copy
|
||||
- skeleton/aspect-ratio UX
|
||||
- above-the-fold preload/priority
|
||||
- route/SSR preload hint
|
||||
- click/open/download behavior
|
||||
|
||||
descriptor refresh 실패를 native broken-image UI에만 맡기지 않고 제품이 승인한
|
||||
placeholder outcome으로 매핑한다.
|
||||
|
||||
## Provider contract harness
|
||||
|
||||
### 12. 재사용 가능한 suite
|
||||
|
||||
frontend는 transport 구현과 분리된 provider contract harness를 제공한다. 같은
|
||||
case set을 deterministic fake, local emulator와 실제 BFF/object storage/CDN에
|
||||
실행한다.
|
||||
|
||||
Presigned/download case:
|
||||
|
||||
- exact protocol/status/content type/body cap
|
||||
- method/origin/path/query/header binding
|
||||
- redirect와 credential omission
|
||||
- expiry/revocation/replay
|
||||
- truncation/overrun/content encoding
|
||||
- CORS exposed receipt/checksum
|
||||
|
||||
Multipart case:
|
||||
|
||||
- create/status/part/complete/abort idempotency
|
||||
- part layout/checksum/receipt reconciliation
|
||||
- 404/410/expiry와 orphan cleanup
|
||||
- quarantine/promotion
|
||||
- retry-after와 ambiguous completion
|
||||
|
||||
Image case:
|
||||
|
||||
- protocol/issuer/key/preset exact match
|
||||
- old/new signing key overlap과 removal
|
||||
- immutable revision/cache key
|
||||
- private no-store/CORS/CSP
|
||||
- pixel/decode/encoded byte ceiling
|
||||
- malformed/animated/active content
|
||||
- expiry refresh, revocation과 placeholder
|
||||
|
||||
actual provider test는 bearer URL, signature, account/asset/session ID를 artifact에
|
||||
기록하지 않는다. fixture는 synthetic opaque values와 disposable storage를 쓴다.
|
||||
|
||||
## Failure, readiness와 fallback
|
||||
|
||||
### 13. Readiness report
|
||||
|
||||
readiness는 application-safe capability별 결과다.
|
||||
|
||||
| 상태 | 의미 |
|
||||
| --- | --- |
|
||||
| `READY` | 필수 provider/config/browser probe가 모두 유효 |
|
||||
| `DEGRADED` | 승인된 좁은 fallback만 가능 |
|
||||
| `UNAVAILABLE` | 기능을 노출하지 않음 |
|
||||
| `DRAINING` | 기존 작업만 정리 중 |
|
||||
| `CLOSED` | terminal |
|
||||
|
||||
`DEGRADED`는 byte/pixel/security ceiling을 낮출 수는 있지만 높이지 않는다.
|
||||
예를 들어 enhanced picker off → native input, multipart concurrency off →
|
||||
sequential, private Image CDN off → approved placeholder는 가능하다.
|
||||
integrity off, arbitrary URL 허용, private response cache 또는 unbounded Blob은
|
||||
fallback이 아니다.
|
||||
|
||||
### 14. Kill switch
|
||||
|
||||
최소한 다음 switch를 독립적으로 둔다.
|
||||
|
||||
- new presigned issuance
|
||||
- direct object-storage data plane
|
||||
- new upload session
|
||||
- upload resume
|
||||
- upload complete
|
||||
- Range resume
|
||||
- picker streaming save
|
||||
- private image descriptor issuance
|
||||
- advanced image format
|
||||
- responsive candidates
|
||||
|
||||
switch 변경은 active operation의 의미를 소급 변경하지 않는다. 신규 진입을
|
||||
닫은 뒤 reconcile/drain한다. remote runtime config를 사용한다면 config의
|
||||
authenticity, release compatibility와 last-known-safe 정책을 별도 hosting 계약으로
|
||||
검증한다.
|
||||
|
||||
## 관측성과 개인정보
|
||||
|
||||
허용:
|
||||
|
||||
- operation kind와 safe outcome
|
||||
- runtime/readiness state
|
||||
- byte/part/candidate/retry/age/deadline bucket
|
||||
- policy rejection, abort, reconcile와 drain bucket
|
||||
- aggregate active count, checkpoint count와 orphan age
|
||||
|
||||
금지:
|
||||
|
||||
- URL, query, signed/request/response header
|
||||
- capability, bearer token, signature와 key material
|
||||
- resource/session/asset/upload/account/tenant ID
|
||||
- file name, local path, object key와 storage physical key
|
||||
- digest, ETag, receipt와 checkpoint payload
|
||||
- raw backend/browser exception message와 stack
|
||||
|
||||
runtime generation과 registry ID도 외부 telemetry에 그대로 보내지 않고 bounded
|
||||
compatibility bucket으로 변환한다.
|
||||
|
||||
## Rollout과 rollback
|
||||
|
||||
### 15. Rollout
|
||||
|
||||
1. ledger와 관련 ADR을 accepted로 고정한다.
|
||||
2. closed config/port와 provider contract harness를 먼저 구현한다.
|
||||
3. fake와 negative fixture에서 partial composition/late result를 거절한다.
|
||||
4. top-level runtime을 `AVAILABLE_NOT_COMPOSED`로 유지하고 removal gate를 만든다.
|
||||
5. product owner, exact scope와 provider config를 선택하고 bootstrap에 kill
|
||||
switch `DISABLED` 상태로 조합한다. 이 시점 primary status는 `COMPOSED`다.
|
||||
6. 실제 BFF/storage/CDN conformance와 readiness probe를 통과한다.
|
||||
7. Chromium/Firefox/WebKit과 실제 device에서 account switch, expiry와 crash를
|
||||
검증한다.
|
||||
8. runbook/rollback drill 뒤 internal cohort의 read-only/image public 또는 upload
|
||||
shadow flow부터 연다.
|
||||
9. private/image upload/download capability를 독립 canary와 kill switch로 확대한다.
|
||||
|
||||
### 16. Rollback
|
||||
|
||||
1. 신규 issuance/session/descriptor를 중지한다.
|
||||
2. runtime을 `DRAINING`으로 바꾸고 bounded operation을 마무리한다.
|
||||
3. ambiguous upload는 server reconcile하고 Range partial은 checkpoint 정책대로
|
||||
보존 또는 삭제한다.
|
||||
4. private capability를 backend에서 revoke하고 client runtime을 close한다.
|
||||
5. old compatible composition을 새 generation으로 다시 생성하거나 기능을
|
||||
unavailable로 유지한다.
|
||||
6. optional source, config와 facade를 제거하고 production module inventory와
|
||||
removal gate를 재검증한다.
|
||||
|
||||
schema version을 내리거나 checkpoint를 무조건 삭제해 rollback하지 않는다.
|
||||
|
||||
## 검증과 완료 기준
|
||||
|
||||
### 17. Deterministic
|
||||
|
||||
- partial factory failure의 reverse-order cleanup
|
||||
- close idempotency와 closed-runtime rejection
|
||||
- account switch 중 late fetch/hash/transaction/decode drop
|
||||
- config duplicate/ceiling/missing provider fail-closed
|
||||
- selector의 picker/size/resource matrix
|
||||
- upload pause/abort/reconcile race와 bounded inventory
|
||||
- descriptor refresh single-flight, expiry와 generation fence
|
||||
- picture projection의 arbitrary URL/query 생성 0건
|
||||
- diagnostics forbidden-value negative fixtures
|
||||
|
||||
### 18. Native browser
|
||||
|
||||
- Chromium/Firefox/WebKit의 native input/save picker fallback
|
||||
- multi-tab upload pause/cancel과 unsupported Web Locks path
|
||||
- page reload/account switch 중 active transfer drain
|
||||
- public/private Image fetch, actual CORS/no-store와 bitmap decode
|
||||
- offline/timeout/abort/late completion cleanup
|
||||
- Safari/WebView/private mode의 approved selector result
|
||||
|
||||
### 19. Provider와 operations
|
||||
|
||||
- fake/emulator/실제 BFF·object storage·CDN 동일 contract suite
|
||||
- signing key rotation과 emergency revoke drill
|
||||
- checkpoint retention/orphan cleanup drill
|
||||
- capability별 kill switch와 N-1 rollback
|
||||
- runtime removal 후 source-module inventory 0건
|
||||
|
||||
다음 조건 전에는 목표 상태를 `AVAILABLE_NOT_COMPOSED`로 올리지 않는다.
|
||||
|
||||
- [ ] top-level closed config와 atomic factory가 구현됐다.
|
||||
- [ ] generation-bound lifecycle과 account teardown이 구현됐다.
|
||||
- [ ] `RESUMABLE_UPLOAD_PAUSE_V1`, checkpoint schema v2 old-writer
|
||||
migration과 bounded upload inventory/retention owner가 구현됐다.
|
||||
- [ ] `PRESIGNED_TRANSFER_V1`과 선택 protocol의 strict codec, unknown-version
|
||||
rejection 및 reusable provider contract harness가 구현됐다.
|
||||
- [ ] strategy selector가 browser fallback을 fail-closed한다.
|
||||
- [ ] Image descriptor provider/refresh와 safe projection이 구현됐다.
|
||||
- [ ] deterministic fault, boundary, removal test가 통과한다.
|
||||
|
||||
다음 조건 전에는 제품 상태를 `COMPOSED`로 올리지 않는다.
|
||||
|
||||
- [ ] product owner와 opaque account scope가 정해졌다.
|
||||
- [ ] strict config를 사용하는 top-level runtime이 production bootstrap에서
|
||||
생성되고 실제 feature facade consumer까지 연결됐다.
|
||||
- [ ] traffic 기본값이 `DISABLED`이며 local teardown/close 경로가 연결됐다.
|
||||
|
||||
다음 조건 전에는 product-local production traffic을 승인하지 않는다.
|
||||
|
||||
- [ ] actual BFF/storage/CDN contract harness가 통과한다.
|
||||
- [ ] browser/device evidence와 runbook drill이 보존됐다.
|
||||
- [ ] kill switch와 rollback owner가 운영 승인됐다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [Browser data capability completion ledger](../browser-data-capability-completion-ledger.md)
|
||||
- [Presigned transfer and Image CDN](../presigned-transfer-and-image-cdn.md)
|
||||
- [VD-14 Resumable download와 background download](./VD-14-resumable-download-and-background-transfer.md)
|
||||
- [Server file capability infrastructure](../server-file-capability-infrastructure.md)
|
||||
- [Browser transfer recovery](../../operations/browser-transfer-recovery.md)
|
||||
|
||||
## 선택하지 않은 대안
|
||||
|
||||
- feature가 개별 transfer adapter factory를 직접 조합
|
||||
- singleton runtime을 여러 account/tenant가 공유
|
||||
- raw presigned/Image URL을 Query cache나 persistence에 저장
|
||||
- Image descriptor endpoint/transform을 request마다 caller가 지정
|
||||
- page abort를 upload pause 또는 server abort 완료로 간주
|
||||
- browser-managed handoff를 저장 완료로 간주
|
||||
- user-agent 문자열만으로 Safari fallback 결정
|
||||
- Service Worker를 설치하면 background upload/download가 보장된다고 가정
|
||||
- capability 일부만 준비된 partial runtime을 degraded success로 반환
|
||||
|
||||
## 결과
|
||||
|
||||
장점:
|
||||
|
||||
- account 전환과 teardown의 한 owner가 생긴다.
|
||||
- 개별 runtime의 안전한 메커니즘을 제품별 facade로 좁혀 조합할 수 있다.
|
||||
- provider mock과 실제 인프라 사이의 계약 차이를 같은 suite로 찾을 수 있다.
|
||||
- Image URL과 transform이 application/presentation에서 재조립되지 않는다.
|
||||
- capability별 rollout, kill switch와 제거가 독립적이다.
|
||||
|
||||
비용:
|
||||
|
||||
- config, lifecycle, provider harness와 browser evidence가 늘어난다.
|
||||
- 제품이 선택하지 않은 capability는 여전히 조합할 수 없으며 이것이 의도된
|
||||
결과다.
|
||||
- actual provider와 운영 증거 없이는 reference runtime 구현만으로 production
|
||||
완료를 주장할 수 없다.
|
||||
@@ -0,0 +1,778 @@
|
||||
# VD-23: API transport selection과 REST execution
|
||||
|
||||
- 상태: Accepted — REST v2 security/execution baseline composed, advanced profiles pending
|
||||
- 결정일: 2026-07-28
|
||||
- installed REST reference vertical: `COMPOSED`
|
||||
- REST v2 security/execution baseline: `COMPOSED`
|
||||
- provider/path/auth profile baseline: `COMPOSED`
|
||||
- conditional execution/provider-conformance delta: `DESIGNED_NOT_IMPLEMENTED`
|
||||
- GraphQL/Connect/gRPC-Web/REST Gateway product selection: `NOT_SELECTED`
|
||||
- 관련 결정: VD-13, VD-24, VD-25, VD-26, VD-27, VD-29, VD-30
|
||||
- 상세 설계:
|
||||
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
|
||||
- browser Protobuf/gateway 설계:
|
||||
[Protobuf browser transport와 REST Gateway](../protobuf-browser-transport-and-rest-gateway.md)
|
||||
|
||||
## 배경
|
||||
|
||||
현재 reference feature는 operation registry, request Zod schema, shared HTTP
|
||||
executor, response envelope/payload schema, mapper, gateway, application input과
|
||||
TanStack Query까지 실제 production composition에 연결된다. 따라서 REST 경로
|
||||
자체를 미구현으로 표시하지 않는다.
|
||||
|
||||
현재 reference operation은 v2 metadata, collision-aware composition, allowlisted
|
||||
credential patch, auth fail-before-fetch, prefix-preserving target, shared logical
|
||||
deadline, exact JSON media/success status, bounded decoder와 typed mapping failure를
|
||||
실행한다. correlation header, success status group과 401 replay를 포함한 physical
|
||||
attempt도 terminal observation에 연결한다.
|
||||
|
||||
operation은 path placeholder↔codec key exact join, named provider/bearer/CSRF
|
||||
profile, provider credential-mode ceiling과 encoded query byte ceiling도 실행한다.
|
||||
남은 production delta는 cookie-CSRF/CORS evidence, 204/304/412 conditional
|
||||
transaction, artifact digest와 실제 provider conformance다.
|
||||
`API_CONTRACT_VERSION` 문자열 일치만 backend compatibility 증거로 사용하지 않는다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. Protocol 선택은 operation registry가 소유한다
|
||||
|
||||
feature application port는 protocol-neutral이다.
|
||||
|
||||
```text
|
||||
feature use case
|
||||
-> feature gateway
|
||||
-> exact semantic operation
|
||||
-> REST adapter
|
||||
-> persisted GraphQL adapter
|
||||
-> Connect adapter
|
||||
-> gRPC-Web adapter
|
||||
```
|
||||
|
||||
- 한 `operationId`는 한 protocol에만 binding한다.
|
||||
- UI, query hook과 use case는 protocol을 선택하지 않는다.
|
||||
- URL, GraphQL document, service/method와 generated request를 application input으로
|
||||
받지 않는다.
|
||||
- 같은 command를 provider failure 때문에 다른 protocol로 자동 replay하지 않는다.
|
||||
- read fallback도 동일 auth/freshness/schema/mapper/query identity와 하나의 total
|
||||
retry budget을 증명한 registered policy만 허용한다.
|
||||
- GraphQL/Connect/gRPC-Web adapter를 공통 `HttpClient`의 mode flag로 넣지 않는다.
|
||||
공통으로 공유하는 것은 execution context, auth collaboration, failure vocabulary와
|
||||
observation뿐이다.
|
||||
|
||||
### 2. REST operation은 실행 source와 정적 manifest를 분리하지 않는다
|
||||
|
||||
목표 API는 feature가 generic이 연결된 definition을 만든다.
|
||||
|
||||
```text
|
||||
defineRestOperation<
|
||||
Path,
|
||||
Search,
|
||||
Body,
|
||||
SuccessWire,
|
||||
SuccessValue,
|
||||
Failure
|
||||
>({
|
||||
common,
|
||||
providerId,
|
||||
method,
|
||||
uriTemplate,
|
||||
pathCodec,
|
||||
searchCodec,
|
||||
bodyCodec,
|
||||
successCodec,
|
||||
errorCodec,
|
||||
mapper,
|
||||
policies
|
||||
})
|
||||
```
|
||||
|
||||
실행 definition에서 registry manifest를 결정적으로 투영한다. schema metadata,
|
||||
실제 codec map, operation-specific TypeScript map과 mapper를 서로 다른 string
|
||||
dispatch table에 수기로 중복하지 않는다.
|
||||
|
||||
manifest 최소 field:
|
||||
|
||||
```text
|
||||
protocol = REST
|
||||
registryVersion
|
||||
providerId
|
||||
operationId
|
||||
owner
|
||||
semantics
|
||||
method
|
||||
relativePathTemplate
|
||||
pathSchemaId
|
||||
searchSchemaId
|
||||
bodySchemaId
|
||||
requestMediaProfile
|
||||
successStatusProfiles[]
|
||||
errorStatusProfiles[]
|
||||
responseSchemaId
|
||||
mapperId
|
||||
authProfileId
|
||||
csrfProfileId
|
||||
replayPolicy
|
||||
idempotencyKeyPolicy
|
||||
deadlineProfileId
|
||||
retryProfileId
|
||||
paginationProfileId | null
|
||||
conditionalProfileId
|
||||
serverStateProfileId | null
|
||||
maxRequestBytes
|
||||
maxDecodedResponseBytes
|
||||
maxResponseItems
|
||||
observabilityProfileId
|
||||
compatibility
|
||||
```
|
||||
|
||||
### 3. Contribution composer는 collision 전에 실패한다
|
||||
|
||||
```text
|
||||
feature contributions[]
|
||||
-> preserve every source row
|
||||
-> validate contribution owner/version
|
||||
-> detect duplicate operation/schema/mapper/query/topic ID
|
||||
-> resolve every codec/mapper/profile reference
|
||||
-> validate semantic coherence
|
||||
-> freeze installed registry
|
||||
-> emit compatibility manifest
|
||||
```
|
||||
|
||||
object spread의 last-write-wins를 금지한다. duplicate ID가 payload까지 동일해도
|
||||
owner를 하나 선택하지 않고 build를 실패시킨다. alias/rename은 versioned migration
|
||||
row로만 허용한다.
|
||||
|
||||
semantic coherence:
|
||||
|
||||
- `GET`/`HEAD`는 body 없음, `SAFE`만 허용
|
||||
- `HEAD`는 body success codec 없음
|
||||
- `POST`/`PATCH` retry는 `IDEMPOTENT | KEYED_COMMAND` 또는 명시적
|
||||
`SAFE` semantics 필요
|
||||
- `KEYED_COMMAND`는 `idempotencyKeyPolicy=REQUIRED`, 다른 replay policy의 key
|
||||
attach/금지는 exact key profile과 일치
|
||||
- `NON_REPLAYABLE`은 retry와 401 replay 금지
|
||||
- success/error status가 겹치지 않음
|
||||
- 204 profile은 body/schema 없음
|
||||
- 304는 query + conditional profile + existing cache binding 필요
|
||||
- 412는 precondition profile 필요
|
||||
- path placeholder 집합과 path codec key 집합이 exact match
|
||||
- query/cache profile은 `QUERY` operation에만 연결
|
||||
- mutation invalidation은 registered topic만 사용
|
||||
|
||||
### 4. Provider endpoint와 URI
|
||||
|
||||
application은 provider URL을 받지 않는다.
|
||||
|
||||
```text
|
||||
RestProviderProfile
|
||||
providerId
|
||||
baseOrigin
|
||||
basePathPrefix
|
||||
allowedCredentialsModes
|
||||
corsProfile
|
||||
referrerPolicy
|
||||
redirectPolicy = ERROR
|
||||
defaultHeaders
|
||||
allowedResponseOrigins
|
||||
```
|
||||
|
||||
- non-local은 HTTPS만 허용한다.
|
||||
- provider는 credential mode ceiling만 제공하고 operation의 auth transport
|
||||
profile이 exact mode를 선택한다. anonymous는 `omit`, same-origin cookie는
|
||||
approved `same-origin`, approved cross-origin cookie만 `include`다.
|
||||
- base URL의 username, password, query와 fragment를 금지한다.
|
||||
- exact origin과 canonical base path prefix를 보존한다.
|
||||
- operation template은 relative API path이며 scheme, authority, query와 fragment를
|
||||
포함하지 않는다.
|
||||
- leading slash가 base prefix를 제거하는 `new URL()` ambiguity를 쓰지 않는다.
|
||||
- encoded slash, dot segment, NUL/control, duplicate slash와 overlong path를
|
||||
정책대로 거절한다.
|
||||
- redirect는 기본 `error`다. 로그인/다운로드 handoff는 일반 JSON REST operation과
|
||||
다른 capability다.
|
||||
|
||||
path parameter:
|
||||
|
||||
- path codec의 parsed output만 encode한다.
|
||||
- missing, extra, empty와 length 초과 parameter를 network 전에 거절한다.
|
||||
- Unicode normalization을 업무 ID에 임의 적용하지 않는다.
|
||||
- path value와 최종 URL은 diagnostics에 기록하지 않는다.
|
||||
|
||||
query:
|
||||
|
||||
- key ordering, repeated-array/comma style, boolean, null/absent/empty semantics를
|
||||
profile에 고정한다.
|
||||
- URL encoded byte ceiling을 적용한다.
|
||||
- raw `URLSearchParams`, query string과 next URL을 caller에게 받지 않는다.
|
||||
- sensitive/private value를 GET query에 넣는 operation은 별도 security review가
|
||||
없으면 금지한다.
|
||||
|
||||
### 5. Request projection과 final invariant
|
||||
|
||||
request는 다음 소유 순서로 만든다.
|
||||
|
||||
```text
|
||||
operation + validated input
|
||||
-> immutable request binding
|
||||
-> body canonical serialization + digest
|
||||
-> transport-owned headers/options
|
||||
-> constrained auth/CSRF patch
|
||||
-> final invariant validation
|
||||
-> fetch
|
||||
```
|
||||
|
||||
transport-owned header:
|
||||
|
||||
- `Accept`, `Content-Type`
|
||||
- contract/media version
|
||||
- bounded correlation/trace context
|
||||
- idempotency key
|
||||
- conditional validator
|
||||
- approved CSRF header
|
||||
|
||||
caller와 feature mapper가 arbitrary header를 추가하지 않는다.
|
||||
|
||||
auth owner target:
|
||||
|
||||
```text
|
||||
CredentialPatch
|
||||
credentialMode
|
||||
allowlisted header name/value
|
||||
proof expiry/generation
|
||||
```
|
||||
|
||||
operation auth profile은 final Fetch `credentials`를 exact하게 고정한다.
|
||||
`ANONYMOUS | BEARER_HEADER`는 ambient cookie가 섞이지 않게 `omit`,
|
||||
same-origin cookie session은 `same-origin`, cross-origin cookie는 별도 CORS/CSRF
|
||||
provider evidence가 있는 profile만 `include`다. provider ceiling과 맞지 않으면
|
||||
fetch 0회다.
|
||||
|
||||
auth owner가 `Request` 전체를 반환하지 않는다. transition 기간에 current port를
|
||||
사용한다면 attach 전후의 다음 값이 exact하게 같아야 한다.
|
||||
|
||||
- URL/origin/path/query
|
||||
- method
|
||||
- body digest
|
||||
- content type/length
|
||||
- correlation, idempotency, conditional와 CSRF binding
|
||||
- redirect/cache/referrer/credentials/mode
|
||||
|
||||
다르면 `AUTH_INTEGRATION_FAILURE`, fetch 0회다.
|
||||
|
||||
auth-required operation은 session state가 `authenticated`가 아니면 fetch하지 않는다.
|
||||
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
|
||||
request로 downgrade하지 않는다.
|
||||
|
||||
#### 5-0. Logical effect certainty는 단조 증가한다
|
||||
|
||||
`PhysicalAttemptState`는 현재 attempt만 설명한다. logical execution 전체에는
|
||||
별도의 monotonic accumulator를 두고 `joinMutationEffectCertainty`로 join한다.
|
||||
join 순서는 보수적이다.
|
||||
|
||||
```text
|
||||
NOT_STARTED < NOT_APPLIED < MAYBE_APPLIED < APPLIED_CONFIRMED
|
||||
```
|
||||
|
||||
`fetch()` dispatch 시점에 command는 즉시 `MAYBE_APPLIED`를 기록한다. 이후 retry
|
||||
loop entry, pre-dispatch final invariant, scope fence, cancellation, timeout
|
||||
return은 모두 accumulator를 읽는다. 아직 보내지 않은 새 retry가 있다는 이유로
|
||||
전체 logical operation을 `NOT_STARTED`로 되돌리지 않는다. query operation은
|
||||
`NOT_APPLICABLE`로 남고 이 lattice를 쓰지 않는다.
|
||||
|
||||
#### 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을 기본 권장한다.
|
||||
|
||||
- cookie는 Secure/HttpOnly이며 provider가 SameSite 정책을 소유한다.
|
||||
- unsafe method는 server의 exact Origin/Fetch Metadata 검증과 composition-issued
|
||||
anti-CSRF proof를 요구한다.
|
||||
- CSRF proof는 application/query/cache에 노출하거나 persistence하지 않는다.
|
||||
- custom content type/preflight가 있다는 사실만 CSRF 방어로 간주하지 않는다.
|
||||
|
||||
cross-origin profile은 다음을 actual provider에서 증명한다.
|
||||
|
||||
- exact `Access-Control-Allow-Origin`, wildcard 금지
|
||||
- credentials mode와 allow-credentials 일치
|
||||
- exact allow-method/allow-header
|
||||
- 필요한 request ID, ETag, Retry-After만 expose
|
||||
- OPTIONS와 actual response의 policy 동등성
|
||||
- redirect 없음
|
||||
|
||||
### 7. Replay와 idempotency
|
||||
|
||||
```text
|
||||
ReplayPolicy
|
||||
SAFE
|
||||
IDEMPOTENT
|
||||
KEYED_COMMAND
|
||||
NON_REPLAYABLE
|
||||
```
|
||||
|
||||
- `SAFE`: read-only이며 network/recovery retry 가능
|
||||
- `IDEMPOTENT`: 같은 principal/operation/payload/precondition의 반복 request가
|
||||
의도한 server effect를 추가로 만들지 않으며 duplicate response/status mapping을
|
||||
backend contract가 명시한다. response byte가 항상 동일하다는 뜻은 아니다.
|
||||
- `KEYED_COMMAND`: application logical command lease가 key를 생성하고 lifecycle
|
||||
전체에서 유지
|
||||
- `NON_REPLAYABLE`: ambiguous result에서 자동 재실행 금지
|
||||
|
||||
`KEYED_COMMAND` binding:
|
||||
|
||||
```text
|
||||
principal scope
|
||||
operation ID/version
|
||||
canonical payload digest
|
||||
idempotency key
|
||||
server retention/expiry
|
||||
```
|
||||
|
||||
backend는 atomic claim, concurrent same-key join/replay, same-key different-payload
|
||||
rejection과 terminal receipt를 제공한다. header를 보냈다는 사실만 replay safety가
|
||||
아니다. provider conformance가 없으면 retry/401 recovery traffic을 켜지 않는다.
|
||||
|
||||
current client가 execute 호출마다 key를 생성하는 방식은 network attempt 안에서는
|
||||
재사용되지만 ambiguous terminal 뒤 사용자 retry와 연결되지 않는다. 목표 command
|
||||
owner가 effect certainty를 다음처럼 반환한다.
|
||||
|
||||
```text
|
||||
NOT_APPLIED | COMMITTED | UNKNOWN
|
||||
```
|
||||
|
||||
`UNKNOWN`은 새 key 자동 retry가 아니라 status/reconcile 또는 명시적 사용자 복구로
|
||||
닫는다.
|
||||
|
||||
### 8. Logical deadline, cancel과 retry
|
||||
|
||||
```text
|
||||
LogicalExecutionBudget
|
||||
totalDeadlineMs
|
||||
attemptTimeoutMs
|
||||
maxAttempts
|
||||
maxCumulativeSleepMs
|
||||
maxRetryAfterMs
|
||||
authRecoveryCount = 0 | 1
|
||||
```
|
||||
|
||||
total deadline은 다음 모두를 포함한다.
|
||||
|
||||
- operation/schema lookup과 request encode
|
||||
- credential/CSRF attachment
|
||||
- fetch attempt
|
||||
- body read/decode/schema/mapper
|
||||
- 401 recovery
|
||||
- retry backoff/Retry-After
|
||||
|
||||
각 phase는 남은 total budget보다 긴 timer를 만들지 않는다. timer, abort listener,
|
||||
response reader와 auth waiter는 모든 terminal path에서 정리한다.
|
||||
|
||||
초회, network/status retry와 401 recovery replay를 포함한 **모든 API provider
|
||||
fetch**는 하나의 monotonic `physicalAttemptCount`를 증가시키고 `maxAttempts`를
|
||||
소비한다. `authRecoveryCount`는 추가 상한일 뿐 attempt counter, sleep budget이나
|
||||
total deadline을 reset하거나 우회하지 않는다.
|
||||
|
||||
retry status는 operation의 exact subset만 허용한다.
|
||||
|
||||
```text
|
||||
network failure
|
||||
408
|
||||
429
|
||||
502
|
||||
503
|
||||
504
|
||||
```
|
||||
|
||||
- 429와 503의 `Retry-After`를 injected clock으로 parse한다.
|
||||
- hard ceiling을 넘는 Retry-After는 sleep하지 않고 terminal로 닫는다.
|
||||
- full jitter와 attempt/sleep/elapsed 세 상한을 모두 적용한다.
|
||||
- schema, mapper, 4xx validation/authz/conflict와 redirect failure는 retry하지 않는다.
|
||||
- caller cancel, navigation supersede, runtime teardown, attempt timeout과 logical
|
||||
deadline을 다른 safe failure로 유지한다.
|
||||
- 401 recovery는 auth-required이면서 replay-safe한 operation만 한 번 수행한다.
|
||||
- 동시 401은 session owner의 single-flight recovery를 공유한다.
|
||||
- physical attempt, logical retry와 auth replay를 따로 관측한다.
|
||||
|
||||
### 9. Closed execution
|
||||
|
||||
public executor는 promise rejection 대신 항상 closed result로 끝난다.
|
||||
|
||||
```text
|
||||
RestExecutionResult<T>
|
||||
SUCCESS
|
||||
VALIDATION_REJECTED
|
||||
AUTH_REQUIRED | AUTH_INTEGRATION_FAILURE
|
||||
REQUEST_ABORTED
|
||||
REQUEST_ATTEMPT_TIMEOUT | REQUEST_DEADLINE_EXCEEDED
|
||||
NETWORK_UNREACHABLE
|
||||
RATE_LIMITED | SERVER_FAILURE
|
||||
HTTP_FAILURE
|
||||
CONTENT_TYPE_MISMATCH | BODY_LIMIT_EXCEEDED | MALFORMED_BODY
|
||||
SCHEMA_MISMATCH | MAPPING_CONTRACT_VIOLATION
|
||||
PRECONDITION_FAILED | CONFLICT
|
||||
CONTRACT_INCOMPATIBLE
|
||||
```
|
||||
|
||||
attempt timer와 전체 logical deadline은 error registry, safe user copy와 telemetry
|
||||
bucket에서도 별도 closed kind로 유지한다. 둘 다 raw URL/timing detail을
|
||||
노출하지 않는다.
|
||||
|
||||
operation lookup, URL construction, `Headers`, body serialization, `Request`,
|
||||
auth collaboration, fetch, read, parse, schema와 mapper를 모두 catch/normalize
|
||||
경계 안에 둔다. thrown value/body/header/URL을 failure에 복사하지 않는다.
|
||||
|
||||
### 10. Response admission과 bounded decoder
|
||||
|
||||
body를 `Response.json()`으로 바로 읽지 않는다.
|
||||
|
||||
```text
|
||||
response
|
||||
-> final URL/status/header admission
|
||||
-> exact media type parser
|
||||
-> present/valid Content-Length advisory preflight
|
||||
-> bounded stream reader
|
||||
-> actual browser-visible decoded byte count
|
||||
-> UTF-8/profile decoder
|
||||
-> JSON structural ceiling
|
||||
-> envelope/status codec
|
||||
-> operation response codec
|
||||
-> mapper
|
||||
```
|
||||
|
||||
browser Fetch의 `Response.body`는 일반적으로 content decoding 뒤 stream이므로
|
||||
client가 actual wire/encoded byte를 신뢰성 있게 세었다고 주장하지 않는다.
|
||||
|
||||
- BFF/proxy/CDN가 encoded transfer와 decompression ratio ceiling을 집행한다.
|
||||
- browser client는 present/valid `Content-Length`를 advisory rejection에만 쓰고
|
||||
actual decoded bytes를 hard cap으로 센다.
|
||||
- 표준 `JSON.parse` profile은 decoded-byte cap이 pre-parse resource guard이고
|
||||
depth/node/key/string/item cap은 materialization 뒤 admission guard다.
|
||||
- 구조 cap을 parse 중 강제해야 하는 더 큰 profile은 bounded tokenizing JSON
|
||||
parser를 별도로 선택하고 actual browser evidence를 가져야 한다.
|
||||
|
||||
cap 초과, truncation과 invalid UTF-8에서 reader를 cancel하고 cache에 쓰지 않는다.
|
||||
|
||||
media parser는 type/subtype/parameter를 exact하게 해석한다.
|
||||
|
||||
- `application/json`
|
||||
- approved vendor `application/*+json`
|
||||
- RFC Problem Details profile
|
||||
- explicit 204 no-content
|
||||
|
||||
`includes("application/json")` 검사는 목표 계약이 아니다.
|
||||
|
||||
각 success status는 response codec을 가진다. 현재 envelope는
|
||||
`REST_ENVELOPE_V1` profile로 유지할 수 있지만 모든 REST provider에 강제하지
|
||||
않는다. success status와 error body가 모순되면 status/profile 계약 실패다.
|
||||
|
||||
### 11. Error projection
|
||||
|
||||
backend error는 먼저 status/media별 codec을 통과한다.
|
||||
|
||||
- raw message, stack, body와 arbitrary extensions를 버린다.
|
||||
- code/category는 operation error profile의 allowlist로 mapping한다.
|
||||
- unknown backend code는 closed generic failure다.
|
||||
- validation issue는 최대 count, path/code byte/charset와 allowed path를 제한한다.
|
||||
- request ID, trace ID와 correlation ID도 length/charset cap을 적용한다.
|
||||
- `retryable` backend boolean을 client retry authority로 사용하지 않는다.
|
||||
- 401/403/404 existence-hiding은 provider/product policy를 따른다.
|
||||
- 409 business conflict와 412 representation precondition failure를 분리한다.
|
||||
|
||||
### 12. Cursor pagination
|
||||
|
||||
```text
|
||||
CursorPageWire<T>
|
||||
items
|
||||
nextCursor | null
|
||||
hasMore
|
||||
snapshotToken | null
|
||||
```
|
||||
|
||||
response codec은 item count, item size, cursor/snapshot byte와 total decoded ceiling을
|
||||
검증한다. mapper는 immutable `CursorPage<ApplicationProjection>`을 만든다.
|
||||
|
||||
- cursor는 opaque이며 decode/로그/telemetry 금지
|
||||
- arbitrary next URL을 따라가지 않음
|
||||
- filters/sort/scope/snapshot과 cursor를 exact binding
|
||||
- same cursor 반복, `hasMore=true`인데 cursor 없음, non-progress loop 거절
|
||||
- maximum pages/items/cache bytes 이후 fetch 중지
|
||||
- TanStack infinite query의 root key에는 semantic filter만 넣고 cursor는 bounded
|
||||
`pageParam`으로 관리
|
||||
- offset pagination은 stable small dataset이 증명된 별도 profile만 허용
|
||||
|
||||
current reference list array는 complete pagination 구현이 아니다.
|
||||
|
||||
### 13. Conditional read와 optimistic concurrency
|
||||
|
||||
operation별 owner:
|
||||
|
||||
```text
|
||||
ConditionalProfile
|
||||
NO_STORE
|
||||
APP_ETAG
|
||||
BROWSER_HTTP_CACHE
|
||||
APPLICATION_REVISION
|
||||
```
|
||||
|
||||
모든 operation은 한 profile을 가져야 한다.
|
||||
|
||||
- `NO_STORE`, `APP_ETAG`, `APPLICATION_REVISION`은 Fetch `cache=no-store`.
|
||||
- `BROWSER_HTTP_CACHE`만 exact browser cache mode와 server
|
||||
`Cache-Control`/`Vary` contract를 사용한다.
|
||||
- caller/library default에 맡기는 implicit `NONE`은 없다.
|
||||
|
||||
`APP_ETAG`:
|
||||
|
||||
- strong ETag/generation은 adapter-private metadata
|
||||
- exact operation/query/scope/representation binding과 함께 memory에 보존
|
||||
- `If-None-Match`를 transport가 생성
|
||||
- 304는 same binding의 mapped cached value가 있을 때만 freshness 갱신
|
||||
- 304는 same query-entry cache revision CAS가 성공할 때만 commit
|
||||
- cached value가 없으면 one-time unconditional request 또는 closed failure
|
||||
- app-managed conditional operation은 fetch cache mode를 `no-store`로 고정
|
||||
- full 200 mapped commit과 validator install/update는 같은 entry transaction
|
||||
- query removal/GC, scope/logout, release/contract/schema/mapper epoch reset에서
|
||||
validator sidecar도 함께 폐기
|
||||
- ordinary invalidation에서 validator 보존 여부는 profile이 고정
|
||||
|
||||
`BROWSER_HTTP_CACHE`:
|
||||
|
||||
- standard browser cache가 revalidation을 소유
|
||||
- application이 hidden validator/304 logic을 중복 구현하지 않음
|
||||
- HTTP `Cache-Control`을 TanStack `staleTime`으로 자동 변환하지 않음
|
||||
|
||||
write precondition:
|
||||
|
||||
- exact resource revision을 `If-Match` 또는 body contract로 binding
|
||||
- 412는 `PRECONDITION_FAILED`
|
||||
- current server representation을 refetch한 뒤 feature use case가 overwrite,
|
||||
merge 또는 cancel을 결정
|
||||
- 409 business conflict와 합치지 않음
|
||||
|
||||
ETag/revision은 authorization proof가 아니며 raw value를 diagnostics에 넣지 않는다.
|
||||
|
||||
### 14. Schema와 Mapper 연결
|
||||
|
||||
VD-24의 typed codec/mapper를 사용한다.
|
||||
|
||||
```text
|
||||
unknown
|
||||
-> RuntimeCodec<ValidatedResponseDto>
|
||||
-> Mapper<ValidatedResponseDto, ApplicationProjection>
|
||||
-> Result
|
||||
```
|
||||
|
||||
request는 strict/normalized parsed output만 serialize한다. ordinary additive
|
||||
response는 required/discriminant를 검증하고 unknown field를 폐기한다. control,
|
||||
authorization와 sealed union은 unknown을 거절한다.
|
||||
|
||||
generated OpenAPI client/DTO를 선택해도 adapter-private이다. handwritten gateway와
|
||||
mapper를 제거하지 않는다.
|
||||
|
||||
### 15. Query cache와의 관계
|
||||
|
||||
REST transport는 raw response cache를 소유하지 않는다. VD-25가 mapped application
|
||||
projection을 TanStack Query에 admission한다.
|
||||
|
||||
- operation registry의 cache profile만 query를 만들 수 있다.
|
||||
- Query retry는 `false`; REST transport가 network retry를 소유한다.
|
||||
- URL, ETag, idempotency key, Response와 DTO를 query key/value에 넣지 않는다.
|
||||
- mutation success 뒤 registered invalidation topic 또는 exact typed seed policy만
|
||||
사용한다.
|
||||
- REST timeout/error를 Query가 다시 network retry하지 않는다.
|
||||
|
||||
### 16. Contract source와 release coherence
|
||||
|
||||
OpenAPI가 backend authority인 제품:
|
||||
|
||||
```text
|
||||
authenticated immutable OpenAPI artifact
|
||||
-> source digest/provenance
|
||||
-> lint + breaking diff
|
||||
-> pinned deterministic generation
|
||||
-> runtime codec parity
|
||||
-> adapter-private DTO/client
|
||||
-> handwritten mapper/gateway
|
||||
```
|
||||
|
||||
CI:
|
||||
|
||||
- generator/runtime/plugin/Node version pin
|
||||
- clean checkout regenerate diff 0
|
||||
- stable operation ID
|
||||
- source/artifact/generated output digest
|
||||
- runtime codec vs specification fixtures
|
||||
- N/N-1 and future-major failure
|
||||
- generated import boundary
|
||||
|
||||
global `API_CONTRACT_VERSION`은 selected contract-set compatibility와 digest에
|
||||
연결한다. runtime config와 release manifest의 같은 문자열만으로 backend
|
||||
compatibility를 주장하지 않는다.
|
||||
|
||||
major version 전략은 URI version 또는 vendor media version 중 provider가 하나를
|
||||
선택한다. 동시에 둘을 임의 증가시키지 않는다. v1/v2 adapters는 같은
|
||||
application gateway를 구현할 수 있지만 같은 query entry에 representation을
|
||||
섞지 않는다.
|
||||
|
||||
### 17. Security와 observability
|
||||
|
||||
관측 허용:
|
||||
|
||||
- semantic operation/provider/profile ID
|
||||
- method/semantics
|
||||
- outcome/AppFailure kind/HTTP status group
|
||||
- logical retry, auth recovery와 physical attempt bucket
|
||||
- duration/deadline/request-response byte/item bucket
|
||||
- conditional/cache outcome
|
||||
|
||||
금지:
|
||||
|
||||
- URL, path/search/header/body
|
||||
- cursor/snapshot/ETag/revision
|
||||
- idempotency/CSRF/auth token
|
||||
- raw backend code/message/request/trace value
|
||||
- application resource/account/tenant ID
|
||||
|
||||
client correlation ID는 bounded syntax로 request에 전달하고 server-projected
|
||||
request/trace ID는 safe failure/observation에만 제한한다. 한 logical execution은
|
||||
terminal event 하나를 만든다.
|
||||
|
||||
### 18. Composition과 readiness
|
||||
|
||||
REST v2 composition owner가 다음을 atomic하게 만든다.
|
||||
|
||||
```text
|
||||
parse provider/config
|
||||
-> compose collision-free operation registry
|
||||
-> resolve codec/mapper/policy references
|
||||
-> install auth/CSRF owner
|
||||
-> run compatibility/provider probes
|
||||
-> publish READY facade
|
||||
```
|
||||
|
||||
partial registry/client를 application에 노출하지 않는다.
|
||||
|
||||
```text
|
||||
Selection
|
||||
TrafficAdmission
|
||||
RuntimeHealth
|
||||
PromotionEvidence
|
||||
```
|
||||
|
||||
primary status가 `COMPOSED`여도 provider/auth/CSRF/idempotency/conditional
|
||||
conformance가 없으면 `TrafficAdmission=DISABLED`와
|
||||
`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 닫는다.
|
||||
|
||||
kill switch:
|
||||
|
||||
- provider 전체
|
||||
- operation family
|
||||
- keyed retry/401 replay
|
||||
- conditional request
|
||||
- optimistic mutation
|
||||
- query cache admission
|
||||
|
||||
### 19. Test와 provider conformance
|
||||
|
||||
deterministic:
|
||||
|
||||
- contribution collision, missing codec/mapper/profile와 owner mismatch
|
||||
- URI template/path/query canonicalization/base-prefix preservation
|
||||
- method/body/replay/status/media coherence
|
||||
- anonymous/cookie/bearer credentials와 Fetch cache mode matrix
|
||||
- auth final-request mutation 공격과 unavailable auth fetch 0
|
||||
- attempt timeout vs total deadline, timer/listener/reader cleanup
|
||||
- concurrent 401 single-flight
|
||||
- 401 replay를 포함한 monotonic physical-attempt cap
|
||||
- retry matrix, injected-clock 429/503 Retry-After
|
||||
- same key/same payload replay와 same key/different payload rejection
|
||||
- 204/304/412/422/problem/envelope
|
||||
- oversized/truncated/malformed/decompression overflow
|
||||
- cursor loop/snapshot/page ceiling
|
||||
- ETag 304 without cache, If-Match 412
|
||||
- mapper failure와 redaction
|
||||
|
||||
actual staging provider:
|
||||
|
||||
- HTTPS/base path/CORS/preflight/credential
|
||||
- cookie/Origin/CSRF
|
||||
- idempotency concurrent claim/TTL/reconcile
|
||||
- status/media/error codec
|
||||
- cursor/snapshot/conditional semantics
|
||||
- rate limit/Retry-After
|
||||
- correlation/request/trace projection
|
||||
- proxy/CDN content encoding and body cap
|
||||
- browser decoded-byte cap과 provider encoded/decompression ceiling
|
||||
- outbound correlation, success status group와 401 physical-attempt observation
|
||||
|
||||
MSW 통과는 provider conformance가 아니다.
|
||||
|
||||
### 20. Rollout
|
||||
|
||||
1. collision-aware v2 registry/codec과 boot-time binding 검증을 설치한다.
|
||||
2. auth fail-closed, final invariant, bounded decoder와 total deadline을 local
|
||||
reference vertical에서 검증한다.
|
||||
3. actual provider에 같은 fixture를 실행하고 read operation을 canary한다.
|
||||
4. keyed command는 backend idempotency conformance 뒤 별도 canary한다.
|
||||
5. pagination/conditional operation을 각각 별도 traffic gate로 올린다.
|
||||
6. provider/browser/operations evidence가 complete인 operation만 enabled한다.
|
||||
7. rollback은 우선 safe unavailable로 내리고 contract artifact/frontend/backend를
|
||||
coherent set으로 복구한다. v1 fallback은 해당 operation의 unexpired
|
||||
provider/security evidence가 있고 incident가 v1/shared boundary에 영향이 없으며
|
||||
auth fail-close/final invariant hardening이 유지될 때만 허용한다.
|
||||
|
||||
### 21. Removal
|
||||
|
||||
GraphQL/Connect/gRPC-Web/REST Gateway 선택을 취소해도 REST v2 common execution
|
||||
context는 남을 수 있다.
|
||||
REST provider 제거 시:
|
||||
|
||||
1. 신규 operation admission 중지
|
||||
2. read cancel, command effect certainty reconcile
|
||||
3. auth/CSRF/retry timer와 response reader close
|
||||
4. current scope query cache clear/invalidate
|
||||
5. operation/schema/mapper/query profile 제거
|
||||
6. provider config/proxy/dependency/fixture 제거
|
||||
7. production module inventory와 backend route retirement evidence
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- installed REST operation이 typed path/search/body/success/error codec과 mapper에
|
||||
하나의 definition으로 연결된다.
|
||||
- auth unavailable 또는 mutated final request에서 fetch가 0회다.
|
||||
- 모든 throw/response size/status/media/schema/mapper failure가 closed result다.
|
||||
- total logical deadline이 auth/recovery/backoff/decode/mapper를 포함한다.
|
||||
- replay는 declared policy와 actual backend idempotency evidence를 가진다.
|
||||
- complete cursor page와 conditional/412 state가 bounded하게 동작한다.
|
||||
- response DTO/URL/header/token/validator가 application/query/log에 없다.
|
||||
- actual provider conformance와 rollback/removal drill이 통과한다.
|
||||
@@ -0,0 +1,585 @@
|
||||
# VD-24: Runtime Schema와 boundary Mapper
|
||||
|
||||
- 상태: Accepted — reference typed codec/mapper baseline composed, artifact governance pending
|
||||
- 결정일: 2026-07-28
|
||||
- reference REST Schema/Mapper vertical: `COMPOSED`
|
||||
- semantic compatibility/codegen governance delta:
|
||||
`DESIGNED_NOT_IMPLEMENTED`
|
||||
- generated API product selection: `NOT_SELECTED`
|
||||
- 관련 결정: VD-13, VD-23, VD-25, VD-26, VD-27, VD-29, VD-30
|
||||
- 상세 설계:
|
||||
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
|
||||
|
||||
## 배경
|
||||
|
||||
현재 reference vertical은 다음 trust path를 실제 실행한다.
|
||||
|
||||
```text
|
||||
HTTP response
|
||||
-> common envelope Zod
|
||||
-> feature payload Zod
|
||||
-> feature mapper
|
||||
-> domain factory
|
||||
-> application view
|
||||
-> TanStack Query
|
||||
```
|
||||
|
||||
reference baseline은 schema contribution collision을 boot 전에 거절하고,
|
||||
schema version/direction/unknown-field policy를 기록한다. request는 strict reject,
|
||||
ordinary response DTO는 strip projection을 사용하며 list item 수와 response/cache
|
||||
byte admission을 제한한다. mapper는 no-throw `MappingResult`를 반환하고 예상 가능한
|
||||
drift는 `MAPPING_CONTRACT_VIOLATION`으로 분류한다.
|
||||
|
||||
runtime schema codec과 mapper contribution은 collision-aware composer로 설치되고,
|
||||
각 REST operation의 path/request/response schema와 mapper input schema reference를
|
||||
boot 전에 exact resolve한다. operation별 cast-free result guard도 raw executor의
|
||||
성공 값을 fail-closed로 재검증한다. 남은 delta는
|
||||
actual codec fingerprint/source provenance/generated artifact join과 전체 scalar
|
||||
policy set이다.
|
||||
|
||||
이 결정은 runtime validation을 특정 library 이름으로 축소하지 않는다. 현재
|
||||
owned schema는 Zod를 사용하지만 GraphQL generated types와 protobuf messages에도
|
||||
동일한 trust transition을 적용한다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. 다섯 validation 경계를 분리한다
|
||||
|
||||
| 경계 | owner | 목적 |
|
||||
| --- | --- | --- |
|
||||
| route/form input | presentation/feature | 사용자 입력 정규화와 UX issue |
|
||||
| application command/query input | application/feature | use-case precondition과 canonical semantic input |
|
||||
| transport request wire | adapter/contract | exact outbound representation |
|
||||
| transport response wire | adapter/contract | untrusted server bytes/message 검증 |
|
||||
| domain invariant | domain | 업무상 유효한 entity/value 생성 |
|
||||
|
||||
하나의 Zod schema를 form, API request, response와 domain에 재사용하지 않는다.
|
||||
field 이름이 같아도 trust source와 failure semantics가 다르다.
|
||||
|
||||
### 2. TypeScript와 generated type은 proof가 아니다
|
||||
|
||||
```text
|
||||
unknown bytes/message
|
||||
-> bounded decoder
|
||||
-> RuntimeCodec<ValidatedDto>
|
||||
-> ValidatedDto
|
||||
-> BoundaryMapper<ValidatedDto, ApplicationValue>
|
||||
-> MappingResult<ApplicationValue>
|
||||
```
|
||||
|
||||
`as Dto`, generic `execute<T>()`, generated TypeScript interface와 protobuf class
|
||||
instance는 runtime proof를 만들지 않는다.
|
||||
|
||||
목표 API:
|
||||
|
||||
```text
|
||||
RuntimeCodec<Input, Output>
|
||||
schemaId
|
||||
parse(input, budget) -> ValidationResult<Output>
|
||||
|
||||
BoundaryMapper<ValidatedDto, ApplicationValue>
|
||||
mapperId
|
||||
inputSchemaId
|
||||
map(dto) -> MappingResult<ApplicationValue>
|
||||
|
||||
BoundOperation<Input, Dto, Value>
|
||||
requestCodec
|
||||
responseCodec
|
||||
mapper
|
||||
```
|
||||
|
||||
operation definition 생성 시 codec output과 mapper input type을 compiler가
|
||||
연결한다. runtime registry도 same IDs/fingerprints를 검증한다.
|
||||
|
||||
### 3. Schema registry v2
|
||||
|
||||
```text
|
||||
SchemaDefinitionV2
|
||||
schemaId
|
||||
wireVersion
|
||||
boundary
|
||||
protocol
|
||||
sourceKind = OWNED | GENERATED
|
||||
sourceArtifactId
|
||||
sourceArtifactDigest
|
||||
codecId
|
||||
codecFingerprint
|
||||
unknownFieldPolicy
|
||||
numericPolicyId
|
||||
temporalPolicyId
|
||||
providerMaxEncodedBytes
|
||||
maxDecodedBytes
|
||||
maxDepth
|
||||
maxNodes
|
||||
maxObjectKeys
|
||||
maxStringBytes
|
||||
maxCollectionItems
|
||||
compatibilityPolicy
|
||||
dataClassification
|
||||
owner
|
||||
```
|
||||
|
||||
정적 metadata는 실행 codec definition에서 결정적으로 투영한다. 실제 codec
|
||||
resolver가 없는 schema ID, fingerprint가 다른 resolver와 duplicate ID는
|
||||
contribution composition에서 실패한다.
|
||||
|
||||
`codecFingerprint`는 library 내부 AST serialization을 무조건 신뢰하지 않는다.
|
||||
프로젝트가 소유한 canonical schema manifest를 사용한다.
|
||||
|
||||
```text
|
||||
canonical schema manifest
|
||||
field/path
|
||||
required/nullability
|
||||
scalar/format/range
|
||||
enum/discriminant
|
||||
collection/item ceiling
|
||||
unknown-field policy
|
||||
transform identifier/version
|
||||
```
|
||||
|
||||
Zod upgrade로 내부 representation이 바뀌어도 canonical meaning diff가 안정적이어야
|
||||
한다.
|
||||
|
||||
### 4. Decode budget
|
||||
|
||||
Content-Length나 protobuf frame length만으로 충분하지 않다.
|
||||
|
||||
```text
|
||||
ValidationBudget
|
||||
decodedBytesRemaining
|
||||
nodesRemaining
|
||||
depthRemaining
|
||||
objectKeysRemaining
|
||||
stringBytesRemaining
|
||||
collectionItemsRemaining
|
||||
deadlineRemaining
|
||||
```
|
||||
|
||||
- BFF/proxy/provider가 actual wire/encoded transfer와 decompression-ratio cap을
|
||||
집행한다.
|
||||
- browser Fetch adapter는 present/valid Content-Length를 advisory preflight로만
|
||||
사용하고 browser-visible decoded stream bytes를 hard cap으로 센다.
|
||||
- codec은 depth/node/key/string/item cap을 적용한다.
|
||||
- collection nested item도 global budget을 함께 소모한다.
|
||||
- transform/refine도 남은 logical deadline 안에서 동기적이고 bounded해야 한다.
|
||||
- async network/storage refinement를 runtime wire schema에 넣지 않는다.
|
||||
- budget 초과는 validation issue list를 무한 생성하지 않고 첫 bounded summary로
|
||||
닫는다.
|
||||
|
||||
표준 `JSON.parse` profile에서 decoded-byte cap은 pre-parse guard지만
|
||||
depth/node/key/string/item cap은 materialization 뒤 admission guard다. parse 중
|
||||
구조 cap이 필요한 payload는 bounded tokenizing parser를 별도 profile로 선택하며,
|
||||
그 구현 전에는 큰 byte ceiling을 승인하지 않는다.
|
||||
|
||||
current request `limit <= 100`은 response item ceiling이 아니다. response schema가
|
||||
items maximum과 total byte budget을 별도로 검증한다.
|
||||
|
||||
### 5. Unknown-field 정책
|
||||
|
||||
```text
|
||||
UnknownFieldPolicy
|
||||
REJECT_UNKNOWN
|
||||
STRIP_UNKNOWN
|
||||
```
|
||||
|
||||
`PRESERVE_UNKNOWN`은 application boundary에서 허용하지 않는다.
|
||||
|
||||
| schema class | 기본 정책 |
|
||||
| --- | --- |
|
||||
| request, config, command, capability | `REJECT_UNKNOWN` |
|
||||
| auth/authorization/control envelope | `REJECT_UNKNOWN` |
|
||||
| ordinary additive REST response DTO | `STRIP_UNKNOWN` |
|
||||
| GraphQL selected data object | requested field shape만 투영 |
|
||||
| sealed discriminated union | unknown discriminator 거절 |
|
||||
| protobuf generated message | codec/library unknown-field behavior 뒤 mapper는 known projection만 사용 |
|
||||
|
||||
current response `.strict()`를 모두 `.passthrough()`로 바꾸지 않는다. unknown
|
||||
field를 제거한 typed projection만 mapper로 보낸다. unknown field 이름/value를
|
||||
log에 남기지 않는다. 필요한 경우 low-cardinality `unknown-field-detected`
|
||||
observation만 sampling한다.
|
||||
|
||||
### 6. Request와 response 방향성
|
||||
|
||||
request:
|
||||
|
||||
- strict field set
|
||||
- trim/coerce/default/normalization 정책이 명시됨
|
||||
- parsed output만 transport가 serialize
|
||||
- input 원본을 query key나 request에 따로 사용하지 않음
|
||||
- route/search/application command 변환이 같은 canonical semantic input을 공유
|
||||
|
||||
response:
|
||||
|
||||
- untrusted value를 coerce하지 않음
|
||||
- required/nullability/discriminant/range를 검증
|
||||
- additive unknown은 profile에 따라 strip
|
||||
- default value를 서버가 보낸 값처럼 조용히 생성하지 않음
|
||||
- missing/null/empty를 mapper가 명시적으로 소진
|
||||
|
||||
`z.coerce`는 URL/form 같은 string input 경계에서만 허용한다. JSON/protobuf
|
||||
response에 적용하지 않는다.
|
||||
|
||||
### 7. Scalar 의미
|
||||
|
||||
#### ID
|
||||
|
||||
- opaque bounded string
|
||||
- empty/control/overlong 거절
|
||||
- 업무 계약이 없는 case folding, Unicode normalization과 numeric parse 금지
|
||||
- account/resource ID를 diagnostics label이나 physical cache key에 직접 넣지 않음
|
||||
|
||||
#### Integer와 decimal
|
||||
|
||||
- JSON integer는 finite safe integer 범위를 증명
|
||||
- `int64`/`uint64`는 JavaScript number로 mapping하지 않음
|
||||
- protobuf bigint/string representation은 adapter-private
|
||||
- money/decimal/high precision은 canonical decimal string + currency/scale policy
|
||||
- `NaN`, Infinity와 negative zero가 의미상 허용되는지 explicit
|
||||
- string-to-number response coercion 금지
|
||||
|
||||
#### Time
|
||||
|
||||
- exact RFC 3339 profile과 offset/precision을 검증
|
||||
- date-only, instant, local date-time과 duration을 다른 type으로 둠
|
||||
- leap/invalid date를 JavaScript `Date` normalization에 맡기지 않음
|
||||
- protobuf Timestamp/Duration range/nanos를 검증
|
||||
- mapper가 application temporal value로 변환
|
||||
- locale/timezone formatting은 presentation에서만 수행
|
||||
|
||||
#### Null과 absent
|
||||
|
||||
```text
|
||||
ABSENT
|
||||
NULL
|
||||
EMPTY
|
||||
VALUE
|
||||
```
|
||||
|
||||
네 의미를 schema/mapper contract에 명시한다. current mapper처럼 “string이
|
||||
아니면 모두 null”로 합치지 않는다. optional server field의 default가 필요하면
|
||||
application policy가 이름 있는 결정으로 적용한다.
|
||||
|
||||
#### Enum/union/oneof
|
||||
|
||||
- unknown discriminator는 sealed control union에서 fail-closed
|
||||
- evolvable business enum은 domain이 explicit `UNKNOWN` case와 UX를 소유한
|
||||
경우에만 mapping
|
||||
- raw unknown string/number를 domain에 전달하지 않음
|
||||
- protobuf enum zero value, unknown numeric enum과 oneof absence를 명시적으로
|
||||
처리
|
||||
|
||||
#### Binary
|
||||
|
||||
- REST base64는 decoded byte cap과 canonical encoding profile 필요
|
||||
- GraphQL upload/binary는 이 JSON schema 경계의 기본 기능이 아님
|
||||
- protobuf `bytes`는 bounded copy/stream policy 뒤에만 application으로 projection
|
||||
- large binary는 File/transfer capability를 사용
|
||||
|
||||
### 8. Transport-specific schema
|
||||
|
||||
#### REST
|
||||
|
||||
- exact status/media/envelope profile 뒤 operation DTO codec 실행
|
||||
- error body도 별도 bounded codec
|
||||
- Problem Details의 type/title/detail/instance를 raw UI copy로 사용하지 않음
|
||||
- response envelope와 payload unknown policy를 따로 설정
|
||||
|
||||
#### GraphQL
|
||||
|
||||
- variables와 selected `data` shape에 separate codec
|
||||
- top-level `data`, `errors`, `extensions`를 GraphQL response codec이 검증
|
||||
- errors path/message/extensions는 safe failure mapper 전 untrusted
|
||||
- partial policy가 허용한 missing/null만 operation DTO type에 표현
|
||||
- persisted operation manifest의 schema digest와 codec fingerprint 일치
|
||||
|
||||
#### gRPC-Web
|
||||
|
||||
- frame/trailer 검증 뒤 generated protobuf decoder 실행
|
||||
- generated decode success 뒤에도 semantic validator가 range/presence/enum/oneof를
|
||||
검증
|
||||
- descriptor digest/message full name과 codec binding 일치
|
||||
- `google.rpc.Status` details는 allowlisted type만 decode
|
||||
|
||||
#### Connect-Web/Connect
|
||||
|
||||
- Connect unary HTTP/error 또는 stream EndStream proof 뒤 generated message decode
|
||||
- JSON/binary encoding과 descriptor/message binding을 operation profile에 고정
|
||||
- generated decode와 `ConnectError` code는 semantic domain proof가 아니므로
|
||||
같은 validator/mapper와 safe failure vocabulary를 통과
|
||||
|
||||
#### Protobuf REST Gateway
|
||||
|
||||
- HttpRule/ProtoJSON/status/error profile 뒤 ordinary REST DTO codec 실행
|
||||
- generated OpenAPI type이나 ProtoJSON message를 application model로 사용하지 않음
|
||||
- direct gateway와 curated BFF의 envelope/schema를 같은 codec으로 추측하지 않음
|
||||
|
||||
### 9. Boundary Mapper v2
|
||||
|
||||
```text
|
||||
MapperDefinitionV2
|
||||
mapperId
|
||||
mapperVersion
|
||||
inputSchemaId
|
||||
outputContractId
|
||||
scalarPolicySetId
|
||||
maxOutputItems
|
||||
maxEstimatedOutputBytes
|
||||
owner
|
||||
```
|
||||
|
||||
mapper는:
|
||||
|
||||
- pure
|
||||
- deterministic
|
||||
- synchronous
|
||||
- side-effect-free
|
||||
- locale/timezone-independent
|
||||
- input mutation 없음
|
||||
- immutable output
|
||||
- exhaustive
|
||||
- bounded
|
||||
|
||||
mapper가 호출하면 안 되는 것:
|
||||
|
||||
- fetch, generated client, QueryClient
|
||||
- clock/random
|
||||
- storage/cache
|
||||
- telemetry/logger
|
||||
- DOM/browser API
|
||||
- authorization/feature flag
|
||||
|
||||
### 10. Mapping result
|
||||
|
||||
```text
|
||||
MappingResult<T>
|
||||
{ ok: true, value: T }
|
||||
{ ok: false,
|
||||
error:
|
||||
MAPPING_INVARIANT_REJECTED |
|
||||
UNSUPPORTED_WIRE_VALUE |
|
||||
OUTPUT_LIMIT_EXCEEDED }
|
||||
```
|
||||
|
||||
예상 가능한 domain invariant/unknown enum/temporal conversion 실패는 throw하지
|
||||
않는다. programming defect가 throw되더라도 adapter boundary가
|
||||
`MAPPING_CONTRACT_VIOLATION`으로 정규화한다. raw DTO/value/path/message를 failure에
|
||||
복사하지 않는다.
|
||||
|
||||
`UNKNOWN_FAILURE`는 mapper drift의 정상 분류가 아니다. operation ID,
|
||||
schema/mapper profile/version과 safe outcome만 관측한다.
|
||||
|
||||
### 11. Domain, application projection과 view
|
||||
|
||||
```text
|
||||
ValidatedDto
|
||||
-> domain value/entity factory
|
||||
-> ApplicationReadModel / command result
|
||||
-> presentation-only ViewModel
|
||||
```
|
||||
|
||||
- DTO는 adapter/contracts 내부
|
||||
- domain은 transport nullability/error/envelope를 모름
|
||||
- application read model은 query cache에 넣을 수 있는 immutable plain value
|
||||
- presentation view는 locale/formatted copy와 UI-only optimistic marker를 소유
|
||||
- domain class/service, function, native object와 generated message를 Query cache에
|
||||
넣지 않음
|
||||
|
||||
current reference가 domain을 거쳐 view를 만드는 구조는 유지한다. 단 collection과
|
||||
return object를 immutable/bounded하게 만들고 mapping type proof를 연결한다.
|
||||
|
||||
### 12. Collection mapping
|
||||
|
||||
- input array/page count는 codec에서 먼저 제한
|
||||
- mapper는 output count와 estimated bytes를 다시 제한
|
||||
- item 하나 실패 시 partial collection을 success/cache하지 않음
|
||||
- stable identity, ordering, duplicate 의미는 feature contract가 결정
|
||||
- duplicate ID를 임의로 마지막 값으로 덮지 않음
|
||||
- mapper가 sort/filter/deduplicate를 한다면 이름 있는 policy와 fixture 필요
|
||||
- pagination page/snapshot binding을 보존
|
||||
|
||||
estimated output bytes는 quota/serialization exact value가 아니라 cache admission
|
||||
ceiling용 보수적 측정이다. 측정 실패는 unlimited로 간주하지 않고 cache
|
||||
admission을 거절한다.
|
||||
|
||||
### 13. Generated와 owned source
|
||||
|
||||
```text
|
||||
backend-authoritative contract
|
||||
-> authenticated immutable source artifact
|
||||
-> source digest + provenance
|
||||
-> pinned codegen
|
||||
-> adapter-private DTO/client/codec
|
||||
-> owned semantic validator where required
|
||||
-> handwritten boundary mapper
|
||||
```
|
||||
|
||||
| protocol | generated source 후보 | 반드시 owned인 것 |
|
||||
| --- | --- | --- |
|
||||
| REST | OpenAPI DTO/client/codec | gateway, mapper, application model, query policy |
|
||||
| GraphQL | schema types, operation types | persisted manifest policy, runtime result/error codec, mapper |
|
||||
| gRPC-Web | protobuf messages/client | semantic validation, failure mapping, mapper, stream reducer |
|
||||
|
||||
normal build가 네트워크에서 최신 schema를 암묵적으로 내려받지 않는다. source
|
||||
fetch/update는 authenticated explicit workflow이며 reviewable diff를 만든다.
|
||||
|
||||
generator:
|
||||
|
||||
- exact package/plugin/runtime/Node version pin
|
||||
- reproducible output
|
||||
- generated directory 수동 수정 금지
|
||||
- clean regenerate diff 0
|
||||
- license/SBOM/secret scan
|
||||
- vendor import boundary
|
||||
- generated artifact removal gate
|
||||
|
||||
현재 `generated-api` recipe의 generic `execute<TOutput>(unknown)`와 caller-selected
|
||||
cast는 production schema proof가 아니다.
|
||||
|
||||
### 14. Compatibility
|
||||
|
||||
change classification:
|
||||
|
||||
| 변경 | 기본 판정 |
|
||||
| --- | --- |
|
||||
| optional ordinary response field 추가 + strip policy | additive |
|
||||
| request required field 추가 | breaking |
|
||||
| response required field 제거/rename/type/nullability 축소 | breaking |
|
||||
| enum value 추가 | domain unknown policy에 따라 additive 또는 breaking |
|
||||
| numeric range/precision/temporal profile 변경 | semantic breaking |
|
||||
| mapper output meaning/identity/order 변경 | application breaking |
|
||||
| unknown-field policy 변경 | compatibility review |
|
||||
| codec transform/default 변경 | semantic diff 필수 |
|
||||
|
||||
`apiContractVersion` 하나만 올리지 않는다.
|
||||
|
||||
```text
|
||||
ContractSetManifest
|
||||
globalCompatibilityVersion
|
||||
REST/OpenAPI artifact digest
|
||||
GraphQL schema + persisted operation digest
|
||||
protobuf descriptor digest
|
||||
runtime schema registry digest
|
||||
mapper registry digest
|
||||
query policy digest
|
||||
```
|
||||
|
||||
- N과 N-1 fixture를 보존
|
||||
- breaking deployment는 old frontend window와 backend compatibility를 고려
|
||||
- future major는 fail-closed
|
||||
- rollback은 frontend, generated artifacts, config, BFF/router/proxy와 backend
|
||||
compatibility를 coherent set으로 복구
|
||||
- mapper-only semantic change도 cache/release epoch invalidation을 검토
|
||||
|
||||
### 15. Failure와 cache admission
|
||||
|
||||
다음 상태에서는 cache write가 0회다.
|
||||
|
||||
- body/frame limit
|
||||
- envelope/status/media mismatch
|
||||
- operation schema mismatch
|
||||
- mapper failure
|
||||
- scope/runtime generation mismatch
|
||||
- output item/byte ceiling 초과
|
||||
- incompatible contract/source digest
|
||||
|
||||
stale data를 유지할지는 VD-25 query profile이 결정한다. schema/mapper
|
||||
incompatibility를 ordinary transient network failure와 동일하게 retry하지 않는다.
|
||||
|
||||
### 16. Security와 privacy
|
||||
|
||||
- validation failure에 raw value를 포함하지 않음
|
||||
- issue path/code를 allowlist와 count/byte cap으로 projection
|
||||
- schema/mapper error가 PII field/value를 diagnostics에 넣지 않음
|
||||
- prototype pollution key와 accessor/class/native object 거절
|
||||
- `structuredClone` 성공을 safe plain-data proof로 사용하지 않음
|
||||
- generated code가 arbitrary URL/header/logger를 application에 노출하지 않음
|
||||
- source artifact와 generator provenance 검증
|
||||
- schema가 frontend authorization boundary라는 주장 금지
|
||||
|
||||
관측 허용:
|
||||
|
||||
- operation/schema/mapper ID와 version
|
||||
- source/compatibility outcome
|
||||
- validation/mapping failure kind
|
||||
- encoded/decoded/output size와 item bucket
|
||||
- unknown-field detected bucket
|
||||
|
||||
source digest 실제 값, field path/value, DTO, GraphQL error path와 protobuf payload는
|
||||
high-cardinality/sensitive이므로 telemetry label에 넣지 않는다.
|
||||
|
||||
### 17. Testing
|
||||
|
||||
schema:
|
||||
|
||||
- missing/null/empty/unknown field
|
||||
- numeric safe bounds, decimal, negative zero, NaN/Infinity
|
||||
- RFC 3339/Timestamp/Duration edge
|
||||
- enum/union/oneof future value
|
||||
- depth/node/key/string/array/byte cap
|
||||
- invalid UTF-8, base64와 binary cap
|
||||
- N/N-1/future-major
|
||||
|
||||
registry:
|
||||
|
||||
- duplicate ID before spread
|
||||
- missing/mismatched codec/mapper resolver
|
||||
- codec fingerprint/source digest drift
|
||||
- operation schema/mapper output type binding
|
||||
- orphan/owner/version mismatch
|
||||
|
||||
mapper:
|
||||
|
||||
- typed DTO only
|
||||
- deterministic/pure/input unmodified
|
||||
- immutable output
|
||||
- no throw for expected semantic rejection
|
||||
- collection partial failure
|
||||
- item/output byte ceiling
|
||||
- missing/null/date/numeric/unknown enum matrix
|
||||
- no raw value leakage
|
||||
|
||||
codegen:
|
||||
|
||||
- source provenance/digest
|
||||
- lint/breaking
|
||||
- clean reproducible generation
|
||||
- generated import boundary
|
||||
- runtime codec fixture parity
|
||||
- dependency/removal inventory
|
||||
|
||||
integration:
|
||||
|
||||
- bytes → decoder → schema → mapper → application/query
|
||||
- schema/mapper drift에서 cache write 0
|
||||
- old runtime generation result 폐기
|
||||
- actual REST/GraphQL/gRPC provider fixture
|
||||
|
||||
### 18. Rollout
|
||||
|
||||
1. current string-dispatch schema/mapper를 그대로 두고 typed definition builder를
|
||||
추가한다.
|
||||
2. reference operation에 shadow validation/mapping을 실행하되 secondary result를
|
||||
UI/cache에 쓰지 않는다.
|
||||
3. collision-aware contribution composer와 codec fingerprint를 먼저 blocking한다.
|
||||
4. bounded decoder/collection ceiling을 query read부터 canary한다.
|
||||
5. typed mapping result와 failure taxonomy를 적용한다.
|
||||
6. current unchecked binder/cast를 제거한다.
|
||||
7. OpenAPI/GraphQL/proto generation은 제품 contract source가 선택된 것만
|
||||
별도 canary한다.
|
||||
8. schema/mapper version을 release/cache epoch와 연결한다.
|
||||
|
||||
rollback은 codec schema number를 낮추거나 cache의 incompatible value를 억지로
|
||||
decode하지 않는다. old adapter/backend path와 coherent artifact로 돌리고 current
|
||||
scope의 incompatible mapped cache를 폐기한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- runtime codec output과 mapper input이 compiler/runtime registry 양쪽에서 연결된다.
|
||||
- schema registry가 actual meaning fingerprint, provenance와 budget을 가진다.
|
||||
- duplicate contribution이 overwrite 전에 실패한다.
|
||||
- request strict/response additive 방향 정책이 test로 증명된다.
|
||||
- scalar/null/enum/collection 의미가 mapper policy로 닫힌다.
|
||||
- mapper가 typed DTO만 받고 expected failure를 `Result`로 반환한다.
|
||||
- generated DTO/message가 domain/application/presentation/query public type에 없다.
|
||||
- schema/mapper failure에서 cache write와 raw-data observation이 0회다.
|
||||
- N/N-1, breaking diff, provider fixture와 rollback/removal drill이 통과한다.
|
||||
@@ -0,0 +1,802 @@
|
||||
# VD-25: Server State Cache lifecycle
|
||||
|
||||
- 상태: Accepted — reference bound-query/input-aware mutation baseline composed, lifecycle delta pending
|
||||
- 결정일: 2026-07-28
|
||||
- TanStack Query memory runtime: `COMPOSED`
|
||||
- reference bound-query/profile/input-aware duplicate coordination: `COMPOSED`
|
||||
- session-generation/identity lifecycle: `COMPOSED`
|
||||
- conditional sidecar/optimistic layer/cursor runtime: `AVAILABLE_NOT_COMPOSED`
|
||||
- account projection/infinite-query/effect reconciliation delta:
|
||||
`DESIGNED_NOT_IMPLEMENTED`
|
||||
- normalized graph cache: `NOT_SELECTED`
|
||||
- query persistence product selection: `NOT_SELECTED`
|
||||
- 관련 결정: VD-13, VD-23, VD-24, VD-26, VD-27, VD-29, VD-30
|
||||
- 상세 설계:
|
||||
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
|
||||
|
||||
## 배경
|
||||
|
||||
현재 production bootstrap은 runtime별 QueryClient와 invalidation coordinator를
|
||||
실제로 조립한다. reference presentation은 application input을
|
||||
`useApplicationQuery()`/`useApplicationMutation()`에 연결하며 다음을 제공한다.
|
||||
|
||||
- AbortSignal cancellation
|
||||
- finite global stale/gc default
|
||||
- Query network retry off
|
||||
- stale-degraded 표시
|
||||
- exact-key optimistic snapshot/rollback
|
||||
- conflict 표면
|
||||
- mutation topic lease와 cross-context invalidate-only hint
|
||||
|
||||
reference list/detail은 bound definition이 key, executor와 profile을 함께
|
||||
제공한다. strict canonical codec은 depth/node/string/encoded-byte, cycle/shared
|
||||
reference, undefined/NaN/negative-zero, sparse array, accessor와 non-plain object를
|
||||
닫고 runtime-private opaque identity만 query key에 넣는다. profile의 stale/gc/
|
||||
refetch/retry owner와 result item/byte admission이 실제 hook에 적용된다.
|
||||
|
||||
current invalidation definition은 namespace당 singular topic이고 같은 topic의
|
||||
multi-namespace fan-out을 compose하지 못한다. 아래 bounded topic-set registry는
|
||||
target delta이며 현재 runtime 증거가 아니다.
|
||||
|
||||
production scope runtime은 session transition 즉시 old generation을 fence하고
|
||||
QueryClient cancel/clear 뒤 새 opaque scope를 발급한다. identity registry는 active
|
||||
lease, refcount, bounded LRU, canonical-byte/entry ceiling과 token collision 검사를
|
||||
scope별로 소유한다. mutation duplicate baseline도 scope exact semantic input만
|
||||
join하고 late result를 폐기한다.
|
||||
|
||||
ordered optimistic layer, conditional validator CAS sidecar와 bounded cursor chain은
|
||||
실행 가능한 test runtime까지 존재하지만 reference backend/definition에는 아직
|
||||
연결하지 않았다. 남은 부분은 account identity projection, logical-key serialization,
|
||||
HTTP 304 transaction, product optimistic membership/revision, infinite-query binding과
|
||||
effect certainty reconcile이다.
|
||||
|
||||
## 소유권
|
||||
|
||||
VD-13:
|
||||
|
||||
- `CacheScopeSnapshot`
|
||||
- ORIGIN/ACCOUNT/SESSION projection
|
||||
- QueryClient generation/fence/remount
|
||||
- strict canonical key codec 공통 구현
|
||||
- cross-tab wire와 durable namespace epoch
|
||||
- optional IndexedDB persistence와 restore
|
||||
- logout/account switch purge
|
||||
|
||||
VD-25:
|
||||
|
||||
- operation/application query definition binding
|
||||
- per-query freshness/gc/refetch/result budget
|
||||
- cache admission과 mapped value contract
|
||||
- cursor/infinite pagination
|
||||
- conditional revalidation integration
|
||||
- mutation concurrency, optimistic patch와 reconciliation
|
||||
- invalidation/seed policy
|
||||
- transport-independent error/stale behavior
|
||||
|
||||
VD-25는 VD-13의 scope snapshot/key codec을 소비하고 다른 epoch/fingerprint
|
||||
protocol을 만들지 않는다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. TanStack Query가 유일한 기본 Server State owner다
|
||||
|
||||
REST, GraphQL과 gRPC-Web unary result는 transport-independent application
|
||||
projection으로 mapping된 뒤 TanStack Query memory cache에 들어갈 수 있다.
|
||||
|
||||
기본적으로 설치하지 않는다.
|
||||
|
||||
- Redux/Zustand server entity copy
|
||||
- Apollo/urql normalized cache
|
||||
- raw HTTP response cache wrapper
|
||||
- generated client SDK cache
|
||||
- custom Map singleton
|
||||
|
||||
GraphQL normalized cache가 실제로 필요하면 bounded context에서 TanStack
|
||||
operation-result cache를 대체하는 별도 ADR을 승인한다. 두 cache에 같은 entity를
|
||||
동시 write하지 않는다.
|
||||
|
||||
browser HTTP cache/Cache Storage, TanStack Query memory와 IndexedDB query
|
||||
persistence는 서로 다른 owner다.
|
||||
|
||||
### 2. Bound Query Definition
|
||||
|
||||
caller가 query key와 executor를 독립적으로 조립하지 않는다.
|
||||
|
||||
```text
|
||||
QueryDefinition<Input, Value>
|
||||
definitionId
|
||||
owner
|
||||
operationId
|
||||
inputCodec
|
||||
keyCodecId
|
||||
serverStateProfileId
|
||||
scopePersistencePolicyId
|
||||
resultContractId
|
||||
execute(validatedInput, executionContext)
|
||||
```
|
||||
|
||||
binding:
|
||||
|
||||
```text
|
||||
bindQuery(definition, rawInput, CacheScopeSnapshot)
|
||||
-> validate/canonicalize input
|
||||
-> derive branded query key
|
||||
-> resolve immutable policy
|
||||
-> freeze execute closure and captured generation
|
||||
-> BoundQuery<Value>
|
||||
```
|
||||
|
||||
presentation API:
|
||||
|
||||
```text
|
||||
useApplicationQuery(boundQuery)
|
||||
```
|
||||
|
||||
`queryKey`, `queryFn`, stale/gc/retry와 arbitrary TanStack option을 feature page에서
|
||||
따로 넘기지 않는다. escape hatch가 필요하면 새 profile을 먼저 등록한다.
|
||||
|
||||
### 3. Semantic query identity
|
||||
|
||||
query key는 VD-13의 단일 normative layout을 그대로 사용한다.
|
||||
|
||||
```text
|
||||
[
|
||||
"query",
|
||||
keySchemaVersion,
|
||||
scopeProjectionFingerprint,
|
||||
namespaceName,
|
||||
namespaceVersion,
|
||||
queryDefinitionVersion,
|
||||
canonicalSemanticInput
|
||||
]
|
||||
```
|
||||
|
||||
- namespace/key schema/version과 scope projection은 VD-13 profile-owned
|
||||
- query definition version과 semantic input projection은 VD-25 definition-owned
|
||||
- scope projection은 VD-13의 exact profile output
|
||||
- input은 strict codec의 plain immutable representation
|
||||
- REST URL/query string, ETag와 cursor raw value를 root identity에 넣지 않음
|
||||
- GraphQL document/persisted hash를 넣지 않음
|
||||
- protobuf bytes/generated message를 넣지 않음
|
||||
- presentation locale/formatted string을 넣지 않음
|
||||
|
||||
transport migration이 use-case/result meaning을 보존하면 semantic query family를
|
||||
유지할 수 있다. schema/mapper meaning, scope나 output identity가 바뀌면
|
||||
query-definition/release epoch를 바꾼다.
|
||||
|
||||
strict key codec은 다음을 거절한다.
|
||||
|
||||
- `undefined`, sparse array
|
||||
- NaN, Infinity, negative zero policy mismatch
|
||||
- bigint/symbol/function
|
||||
- Date/Map/Set/RegExp/typed array/native/class instance
|
||||
- accessor/proxy/prototype pollution key
|
||||
- cycle/shared-reference ambiguity
|
||||
- non-plain object
|
||||
- depth/node/part/string/encoded-byte ceiling 초과
|
||||
|
||||
query key에 PII/business ID를 직접 넣지 않는다. 필요한 resource identity는
|
||||
feature policy가 발급한 opaque bounded token으로 투영한다.
|
||||
|
||||
cursor와 command 동일성은 raw value나 충돌 가능 digest만으로 판정하지 않는다.
|
||||
|
||||
```text
|
||||
RuntimeIdentityTokenCodecV1
|
||||
canonicalCodecVersion
|
||||
maxCanonicalBytes
|
||||
maxInternEntries
|
||||
maxInternCanonicalBytes
|
||||
tokenEntropyBits >= 128
|
||||
lifetime = RUNTIME_SCOPE
|
||||
```
|
||||
|
||||
- strict length-prefixed typed canonical encoding이 exact equality source다.
|
||||
- runtime-private intern table이 canonical bytes를 opaque random token에
|
||||
일대일로 binding하고 token collision을 reverse map으로 검사한다.
|
||||
- 같은 token 후보가 다른 canonical bytes와 충돌하면 새 token을 발급한다. bounded
|
||||
재시도 후에도 해결되지 않으면 `IDENTITY_TOKEN_COLLISION`으로 admission/join을
|
||||
fail-closed한다.
|
||||
- intern row는 lease/refcount를 가진다. Query entry가 설치된 동안, active
|
||||
observer/fetch와 mutation/join이 진행되는 동안 해당 token을 eviction하지 않는다.
|
||||
- Query removal/GC에서 query token lease를, mutation terminal/join waiter
|
||||
settlement에서 command token lease를 exact once release한다. runtime/scope
|
||||
close는 남은 table을 전부 폐기한다.
|
||||
- refcount 0 row만 bounded LRU eviction할 수 있다. entry 수 또는 total canonical
|
||||
bytes ceiling을 active lease 때문에 회수할 수 없으면
|
||||
`IDENTITY_INTERN_LIMIT_EXCEEDED`로 신규 cache/command admission을 fail-closed한다.
|
||||
- canonical bytes/raw cursor/command input은 query key, diagnostics,
|
||||
cross-context wire와 persistence에 넣지 않고 runtime/scope close에서 폐기한다.
|
||||
- token은 backend idempotency key, authorization proof나 durable identity가 아니다.
|
||||
|
||||
### 4. ServerStateProfile
|
||||
|
||||
```text
|
||||
ServerStateProfileV1
|
||||
profileId
|
||||
classification
|
||||
scopePersistencePolicyId
|
||||
staleTimeMs
|
||||
gcTimeMs
|
||||
refetchOnMount
|
||||
refetchOnFocus
|
||||
refetchOnReconnect
|
||||
networkMode
|
||||
retryOwner = TRANSPORT | QUERY | NONE
|
||||
maxResultItems
|
||||
maxEstimatedResultBytes
|
||||
paginationProfileId | null
|
||||
conditionalProfileId | null
|
||||
placeholderPolicy
|
||||
initialFailurePolicy
|
||||
refreshFailurePolicy
|
||||
authorizationFailurePolicy
|
||||
contractFailurePolicy
|
||||
invalidationTopicRefs[] = { topicId, topicVersion }
|
||||
owner
|
||||
```
|
||||
|
||||
implementation ceilings:
|
||||
|
||||
- query `invalidationTopicRefs` set/version은 joined VD-13
|
||||
`QueryScopePersistencePolicy`와 exact match
|
||||
- `gcTimeMs`는 inactive retention이고 `staleTimeMs`는 freshness이므로
|
||||
`staleTimeMs <= gcTimeMs`를 일반 불변조건으로 강제하지 않는다.
|
||||
- VD-13 persistence를 선택한 경우에만 restore `maxAgeMs`, retention과
|
||||
`gcTimeMs`의 join compatibility를 검증한다.
|
||||
- finite gc 기본, `Infinity`는 explicit immortal-static profile만
|
||||
- stale/gc 최대값
|
||||
- result item/estimated byte 상한
|
||||
- maximum pages
|
||||
- refetch trigger storm coalescing
|
||||
- foreground/background concurrency
|
||||
|
||||
현재 global 30초/5분은 reference default이지 모든 product query의 production
|
||||
정책이 아니다.
|
||||
|
||||
### 5. Retry owner
|
||||
|
||||
한 network operation에는 retry owner가 정확히 하나다.
|
||||
|
||||
| 상황 | 기본 owner |
|
||||
| --- | --- |
|
||||
| installed REST | REST transport |
|
||||
| persisted GraphQL | GraphQL transport |
|
||||
| Connect unary | Connect adapter 또는 selected edge 중 exact one |
|
||||
| gRPC-Web unary | gRPC-Web transport |
|
||||
| pure local query computation | Query 또는 none |
|
||||
|
||||
transport retry가 있는 definition은 TanStack `retry=false`다. Query retry callback이
|
||||
same gateway call을 다시 실행해 transport attempts를 배가하지 않는다.
|
||||
|
||||
manual UI retry는 새 logical query execution이다. keyed command의 ambiguous outcome을
|
||||
query retry처럼 재실행하지 않는다.
|
||||
|
||||
### 6. Cache admission
|
||||
|
||||
query cache에 admission 가능한 값:
|
||||
|
||||
- VD-24 mapper가 만든 immutable plain application read model
|
||||
- exact result contract/version
|
||||
- current scope/runtime generation
|
||||
- item/estimated-byte ceiling 안
|
||||
- complete result 또는 operation이 허용한 typed partial result
|
||||
|
||||
금지:
|
||||
|
||||
- raw JSON/GraphQL envelope
|
||||
- generated protobuf message/client
|
||||
- Response/ReadableStream
|
||||
- auth/CSRF/idempotency/cursor/validator/trace metadata
|
||||
- thrown Error/AppFailure detail payload
|
||||
- function/class/domain service/native object
|
||||
|
||||
admission 순서:
|
||||
|
||||
```text
|
||||
transport success
|
||||
-> schema
|
||||
-> mapper
|
||||
-> result budget
|
||||
-> scope/generation fence
|
||||
-> Query commit
|
||||
```
|
||||
|
||||
어느 단계든 실패하면 cache write 0회다.
|
||||
|
||||
### 7. Result size
|
||||
|
||||
`maxEstimatedResultBytes`는 memory reservation이 아니라 hard admission guard다.
|
||||
|
||||
- mapper output plain data를 bounded estimator로 측정
|
||||
- string UTF-8 bytes, key overhead, array/object node count를 보수적으로 합산
|
||||
- cycle/class/native/accessor는 측정 전에 거절
|
||||
- 측정 자체가 deadline/node ceiling을 넘으면 admission 거절
|
||||
- result cap을 넘겨도 transport success를 unbounded UI state로 반환하지 않고
|
||||
`RESULT_LIMIT_EXCEEDED` 또는 server pagination requirement로 닫음
|
||||
|
||||
large binary/collection은 streaming/file 또는 cursor page capability로 이동한다.
|
||||
|
||||
### 8. Query lifecycle
|
||||
|
||||
```text
|
||||
IDLE
|
||||
-> LOADING
|
||||
-> SUCCESS | EMPTY | TERMINAL_ERROR
|
||||
|
||||
SUCCESS | EMPTY
|
||||
-> REFRESHING
|
||||
-> SUCCESS | EMPTY
|
||||
-> STALE_DEGRADED
|
||||
-> TERMINAL/REAUTH when policy forbids stale visibility
|
||||
```
|
||||
|
||||
current AsyncState의 base와 overlay 구분을 유지한다.
|
||||
|
||||
failure policy:
|
||||
|
||||
| failure | default |
|
||||
| --- | --- |
|
||||
| transient network/5xx refresh failure | valid previous data + stale-degraded |
|
||||
| caller/navigation abort | terminal error로 표시하지 않음 |
|
||||
| auth required | sensitive/account query는 stale 숨김, reauth |
|
||||
| forbidden/account switch | current scope data 즉시 숨김/clear |
|
||||
| schema/mapper/contract mismatch | cache write 금지, default stale 숨김 또는 explicit safe-static exception |
|
||||
| rate limit | previous data 정책 + retry-after UX |
|
||||
| not found | feature policy에 따라 empty/remove/tombstone |
|
||||
|
||||
query profile이 sensitive stale data를 계속 보여 주는 결정을 global fallback으로
|
||||
상속하지 않는다.
|
||||
|
||||
### 9. Freshness와 refetch
|
||||
|
||||
`staleTime`은 business correctness/authorization TTL이 아니다.
|
||||
|
||||
- focus/reconnect/mount refetch는 profile별
|
||||
- simultaneous trigger는 one in-flight query로 coalesce
|
||||
- minimum refetch interval과 deadline 적용
|
||||
- visibility offline state는 hint이며 server revision을 대체하지 않음
|
||||
- response age/cache-control을 staleTime으로 자동 변환하지 않음
|
||||
- backend push/invalidation은 stale hint이며 authoritative refetch를 시작
|
||||
|
||||
freshness-sensitive command/read-after-write는 mutation receipt/revision 또는
|
||||
authoritative refetch contract를 사용한다.
|
||||
|
||||
### 10. Conditional revalidation
|
||||
|
||||
REST app-managed ETag profile만 internal validator metadata를 사용할 수 있다.
|
||||
|
||||
```text
|
||||
ValidatorBinding
|
||||
query definition/fingerprint
|
||||
scope fingerprint
|
||||
runtime generation
|
||||
representation version
|
||||
opaque validator
|
||||
```
|
||||
|
||||
- raw validator는 query value/key/diagnostics에 넣지 않음
|
||||
- 304는 exact binding + existing mapped cache value가 있을 때만 fresh transition
|
||||
- 304 freshness transition은 같은 query-entry cache revision에 CAS가 성공할 때만
|
||||
commit하며, concurrent 200/removal 뒤의 late 304를 폐기
|
||||
- value가 없거나 wrong generation이면 304 success로 만들지 않음
|
||||
- validator mismatch/full 200은 normal schema/mapper/admission을 다시 수행하고
|
||||
mapped value commit과 validator install/update를 하나의 entry transaction으로
|
||||
취급
|
||||
- Query removal/GC, scope/logout/account switch, release/contract/schema/mapper
|
||||
epoch 변경과 incompatible cache clear에서 validator sidecar도 함께 폐기
|
||||
- ordinary invalidation 때 validator를 conditional refetch까지 보존할지 즉시
|
||||
폐기할지는 profile이 고정하며 query entry와 독립적으로 남기지 않음
|
||||
|
||||
GraphQL/gRPC metadata를 arbitrary ETag로 해석하지 않는다. application revision
|
||||
field를 schema/mapper가 명시적으로 제공한 경우 별도 revalidation policy가
|
||||
사용한다.
|
||||
|
||||
### 11. Cursor pagination
|
||||
|
||||
```text
|
||||
PaginationProfile
|
||||
CURSOR_SINGLE_PAGE
|
||||
CURSOR_INFINITE
|
||||
OFFSET_STABLE
|
||||
```
|
||||
|
||||
cursor page:
|
||||
|
||||
```text
|
||||
CursorPage<T>
|
||||
items
|
||||
nextCursor | null
|
||||
hasMore
|
||||
snapshotToken | null
|
||||
```
|
||||
|
||||
page invariant:
|
||||
|
||||
- `hasMore === (nextCursor !== null)`을 codec에서 강제한다.
|
||||
- page item count는 requested/implementation ceiling 이하다.
|
||||
- chain 안의 snapshot token은 provider profile이 허용한 null/동일 값만 사용한다.
|
||||
- `hasMore=true`인 empty/non-progress page는 explicit sparse-page profile이 없으면
|
||||
contract failure다.
|
||||
|
||||
`CURSOR_INFINITE` root key:
|
||||
|
||||
- filters/sort/page size semantics
|
||||
- scope
|
||||
- page definition version
|
||||
- cursor 제외
|
||||
|
||||
`CURSOR_INFINITE` page parameter:
|
||||
|
||||
- adapter-private/opaque bounded cursor
|
||||
- previous page/snapshot binding
|
||||
- raw cursor를 diagnostics/URL state/persistence에 임의 저장하지 않음
|
||||
|
||||
`CURSOR_SINGLE_PAGE`:
|
||||
|
||||
- first page의 null marker 또는 current cursor의 runtime-scoped non-reversible
|
||||
identity token을 `canonicalSemanticInput`에 포함한다.
|
||||
- raw cursor는 bound executor closure에만 두며 query key/value/diagnostics에 넣지
|
||||
않는다.
|
||||
- runtime-private exact equality guard/token binding이 실패하면 single-page cache
|
||||
admission을 끄고 closed failure로 끝낸다.
|
||||
- runtime-scoped token을 쓰는 `CURSOR_SINGLE_PAGE`는 `MEMORY_ONLY`다.
|
||||
|
||||
infinite policy:
|
||||
|
||||
- max pages
|
||||
- max total items
|
||||
- max estimated bytes
|
||||
- repeated cursor/non-progress/loop detection
|
||||
- page eviction direction
|
||||
- refresh strategy: first page only, visible window 또는 complete bounded chain
|
||||
- item stable identity/duplicate/revision conflict policy
|
||||
- snapshot changed 시 old/new page를 섞지 않고 restart
|
||||
|
||||
pagination persistence는 기본 disabled다. `CURSOR_SINGLE_PAGE`를 durable하게
|
||||
만들려면 stable partition-bound keyed codec/key lifecycle을 별도 ADR로 승인해야
|
||||
하며 runtime token을 persistence key로 재사용하지 않는다. `CURSOR_INFINITE`
|
||||
persistence를 선택하려면 VD-13 profile이 cursor와 snapshot의
|
||||
classification/expiry, maximum persisted pages/bytes, restored `pageParams` 사용
|
||||
여부를 명시적으로 승인해야 한다. raw sensitive cursor 또는 만료 후 page
|
||||
parameter를 IndexedDB에 저장하지 않는다.
|
||||
|
||||
offset pagination은 insert/delete drift를 허용하는 dataset에서 사용하지 않는다.
|
||||
|
||||
### 12. Mutation Definition
|
||||
|
||||
```text
|
||||
MutationDefinition<Input, Value>
|
||||
definitionId
|
||||
operationId
|
||||
inputCodec
|
||||
logicalKeyCodec
|
||||
commandIdentityTokenCodecId
|
||||
concurrencyPolicy
|
||||
duplicatePolicy
|
||||
optimisticPolicyId | null
|
||||
invalidationTopicRefs[] = { topicId, topicVersion }
|
||||
seedPolicyId | null
|
||||
conflictPolicyId
|
||||
effectCertaintyPolicy
|
||||
owner
|
||||
```
|
||||
|
||||
presentation:
|
||||
|
||||
```text
|
||||
useApplicationMutation(boundMutation)
|
||||
```
|
||||
|
||||
caller는 raw optimistic query key/update function과 invalidation topic을 조립하지
|
||||
않는다.
|
||||
|
||||
mutation topic ref는 unique 0..16개이고 모두 VD-13 global topic registry의 exact
|
||||
version으로 resolve돼야 한다. 한 ref가 가리키는 bounded namespace set만
|
||||
invalidate하며 caller가 runtime에 topic을 추가하지 못한다.
|
||||
|
||||
### 13. Mutation concurrency
|
||||
|
||||
```text
|
||||
ConcurrencyPolicy
|
||||
PARALLEL
|
||||
SERIAL_BY_LOGICAL_KEY
|
||||
SUPERSEDE_PENDING_READ_BY_LOGICAL_KEY
|
||||
REJECT_WHILE_ACTIVE_BY_LOGICAL_KEY
|
||||
|
||||
DuplicatePolicy
|
||||
JOIN_IDENTICAL
|
||||
REJECT_DUPLICATE
|
||||
ALLOW_INDEPENDENT
|
||||
```
|
||||
|
||||
- logical key는 validated input의 approved opaque identity
|
||||
- command identity token은 operation/definition version, current scope
|
||||
partition과 **전체 validated semantic input**을
|
||||
`RuntimeIdentityTokenCodecV1`으로 intern해 만든다. UI transient field와
|
||||
transport bytes/idempotency key는 canonical equality source에 포함하지 않는다.
|
||||
- logical key는 serialization/conflict group이고 exact canonical equality +
|
||||
identity token은 동일 command 판별 값이다. 두 값을 서로 대체하지 않는다.
|
||||
- `JOIN_IDENTICAL`은 exact equality guard도 통과한 같은 identity token의 기존
|
||||
in-flight Promise와 terminal result를 공유하며 transport/optimistic layer를
|
||||
추가하지 않는다.
|
||||
- `REJECT_DUPLICATE`는 exact-identical token이 active이면 fetch 0회와 closed
|
||||
`DUPLICATE_IN_FLIGHT`를 반환한다.
|
||||
- `ALLOW_INDEPENDENT`는 같은 exact identity도 독립 command로 실행한다. backend
|
||||
replay/idempotency와 UX가 이를 명시적으로 허용한 operation에만 등록한다.
|
||||
- distinct input을 같은 Promise에 join하지 않음
|
||||
- same hook, two hooks, two routes의 coordinator가 동일 policy를 사용
|
||||
- coordinator는 hook-local singleton이 아니라 runtime/scope 수명의 registry-owned
|
||||
service이며 scope generation 전환에서 신규 admission을 닫고 late commit을 fence
|
||||
- `SUPERSEDE`는 이미 server로 보낸 non-replayable command를 cancel/rollback했다고
|
||||
가정하지 않음
|
||||
- local serialization은 server idempotency/concurrency authority가 아님
|
||||
- scope/generation change는 pending result commit을 fence
|
||||
|
||||
### 14. Optimistic patch
|
||||
|
||||
snapshot 전체 restore만 사용하지 않는다.
|
||||
|
||||
```text
|
||||
OptimisticLayer
|
||||
mutationId
|
||||
logicalKey
|
||||
commandIdentityToken
|
||||
baseCacheRevision
|
||||
expectedEntityRevision | null
|
||||
patch
|
||||
inversePatch
|
||||
affectedQueryDefinitions
|
||||
```
|
||||
|
||||
선택 가능한 구현:
|
||||
|
||||
- cache entry revision CAS
|
||||
- ordered optimistic layer log
|
||||
- operation-specific compare-and-apply patch
|
||||
|
||||
공통:
|
||||
|
||||
1. affected exact queries cancel
|
||||
2. bounded current revision/value 확인
|
||||
3. registered pure patch 적용
|
||||
4. other mutation layer와 ordering 보존
|
||||
5. failure에서 자기 layer만 제거/역적용
|
||||
6. success result/revision과 reconcile
|
||||
7. invalidation/refetch
|
||||
|
||||
old whole snapshot을 복원해 다른 mutation commit을 덮지 않는다.
|
||||
|
||||
optimistic update를 하지 않는 조건:
|
||||
|
||||
- snapshot/patch/result byte ceiling 초과
|
||||
- cache entry missing/wrong revision
|
||||
- non-deterministic merge
|
||||
- high-conflict command
|
||||
- scope/generation transition
|
||||
- unknown effect certainty
|
||||
|
||||
그 경우 pending UX만 보여 주고 server response/refetch를 기다린다.
|
||||
|
||||
### 15. Effect certainty와 conflict
|
||||
|
||||
```text
|
||||
MutationEffect
|
||||
NOT_APPLIED
|
||||
COMMITTED
|
||||
UNKNOWN
|
||||
```
|
||||
|
||||
- timeout/cancel/network failure가 `NOT_APPLIED`를 자동 의미하지 않음
|
||||
- keyed backend status/receipt가 있어야 ambiguous command reconcile 가능
|
||||
- `UNKNOWN`은 새 idempotency key로 자동 retry 금지
|
||||
- 409 business conflict, REST 412, GraphQL safe conflict code, gRPC `ABORTED`를
|
||||
common conflict surface로 mapping하되 의미 차이는 feature policy가 소유
|
||||
- server revision/merge/overwrite decision은 cache가 아니라 use case 소유
|
||||
|
||||
### 16. Mutation success, seed와 invalidation
|
||||
|
||||
server commit 뒤:
|
||||
|
||||
- exact returned result를 schema/mapper/fence/budget 검증
|
||||
- registered detail seed policy가 있으면 exact current entity/revision만 write
|
||||
- list/aggregate는 default invalidate
|
||||
- list patch는 deterministic sort/filter/membership policy가 있을 때만
|
||||
- invalidation topic은 query key가 아닌 opaque registry identity
|
||||
- current mutation lease 동안 remote hints coalesce
|
||||
|
||||
local invalidation failure는 committed command를 failure로 바꾸지 않는다.
|
||||
cache health를 degraded로 기록하고 bounded authoritative refetch를 예약한다.
|
||||
|
||||
### 17. Cross-context
|
||||
|
||||
현재 cross-tab wire는 invalidate-only다. 유지한다.
|
||||
|
||||
- query data/key/input/cursor/validator를 broadcast하지 않음
|
||||
- remote event는 authority가 아니라 stale hint
|
||||
- account/scope/version 검증은 VD-13
|
||||
- mutation ordering/optimistic layer를 tab 간 복제하지 않음
|
||||
- sequence gap은 모든 registered namespace를 stale 처리하되 active query만
|
||||
bounded refetch한다. inactive query는 다음 mount/focus에서 revalidate하고,
|
||||
persistence가 선택된 경우 durable ledger refresh는 VD-13 절차를 따른다.
|
||||
|
||||
### 18. GraphQL과 normalized cache
|
||||
|
||||
persisted GraphQL query도 mapped operation result를 TanStack에 cache한다.
|
||||
|
||||
- query key는 semantic application input
|
||||
- GraphQL document/hash는 key/value에 없음
|
||||
- GraphQL SDK cache는 `no-cache`/disabled
|
||||
- partial data default reject
|
||||
- approved partial result는 completeness metadata를 application contract가 소유
|
||||
- missing/error field를 previous complete value와 자동 merge하지 않음
|
||||
|
||||
normalized entity cache가 필요하면:
|
||||
|
||||
- bounded context 하나가 TanStack operation cache를 대체
|
||||
- key fields/typename, eviction, pagination merge, optimistic layer, logout/scope,
|
||||
persistence와 removal을 별도 ADR
|
||||
- dual write/read 금지
|
||||
|
||||
현재 `NOT_SELECTED`다.
|
||||
|
||||
### 19. Connect/gRPC-Web server stream
|
||||
|
||||
ordinary Query는 terminal operation 결과를 전제로 한다.
|
||||
|
||||
- unary는 normal query 가능
|
||||
- finite server stream을 complete aggregate로 쓸 경우 staging buffer에 bounded
|
||||
accumulate하고 valid Connect EndStream 또는 gRPC-Web terminal status,
|
||||
schema/mapper/fence 뒤 atomic cache commit
|
||||
- long-running stream은 `ServerStreamPort`와 registered reducer/invalidation owner
|
||||
- frame마다 query cache를 append하여 unbounded event history를 만들지 않음
|
||||
- stream reconnect를 Query retry로 하지 않음
|
||||
- gap/overflow는 current snapshot 폐기 또는 authoritative query refetch
|
||||
|
||||
### 20. Persistence, SSR와 offline
|
||||
|
||||
- memory cache runtime은 `COMPOSED`
|
||||
- IndexedDB query persister reference는 VD-13 기준
|
||||
`DESIGNED_NOT_IMPLEMENTED`
|
||||
- product persistence는 `NOT_SELECTED`
|
||||
- SSR hydration은 `NOT_SELECTED`
|
||||
- offline mutation queue는 `NOT_SELECTED`
|
||||
|
||||
VD-25 profile은 persistence를 직접 켜지 않는다. joined VD-13
|
||||
scope/persistence profile, reference runtime과 제품 allowlist/retention/scope가
|
||||
모두 구현·선택된 query만 IndexedDB persistence를 사용할 수 있다.
|
||||
|
||||
server-state policy를 이유로 Web Storage에 query payload를 넣지 않는다.
|
||||
|
||||
### 21. Security와 privacy
|
||||
|
||||
- authorization result를 staleTime/cache hit으로 대체하지 않음
|
||||
- account/logout transition에서 sensitive data를 즉시 fence/hide
|
||||
- query key에 raw PII/account/resource ID/URL/document/message 금지
|
||||
- cache value에 credential/header/validator/trace/error raw data 금지
|
||||
- optimistic layer에도 command token/raw body를 저장하지 않음
|
||||
- command identity token/logical key를 diagnostics, cross-context wire나 persistence에
|
||||
넣지 않음
|
||||
- developer tools/diagnostics production exposure policy
|
||||
- cache poisoning 방지를 위해 schema/mapper/result contract와 generation 검증
|
||||
- cross-context event에 data 없음
|
||||
|
||||
### 22. Observability
|
||||
|
||||
허용:
|
||||
|
||||
- query/mutation definition/profile ID
|
||||
- hit/miss/stale/fresh/refresh/evict outcome
|
||||
- result item/estimated-byte/page bucket
|
||||
- runtime identity intern entry/canonical-byte/active-lease bucket
|
||||
- focus/reconnect/invalidation refetch reason
|
||||
- mutation concurrency/duplicate/optimistic/rollback/conflict/effect bucket
|
||||
- invalidation/seed/degraded recovery outcome
|
||||
- scope/profile version의 low-cardinality bucket
|
||||
|
||||
금지:
|
||||
|
||||
- query key/input/value
|
||||
- command identity token/logical key
|
||||
- identity intern canonical bytes/token actual value
|
||||
- cursor/snapshot/validator/revision actual value
|
||||
- resource/account/tenant ID
|
||||
- GraphQL/protobuf/REST DTO
|
||||
- optimistic patch/snapshot
|
||||
|
||||
### 23. Testing
|
||||
|
||||
query definition/key:
|
||||
|
||||
- definition/input/key/executor type/runtime binding
|
||||
- VD-13↔VD-25 topic set/version exact join과 bounded many-to-many fan-out
|
||||
- runtime identity token same-input stability, random-token collision regeneration과
|
||||
bounded failure의 cache/join 0회
|
||||
- intern entry/total-byte ceiling, active non-eviction, Query GC/mutation terminal
|
||||
lease release와 runtime-close leak 0
|
||||
- undefined/NaN/Date/class/accessor/cycle/sparse/oversize collision fixture
|
||||
- same semantic input stable identity
|
||||
- protocol wire identity 변화가 key에 들어가지 않음
|
||||
- per-profile stale/gc/refetch/retry owner
|
||||
|
||||
cache admission:
|
||||
|
||||
- mapped immutable plain value only
|
||||
- item/estimated byte cap
|
||||
- schema/mapper/generation failure write 0
|
||||
- auth/contract failure stale visibility
|
||||
|
||||
mutation:
|
||||
|
||||
- same semantic command identity, same logical key의 distinct input
|
||||
- same hook/two hooks/two routes
|
||||
- runtime/scope coordinator의 logical-key serial/parallel과
|
||||
join/reject/independent duplicate 결과
|
||||
- out-of-order success/failure
|
||||
- optimistic layer/CAS rollback without overwriting other commit
|
||||
- effect `NOT_APPLIED/COMMITTED/UNKNOWN`
|
||||
- server response detail seed + list invalidate
|
||||
- invalidation failure after commit
|
||||
- account switch during pending command
|
||||
|
||||
pagination:
|
||||
|
||||
- null/repeated/cyclic cursor
|
||||
- single-page cursor identity-token collision/isolation과 memory-only enforcement
|
||||
- `hasMore`/`nextCursor` 불일치와 snapshot drift
|
||||
- snapshot change
|
||||
- max page/item/byte
|
||||
- page eviction/refetch
|
||||
- duplicate identity/revision policy
|
||||
- cancellation and late page
|
||||
|
||||
integration:
|
||||
|
||||
- REST/GraphQL/gRPC unary → schema → mapper → cache → UI
|
||||
- focus/reconnect/offline/stale-degraded
|
||||
- conditional 304 exact binding
|
||||
- cross-tab invalidation/mutation lease
|
||||
- logout/account/release generation
|
||||
- finite stream atomic commit and overflow
|
||||
|
||||
### 24. Rollout
|
||||
|
||||
1. VD-13 strict key codec/scope snapshot interface를 확정한다.
|
||||
2. current `useApplicationQuery({queryKey, execute})` 뒤에 bound definition adapter를
|
||||
추가한다.
|
||||
3. reference queries를 shadow key/policy로 비교하되 secondary cache write 금지.
|
||||
4. per-profile policy/result ceiling을 read query에 canary한다.
|
||||
5. typed mutation definition과 input-aware coordinator를 도입한다.
|
||||
6. optimistic layer/CAS를 low-conflict command에만 canary한다.
|
||||
7. cursor page reference vertical을 구현한다.
|
||||
8. arbitrary key/executor와 raw optimistic callback API를 제거한다.
|
||||
9. account/generation browser evidence와 runbook drill 뒤 traffic을 올린다.
|
||||
|
||||
rollback:
|
||||
|
||||
- 신규 query/mutation admission/optimistic patch를 kill switch로 닫음
|
||||
- current scope queries cancel
|
||||
- unsafe/incompatible memory cache clear
|
||||
- pending command effect certainty reconcile
|
||||
- current basic facade 또는 no-optimistic authoritative refetch로 downgrade
|
||||
- schema/mapper/query definition/backend artifact를 coherent set으로 복구
|
||||
|
||||
## 규범 기준
|
||||
|
||||
- [TanStack Query v5 Important Defaults](https://tanstack.com/query/v5/docs/framework/react/guides/important-defaults)
|
||||
- [TanStack Query v5 Query Cancellation](https://tanstack.com/query/v5/docs/framework/react/guides/query-cancellation)
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- caller가 arbitrary key/executor/TanStack option을 조합할 수 없다.
|
||||
- strict key codec과 VD-13 scope projection이 모든 query에 적용된다.
|
||||
- VD-13 normative key layout과 scope/persistence profile을 재정의하지 않고 exact
|
||||
join한다.
|
||||
- mapped/bounded/current-generation value만 cache에 들어간다.
|
||||
- transport와 Query retry owner가 중복되지 않는다.
|
||||
- cursor page가 next/snapshot/loop/page/item/byte ceiling을 갖는다.
|
||||
- runtime-private exact equality guard까지 통과한 command identity만 declared
|
||||
join되고 distinct input이 같은 Promise로 잘못 join되지 않는다.
|
||||
- concurrent optimistic rollback이 다른 committed update를 덮지 않는다.
|
||||
- effect certainty, conflict, seed와 invalidation owner가 operation별로 닫힌다.
|
||||
- GraphQL normalized dual cache와 unbounded stream cache가 없다.
|
||||
- scope/logout/provider fault와 rollback/removal evidence가 통과한다.
|
||||
@@ -0,0 +1,653 @@
|
||||
# VD-26: Persisted GraphQL operation
|
||||
|
||||
- 상태: Accepted design — reference runtime implementation pending
|
||||
- 결정일: 2026-07-28
|
||||
- provider-neutral GraphQL reference adapter:
|
||||
`DESIGNED_NOT_IMPLEMENTED`
|
||||
- product GraphQL composition: `NOT_SELECTED`
|
||||
- batching/subscription/`@defer`/`@stream`: `NOT_SELECTED`
|
||||
- normalized GraphQL cache: `NOT_SELECTED`
|
||||
- 관련 결정: VD-13, VD-23, VD-24, VD-25, VD-28
|
||||
- 상세 설계:
|
||||
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
|
||||
|
||||
## 배경
|
||||
|
||||
현재 source, package direct dependency, config와 test에는 GraphQL runtime,
|
||||
operation document/codegen, persisted manifest나 endpoint provider가 없다.
|
||||
lockfile의 transitive `graphql` package는 MSW 개발 의존성일 뿐 capability
|
||||
구현 증거가 아니다.
|
||||
|
||||
GraphQL은 임의 query string을 보내는 범용 API escape hatch로 도입하지 않는다.
|
||||
제품이 여러 backend aggregate를 화면별 shape로 조회해야 하고 schema/router,
|
||||
field authorization, persisted allowlist와 cost budget을 운영할 수 있을 때만
|
||||
bounded-context operation family로 선택한다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. Production GraphQL은 persisted operation only다
|
||||
|
||||
```text
|
||||
semantic application query/command
|
||||
-> registered GraphqlOperationDefinition
|
||||
-> fixed endpoint
|
||||
-> persisted operation ID/hash
|
||||
-> validated variables
|
||||
-> bounded GraphQL response decoder
|
||||
-> operation data schema
|
||||
-> boundary mapper
|
||||
-> application projection
|
||||
-> TanStack Query or command result
|
||||
```
|
||||
|
||||
production runtime은 다음을 받지 않는다.
|
||||
|
||||
- arbitrary GraphQL document
|
||||
- caller-provided operation name/hash
|
||||
- arbitrary endpoint/header
|
||||
- generated SDK selection set builder
|
||||
- field/fragment string
|
||||
|
||||
### 2. Operation artifact
|
||||
|
||||
```text
|
||||
PersistedGraphqlOperationV1
|
||||
protocol = PERSISTED_GRAPHQL_V1
|
||||
semanticOperationId
|
||||
operationName
|
||||
operationKind = QUERY | MUTATION
|
||||
canonicalDocumentSha256
|
||||
persistedOperationId
|
||||
schemaArtifactId
|
||||
schemaDigest
|
||||
variablesSchemaId
|
||||
dataSchemaId
|
||||
mapperId
|
||||
errorProfileId
|
||||
partialDataPolicy
|
||||
endpointId
|
||||
graphqlHttpProfileRevision
|
||||
persistedEnvelopeProfileId
|
||||
responseStatusMediaProfileId
|
||||
authProfileId
|
||||
csrfProfileId
|
||||
replayPolicy
|
||||
deadlineProfileId
|
||||
retryProfileId
|
||||
serverStateProfileId | null
|
||||
maxVariablesBytes
|
||||
maxResponseBytes
|
||||
maxErrorCount
|
||||
maxCost
|
||||
maxDepth
|
||||
maxAliases
|
||||
owner
|
||||
```
|
||||
|
||||
canonical document는 build artifact이고 runtime string이 아니다. stable operation
|
||||
ID와 hash는 schema/operation manifest에 binding한다.
|
||||
|
||||
manifest 생성:
|
||||
|
||||
```text
|
||||
authenticated immutable schema
|
||||
-> named operation sources
|
||||
-> parse/validate against schema
|
||||
-> canonical document
|
||||
-> operation hash/ID
|
||||
-> variables/result type generation
|
||||
-> runtime codec manifest
|
||||
-> mapper/query profile binding
|
||||
-> persisted operation manifest
|
||||
```
|
||||
|
||||
### 3. Schema와 codegen
|
||||
|
||||
- schema source URL에서 normal build마다 latest를 받지 않는다.
|
||||
- authenticated explicit update workflow가 immutable artifact와 provenance를
|
||||
저장한다.
|
||||
- schema/source/operation manifest digest를 release contract set에 binding한다.
|
||||
- anonymous operation, duplicate operation name와 invalid fragment를 거절한다.
|
||||
- generator, plugins, Node와 runtime version을 pin한다.
|
||||
- clean checkout regenerate diff가 0이어야 한다.
|
||||
- schema breaking diff, operation validation, deprecated field budget와 generated
|
||||
output digest를 CI gate로 둔다.
|
||||
- generated type은 adapter-private DTO다.
|
||||
- generated TypeScript type만 믿지 않고 variables/data runtime codec과 mapper를
|
||||
유지한다.
|
||||
- schema introspection을 production에서 끄는 결정은 server 보안 옵션일 뿐
|
||||
authorization/cost control을 대체하지 않는다.
|
||||
|
||||
### 4. Endpoint와 HTTP profile
|
||||
|
||||
```text
|
||||
GraphqlProviderProfile
|
||||
endpointId
|
||||
fixedHttpsUrl
|
||||
graphqlHttpProfileRevision
|
||||
persistedEnvelopeProfileId
|
||||
methodPolicy
|
||||
credentialsMode
|
||||
corsProfile
|
||||
referrerPolicy
|
||||
redirect = ERROR
|
||||
mediaProfile
|
||||
```
|
||||
|
||||
GraphQL-over-HTTP draft를 움직이는 implicit `latest`로 구현하지 않는다. selected
|
||||
revision의 request/response/status 규칙과 provider의 persisted-operation
|
||||
extension을 immutable profile/fixture에 고정한다. persisted ID/hash-only envelope는
|
||||
표준 request의 required `query` field를 생략하는 provider extension일 수 있으므로
|
||||
generic GraphQL-over-HTTP compliance로 가장하지 않는다.
|
||||
|
||||
private query와 mutation은 POST가 기본이다.
|
||||
|
||||
GET은 다음을 모두 만족하는 public read profile에서만 선택한다.
|
||||
|
||||
- persisted ID/hash와 non-sensitive bounded variables
|
||||
- URL byte ceiling
|
||||
- no credential/private representation 또는 명시된 safe cache contract
|
||||
- exact cache key/Vary/CDN policy
|
||||
- mutation 아님
|
||||
|
||||
raw document와 sensitive variables를 URL에 넣지 않는다.
|
||||
|
||||
request `Content-Type: application/json`과
|
||||
`Accept: application/graphql-response+json`을 기본 exact profile로 둔다.
|
||||
`application/json` response 지원은 legacy provider profile로 분리한다. caller가
|
||||
`fetch` option, headers와 credentials를 override하지 않는다.
|
||||
|
||||
status/media matrix:
|
||||
|
||||
- final URL/origin과 media/body ceiling을 먼저 확인한다.
|
||||
- `application/graphql-response+json`은 profile이 허용한 HTTP status 전체에서
|
||||
bounded GraphQL envelope를 먼저 decode하고 selected revision의 status/body
|
||||
불변조건을 교차 검증한다.
|
||||
- non-null `data`가 있는 response는 selected revision이 요구하는 2xx여야 한다.
|
||||
no-data/error와 partial response의 status는 pinned revision/provider fixture와
|
||||
exact match해야 한다.
|
||||
- legacy `application/json`은 허용된 2xx body만 GraphQL envelope로 신뢰한다.
|
||||
non-2xx body는 intermediary일 수 있으므로 GraphQL error/extensions로
|
||||
해석하지 않고 bounded generic HTTP failure로 닫는다.
|
||||
|
||||
### 5. Request envelope
|
||||
|
||||
wire shape는 provider의 persisted-envelope extension이 versioned codec으로
|
||||
고정한다. 최소 의미:
|
||||
|
||||
```text
|
||||
protocol
|
||||
persisted operation ID
|
||||
canonical document hash
|
||||
operation name
|
||||
validated variables
|
||||
client contract manifest version
|
||||
```
|
||||
|
||||
full document는 포함하지 않는다.
|
||||
|
||||
provider가 ID/hash-only envelope를 지원하지 않으면 이 capability를 그 endpoint에
|
||||
compose하지 않는다. production에서 표준 `query` field를 채우기 위해 full
|
||||
document fallback을 보내는 것으로 우회하지 않는다.
|
||||
|
||||
variables:
|
||||
|
||||
- request runtime schema의 parsed output만 사용
|
||||
- unknown field 거절
|
||||
- depth/node/string/list/encoded byte ceiling
|
||||
- File/Blob/stream/native/generated class 금지
|
||||
- ID/decimal/int64/time semantics는 VD-24
|
||||
- secret/credential를 variable로 전달하는 operation 금지
|
||||
|
||||
### 6. APQ와 manifest miss
|
||||
|
||||
runtime Automatic Persisted Query negotiation을 production default로 사용하지
|
||||
않는다.
|
||||
|
||||
```text
|
||||
persisted miss/hash mismatch
|
||||
-> body/reader cancel
|
||||
-> PERSISTED_OPERATION_MISMATCH
|
||||
-> operation traffic disable or coherent manifest recovery
|
||||
```
|
||||
|
||||
hash miss 뒤 full document를 자동 전송하면 server allowlist와 cost governance를
|
||||
우회할 수 있다. trusted development profile에서만 explicit opt-in 가능하며
|
||||
production promotion 증거로 사용하지 않는다.
|
||||
|
||||
frontend manifest와 router manifest의 N/N-1 rollout을 먼저 증명한다.
|
||||
|
||||
### 7. Total deadline, cancellation과 retry
|
||||
|
||||
VD-23 common logical deadline을 사용한다.
|
||||
|
||||
- credential/CSRF attach
|
||||
- network attempts/backoff
|
||||
- response read/JSON parse
|
||||
- GraphQL envelope/data/error validation
|
||||
- mapper
|
||||
|
||||
Query retry와 GraphQL transport retry를 중복하지 않는다.
|
||||
|
||||
retry:
|
||||
|
||||
- idempotent query의 selected network/408/429/502/503/504
|
||||
- keyed mutation은 backend idempotency evidence가 있을 때만
|
||||
- GraphQL validation, persisted miss, cost/depth, schema/data/error mismatch는
|
||||
retry하지 않음
|
||||
- HTTP 200 GraphQL business error를 transient network failure로 자동 retry하지 않음
|
||||
- UNAUTHENTICATED recovery는 safe query/keyed mutation만 same logical binding으로
|
||||
한 번
|
||||
|
||||
AbortSignal은 fetch와 body/incremental reader를 cancel한다. local cancel이 mutation
|
||||
미적용을 의미하지 않으며 ambiguous effect는 status/reconcile contract로 닫는다.
|
||||
|
||||
### 8. Response decoder
|
||||
|
||||
```text
|
||||
HTTP response
|
||||
-> final URL/origin/media/header
|
||||
-> present/valid Content-Length advisory preflight
|
||||
-> bounded stream reader
|
||||
-> decoded byte/depth/node/string/list cap
|
||||
-> GraphQL response envelope
|
||||
-> pinned HTTP status/body matrix
|
||||
-> data/errors state machine
|
||||
-> operation data codec
|
||||
-> mapper
|
||||
```
|
||||
|
||||
top-level:
|
||||
|
||||
```text
|
||||
GraphqlResponse
|
||||
data?
|
||||
errors?
|
||||
extensions?
|
||||
```
|
||||
|
||||
unknown top-level/extension behavior는 provider profile과 VD-24 unknown-field
|
||||
정책을 따른다. response body, error message, path와 extensions를 log에 복사하지
|
||||
않는다.
|
||||
|
||||
### 9. Data/error state machine
|
||||
|
||||
다음 순서로 배타적으로 처리한다.
|
||||
|
||||
1. network/final URL/unsupported media/body limit 실패 또는 legacy
|
||||
`application/json` non-2xx:
|
||||
transport 또는 media/limit failure, data/cache write 0.
|
||||
2. `application/graphql-response+json`은 profile이 허용한 status 전체에서,
|
||||
legacy `application/json`은 profile-admitted 2xx에서만 bounded parse한다.
|
||||
top-level response shape 불일치는 `GRAPHQL_ENVELOPE_MISMATCH`.
|
||||
3. `errors` key가 있으면 non-empty list여야 한다. `errors=[]`는 항상
|
||||
`GRAPHQL_ENVELOPE_MISMATCH`다.
|
||||
4. selected GraphQL-over-HTTP revision의 status/body matrix가 맞지 않으면
|
||||
`GRAPHQL_HTTP_PROFILE_MISMATCH`다.
|
||||
5. `data` key 존재 + non-null, `errors` 없음:
|
||||
data codec → mapper → generation fence → success.
|
||||
6. `data` 없음/null, non-empty `errors`:
|
||||
safe error mapping; success/cache write 0.
|
||||
7. non-null `data`와 non-empty `errors` 동시:
|
||||
operation `partialDataPolicy` 적용.
|
||||
8. `data` 없음/null이고 errors도 없음:
|
||||
contract mismatch.
|
||||
|
||||
### 10. Error projection
|
||||
|
||||
GraphQL error는 untrusted다.
|
||||
|
||||
```text
|
||||
GraphqlError
|
||||
message
|
||||
locations
|
||||
path
|
||||
extensions
|
||||
```
|
||||
|
||||
application에 허용:
|
||||
|
||||
- operation error profile이 allowlist한 `extensions.code`
|
||||
- bounded typed validation field issue
|
||||
- effect certainty/conflict category
|
||||
- bounded server request/trace ID projection
|
||||
|
||||
금지:
|
||||
|
||||
- raw `message`
|
||||
- source location
|
||||
- path actual value
|
||||
- arbitrary extensions
|
||||
- resolver/service/stack/database detail
|
||||
|
||||
error count, path segment/count/string와 extensions decoded byte cap을 적용한다.
|
||||
unknown code는 generic closed failure다. backend `retryable` boolean은 retry
|
||||
authority가 아니다.
|
||||
|
||||
common mapping 예:
|
||||
|
||||
| safe GraphQL category | AppFailure |
|
||||
| --- | --- |
|
||||
| unauthenticated | `AUTH_REQUIRED` |
|
||||
| forbidden | `FORBIDDEN` |
|
||||
| not found | `NOT_FOUND` 또는 existence-hiding policy |
|
||||
| validation | `VALIDATION_REJECTED` |
|
||||
| conflict/precondition | `CONFLICT` 또는 typed precondition |
|
||||
| rate limited | `RATE_LIMITED` |
|
||||
| internal/unavailable | `SERVER_FAILURE` |
|
||||
| unknown | `UNKNOWN_CLIENT_FAILURE` 또는 contract failure |
|
||||
|
||||
### 11. Partial data
|
||||
|
||||
default:
|
||||
|
||||
```text
|
||||
partialDataPolicy = REJECT
|
||||
```
|
||||
|
||||
query에만 다음 explicit profile을 허용할 수 있다.
|
||||
|
||||
```text
|
||||
ALLOW_TYPED_PARTIAL
|
||||
requiredCompletePaths
|
||||
optionalPartialPaths
|
||||
errorCodeAllowlist
|
||||
completenessSchemaId
|
||||
staleVisibilityPolicy
|
||||
```
|
||||
|
||||
조건:
|
||||
|
||||
- data codec이 missing/null path를 정확히 표현
|
||||
- mapper가 completeness를 application result로 투영
|
||||
- UI가 complete success와 partial-degraded를 구분
|
||||
- partial value/result size ceiling
|
||||
- authorization/error path를 숨기며 unsafe field를 사용하지 않음
|
||||
- previous complete cache와 field 단위로 임의 merge하지 않음
|
||||
|
||||
mutation은 errors가 있으면 partial success data를 ordinary command success로
|
||||
cache하지 않는다. backend가 effect certainty/receipt를 제공해야
|
||||
`COMMITTED | NOT_APPLIED | UNKNOWN`을 판단한다. error가 있다는 이유만으로
|
||||
optimistic layer 전체를 즉시 rollback해 다른 commit을 덮지 않는다.
|
||||
|
||||
### 12. Null bubbling
|
||||
|
||||
GraphQL nullability propagation은 application null 의미와 다르다.
|
||||
|
||||
- nullable field, error-caused null과 absent partial field를 data/error state
|
||||
machine이 함께 해석
|
||||
- generated type의 `T | null`만으로 cause를 추측하지 않음
|
||||
- operation data codec/mapper가 approved partial path와 error code를 결합
|
||||
- required root/aggregate null은 default failure
|
||||
- unauthorized field null을 stale previous field로 자동 채우지 않음
|
||||
|
||||
### 13. Cache identity
|
||||
|
||||
VD-25 TanStack Query가 기본 sole owner다.
|
||||
|
||||
- query key는 semantic operation input + VD-13 scope
|
||||
- persisted operation ID/hash/document를 key에 넣지 않음
|
||||
- GraphQL data/envelope/generated type을 cache하지 않음
|
||||
- mapped bounded application projection만 cache
|
||||
- schema/mapper meaning change는 query/release epoch invalidation
|
||||
- GraphQL client library cache는 disabled/`no-cache`
|
||||
|
||||
normalized cache가 필요하면 separate ADR:
|
||||
|
||||
- key fields/`__typename`
|
||||
- fragment completeness
|
||||
- pagination merge
|
||||
- optimistic layers
|
||||
- eviction/gc/logout/scope
|
||||
- persistence/SSR
|
||||
- TanStack replacement/removal
|
||||
|
||||
dual cache는 금지한다.
|
||||
|
||||
### 14. Batching
|
||||
|
||||
현재 `NOT_SELECTED`.
|
||||
|
||||
`@defer`/`@stream`은 한 GraphQL HTTP operation의 finite incremental response다.
|
||||
장기 subscription이나 unsolicited realtime event가 아니며, reconnect/resume
|
||||
owner를 realtime runtime에 넘기지 않는다.
|
||||
|
||||
선택 조건:
|
||||
|
||||
- 같은 endpoint/auth/scope
|
||||
- query only
|
||||
- same credentials/CSRF policy
|
||||
- max operation count
|
||||
- total variables/request bytes
|
||||
- total cost/depth
|
||||
- per-operation deadline/result/error/observation 보존
|
||||
- one operation cancel/failure가 다른 operation semantics를 바꾸지 않음
|
||||
|
||||
금지:
|
||||
|
||||
- mutation 포함
|
||||
- query+mutation mixed batch
|
||||
- 서로 다른 account/session
|
||||
- batching으로 idempotency/retry owner 합치기
|
||||
- one HTTP result를 one query cache value로 저장
|
||||
|
||||
batch transport failure와 per-operation GraphQL failure를 분리한다.
|
||||
|
||||
### 15. Incremental `@defer`/`@stream`
|
||||
|
||||
현재 `NOT_SELECTED`.
|
||||
|
||||
선택 시 별도 profile:
|
||||
|
||||
- exact incremental-delivery draft/provider revision
|
||||
- exact `Accept`, response `Content-Type`와 boundary/version parameter
|
||||
- exact `multipart/mixed` media/boundary parser
|
||||
- total bytes/parts/depth/patch count
|
||||
- initial/subsequent/terminal payload discriminant와 completion grammar
|
||||
- operation-owned ID/label/path allowlist와 path progression
|
||||
- patch/data/items/errors/extensions runtime schema
|
||||
- part별 및 cumulative error/extension count/byte ceiling
|
||||
- duplicate/out-of-order/missing path
|
||||
- terminal marker
|
||||
- idle/total deadline
|
||||
- backpressure/cancel/reader cleanup
|
||||
- proxy/CDN buffering conformance
|
||||
|
||||
cache:
|
||||
|
||||
- staging projection에 immutable patch 적용
|
||||
- terminal integrity/completeness 뒤 atomic commit
|
||||
- 또는 UI가 explicit progressive state를 소유
|
||||
- existing cached object를 in-place mutate하지 않음
|
||||
- truncated stream을 complete success로 cache하지 않음
|
||||
|
||||
Chromium/Firefox/WebKit과 actual proxy 증거 없이는 traffic promotion 금지다.
|
||||
exact protocol revision/profile이 없으면 registry composition 자체를 거절한다.
|
||||
|
||||
### 16. Subscription
|
||||
|
||||
GraphQL HTTP query adapter에 subscription을 넣지 않는다. 현재 `NOT_SELECTED`.
|
||||
|
||||
선택 시 transport-specific registered GraphQL subscription capability와
|
||||
feature-owned `FeatureEventInput`이 필요하다. 범용 `RealtimePort`를 만들지
|
||||
않는다.
|
||||
|
||||
```text
|
||||
GraphqlSubscriptionCapability
|
||||
subscribe(registered subscription, validated variables, signal)
|
||||
-> AsyncIterable<Result<MappedEvent>>
|
||||
-> unsubscribe()
|
||||
```
|
||||
|
||||
선택된 WebSocket/SSE subprotocol adapter가 frame, media, auth, reconnect/resume를
|
||||
소유하고 GraphQL event schema와 pure mapper를 통과한 event만
|
||||
`FeatureEventInput` 또는 invalidation bridge로 전달한다. backend contract가
|
||||
명시적으로 같은 의미를 채택하지 않는 한 GraphQL payload를
|
||||
`REALTIME_EVENT_V1`로 강제하거나 다시 감싸지 않는다.
|
||||
|
||||
backend 계약:
|
||||
|
||||
- exact WebSocket/SSE protocol/version
|
||||
- auth attach/refresh/revoke
|
||||
- heartbeat/idle timeout
|
||||
- reconnect/backoff
|
||||
- sequence/duplicate/gap/resume cursor
|
||||
- bounded queue/overflow
|
||||
- logout/route unmount unsubscribe
|
||||
|
||||
event는 invalidation hint 또는 registered bounded reducer를 통해 server-state를
|
||||
갱신한다. raw event history를 Query cache에 무한 적재하지 않는다.
|
||||
|
||||
### 17. Authorization, CSRF와 DoS
|
||||
|
||||
- BFF/router가 field/resource authorization을 매 request에 수행
|
||||
- persisted allowlist는 authorization이 아님
|
||||
- cookie mutation은 POST + exact Origin/Fetch Metadata + approved CSRF proof
|
||||
- SameSite/custom header/preflight 단일 요소만 방어라고 주장하지 않음
|
||||
- cross-origin credential wildcard 금지
|
||||
- server에서 depth, aliases, fragments, variables/list/page/field cost, total
|
||||
execution와 response bytes 강제
|
||||
- frontend ceiling은 server DoS 방어를 대체하지 않음
|
||||
- introspection off는 field authorization/cost control 대체 아님
|
||||
- persisted operation manifest와 field authorization change를 coherent rollout
|
||||
|
||||
### 18. Backend/router 계약
|
||||
|
||||
provider가 제공:
|
||||
|
||||
- immutable schema artifact/provenance
|
||||
- persisted operation registration/lookup
|
||||
- exact operation hash/schema digest binding
|
||||
- N/N-1 manifest window와 retirement
|
||||
- cost/depth/alias/list/response budget enforcement
|
||||
- stable safe error code vocabulary
|
||||
- partial/null/effect certainty semantics
|
||||
- idempotency/conflict/revision
|
||||
- auth/CSRF/CORS
|
||||
- request/trace projection
|
||||
- kill switch와 per-operation traffic
|
||||
|
||||
frontend manifest echo만으로 등록/authorization을 승인하지 않는다. router가
|
||||
server-owned manifest에서 operation binding을 재계산한다.
|
||||
|
||||
### 19. Observability
|
||||
|
||||
허용:
|
||||
|
||||
- semantic operation ID/persisted profile ID
|
||||
- schema/manifest compatibility outcome
|
||||
- full/partial/rejected/transport outcome
|
||||
- safe GraphQL error category
|
||||
- cost/depth/variables/response/error/part count bucket
|
||||
- duration/deadline/retry/auth recovery bucket
|
||||
- cache hit/stale/admission outcome
|
||||
|
||||
금지:
|
||||
|
||||
- document/hash actual value
|
||||
- variables/data
|
||||
- raw error message/path/extensions
|
||||
- field/resolver name high-cardinality label
|
||||
- account/resource/cursor/revision
|
||||
|
||||
server가 resolver-level telemetry를 소유한다. browser가 raw field trace를 수집하지
|
||||
않는다.
|
||||
|
||||
### 20. Testing
|
||||
|
||||
build/contract:
|
||||
|
||||
- schema source provenance/digest
|
||||
- schema lint/breaking/deprecation budget
|
||||
- named operation validation
|
||||
- canonical hash/manifest determinism
|
||||
- clean codegen diff
|
||||
- generated import boundary
|
||||
- variables/data codec parity
|
||||
- N/N-1 persisted manifest and retirement
|
||||
|
||||
runtime:
|
||||
|
||||
- unknown/hash mismatch, full-document fallback 0
|
||||
- variables depth/node/string/list/byte cap
|
||||
- GraphQL HTTP revision/media/status-body matrix와 legacy intermediary body
|
||||
- HTTP/media/body cap
|
||||
- all data/errors state branches
|
||||
- empty errors와 null/absent data matrix
|
||||
- error count/path/extensions cap/redaction
|
||||
- null bubbling
|
||||
- partial allowed/rejected/completeness
|
||||
- timeout/cancel/retry/auth recovery
|
||||
- mutation effect certainty/idempotency
|
||||
- scope/generation late result
|
||||
- cache admission/write 0 on failure
|
||||
|
||||
optional:
|
||||
|
||||
- batching mixed/mutation/limit rejection
|
||||
- multipart boundary/truncated/duplicate/out-of-order/terminal
|
||||
- subscription ordering/reconnect/resume/logout
|
||||
|
||||
provider/browser:
|
||||
|
||||
- actual BFF/router allowlist/cost/auth/CSRF/CORS
|
||||
- manifest rollout/retirement
|
||||
- proxy/CDN media/body behavior
|
||||
- Chromium/Firefox/WebKit for selected incremental/subscription capability
|
||||
|
||||
### 21. Rollout
|
||||
|
||||
1. product owner가 GraphQL이 필요한 bounded operation family를 승인한다.
|
||||
2. schema/router/manifest owner와 endpoint/auth/cost/error contract를 확정한다.
|
||||
3. provider-neutral codec/adapter/fake를 구현한다.
|
||||
4. generated source, boundary mapper와 TanStack query definition을 연결한다.
|
||||
5. REST current read와 GraphQL shadow read를 비교하되 shadow result는 UI/cache에
|
||||
쓰지 않는다.
|
||||
6. actual router conformance를 통과한다.
|
||||
7. `AVAILABLE_NOT_COMPOSED`에서 product composition behind
|
||||
`TrafficAdmission=DISABLED`로 이동한다.
|
||||
8. read-only internal canary 뒤 selected operation만 traffic을 올린다.
|
||||
9. mutation은 idempotency/effect certainty provider evidence 뒤 별도 canary한다.
|
||||
10. batching/incremental/subscription은 계속 `NOT_SELECTED` 또는 독립 gate다.
|
||||
|
||||
rollback:
|
||||
|
||||
- 신규 GraphQL operation admission 중지
|
||||
- in-flight query cancel, mutation effect reconcile
|
||||
- current scope GraphQL-mapped query cache clear
|
||||
- coherent frontend/schema/manifest/router rollback
|
||||
- approved REST read fallback이 있으면 새 logical read로 전환
|
||||
- arbitrary/full-document fallback 금지
|
||||
|
||||
### 22. Removal
|
||||
|
||||
1. operation traffic/registration retirement 시작
|
||||
2. query/subscription cancel과 mutation reconcile
|
||||
3. Query cache/invalidation listener clear
|
||||
4. operation/codec/mapper/query profile 제거
|
||||
5. generated files, GraphQL runtime/codegen dependencies 제거
|
||||
6. schema/operation manifest/config/endpoint 제거
|
||||
7. router persisted entries는 N/N-1 window 뒤 제거
|
||||
8. production module/dependency/SBOM/removal test 통과
|
||||
|
||||
## 규범 기준
|
||||
|
||||
- [GraphQL Specification, September 2025](https://spec.graphql.org/September2025/)
|
||||
- [GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/)
|
||||
|
||||
GraphQL-over-HTTP 문서는 현재 draft이므로 링크의 moving text를 production
|
||||
profile로 쓰지 않고 위에서 결정한 revision/provider fixture로 고정한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- production에서 registered persisted operation 외 document가 전송되지 않는다.
|
||||
- schema/operation/codegen/runtime codec/mapper manifest가 digest로 연결된다.
|
||||
- variables/response/errors가 bounded runtime validation을 거친다.
|
||||
- persisted envelope extension과 GraphQL-over-HTTP revision/media/status matrix가
|
||||
actual router profile에 고정된다.
|
||||
- data/errors/partial/null/effect certainty 상태가 배타적으로 닫힌다.
|
||||
- auth/CSRF/cost/field authorization과 manifest N/N-1을 actual router에서 증명한다.
|
||||
- GraphQL SDK normalized cache와 TanStack dual cache가 없다.
|
||||
- query key/cache에 document/hash/envelope/generated DTO가 없다.
|
||||
- batching/incremental/subscription은 선택 전 설치되지 않는다.
|
||||
- kill switch, rollback과 dependency/manifest removal drill이 통과한다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,660 @@
|
||||
# VD-28: Realtime events, Web Push와 bounded polling
|
||||
|
||||
- 상태: Accepted — reference runtime available, product implementation pending
|
||||
- 결정일: 2026-07-28
|
||||
- 관련 결정: VD-10, VD-13, VD-23, VD-24, VD-25, VD-26, VD-27, VD-29
|
||||
- 상세 설계:
|
||||
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`
|
||||
- 현재 product selection: `NOT_SELECTED`
|
||||
- common runtime delta: `AVAILABLE_NOT_COMPOSED`
|
||||
- 재검토:
|
||||
첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider
|
||||
protocol이 바뀔 때
|
||||
|
||||
|
||||
## 스트림 lifecycle은 freshness와 직교한다 (R-02, R-03)
|
||||
|
||||
`RealtimeStreamLifecycle = OPEN | DRAINING | CLOSED`는 freshness
|
||||
(`UNKNOWN/CURRENT/STALE/RESYNCING`)와 별개다.
|
||||
|
||||
- effect/recovery deadline에 도달하면 commit capability를 즉시 영구 무효화하고
|
||||
abort한다. caller에는 bounded `IDLE_TIMEOUT`(non-retryable, operation
|
||||
`APPLY`/`RECOVER`)을 반환하되 **실제 task는 버리지 않고 retain**한다.
|
||||
- retain된 task가 하나라도 있으면 stream은 `DRAINING`이고 새 event/recovery
|
||||
admission을 거절한다. 실제 settlement가 일어나야 `STALE`로 돌아가
|
||||
authoritative recovery를 요구하거나, close 요청이면 `CLOSED`가 된다.
|
||||
- `close()`는 `Promise<RealtimeResult<void>>`다. 모든 retain task가 실제로
|
||||
settle해야 success이고, drain bound를 넘기면 `IDLE_TIMEOUT/CLOSE`를 반환하며
|
||||
stream은 계속 `DRAINING`이다. teardown success가 곧 quiescence다.
|
||||
- LIVE↔POLL overflow fail-close는 active/probe/quiescing/transition lease를
|
||||
모두 abort한 뒤 **retired writer set**으로 옮기고 나서 reference를 지운다.
|
||||
`close()`는 current와 retired를 dedupe해 함께 기다리므로, 버려진
|
||||
non-cooperative writer가 아직 실행 중인데 close가 성공을 보고할 수 없다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 optional recipe catalog는 realtime capability에
|
||||
`referenceRuntime.status=AVAILABLE_NOT_COMPOSED`를 기록한다. 공통 event authority,
|
||||
bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE,
|
||||
bounded Polling, closed WebSocket protocol과 Web Push window/worker adapter는
|
||||
deterministic test와 함께 존재하지만 production entry에서는 제외된다. generic
|
||||
mega `RealtimePort`, 제품 event schema, 실제 endpoint, backend replay/provider
|
||||
contract와 composition은 선택하지 않았다.
|
||||
|
||||
추가 설계 범위에는 성격이 다른 네 capability가 있다.
|
||||
|
||||
- SSE: active document의 server-to-client event stream
|
||||
- WebSocket: active document의 duplex application protocol
|
||||
- Web Push: inactive browser에도 도착할 수 있는 Service Worker 기반 notification
|
||||
- bounded polling: 기존 HTTP/query operation의 제한된 scheduling policy
|
||||
|
||||
이를 “realtime transport” 하나로 합치면 다음 문제가 생긴다.
|
||||
|
||||
- Web Push의 permission, push service와 worker lifecycle이 connection 상태에 숨는다.
|
||||
- Polling을 무한 timer나 transport downgrade로 오해한다.
|
||||
- WebSocket이 필요하지 않은 server notification까지 duplex protocol이 된다.
|
||||
- connection open, event delivery, application effect와 server 최신성을 같은 성공으로
|
||||
표시한다.
|
||||
- auth refresh, reconnect, HTTP retry와 Query retry가 중첩된다.
|
||||
- gap, cursor expiry와 browser restore 뒤 authoritative resync owner가 사라진다.
|
||||
- push subscription endpoint/key나 cursor가 일반 application state와 telemetry에
|
||||
노출될 수 있다.
|
||||
|
||||
기존 recipe의 generic `channel: string`, `sequence: number`, 고정
|
||||
`resumeToken`, callback과 `heartbeat()`는 선택 시 복사해 좁힐 출발점이다.
|
||||
scope/epoch, closed event type, byte/queue limit, gap/reset, 진행되는 cursor,
|
||||
generation과 effect certainty가 없어 production wire authority로 사용할 수 없다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| optional realtime catalog/recipe | `RECIPE_AVAILABLE` / product `NOT_SELECTED` | uncomposed reference runtime과 conformance script가 있음 |
|
||||
| common event/recovery/reconnect runtime | `AVAILABLE_NOT_COMPOSED` | scope/gap/barrier authority, finite reconnect owner와 exact close classification test가 있음 |
|
||||
| live↔Poll handoff coordinator | `AVAILABLE_NOT_COMPOSED` | monotonic generation, one effect writer와 bounded checkpoint/quiescence test가 있음 |
|
||||
| SSE runtime | `AVAILABLE_NOT_COMPOSED` | fetch-stream parser/adapter/reconnect test 있음; local server/browser evidence pending |
|
||||
| WebSocket runtime | `AVAILABLE_NOT_COMPOSED` | exact handshake/protocol/queue/recovery test 있음; load/browser evidence pending |
|
||||
| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | finite single-flight visible/online lease와 deterministic budget test 있음 |
|
||||
| Web Push window/worker runtime | `AVAILABLE_NOT_COMPOSED` | subscription, registration/revoke, durable fence, strict inbound worker factory가 있음; provider/browser evidence pending |
|
||||
| exactly-once/global ordering | `PLATFORM_LIMITED` | 공통 browser delivery 목표로 보장하지 않음 |
|
||||
| always-on background connection/polling | `PLATFORM_LIMITED` | hidden/frozen/terminated document에서 보장하지 않음 |
|
||||
| timely cross-browser Web Push | `PLATFORM_LIMITED` | provider/browser/OS가 즉시 delivery를 보장하지 않음 |
|
||||
|
||||
reference source는 `AVAILABLE_NOT_COMPOSED`까지 승격됐다. 그러나 이 ADR과
|
||||
deterministic test만으로 `COMPOSED` 또는 `PRODUCTION_READY`로 올리지 않는다.
|
||||
제품 endpoint/registry와 backend/provider/target-browser evidence가 생긴 뒤
|
||||
선택 capability만 별도 승격한다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. 네 capability를 분리한다
|
||||
|
||||
다음 의미를 고정한다.
|
||||
|
||||
| capability | 선택 의미 | 기본 fallback |
|
||||
| --- | --- | --- |
|
||||
| SSE | foreground one-way ordered hint stream | bounded polling 또는 stale UI |
|
||||
| WebSocket | foreground duplex interaction protocol | 의미가 축소되지 않으면 bounded polling, 아니면 disabled/stale UI |
|
||||
| Web Push | background user-visible notification hint | foreground inbox/focus refresh |
|
||||
| bounded polling | finite visible HTTP scheduling | manual refresh/explicit stale UI |
|
||||
|
||||
Web Push는 SSE/WebSocket의 fallback이 아니라 보완 capability다. Polling은
|
||||
WebSocket duplex 기능을 대신할 수 없다. SSE↔WebSocket 자동 downgrade도 하지
|
||||
않는다. 같은 사용자 의미를 보존하는 fallback만 registry에 명시한다.
|
||||
|
||||
추가 transport를 선택하기 전 기존 TanStack Query의 focus/reconnect refetch와
|
||||
manual refresh가 측정된 freshness 요구를 만족하는지 먼저 확인한다.
|
||||
|
||||
Connect/gRPC-Web server stream은 VD-29/VD-27의 operation-bound API protocol이고 GraphQL
|
||||
subscription은 현재 `NOT_SELECTED`다. GraphQL `@defer`/`@stream`은 finite
|
||||
incremental HTTP response이지 realtime subscription이 아니다. RPC adapter가
|
||||
protocol-specific terminal proof와 protobuf decode/schema/mapper를 끝낸
|
||||
runtime-wide notification branch에서만 공통
|
||||
scope/gap/resync coordinator를 재사용한다. frame/media/trailer, reconnect와
|
||||
operation deadline owner를 SSE/WebSocket adapter로 합치거나 protobuf message를
|
||||
`REALTIME_EVENT_V1` JSON으로 다시 감싸지 않는다. Polling의 개별 attempt는 VD-23의
|
||||
terminal·replay-safe REST `QUERY` execution contract를 재사용하되 transport/Query
|
||||
retry는 끄고, 이 결정은 attempt 사이 bounded lease만 소유한다.
|
||||
|
||||
### 2. source of truth는 서버다
|
||||
|
||||
SSE/WebSocket event의 기본 효과는 registered `QueryInvalidationTopic`과 authoritative
|
||||
HTTP refetch다. raw event payload를 domain entity나 Query cache의 authoritative
|
||||
state로 자동 승격하지 않는다.
|
||||
|
||||
authoritative delta 적용은 event type별 server revision, base revision, commit
|
||||
뒤 publication, idempotent reducer, gap/reset과 snapshot reconciliation이 모두
|
||||
승인된 경우에만 별도 선택한다.
|
||||
|
||||
Web Push payload는 작은 opaque notification hint다. Poll response는 해당 HTTP
|
||||
representation의 결과다. 어느 것도 authorization이나 exactly-once effect를
|
||||
증명하지 않는다.
|
||||
|
||||
### 3. outbound connection과 inbound event adapter를 분리한다
|
||||
|
||||
outbound가 소유한다.
|
||||
|
||||
- fixed endpoint와 credential 협력
|
||||
- connect/subscribe/resume/reconnect/close
|
||||
- selected WebSocket typed send
|
||||
- push subscription register/revoke
|
||||
- bounded poll scheduling/cancel
|
||||
|
||||
inbound가 소유한다.
|
||||
|
||||
- raw byte/frame hard cap
|
||||
- UTF-8/JSON/schema/version 검증
|
||||
- stream/event/scope/generation 확인
|
||||
- dedupe/order/gap
|
||||
- feature input 또는 query invalidation mapping
|
||||
- effect 뒤 cursor/ack commit
|
||||
|
||||
application/domain에 native browser, TanStack, URL/header나 vendor type을 노출하지
|
||||
않는다. `send(unknown)`과 arbitrary `channel`/endpoint도 금지한다.
|
||||
|
||||
### 4. target event protocol을 versioning한다
|
||||
|
||||
foreground common envelope은 다음 의미를 가져야 한다.
|
||||
|
||||
```text
|
||||
protocol = REALTIME_EVENT_V1
|
||||
streamId = registry-owned ID
|
||||
streamEpoch = opaque server reset epoch
|
||||
eventType = closed registry ID
|
||||
eventId = bounded dedupe ID
|
||||
sequence = canonical unsigned decimal string
|
||||
recoveryMode = CURSOR | SNAPSHOT_ONLY | SESSION_REBUILD
|
||||
resumeCursor = CURSOR면 opaque replay position, 아니면 exact null
|
||||
occurredAt = strict RFC 3339, ordering authority 아님
|
||||
scopeBinding = session/BFF-issued opaque exact-match token
|
||||
payload = event-type-specific closed codec
|
||||
```
|
||||
|
||||
`eventId`, `sequence`, `resumeCursor`와 business revision은 별도 의미다.
|
||||
sequence는 JSON safe-integer 문제를 피하도록 decimal string으로 전달하고
|
||||
stream + epoch 안에서만 비교한다.
|
||||
|
||||
credential, readable subject/account ID, signed URL, PushSubscription material과
|
||||
자유 형식 message는 envelope에 넣지 않는다.
|
||||
event type registry는 payload schema, pure boundary mapper와 effect profile을
|
||||
함께 bind한다. `scopeBinding`은 cache fingerprint/authorization proof가 아니고,
|
||||
cursor는 protocol/stream/feed/epoch/registered subscription set/auth scope에
|
||||
server-side로 bind한다. client는 opaque cursor를 해석하지 않는다.
|
||||
state-bearing stream의 recovery profile은 snapshot operation/checkpoint codec과
|
||||
replay/connect-buffer/server-hold barrier를 닫는다. `SESSION_REBUILD`는
|
||||
EPHEMERAL-only다. V1 server-side subset filter는 `NOT_SELECTED`이며 필요하면
|
||||
contiguous sequence/checkpoint를 가진 별도 stream으로 등록한다.
|
||||
|
||||
### 5. delivery guarantee와 authoritative resync를 분리한다
|
||||
|
||||
apply 순서는 다음과 같다.
|
||||
|
||||
```text
|
||||
byte cap
|
||||
-> parse/schema/version
|
||||
-> registry/scope/generation
|
||||
-> dedupe/order/gap
|
||||
-> registered boundary mapper
|
||||
-> sequential application effect
|
||||
-> effect commit
|
||||
-> last-applied cursor
|
||||
-> optional selected WebSocket protocol ACK
|
||||
```
|
||||
|
||||
effect 뒤 cursor를 commit하므로 crash window에서 duplicate가 생길 수 있다.
|
||||
effect는 idempotent하거나 query invalidation/refetch여야 한다.
|
||||
|
||||
- 전체 browser lifecycle에 대한 delivery guarantee는 없음
|
||||
- retention 안의 `CURSOR` foreground event 처리만 duplicate-tolerant
|
||||
at-least-once model
|
||||
- V1 ordering은 stream-wide 하나; partition은 별도 logical stream
|
||||
- exact duplicate/old sequence는 safe drop
|
||||
- 같은 event ID/sequence의 conflicting content는 protocol failure
|
||||
- old captured generation callback만 safe drop; current connection의
|
||||
`scopeBinding` mismatch는 security protocol violation으로 close/revalidate/resync
|
||||
- sequence gap, stream epoch change, cursor expiry, queue overflow는 delta 적용 중단
|
||||
- authoritative snapshot과
|
||||
`SnapshotCheckpoint(streamEpoch,lastAppliedSequence,resumeCursor|null,snapshotRevision)`를
|
||||
같은 commit point로 얻은 뒤에만 resume
|
||||
- exactly-once와 global ordering은 비목표
|
||||
|
||||
backend는 commit 이후 publication, replay retention, cursor reset과
|
||||
snapshot/checkpoint 의미를 소유한다. subscribe ACK는 accepted cursor와
|
||||
`nextExpectedSequence`를 반환한다. replay가 없는 `SNAPSHOT_ONLY`는
|
||||
connect/bounded-buffer 또는 server hold barrier 없이는 snapshot/connect 사이
|
||||
event를 잃을 수 있으므로 `CURRENT`를 보장하지 않고 finite revalidation/stale UX로
|
||||
degrade한다.
|
||||
|
||||
### 6. lifecycle은 scope generation으로 fence한다
|
||||
|
||||
connection, freshness, authorization, availability와 traffic admission을 별도
|
||||
상태 축으로 둔다. `connected: boolean` 하나로 표현하지 않는다.
|
||||
|
||||
- runtime config/release/session recovery 뒤에만 connect한다.
|
||||
- route lease는 unmount에서 release한다.
|
||||
- logout/account/release transition은 old generation을 먼저 fence한다.
|
||||
- connect/read/backoff/poll/snapshot을 abort하고 queue/cursor/dedupe를 폐기한다.
|
||||
- late event/response/worker handoff는 captured old generation이면 적용하지 않는다.
|
||||
- close/dispose/unsubscribe는 terminal/idempotent다.
|
||||
- React StrictMode 반복 뒤 physical listener/connection/timer가 하나만 남는다.
|
||||
- admission은 canonical `DISABLED | SHADOW | CANARY | ENABLED`만 사용하고,
|
||||
drain은 connection lifecycle의 `DRAINING`으로 표현한다.
|
||||
- `DISABLED`는 새 data-plane side effect를 0으로 한다. 이미 소유한 fixed
|
||||
resource의 idempotent close/revoke만 bounded `DRAINING` cleanup plane에서
|
||||
허용하며 `CLOSED` 뒤 network side effect는 0이다.
|
||||
|
||||
hidden에서는 Polling을 중지하고 live connection은 configured bounded grace 뒤
|
||||
close/pause한다. `pagehide`에서 document-owned SSE/WS/Poll을 모두 정리하고
|
||||
`pageshow`/visible 복귀에는 snapshot freshness gate 뒤 새 runtime으로 resume한다.
|
||||
`unload` 완료에 의존하지 않는다. backend는 active authorization revoke를
|
||||
close/control event로 전파하거나 bounded max connection age에 재인가한다.
|
||||
|
||||
### 7. retry owner를 하나로 제한한다
|
||||
|
||||
reconnect는 capped full-jitter exponential backoff를 사용한다. base/max delay,
|
||||
max attempts와 max elapsed는 immutable registry/implementation ceiling으로
|
||||
제한한다.
|
||||
|
||||
- stable-open window 또는 valid heartbeat/event 뒤에만 attempt reset
|
||||
- valid server hint는 local delay보다 이른 retry를 금지하는 not-before bound
|
||||
- server hint가 implementation max/remaining elapsed budget을 넘으면 낮춰
|
||||
clamp하지 않고 degraded/stale로 종료
|
||||
- offline에서는 timer retry를 멈춤
|
||||
- auth expiry는 session owner single-flight recovery 한 번
|
||||
- forbidden/protocol/schema failure는 terminal
|
||||
- 외부 rate/provider failure는 exact bounded server not-before hint가 있을 때만
|
||||
retry하고, hint가 없으면 terminal
|
||||
- retry budget 소진 뒤 declared Polling fallback 또는 stale UI
|
||||
- reconnect는 realtime coordinator, auth는 session owner, Poll cadence는 poll
|
||||
coordinator가 소유하고 Poll-bound HTTP/Query retry는 비활성
|
||||
- recovery checkpoint는 exact branded object identity로 다음 attempt에 전달한다.
|
||||
SSE `onOpen`/WebSocket `onSubscribed` proof와 attempt 성공 proof가 같은
|
||||
object일 때만 common transport barrier를 확인하고 event admission을 연다.
|
||||
clone/missing proof와 30초 readiness deadline 초과는 fail-closed다.
|
||||
- aborted sleep/attempt/closed-receipt는 기본 2초 bounded drain 뒤 run을
|
||||
fail-closed로 끝내되, 실제 old task가 settle할 때까지 `DRAINING`을 유지한다.
|
||||
정상 active session의 `waitClosed`에는 deadline을 두지 않는다.
|
||||
|
||||
### 8. SSE baseline은 bounded fetch-stream이다
|
||||
|
||||
common reference target은 fixed same-origin BFF에 대한 fetch-stream SSE다.
|
||||
native EventSource보다 다음을 명시적으로 제어하기 위해서다.
|
||||
|
||||
- credential integration
|
||||
- status/content type/redirect
|
||||
- AbortSignal과 lifecycle
|
||||
- parser/event byte ceiling
|
||||
- reconnect/idle/retry budget
|
||||
- explicit current cursor
|
||||
|
||||
native EventSource는 same-origin cookie auth, native `Last-Event-ID`/reconnect,
|
||||
`204` terminal contract와 lifecycle 뒤 cursor recovery를 backend가 수용한
|
||||
별도 profile에서만 허용한다. UA cursor를 application effect commit과 묶을 수
|
||||
없으므로 `INVALIDATION_HINT` 전용이고 reconnect/restore마다 authoritative
|
||||
snapshot gate를 수행한다. gate 중 hint는 bounded `pendingInvalidation`으로
|
||||
coalesce하고 checkpoint 뒤 pending refetch까지 drain한다. 이 buffer/barrier가
|
||||
없으면 `CURRENT`를 금지한다. `AUTHORITATIVE_DELTA`는 fetch-stream만 허용한다.
|
||||
token을 URL에 넣지 않는다.
|
||||
|
||||
fetch-stream parser는 표준 UTF-8 SSE format, BOM/line ending/comment/multi-line
|
||||
data/id/retry/incomplete EOF를 bounded하게 구현한다. exact `200
|
||||
text/event-stream`만 stream 성공이며 auth/rate/reset/provider status를 closed
|
||||
failure로 mapping한다. parsed candidate ID와 effect-committed cursor를 분리하고
|
||||
각 application event block의 직접 `id`와 envelope cursor를 exact match한다.
|
||||
|
||||
SSE baseline은 registry-owned session feed 하나와 feed-wide cursor 하나다.
|
||||
route lease는 local dispatch만 바꾸며 arbitrary server multiplex와
|
||||
per-subscription cursor는 `NOT_SELECTED`다.
|
||||
|
||||
hosting은 proxy buffering, idle/request timeout, heartbeat, cache/transform,
|
||||
HTTP connection budget와 client disconnect cleanup을 실제로 검증한다.
|
||||
|
||||
### 9. WebSocket은 versioned duplex protocol로만 선택한다
|
||||
|
||||
- fixed same-origin `wss:` endpoint와 exact subprotocol
|
||||
- server `Origin` 검증과 current session authorization
|
||||
- URL/query/subprotocol에 credential 금지
|
||||
- closed welcome/subscribe/unsubscribe-ack/event/reset/heartbeat/close frame
|
||||
- baseline text JSON, binary/extension은 별도 승인
|
||||
- application heartbeat/watchdog
|
||||
- bounded incoming sequential queue
|
||||
- bounded outgoing queue와 `bufferedAmount`
|
||||
- raw close reason redaction
|
||||
- same-epoch cursor resume의 `nextExpectedSequence = lastApplied + 1`; accepted
|
||||
cursor silent advance 금지, mismatch는 reset/snapshot
|
||||
- state-bearing initial subscribe는 snapshot/checkpoint + barrier 전 `CURRENT` 금지
|
||||
- `UNSUBSCRIBE` 뒤 matching `UNSUBSCRIBED`까지 tombstone과 quota를 유지하고 late
|
||||
event/control은 effect 없이 버린다. unknown ACK와 ACK deadline 초과는
|
||||
connection-level failure다.
|
||||
|
||||
classic browser WebSocket은 incoming backpressure를 제공하지 않으므로 queue
|
||||
overflow에서 임의 delta drop을 하지 않는다. baseline은 connection을 close하고
|
||||
snapshot resync한다. server의 bounded pause/resume ACK protocol을 별도 증명한
|
||||
profile에서만 subscription pause를 허용한다.
|
||||
|
||||
모든 client control frame은 하나의 FIFO outbound queue를 통과한다. negotiated
|
||||
message count/queued bytes와 native `bufferedAmount` 중 하나라도 넘으면
|
||||
`QUEUE_OVERFLOW`, `retryable=false`, `OVERLOADED`로 generation 전체를 닫고
|
||||
snapshot recovery를 요청한다.
|
||||
|
||||
durable business command는 기존 HTTP path를 기본으로 유지한다. WebSocket
|
||||
command를 선택하면 closed operation, command ID/idempotency, expected revision,
|
||||
ack와 business commit certainty를 별도로 정의한다.
|
||||
|
||||
### 10. Web Push는 별도 window/worker/backend capability다
|
||||
|
||||
Web Push 선택에는 다음이 모두 필요하다.
|
||||
|
||||
- user-action 기반 permission UX
|
||||
- active Service Worker registration
|
||||
- `userVisibleOnly: true`인 window subscription manager
|
||||
- authenticated backend register/revoke
|
||||
- server subscription registry
|
||||
- VAPID private-key/provider owner
|
||||
- worker push/notification/click inbound adapters
|
||||
|
||||
PushSubscription endpoint, `p256dh`, `auth`는 capability material로 취급하고
|
||||
application state, browser storage, URL, BroadcastChannel과 telemetry에서
|
||||
금지한다. VAPID private key는 server-only다.
|
||||
|
||||
push payload는 versioned, association/release-bound, expiring opaque notification
|
||||
hint로 제한한다. 개인 내용은 foreground BFF가 current authorization으로
|
||||
조회한다. worker handler는 `waitUntil` 안에서 bounded validation과
|
||||
`showNotification`만 수행하며 long retry/sync/migration을 하지 않는다.
|
||||
decoded application hint는 3 KiB를 넘지 않으며 최상위 JSON member name 중복은
|
||||
last-wins로 해석하지 않고 거절한다. `issuedAt`의 client clock 대비 future
|
||||
skew는 최대 5분, `expiresAt - issuedAt` lifetime은 최대 24시간이다.
|
||||
window의 native permission/subscription operation은 30초, backend
|
||||
register/reconcile/revoke operation은 15초 안에 종료하며 제품 config는 이
|
||||
implementation ceiling을 높일 수 없다.
|
||||
`pushsubscriptionchange` window handoff도 worker lifecycle abort와 10초
|
||||
deadline을 사용하고, non-cooperative `matchAll()` 또는 동기 `waitUntil()` 예외
|
||||
뒤에는 늦은 `postMessage`를 허용하지 않는다.
|
||||
|
||||
notification copy와 click route는 closed registry를 사용한다. arbitrary backend
|
||||
text나 URL을 OS notification/openWindow에 전달하지 않는다.
|
||||
worker restart 뒤 click을 처리하도록 bounded non-sensitive
|
||||
`NotificationClickDataV1`만 `NotificationOptions.data`에 넣고 click 시
|
||||
codec/expiry/current association/release를 다시 검증한다. logout 때 owned
|
||||
notification은 bounded best-effort close하지만 OS 잔존 가능성 때문에 copy는
|
||||
항상 account-neutral이어야 한다.
|
||||
|
||||
worker는 window in-memory session을 authority로 사용할 수 없으므로 opaque
|
||||
`fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`과
|
||||
`UNASSOCIATED | ACTIVE | REVOKED` association discriminant를 가진
|
||||
adapter-owned IndexedDB `PUSH_CONTROL_V1` record를 사용한다.
|
||||
account ID, endpoint/key, credential과 notification content는 이 record에서
|
||||
금지한다. missing/corrupt/mismatch는 fail-closed한다. 동일 association epoch의
|
||||
`REVOKED`는 terminal tombstone이다. logout은 durable fence generation rotate와
|
||||
REVOKED를 먼저 commit한다. 새 `ACTIVE`는 distinct backend epoch와 captured/current
|
||||
fence generation, server session binding, prior record revision/epoch, release를
|
||||
한 IDB transaction에서 CAS해 stale-tab response를 거절한다. 첫 register 전
|
||||
`UNASSOCIATED` record도 같은 generation을 durable하게 보관하므로 logout과
|
||||
in-flight register response의 race를 association sentinel 없이 닫는다. client
|
||||
`updatedAt`은 ordering authority가 아니다.
|
||||
|
||||
logout은 old generation fence, durable local association `REVOKED` commit과
|
||||
backend account association revoke를 정상 security commit으로 사용한다. boot에서
|
||||
native subscription/local fence/server association을 reconcile하고, local commit
|
||||
실패나 ambiguous revoke는 `PUSH_UNAVAILABLE`로 내려 짧은 TTL, send-time auth와
|
||||
click-time 재인가에 의존한다. native unsubscribe/old notification close는
|
||||
best-effort지만 captured native subscription, exact association tag와 unchanged
|
||||
durable fence를 모두 다시 확인한 경우에만 수행한다. 새 association이 commit되면
|
||||
old cleanup은 건너뛴다. local fence 실패 뒤 current native subscription 조회나
|
||||
association wildcard cleanup은 금지한다.
|
||||
|
||||
control tombstone purge는 자동 revoke 단계가 아니다. 별도 maintenance owner만
|
||||
captured revision/authority/association epoch가 exact한 `REVOKED` record를
|
||||
repository CAS로 삭제할 수 있고, concurrent newer owner가 있으면
|
||||
`STALE_REVISION`으로 끝난다.
|
||||
|
||||
backend register/revoke는 VD-23의 fixed `COMMAND`로 등록하고 cookie session의
|
||||
exact CSRF를 검증한다. register는 keyed idempotency 또는 atomic installation
|
||||
upsert/receipt, revoke는 duplicate/`ALREADY_GONE` 성공 의미를 가져야 하며
|
||||
`associationEpoch`은 server commit 뒤에만 발급한다.
|
||||
|
||||
Service Worker를 우회해 UA가 직접 notification을 표시할 수 있는 declarative push
|
||||
message는 V1에서 `NOT_SELECTED`다. outbound `web_push: 8030` shape를 거절하고
|
||||
별도 ADR 전에는 encrypted `WEB_PUSH_HINT_V1`만 허용한다.
|
||||
|
||||
Service Worker를 선택해도 offline fetch, PWA shell cache나 background sync가
|
||||
자동 승인되지 않는다. 하나의 worker composition/update owner가 선택된 handler를
|
||||
조립한다.
|
||||
|
||||
### 11. Polling은 bounded lease다
|
||||
|
||||
허용 형태:
|
||||
|
||||
- visible query의 낮은 빈도 conditional freshness poll
|
||||
- 사용자 시작 async job의 terminal-state convergence poll
|
||||
|
||||
각 lease는 operation owner, minimum/success/max interval, max attempts,
|
||||
max elapsed, response byte cap, visible-only policy와 terminal states를 가진다.
|
||||
|
||||
- operation은 registered terminal·replay-safe REST `QUERY`여야 함
|
||||
- Poll `maxAttempts`는 physical request 하나인 logical completion을 셈
|
||||
- Poll-bound VD-23 budget은 `maxAttempts=1`, `authRecoveryCount=0`,
|
||||
`maxCumulativeSleepMs=0`; TanStack Query retry도 끔
|
||||
- completion-chained timeout으로 single-flight
|
||||
- hidden/offline/pagehide/unmount/scope change/user cancel에서 stop
|
||||
- ETag/`If-None-Match` 또는 server cursor 사용
|
||||
- `304`, auth, cursor reset, `429/503 Retry-After`를 closed mapping
|
||||
- common recovery coordinator가 `POLL_ACTIVE -> LIVE_PROBING`에서 poll만 effect
|
||||
writer로 유지하고 live candidate는 bounded buffer만 사용. handoff mutex에서
|
||||
poll fence/abort + quiescence를 먼저 완료하고 current-generation
|
||||
snapshot/checkpoint와 buffered event를 적용한 뒤 live를 활성화
|
||||
- active writer effect tail도 in-flight 포함 256건/4MiB로 제한하고 overflow는
|
||||
전체 generation을 `QUEUE_OVERFLOW`로 fail-close
|
||||
- budget 소진 뒤 manual refresh/stale UI
|
||||
- page component `setInterval`과 unlimited loop 금지
|
||||
|
||||
### 12. resource ceiling과 privacy를 fail-closed한다
|
||||
|
||||
상세 설계의 target hard ceiling은 physical connection, logical subscription,
|
||||
event/frame/parser/queue/dedupe/reorder/outbound buffer, reconnect, poll lease,
|
||||
push hint와 worker deadline을 제한한다. 제품 config는 더 작게만 설정할 수 있다.
|
||||
|
||||
2026-07-28 reference-runtime amendment로, RT-01~RT-04 source 전체를
|
||||
tree-shaking 없이 합성하는 optional-recipe gzip 예산을 40,000 bytes로
|
||||
고정한다. 이는 production bundle 허용량이 아니며 미선택 production asset의
|
||||
realtime module 허용량은 계속 0이다. SSE replay-open과 WebSocket
|
||||
`SUBSCRIBED`가 exact recovery checkpoint를 증명하고 common barrier가 확인될
|
||||
때까지 event admission을 막는 readiness gate는 attempt당 최대 30초다.
|
||||
phase abort 뒤 비협조적인 retry sleep, connect attempt 또는 closed-receipt
|
||||
cleanup을 기다리는 drain은 2초로 고정하고 구현 절대 최대는 30초다. 상한을
|
||||
넘긴 task가 settle할 때까지 lifecycle은 `DRAINING`을 유지하며 정상 active
|
||||
session의 `waitClosed`에는 이 cleanup deadline을 적용하지 않는다.
|
||||
|
||||
ceiling 초과는 limit 자동 인상이나 silent drop이 아니라 new lease rejection,
|
||||
connection close, snapshot resync, typed backpressure, stale/degraded 또는
|
||||
notification drop으로 처리한다.
|
||||
|
||||
telemetry에는 transport/registry ID, closed outcome, count/duration/lag bucket만
|
||||
허용한다. raw URL/query/credential/subject/event ID/cursor/payload/close reason/
|
||||
PushSubscription key와 notification private content는 금지한다.
|
||||
|
||||
### 13. 실제 provider/browser/operations evidence 전에는 promotion하지 않는다
|
||||
|
||||
evidence를 분리한다.
|
||||
|
||||
1. pure unit/property와 deterministic fault contract
|
||||
2. 실제 local SSE/WS server integration
|
||||
3. backend replay/snapshot/auth/hosting/provider conformance
|
||||
4. built production asset의 target-browser lifecycle
|
||||
5. Web Push provider + browser/OS 자동·수동 evidence
|
||||
6. load/chaos/security negative gate
|
||||
7. dashboards, kill switch와 drain/recovery/rollback drill
|
||||
|
||||
fake/jsdom/MSW만으로 native stream, socket, worker, notification이나 provider
|
||||
readiness를 주장하지 않는다. 외부 evidence가 없으면 `PromotionEvidence`는
|
||||
`MISSING | PARTIAL`이고 promotion gate result는 `FAIL_UNVERIFIED`다.
|
||||
|
||||
## 선택하지 않은 대안
|
||||
|
||||
### 범용 transport enum을 가진 `RealtimePort`
|
||||
|
||||
전송 교체는 가능해 보이지만 direction, permission, lifecycle, delivery certainty와
|
||||
fallback 의미를 잃는다. 공통 protocol coordinator만 재사용하고 native capability
|
||||
port는 분리한다.
|
||||
|
||||
### 모든 server event에 WebSocket 사용
|
||||
|
||||
one-way notification에도 duplex handshake, heartbeat, queue와 server connection
|
||||
운영 비용을 강제한다. one-way stream은 SSE를 우선 검토한다.
|
||||
|
||||
### native EventSource만 공통 baseline으로 사용
|
||||
|
||||
arbitrary auth header, detailed status mapping, bounded reconnect와 explicit
|
||||
lifecycle cursor 제어가 부족하다. 조건부 profile로는 허용하지만 reference
|
||||
baseline은 fetch-stream이다.
|
||||
|
||||
### token을 SSE/WS URL에 전달
|
||||
|
||||
history, log, proxy, analytics와 referrer에 노출될 수 있다. same-origin
|
||||
BFF/cookie 또는 승인된 별도 handshake를 사용한다.
|
||||
|
||||
### event payload로 Query cache 직접 patch
|
||||
|
||||
filter/pagination/revision/gap 의미가 없으면 stale projection을 만든다. 기본은
|
||||
namespace invalidation과 authoritative refetch다.
|
||||
|
||||
### Web Push를 silent sync로 사용
|
||||
|
||||
permission/browser/OS/provider가 background execution과 timely delivery를
|
||||
보장하지 않는다. user-visible notification hint와 foreground refresh로 제한한다.
|
||||
|
||||
### 무한 `setInterval` Polling
|
||||
|
||||
overlap, hidden resource 사용, retry 중첩과 terminal cleanup 누락을 만든다.
|
||||
finite immutable lease와 single owner를 사용한다.
|
||||
|
||||
### cross-tab leader를 기본 제공
|
||||
|
||||
leader election/crash/handoff/partition과 SharedWorker 지원이 별도 protocol을
|
||||
요구한다. 기본은 tab별 bounded runtime과 focus snapshot이다.
|
||||
|
||||
### exactly-once delivery
|
||||
|
||||
cursor commit과 application effect 사이 crash window, push service와 browser
|
||||
lifecycle을 공통 frontend만으로 제거할 수 없다. retention 안의 CURSOR event만
|
||||
duplicate-tolerant하게 처리하고 나머지는 best-effort + authoritative resync를
|
||||
사용한다.
|
||||
|
||||
## 결과
|
||||
|
||||
긍정적 결과:
|
||||
|
||||
- 전송 선택이 요구와 failure semantics에 연결된다.
|
||||
- server state/query ownership과 clean architecture 경계를 유지한다.
|
||||
- gap, late callback, logout과 page restore가 명시적 복구 경로를 가진다.
|
||||
- Web Push permission/subscription material이 일반 realtime state와 분리된다.
|
||||
- Polling fallback이 resource-unbounded loop가 되지 않는다.
|
||||
- 미선택 capability의 bundle/worker/runtime side effect를 0으로 유지할 수 있다.
|
||||
|
||||
비용:
|
||||
|
||||
- common coordinator 외에도 transport별 adapter와 실제 provider harness가 필요하다.
|
||||
- backend는 replay/snapshot/outbox/auth와 provider 운영 계약을 제공해야 한다.
|
||||
- worker와 window에 별도 composition/test matrix가 필요하다.
|
||||
- direct delta보다 invalidation/refetch가 추가 HTTP 비용을 만들 수 있다.
|
||||
- target browser/OS에서 자동화할 수 없는 Web Push evidence를 운영해야 한다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
```text
|
||||
RT-00 contract/status
|
||||
-> RT-01 event authority + scope/gap/resync
|
||||
-> RT-02 SSE + bounded polling
|
||||
-> RT-03 WebSocket
|
||||
-> RT-04 Web Push
|
||||
-> RT-05 product composition/provider/browser/operations
|
||||
```
|
||||
|
||||
SSE와 WebSocket을 모두 구현해야 skeleton이 완성되는 것은 아니다. 공통
|
||||
mechanism을 구현한 뒤 실제 product requirement에 필요한 최소 transport만
|
||||
선택한다.
|
||||
|
||||
reference source와 deterministic/native evidence가 생기면 해당 runtime만
|
||||
`AVAILABLE_NOT_COMPOSED`로 올린다. 제품 endpoint/event registry/policy가
|
||||
bootstrap에 연결된 transport만 `COMPOSED`다.
|
||||
|
||||
## Rollout
|
||||
|
||||
capability별 traffic admission:
|
||||
|
||||
```text
|
||||
DISABLED -> SHADOW -> CANARY -> ENABLED
|
||||
SHADOW | CANARY | ENABLED -> DISABLED
|
||||
```
|
||||
|
||||
- transport, stream, Poll fallback과 push category kill switch를 분리한다.
|
||||
- safe config default는 `DISABLED`다.
|
||||
- canary 전에 backend/provider/browser/operations evidence를 만료 검증한다.
|
||||
- deploy/drain과 reconnect herd를 load test한다.
|
||||
- freshness/latency만 아니라 gap/resync/queue/memory/battery/push permission
|
||||
지표를 함께 본다.
|
||||
|
||||
## Rollback과 제거
|
||||
|
||||
1. admission을 `DISABLED`, connection lifecycle을 `DRAINING`으로 전환한다.
|
||||
2. logical subscription/send/poll/push registration을 중지한다.
|
||||
3. active reader/socket/timer/handler를 bounded close한다.
|
||||
4. HTTP focus/manual refresh 또는 명시된 fallback을 노출한다.
|
||||
5. server publisher/replay/subscription compatibility window를 유지한다.
|
||||
6. composition/registry/adapter/dependency/worker handler를 제거한다.
|
||||
7. CSP/runtime config/provider key와 retained server subscription을 정리한다.
|
||||
8. typecheck, architecture, tests, build, bundle/module inventory와 removal gate를
|
||||
실행한다.
|
||||
|
||||
미선택/제거 상태에서 connection, timer, push listener/subscription request와
|
||||
production bundle sentinel이 0이어야 한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
### 이 결정의 설계 완료
|
||||
|
||||
- [x] 네 capability의 의미와 선택 조건을 분리했다.
|
||||
- [x] current status와 target runtime 상태를 구분했다.
|
||||
- [x] source of truth와 delivery/effect certainty를 정했다.
|
||||
- [x] target envelope, ordering, cursor와 resync를 정했다.
|
||||
- [x] lifecycle/retry/resource/security/privacy 경계를 정했다.
|
||||
- [x] transport별 auth/hosting/worker/Poll contract를 정했다.
|
||||
- [x] evidence, rollout, rollback과 제거 기준을 정했다.
|
||||
|
||||
### 구현과 promotion 상태
|
||||
|
||||
- [x] RT-01 공통 coordinator/reconnect/contract suite
|
||||
- [x] RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence
|
||||
- [x] RT-03 WebSocket reference runtime과 deterministic evidence
|
||||
- [x] RT-04 Web Push window/worker reference runtime과 deterministic evidence
|
||||
- [x] static boundary/security fixture, synthetic bundle budget와 removal blocking gate
|
||||
- [ ] actual SSE/WS local server, load와 target-browser evidence
|
||||
- [ ] actual Web Push provider, permission UX와 target-browser evidence
|
||||
- [ ] provider/browser evidence와 operations drill의 release-blocking gate 등록
|
||||
- [ ] 실제 product/backend/provider selection
|
||||
- [ ] operations runbook drill
|
||||
|
||||
common runtime status는 `AVAILABLE_NOT_COMPOSED`다. 위의 미완료 promotion
|
||||
항목 전에는 product selection이 계속 `NOT_SELECTED`이고 production-ready를
|
||||
주장하지 않는다.
|
||||
|
||||
## 관련 자료
|
||||
|
||||
- [상세 설계](../realtime-events-web-push-and-bounded-polling.md)
|
||||
- [VD-10 optional capability recipes](./VD-10-optional-capability-recipes.md)
|
||||
- [VD-13 client cache scope and persistence](./VD-13-client-cache-scope-and-persistence.md)
|
||||
- [VD-23 API transport selection and REST execution](./VD-23-api-transport-selection-and-rest-execution.md)
|
||||
- [VD-25 Server State Cache lifecycle](./VD-25-server-state-cache-lifecycle.md)
|
||||
- [VD-27 gRPC-Web unary and server stream](./VD-27-grpc-web-unary-and-server-stream.md)
|
||||
- [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
|
||||
- [Optional adapter recipes](../optional-adapter-recipes.md)
|
||||
- [Client cache and browser storage](../client-cache-and-storage.md)
|
||||
- [Frontend ports, adapters, and boundaries](../frontend-ports-adapters-and-boundaries.md)
|
||||
- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
||||
- [WHATWG WebSockets](https://websockets.spec.whatwg.org/)
|
||||
- [W3C Push API](https://www.w3.org/TR/push-api/)
|
||||
- [WHATWG Notifications API](https://notifications.spec.whatwg.org/)
|
||||
- [W3C Service Workers](https://www.w3.org/TR/service-workers/)
|
||||
- [RFC 8030](https://www.rfc-editor.org/rfc/rfc8030)
|
||||
- [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291)
|
||||
- [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
# 프론트엔드 플랫폼 역량 재검토
|
||||
|
||||
> **정본 안내 (non-authoritative for runtime capability decisions)**
|
||||
>
|
||||
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
|
||||
> Service Worker, offline command와 Background Sync의 구현 결정은
|
||||
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
|
||||
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
|
||||
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
이 문서는 도메인 기능과 실제 운영 환경의 배포 증적을 제외하고, 이 저장소가 새
|
||||
프론트엔드 제품의 출발점으로 제공해야 하는 공통 역량을 다시 평가한다. 평가
|
||||
기준은 다음과 같다.
|
||||
|
||||
- 코드나 설정 파일이 존재하는지만 보지 않는다.
|
||||
- 부트스트랩부터 화면까지 실제 호출 경로가 연결되는지 확인한다.
|
||||
- 선언한 레지스트리와 정책이 런타임 및 CI에서 집행되는지 확인한다.
|
||||
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
|
||||
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
|
||||
|
||||
최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-12 구현 결과를 이 문서에
|
||||
누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
|
||||
문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
|
||||
|
||||
## 2. 결론
|
||||
|
||||
현재 저장소는 다음 기반이 강하다.
|
||||
|
||||
- 런타임 설정과 릴리스 매니페스트 검증
|
||||
- 도메인, 애플리케이션, 프레젠테이션, outbound adapter의 의존 방향
|
||||
- 공통 HTTP 실패 형태와 제한된 retry 정책
|
||||
- 앱 셸, 반응형 내비게이션, 테마, 비동기 상태 표면
|
||||
- Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층
|
||||
- CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서
|
||||
|
||||
RP-01~RP-12에서 TypeScript 도구 안전망, application runtime 주입,
|
||||
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
|
||||
recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과
|
||||
i18n 실행 경계, diagnostics/telemetry production wiring, registry/test 증거,
|
||||
local 공급망 검증과 제거 가능한 optional adapter recipe가 구현됐다. 저장소 내부
|
||||
P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실제 제품
|
||||
도메인과 hosting, IdP, vulnerability/signing provider, analytics consent/provider,
|
||||
지원 browser/접근성·field 증거는 프로젝트가 선택하고 검증해야 한다.
|
||||
|
||||
따라서 더 정확한 표현은 다음과 같다.
|
||||
|
||||
> application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템, 테스트와
|
||||
> local 공급망 증적의 표준 수직 경로와 opt-in adapter recipe는 갖춰졌다.
|
||||
> 실제 capability 설치와 hosting·IdP·취약점/서명/운영 provider는 프로젝트
|
||||
> 통합 범위이며, 없는 외부 증거를 완료로 표시하지 않는다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 판정 | 의미 |
|
||||
| --- | --- |
|
||||
| 준비됨 | 구현, 실제 조립, 자동 검증이 모두 존재한다. |
|
||||
| 부분 준비 | 핵심 구현은 있으나 실제 호출 경로, 정책 집행, 예제가 불완전하다. |
|
||||
| 미제공 | 새 기능을 만들 때 팀이 직접 선택·설계해야 한다. |
|
||||
| 프로젝트 선택 | 기본 번들에 강제하면 비용이 더 크며, 경계와 recipe만 제공한다. |
|
||||
|
||||
## 4. 역량 매트릭스
|
||||
|
||||
| 영역 | 현재 판정 | 근거 | 필요한 다음 상태 |
|
||||
| --- | --- | --- | --- |
|
||||
| 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 |
|
||||
| 계층 의존 방향 | 준비됨 | dependency-cruiser + TS-aware static graph, unresolved/parse/layer/cycle negative fixture | 새 rule shape와 source extension도 같은 fail-closed graph에 추가 |
|
||||
| application facade | 준비됨 | module-augmented feature input registry, typed output catalog, provider, production composition test | feature별 input contribution과 제거 gate 유지 |
|
||||
| REST HTTP client | 준비됨 + hardening delta | reference vertical의 path/search/body projection, attempt timeout/retry, abort/cleanup은 `COMPOSED` | auth fail-close/final invariant, total deadline, bounded decoder, status/media/CSRF/conditional/pagination은 `DESIGNED_NOT_IMPLEMENTED` |
|
||||
| Browser RPC 공통 계약/runtime | 준비됨/미조립 | V3 operation/profile registry, typed application port, bounded unary/server-stream lifecycle와 unavailable adapter는 `AVAILABLE_NOT_COMPOSED` | actual descriptor/generated client, protocol transport, proxy/provider/browser conformance |
|
||||
| GraphQL·Connect·gRPC-Web·Protobuf REST Gateway 제품 adapter | 설계됨/제품 미선택 | wire dependency/generated source/provider는 없고 VD-26/VD-27/VD-29/VD-30 production contract 승인 | 제품 operation/provider 선택 전 `NOT_SELECTED`; 선택 branch wire adapter 구현 뒤에만 `AVAILABLE_NOT_COMPOSED` |
|
||||
| retry | 준비됨 + hardening delta | REST 단일 소유, runtime max attempts, Query retry off, logical terminal diagnostics | total elapsed/sleep, 401 single-flight와 protocol별 exact retry/effect certainty 구현 |
|
||||
| 오류 모델 | 준비됨 | registry-derived `AppFailure`, 공통 `Result`, HTTP normalization과 invalid-kind fixture | 새 failure kind는 registry·copy·telemetry 계약과 함께 추가 |
|
||||
| Schema·Mapper | reference 준비됨 + governance delta | reference Zod → mapper → domain/application path는 `COMPOSED` | typed codec/mapper proof, semantic fingerprint/provenance, bounded decode와 generated drift gate는 `DESIGNED_NOT_IMPLEMENTED` |
|
||||
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
|
||||
| 서버 상태 | 기본 경로 준비됨 + lifecycle delta | reference query/mutation, cancellation, stale, basic optimistic/conflict/rollback은 `COMPOSED` | strict bound query policy/key, result/page ceiling, mutation concurrency/CAS rollback은 `DESIGNED_NOT_IMPLEMENTED` |
|
||||
| 클라이언트 상태 | 준비됨/프로젝트 선택 | local/URL/query/context 소유권, session external store, typed workflow recipe | 실제 cross-page workflow가 생길 때 하나의 store 선택 |
|
||||
| 범용 global store | 프로젝트 선택 | runtime library 없음, typed facade/fake와 server-state duplication gate | VD-10 조건에 따라 Zustand/Redux Toolkit/state machine 중 하나 선택 |
|
||||
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, 분리된 route-input provider, metadata consumer, bounded chunk recovery | 새 lazy route도 router 역참조 없이 contribution으로 추가 |
|
||||
| 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 |
|
||||
| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 |
|
||||
| 디자인 토큰 | 준비됨 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
|
||||
| 공통 UI | 준비됨 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | public story와 visual state matrix 유지 |
|
||||
| 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 |
|
||||
| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
|
||||
| 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 |
|
||||
| logging/diagnostics | 준비됨 | 별도 `DiagnosticsPort`, 8-event registry, allowlist, bounded/no-op adapter와 production producer | 실제 프로젝트의 remote sink는 port 뒤에서 선택 |
|
||||
| telemetry | 준비됨/프로젝트 선택 | 5-event registry, bounded queue, redaction/value policy, boot·HTTP·render·release·drop producer | analytics/RUM/error vendor와 consent는 프로젝트에서 선택 |
|
||||
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
|
||||
| 단위·통합·E2E | 준비됨 | source/test strict typecheck, shared MSW 19개 scenario, 실제 bootstrap, built-dist 3엔진·compact E2E | 제품별 critical flow를 같은 catalog/gate에 추가 |
|
||||
| UI 회귀 검증 | 준비됨 | dev-only Storybook interaction/axe와 pinned Chromium visual baseline 4종 | cloud review와 다중 OS/device는 프로젝트 선택 |
|
||||
| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 9단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
|
||||
| registry·compatibility 집행 | 준비됨 | 10개 registry type/reference/consumer/orphan, 승인 digest와 actual semantic diff, breaking evidence | public 계약 변경 시 baseline review 유지 |
|
||||
| 공급망 검사 | 준비됨/프로젝트 선택 | 561개 transitive inventory/integrity/license, actual diff, CycloneDX, local provenance, secret/reproducible build gate | 실제 vulnerability scanner와 signed attestation 없이는 promotion `FAIL_UNVERIFIED` |
|
||||
| realtime delivery | 참조 런타임 준비됨/프로젝트 미선택 | RT-01~04 공통 event authority, fetch-stream SSE, closed WebSocket, bounded Polling, Web Push window/worker와 handoff/reconnect 조정자는 `AVAILABLE_NOT_COMPOSED`; backend/provider/browser evidence는 없음 | [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)의 RT-05 제품 조립·운영 증거를 capability별로 통과 |
|
||||
| offline·file·browser data | 부분 준비/프로젝트 선택 | opt-in recipe와 gate; capability별 구현·조합·미구현·미선택·platform 제한 상태가 서로 다름 | [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)의 상태와 work package를 통과한 capability만 설치 |
|
||||
|
||||
REST/GraphQL/Connect/gRPC-Web/REST Gateway, Schema/Mapper와 Server State의 상세 current/target 판정은
|
||||
[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)를
|
||||
따른다. Browser Protobuf 축의 선택 기준은
|
||||
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)를
|
||||
따른다. 현재 REST reference 경로의 `COMPOSED` 판정을 GraphQL/Connect/gRPC-Web/
|
||||
Gateway 또는 REST v2 hardening 완료 증거로 재사용하지 않는다.
|
||||
|
||||
## 5. 우선순위별 발견 사항
|
||||
|
||||
### 5.1 P0: 기능 개발을 막는 항목
|
||||
|
||||
#### RP-02에서 application 런타임 우회 해결
|
||||
|
||||
`src/bootstrap/composition-root.ts`가 만든 typed application input API는
|
||||
production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와
|
||||
release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime
|
||||
query만 사용한다.
|
||||
|
||||
목표 상태:
|
||||
|
||||
- `Application`은 UI가 호출할 query/command use case를 제공한다.
|
||||
- `ApplicationProvider`는 이 API만 React tree에 제공한다.
|
||||
- 페이지는 HTTP, storage, auth SDK, telemetry sink를 직접 호출하지 않는다.
|
||||
- bootstrap만 concrete outbound adapter를 알고 조합한다.
|
||||
- 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다.
|
||||
|
||||
#### RP-03에서 표준 서버 상태 bridge 구현
|
||||
|
||||
`src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다.
|
||||
`useApplicationQuery`와 `useApplicationMutation`은 application result를 React
|
||||
lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic
|
||||
rollback, conflict resolution과 invalidation을 검증한다. 다른 presentation
|
||||
경로의 직접 TanStack import는 negative fixture가 거절한다.
|
||||
|
||||
목표 상태:
|
||||
|
||||
- canonical target인 `src/adapters/inbound/react/platform/query`에 벤더 연동을
|
||||
한정한다. 마이그레이션 중에는 기존 `presentation`을 같은 inbound 경계로
|
||||
취급하되 새 대체 경로를 만들지 않는다.
|
||||
- `useApplicationQuery`, `useApplicationMutation` 또는 같은 역할의 typed
|
||||
controller hook을 제공한다.
|
||||
- HTTP retry와 query retry 중 한 계층만 재시도 책임을 갖는다.
|
||||
- loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을
|
||||
reference feature에서 보여 준다.
|
||||
|
||||
#### RP-01에서 TypeScript 검사 도구 안전망 구현
|
||||
|
||||
초기 source는 JS/JSX와 `strict + allowJs + checkJs`를 사용했다. RP-01 후속
|
||||
migration으로 현재 product source, tests, Node scripts와 지원 config는 모두
|
||||
TS/TSX이며 `allowJs`는 꺼져 있다.
|
||||
|
||||
- ESLint의 계층·보안 규칙은 product와 negative fixture의 TS/TSX를 함께 검사한다.
|
||||
- registry scanner와 governance/baseline은 `.ts`와 `.tsx` 경로를 사용한다.
|
||||
- app, Node scripts/config, tests는 분리된 strict project로 모두 검사된다.
|
||||
- architecture gate는 fixture를 포함한 실행 source의 `.js/.jsx/.mjs/.cjs`
|
||||
재유입을 거절한다.
|
||||
|
||||
전환은 tooling glob과 CI를 먼저 고친 뒤 계약 계층부터 화면·테스트·운영 script
|
||||
순서로 완료했다. Node 24가 운영 `.ts` script를 직접 실행하고 NodeNext strict
|
||||
typecheck가 같은 경로를 검증한다.
|
||||
|
||||
#### RP-03에서 HTTP 선언과 실행의 차이 해결
|
||||
|
||||
HTTP request builder는 path segment escaping, canonical optional/array search,
|
||||
Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과
|
||||
0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed
|
||||
failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema
|
||||
failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을
|
||||
검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다.
|
||||
|
||||
client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth,
|
||||
timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP
|
||||
메서드보다 feature가 요구하는 gateway interface를 노출한다.
|
||||
|
||||
#### RP-04에서 route registry를 실행 계약으로 전환
|
||||
|
||||
platform route 계약과 `src/features/installed-feature-contracts.ts`의 직렬화
|
||||
가능한 contribution을 기준으로
|
||||
`src/presentation/routes/app-router.tsx`가 Data Router route object와
|
||||
navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
|
||||
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
|
||||
gate가 모두 거절한다.
|
||||
|
||||
현재 보장:
|
||||
|
||||
- serializable contract와 executable runtime map을 분리한다.
|
||||
- `satisfies Record<RouteId, RouteRuntime>`로 양방향 완전성을 검사한다.
|
||||
- params/search는 Zod codec으로 경계에서 parse하고 URL builder도 같은 codec을
|
||||
사용한다.
|
||||
- loading/error/chunk/access/title/navigation metadata를 실제 route object에
|
||||
연결한다.
|
||||
- route change 시 boundary reset, title, focus와 scroll을 검증한다.
|
||||
- Vite manifest의 실제 dynamic entry와 route chunk ID를 release manifest에
|
||||
연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를
|
||||
production application input까지 연결한다.
|
||||
|
||||
#### RP-05에서 제거 가능한 reference feature 구현
|
||||
|
||||
`src/features/reference-feature`가 domain, application input, outbound gateway,
|
||||
DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를
|
||||
한 소유 경계에 둔다. production composition은 generic feature input catalog를
|
||||
통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다.
|
||||
|
||||
`test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed
|
||||
contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck,
|
||||
architecture, registry, unit/integration, coverage, source evidence, home smoke,
|
||||
build와 fixture ID 잔여 0개를 검사한다. feature-owned coverage/evidence policy도
|
||||
함께 제거되며 generic registry 성공 경로는 공통 unit test가 유지한다. 설치
|
||||
모드에서는 MSW를 사용한 bootstrap → router → application → HTTP → schema →
|
||||
mapper → query cache → page 수직 테스트가 실행된다.
|
||||
|
||||
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다
|
||||
|
||||
공통 async model과 gallery가 있지만 선언 가능한 상태 조합 중 일부는 사용자
|
||||
행동과 모순될 수 있다. stale data가 있는 degraded 상태와 refreshing, mutation
|
||||
pending과 conflict, retry button과 실제 handler 존재 여부를 typed state로
|
||||
닫아야 한다. 상태를 boolean 여러 개로 조합하지 않고 다음과 같은 discriminated
|
||||
state와 action capability로 표현한다.
|
||||
|
||||
```text
|
||||
initial-loading
|
||||
ready
|
||||
refreshing-with-data
|
||||
empty
|
||||
degraded-with-data
|
||||
terminal-error
|
||||
mutation-pending
|
||||
mutation-conflict
|
||||
```
|
||||
|
||||
RP-04에서 lazy import failure는 `ChunkRecoveryBoundary` → application recovery
|
||||
input → `ReleaseInfoPort.refresh()`의 no-store manifest 조회 → build/release 쌍
|
||||
guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 render
|
||||
failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage
|
||||
실패는 지원 표면으로 fail-closed된다.
|
||||
|
||||
#### RP-09에서 diagnostics/telemetry 실행 깊이 보강
|
||||
|
||||
`DiagnosticsPort`와 `TelemetryPort`를 분리하고 boot, HTTP logical outcome,
|
||||
render, cache, storage, route, release mismatch와 delivery drop을 production
|
||||
producer에 연결했다. HTTP retry는 attempt별 terminal event를 발행하지 않고
|
||||
logical execution 종료 시 한 번만 bounded outcome을 남긴다. allowlist와
|
||||
value policy가 raw URL/query/body/storage value/error object를 거절하고 queue와
|
||||
sink failure는 nonrecursive drop evidence로 제한된다.
|
||||
|
||||
registry/compatibility 검사는 다음까지 확장한다.
|
||||
|
||||
- ID와 enum/type의 양방향 완전성
|
||||
- referenced schema/message/token의 존재
|
||||
- 실행 코드에서 소비되지 않는 orphan 항목
|
||||
- 기준 commit과 현재 commit 사이의 실제 contract diff
|
||||
- breaking change의 version/migration/rollback metadata
|
||||
|
||||
공급망 검사는 단순 문자열 secret 탐지에 머물지 않고 transitive dependency,
|
||||
known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해야 한다.
|
||||
도구 장애와 취약점 발견을 구분하고, 예외에는 owner·사유·만료일을 요구한다.
|
||||
|
||||
### 5.2 P1: 공통 플랫폼 기본 제공 항목
|
||||
|
||||
- RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
|
||||
- RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
|
||||
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
|
||||
- token → primitive → pattern → template로 이어지는 디자인 시스템
|
||||
- Lucide를 감싼 local icon registry와 `IconButton`
|
||||
- typed message key, locale provider, formatter, pseudo-locale/RTL smoke
|
||||
- RP-09에서 완료한 redacted structured diagnostics와 telemetry wiring 유지
|
||||
- Storybook 또는 동급 isolated UI workshop
|
||||
- Playwright visual baseline, shared MSW scenarios, built-dist E2E
|
||||
- React Hooks, JSX accessibility, TanStack Query 관련 lint
|
||||
- source와 tests를 모두 포함하는 TypeScript project references
|
||||
- registry/compatibility의 실제 diff와 orphan reference 검사
|
||||
- transitive vulnerability, license, SBOM/provenance 공급망 gate
|
||||
|
||||
#### RP-08에서 국제화 실행 경계 구현
|
||||
|
||||
`src/presentation/i18n`은 shell, route, async/form error, page template와
|
||||
design-system 기본 copy의 canonical 경계다. `MessageKey`와 key별
|
||||
`MessageParameters`가 잘못된 key/보간을 compile time에 막고, runtime
|
||||
`resolveMessage`는 unknown locale/key와 누락 보간에서 raw 값 대신 안전한
|
||||
fallback을 반환한다. application mapper는 locale-formatted date를 반환하지
|
||||
않고 timestamp를 유지하며 presentation formatter가 `UTC` 또는 명시 timezone을
|
||||
적용한다.
|
||||
|
||||
`LocaleProvider`는 `ko-KR`, `en-US`, `en-XA`, `ar-EG` smoke set과 document
|
||||
`lang/dir`을 동기화한다. `en-XA`는 긴 문구 reflow, `ar-EG`는 logical CSS,
|
||||
Drawer, Tabs arrow와 pagination 방향 icon을 검증하기 위한 개발 locale이다.
|
||||
실제 아랍어 번역 완료를 뜻하지 않는다. `check:i18n`과 negative fixture는 common
|
||||
UI literal, backend raw message render와 raw HTML interpolation을 거절한다.
|
||||
새 key rename은 canonical type에는 넣지 않고 runtime alias/migration window로
|
||||
호환한다.
|
||||
|
||||
### 5.3 P2: 경계와 recipe를 제공할 선택 항목
|
||||
|
||||
다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local
|
||||
vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제공한다.
|
||||
|
||||
| capability | 대표 기술 | 기본 제공할 경계 | 실제 설치 조건 |
|
||||
| --- | --- | --- | --- |
|
||||
| foreground realtime | SSE, WebSocket | closed stream/event registry, resume/gap/snapshot, bounded queue와 lifecycle | 측정된 one-way event 또는 duplex interaction 요구와 backend replay/auth owner가 있을 때 |
|
||||
| background notification | Web Push, persistent notification | permission, subscription register/revoke, Service Worker push/click와 safe route | user-visible background notification과 provider/privacy owner가 승인됐을 때 |
|
||||
| bounded polling | conditional HTTP query | visible-only single-flight lease, request/time budget와 terminal stop | relaxed freshness 또는 의미가 보존되는 stream fallback이면 충분할 때 |
|
||||
| structured offline storage | IndexedDB | feature repository, codec/schema 분리, resumable migration, revision/idempotency, blocked/quota recovery | offline record/command가 제품 요구일 때 |
|
||||
| large local binary | OPFS + IndexedDB journal | immutable chunk, generation/integrity, crash reconciliation, bounded GC | 실제 large local object와 retention owner가 있을 때 |
|
||||
| public HTTP representation cache | Cache Storage, Service Worker | auth/private 배제, exact match, candidate integrity, update/rollback | install/offline shell 또는 승인된 public cache가 필요할 때 |
|
||||
| file selection/transfer/delivery | native input, File/Blob, picker, multipart, stream save | opaque file ref, bounded inspection, upload session, handoff/save 구분, object-URL lease | backend 재검증을 포함한 file workflow가 있을 때 |
|
||||
| generated API | OpenAPI, GraphQL, Connect/gRPC-Web, Protobuf | generated client를 semantic gateway 뒤에 감싸고 transport/gateway 축을 분리하는 규칙 | 서버 계약 형식과 selected operation/provider가 확정됐을 때 |
|
||||
| feature flag | local/remote flag provider | typed flag key, default, stale behavior | staged rollout가 필요할 때 |
|
||||
| worker | Web Worker | request/result/cancel protocol | UI thread를 막는 CPU 작업이 있을 때 |
|
||||
| multi-tab | BroadcastChannel | event versioning, source ID, conflict policy | 탭 간 동기화가 필요할 때 |
|
||||
| browser capability | clipboard, notification, media | permission/result port | 해당 UX가 있을 때 |
|
||||
| client workflow | Zustand, Redux Toolkit, state machine | state ownership decision과 local facade | cross-feature workflow가 실제로 생길 때 |
|
||||
| large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 |
|
||||
| analytics/error sink | vendor SDK, OpenTelemetry | redaction, consent, sampling adapter | 운영 provider와 정책이 정해졌을 때 |
|
||||
|
||||
catalog의 12개 항목 수는 유지하며 OPFS는 offline recipe의 large-object
|
||||
sub-capability, Cache Storage는 Service Worker/PWA recipe의 독립 cache policy
|
||||
sub-capability로 깊이를 보강했다. 현재 상태는 모두
|
||||
제품 선택 기준으로 `RECIPE_AVAILABLE / NOT_INSTALLED`다. 다만 File/Blob/picker/
|
||||
download, IndexedDB/OPFS/StorageManager, Cache Storage에는 실제 native API를
|
||||
호출하는 정책 주입형 reference runtime이 `AVAILABLE_NOT_COMPOSED` 상태로 있으며,
|
||||
bootstrap과 installed feature에서는 import하지 않는다.
|
||||
`config/recipes/frontend-capability-recipes.json`이 선택/금지 조건, failure,
|
||||
cleanup, security/privacy, bundle budget, fallback과 제거 절차의 SSOT이며,
|
||||
`recipes/frontend-capabilities`에 production-excluded TypeScript port와
|
||||
fake/unavailable adapter가 있다. file/IndexedDB/OPFS/Cache의 상세한 기술 소유권,
|
||||
journal, migration, cache activation, test와 운영 복구는
|
||||
`docs/architecture/browser-file-and-origin-storage.md`와 VD-11을 따른다.
|
||||
presigned capability, multipart/resume, bounded streaming과 Image CDN은
|
||||
`docs/architecture/presigned-transfer-and-image-cdn.md`와 VD-12를 따르며, 공통
|
||||
도입 절차는 `docs/architecture/optional-adapter-recipes.md`를 따른다.
|
||||
SSE, WebSocket, Web Push와 bounded polling은
|
||||
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`와 VD-28을
|
||||
따른다. copyable recipe/fake와 별개로 RT-01~04 reusable source와 deterministic
|
||||
gate는 `AVAILABLE_NOT_COMPOSED`다. foreground transport, 제품 event registry와
|
||||
Web Push provider selection은 여전히 `NOT_SELECTED`이며 production bootstrap과
|
||||
worker에는 조립하지 않는다.
|
||||
|
||||
이 capability들을 한꺼번에 "준비됨"으로 표시하지 않는다.
|
||||
[Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)가
|
||||
`COMPOSED`, `AVAILABLE_NOT_COMPOSED`, `DESIGNED_NOT_IMPLEMENTED`,
|
||||
`NOT_SELECTED`, `PLATFORM_LIMITED`를 구분하는 현재 상태의 기준이다. 특히
|
||||
session/account Query lifecycle, Range resumable download, cross-store quota
|
||||
orchestration, top-level transfer composition과 Image descriptor HTTP provider는
|
||||
설계가 승인됐어도 runtime 구현 전에는 `DESIGNED_NOT_IMPLEMENTED`다. Query
|
||||
persistence, Service Worker offline fetch와 app-managed background
|
||||
upload/download는 제품이 별도로 선택하기 전에는 `NOT_SELECTED`, 그 cross-browser
|
||||
guarantee는 `PLATFORM_LIMITED` 상태를 유지한다.
|
||||
|
||||
서버의 Redis, MongoDB, PostgreSQL, MinIO를 브라우저가 직접 연결하는 구조는 기본
|
||||
frontend adapter catalog에 넣지 않는다. 브라우저는 권한 있는 backend API/BFF를
|
||||
통해 이 자원에 접근해야 한다. 프론트에서 대응되는 변화 지점은 데이터베이스
|
||||
vendor가 아니라 HTTP/GraphQL/Connect/gRPC-Web/REST Gateway, realtime, file
|
||||
transfer, cache, storage, worker, browser capability 같은 프로토콜·런타임
|
||||
capability다.
|
||||
|
||||
### 성능 최적화는 모두 adapter 문제인가
|
||||
|
||||
아니다. 먼저 측정하고 병목의 소유 계층에 맞는 수단을 적용한다.
|
||||
|
||||
| 문제 | 기본 제공할 수단 | adapter/facade가 필요한 경우 |
|
||||
| --- | --- | --- |
|
||||
| 초기 JS가 큼 | route/feature lazy loading, bundle budget, dependency inventory | remote module이나 별도 delivery 전략이 있을 때 |
|
||||
| 중복 네트워크 | TanStack deduplication/cache, abort, bounded retry | offline cache나 generated client를 교체할 때 |
|
||||
| 느린 화면 전환 | prefetch policy, stable shell, cached-data surface | route별 prefetch provider가 필요할 때 |
|
||||
| 긴 main-thread task | profiler 기준으로 계산 분리 | Web Worker message adapter |
|
||||
| 대량 목록 | pagination과 server filter를 우선 | virtualizer/data-grid facade |
|
||||
| 이미지 전송량 | width/height, lazy loading, responsive source 규칙 | Image CDN URL builder adapter |
|
||||
| 재방문/offline | HTTP cache contract | Service Worker/IndexedDB adapter |
|
||||
| 불필요한 render | 상태 소유권 축소와 component boundary | 보통 adapter가 아니며 측정 후 memoization |
|
||||
|
||||
기본 skeleton은 bundle budget, lazy route, query cancellation/cache, responsive
|
||||
image 규칙, stable layout, lab performance test를 제공한다. Web Worker,
|
||||
virtualization, Image CDN, Service Worker는 실제 병목과 제품 요구가 확인될 때
|
||||
설치한다. 라이브러리를 미리 많이 넣는 것은 최적화가 아니라 초기 번들·공급망
|
||||
표면을 늘리는 일이 될 수 있다.
|
||||
|
||||
## 6. 질문별 직접 답변
|
||||
|
||||
### 프론트도 inbound/outbound로 나누는가
|
||||
|
||||
나눈다. 현재 구조에서는 `presentation`이 사실상 inbound adapter이고
|
||||
`src/adapters`가 outbound adapter다. 이름과 문서가 이 역할을 명확히 드러내지
|
||||
않아 모두 같은 adapter처럼 보인 것이다.
|
||||
|
||||
| 역할 | 프론트 예 |
|
||||
| --- | --- |
|
||||
| input/inbound port | `ListResources`, `CreateResource` 같은 application API |
|
||||
| inbound adapter | React page/controller, router, form event, push-event translator |
|
||||
| output/outbound port | resource gateway, session, storage, clock, diagnostics |
|
||||
| outbound adapter | HTTP, auth SDK, browser storage, TanStack cache, telemetry sink |
|
||||
|
||||
React, router, form library, icon library마다 application port를 만들 필요는 없다.
|
||||
UI 내부 교체만 필요한 라이브러리는 React inbound adapter 내부 vendor facade로
|
||||
충분하다. port는 application 정책과 외부 소유권 사이의 경계에 둔다.
|
||||
|
||||
### 왜 현재 모두 `adapters` 아래에 있는가
|
||||
|
||||
실제로 모두 있지는 않다. UI driver가 `presentation`이라는 이름으로 분리돼 있고,
|
||||
`adapters`에는 주로 outbound 구현이 있다. 다만 다음 두 대안 중 하나를 명시적으로
|
||||
선택해야 한다.
|
||||
|
||||
1. 변경량을 줄여 `presentation = inbound adapter`로 문서화하고
|
||||
`adapters/outbound`만 명시한다.
|
||||
2. TypeScript/feature migration과 함께 `adapters/inbound/react`와
|
||||
`adapters/outbound`로 재구성한다.
|
||||
|
||||
이 저장소는 input API 부재와 flat contracts 문제도 함께 고쳐야 하므로 두 번째
|
||||
구조가 장기적으로 더 명확하다. 단, 대규모 rename 자체를 기능 개선으로 세지 말고
|
||||
architecture gate와 수직 reference feature가 먼저 또는 같은 브랜치에서
|
||||
증명되어야 한다.
|
||||
|
||||
### TypeScript로 바꾸는 것이 좋은가
|
||||
|
||||
좋다. 특히 registry ID, Result/error union, port generic, route params/search,
|
||||
component variant를 컴파일 시점에 닫을 수 있다. 다만 일괄 rename은 권장하지
|
||||
않는다. tooling → core contracts → application ports/use cases → outbound →
|
||||
bootstrap → React TSX → tests 순서로 이동한다.
|
||||
|
||||
### store 기본 설정이 필요한가
|
||||
|
||||
상태 전략은 기본 제공해야 하지만 범용 global store dependency는 필수로 넣지
|
||||
않는다.
|
||||
|
||||
- local interaction: `useState`/`useReducer`
|
||||
- shareable navigation state: URL
|
||||
- server state: TanStack Query
|
||||
- form state: form facade
|
||||
- low-frequency cross-cutting state: context 또는 typed external store
|
||||
- complex cross-feature workflow: Zustand/Redux Toolkit/state machine 중 선택
|
||||
- persistence: `StoragePort`
|
||||
|
||||
서버 데이터를 global store에 복사하지 않는 규칙이 중요하다.
|
||||
[TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview),
|
||||
[Redux Toolkit](https://redux-toolkit.js.org/introduction/getting-started),
|
||||
[Zustand](https://zustand.docs.pmnd.rs/)의 역할은 서로 같지 않다.
|
||||
|
||||
### retry, API client, logger, token manager, error, validation은 어디에 있는가
|
||||
|
||||
- retry: `src/adapters/http/retry-policy.ts`, runtime max-attempt/cleanup 계약까지 구현
|
||||
- API client: `src/adapters/http/client.ts`, path/search/body projection과
|
||||
runtime schema/mapper를 사용하며 feature gateway 뒤에서 실행
|
||||
- logger: `DiagnosticsPort`로 telemetry와 분리해 구현. closed event/level,
|
||||
allowlist와 bounded/no-op adapter 제공
|
||||
- token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유
|
||||
- error: `src/contracts/errors.ts`의 registry-derived `AppFailure`, 공통
|
||||
`Result`와 HTTP normalization으로 구현
|
||||
- validation: runtime/API/route/form Zod와 domain invariant를 소유 경계별로 분리
|
||||
|
||||
token manager를 기본으로 추가하지 않는 이유는 token lifecycle이 인증 방식마다
|
||||
다르고 localStorage token을 일반 해법으로 만들면 보안 위험이 커지기 때문이다.
|
||||
BFF HttpOnly cookie 또는 OIDC/Auth SDK가 credential을 소유하도록 두고, SPA
|
||||
memory token이 필요한 프로젝트만 auth adapter를 추가한다.
|
||||
|
||||
### Lucide React를 쓰면 디자인 시스템이 되는가
|
||||
|
||||
아니다. [Lucide React](https://lucide.dev/guide/packages/lucide-react)는
|
||||
tree-shakable SVG icon source로 적절하지만 select, dialog, menu, focus management
|
||||
같은 UI behavior는 제공하지 않는다. Lucide는 local icon facade 뒤에 두고,
|
||||
복잡한 interaction은 [Radix Primitives](https://www.radix-ui.com/primitives/docs/overview/introduction)
|
||||
또는 React Aria 계열과 같은 headless primitive를 owned wrapper 뒤에서 선택한다.
|
||||
|
||||
### 테스트는 현재 어떤 상태인가
|
||||
|
||||
테스트 도구 구성은 강한 편이다. 다만 다음이 빠져 있다.
|
||||
|
||||
- TS source와 test 전체 typecheck
|
||||
- 실제 composition root부터 page까지의 통합
|
||||
- query/mutation controller와 optimistic rollback
|
||||
- route registry/runtime map 정합성은 RP-04에서 unit, component, negative
|
||||
registry/type fixture와 built artifact 검증으로 구현됨
|
||||
- runtime timeout/retry와 path/query/parsed body
|
||||
- shared MSW scenario catalog
|
||||
- isolated component stories와 interaction test
|
||||
- stable-environment visual regression
|
||||
- built `dist` 대상 release E2E
|
||||
- 위험 기반 coverage gate
|
||||
|
||||
### 라우팅 전략은 무엇이 적절한가
|
||||
|
||||
현재 client-only clean architecture와 TanStack Query 조합은 유지하되, 목표
|
||||
skeleton은 React Router Data Mode의 route object, blocker, scroll restoration,
|
||||
route error 경계를 사용한다. 서버 상태의 소유자는 계속 application input과
|
||||
TanStack Query이며 loader/action이 같은 데이터를 별도로 요청하지 않는다.
|
||||
[React Router 공식 mode 설명](https://reactrouter.com/start/modes)에 따라
|
||||
Framework Mode는 SSR/static generation, route module, framework-owned data
|
||||
loading을 실제 요구할 때만 선택한다.
|
||||
|
||||
### 바로 쓸 수 있는 디자인 패턴은 무엇을 제공해야 하는가
|
||||
|
||||
패턴 이름만 나열하지 않고 다음 executable blueprint를 제공해야 한다.
|
||||
|
||||
- page controller: route/form event를 application input으로 변환
|
||||
- query/mutation adapter: server state lifecycle을 React에 연결
|
||||
- command/query use case: 읽기와 상태 변경 의도를 분리
|
||||
- gateway: application이 외부 데이터 소유자를 추상화
|
||||
- mapper/anti-corruption layer: transport DTO를 core model로 변환
|
||||
- Result + failure mapper: throw와 사용자 메시지 경계를 통제
|
||||
- strategy: retry, cache, auth recovery, feature flag 정책 교체
|
||||
- observer/external store: session/theme/realtime 구독
|
||||
- state machine: 복잡한 workflow에만 선택적으로 사용
|
||||
- compound component/headless wrapper: 접근 가능한 복합 UI를 소유
|
||||
- page template: layout과 상태 표면을 데이터 소유권에서 분리
|
||||
|
||||
## 7. 실전 투입 준비 완료 기준
|
||||
|
||||
막연한 백분율 대신 아래 조건을 모두 자동 또는 명시적 검토로 확인한다.
|
||||
|
||||
1. reference feature가 route → controller → input use case → output gateway →
|
||||
adapter → mapper → query cache → UI 상태 표면을 통과한다.
|
||||
2. application input API 외에는 UI에서 outbound dependency에 접근할 수 없다.
|
||||
3. TypeScript source와 tests가 strict 검사되고 JS 우회 경로가 없다.
|
||||
4. HTTP path/query/body/auth/timeout/retry/cancel/decode 실패가 계약 테스트된다.
|
||||
5. typed route registry와 runtime map이 양방향 완전성을 가진다.
|
||||
6. list/detail/form/status page template과 form error 정책이 준비돼 있다.
|
||||
7. 디자인 시스템 primitive/pattern이 isolated workshop, interaction, a11y,
|
||||
visual test를 가진다.
|
||||
8. sample/reference feature 전체 삭제 후 typecheck/test/build가 통과한다.
|
||||
9. optional adapter는 설치 조건, 보안 경계, 실패 정책, 테스트 recipe가 있다.
|
||||
10. 새 feature 추가 문서가 파일 경로, 금지 의존, 실패 상태, 테스트, 검증 명령까지
|
||||
안내한다.
|
||||
|
||||
이 기준은 배포 provider, 실제 인증 tenant, 운영 telemetry vendor, production field
|
||||
data 같은 프로젝트별 외부 작업을 포함하지 않는다.
|
||||
|
||||
## 8. 관련 상세 문서
|
||||
|
||||
- [API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)
|
||||
- [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)
|
||||
- [프론트 포트·어댑터와 기능 경계](./frontend-ports-adapters-and-boundaries.md)
|
||||
- [TypeScript·상태·데이터 흐름](./typescript-state-and-data-flow.md)
|
||||
- [라우팅·페이지·재사용 패턴](./routing-pages-and-patterns.md)
|
||||
- [프론트 플랫폼 구현 로드맵](./frontend-platform-implementation-roadmap.md)
|
||||
- [디자인 시스템 플랫폼](../styling/design-system-platform.md)
|
||||
- [프론트 플랫폼 테스트 전략](../testing/frontend-platform-testing-strategy.md)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,25 +10,72 @@ adapters implement application-owned ports and are assembled only in
|
||||
| `application` | use cases, ports, orchestration, view-models | domain and application siblings |
|
||||
| `presentation` | routes, components, user interaction and view state | application public API and shared UI |
|
||||
| `adapters` | browser and third-party implementations of application ports | application ports and limited domain values |
|
||||
| `features/<id>` | removable vertical domain/application/contracts/adapters/presentation slice | the same inward rule plus platform public boundaries |
|
||||
| `bootstrap` | runtime configuration, adapter construction and React mount | all selected runtime modules |
|
||||
|
||||
The following edges are forbidden:
|
||||
|
||||
- domain to application, presentation, adapters, bootstrap, React, or browser globals
|
||||
- application to presentation, concrete adapters, bootstrap, React, or browser globals
|
||||
- `contracts` to application or features: contracts is the lower package and
|
||||
owns the shared vocabulary both of them read
|
||||
- presentation to concrete adapters, raw DTO schemas, or storage implementations
|
||||
- generic presentation to the installed-feature registries: which features exist
|
||||
is a product decision owned by `bootstrap`
|
||||
- an adapter to presentation, bootstrap internals, or another concrete adapter
|
||||
- feature domain/application to its presentation or outbound adapter, and
|
||||
feature presentation to its outbound adapter
|
||||
|
||||
## The adapter kernel
|
||||
|
||||
"Another concrete adapter" excludes the adapter kernel, which is shared on
|
||||
purpose and is the only adapter code an adapter may reach across a group for:
|
||||
|
||||
- `src/adapters/platform/**` — the system clock, the shared abort primitive and
|
||||
the bounded-capacity guard
|
||||
- `src/adapters/browser-file-storage/result.ts` — the browser-data result and
|
||||
failure constructors
|
||||
|
||||
Each rule above is enforced by `check:architecture`, including the kernel
|
||||
carve-out, so this table and the executable rules cannot drift apart. Two edges
|
||||
are still open and are named explicitly in `.dependency-cruiser.json` rather
|
||||
than left silent: the generic presentation modules that read the installed
|
||||
registries today, and the two collaborator types `query-cache` reads from
|
||||
`cross-context-invalidation`. Both lists are frozen — a new edge of either kind
|
||||
fails the gate.
|
||||
|
||||
`bootstrap` contains composition only. Business rules and page-specific
|
||||
orchestration belong to domain/application.
|
||||
|
||||
Architecture reports use this shape:
|
||||
The [ports, adapters, and feature-boundary contract](./frontend-ports-adapters-and-boundaries.md)
|
||||
explains how `presentation` acts as the inbound adapter and how feature
|
||||
application APIs augment the generic typed input registry. Concrete output
|
||||
ports are composed in bootstrap and stay hidden behind the application facade.
|
||||
Project-selected capabilities still implement the same output boundaries.
|
||||
|
||||
`check:architecture` keeps dependency-cruiser's report and adds the
|
||||
authoritative TypeScript-aware graph below it:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "ISO-8601",
|
||||
"rules": [{ "name": "rule-id", "severity": "error", "violations": 0 }],
|
||||
"summary": { "errors": 0, "warnings": 0 }
|
||||
"staticImportGraph": {
|
||||
"analyzer": "babel-parser-node-resolver",
|
||||
"modules": [],
|
||||
"dependencies": [],
|
||||
"unresolved": [],
|
||||
"parseFailures": [],
|
||||
"cycles": [],
|
||||
"violations": [],
|
||||
"summary": { "errors": 0 },
|
||||
"fixtureChecks": { "passed": true, "checks": [], "failures": [] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The graph scans TypeScript and TSX, including static, dynamic, type, CommonJS
|
||||
and JSDoc import references. It applies the path rules from
|
||||
`.dependency-cruiser.json`, requires explicit TypeScript extensions for local
|
||||
source imports, and fails closed on JavaScript-family source/specifiers,
|
||||
unsupported rule shapes, unresolved imports, parse failures, error-severity
|
||||
layer violations, or cycles. Regression fixtures prove the allowed resolver
|
||||
path and each rejection class.
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# Optional frontend adapter recipes
|
||||
|
||||
이 문서는 도메인과 무관한 선택형 frontend capability를 실제 프로젝트에
|
||||
도입하는 실행 가이드다. 기본 스켈레톤에는 vendor runtime을 설치하지 않는다.
|
||||
`RECIPE_AVAILABLE`은 catalog에 복사해 좁힐 recipe가 있다는 availability
|
||||
표시다. runtime 구현이나 제품 선택·조립 상태가 아니다. 현재 catalog의 product
|
||||
selection과 production composition은 별도로 `NOT_SELECTED`/미설치다.
|
||||
일부 browser-native capability에는 dependency 없는
|
||||
`referenceRuntime.status=AVAILABLE_NOT_COMPOSED` 구현이 함께 있지만, 이것도
|
||||
제품 dataset·owner·policy가 정해져 bootstrap에 연결되기 전에는 설치된 기능이나
|
||||
production readiness를 뜻하지 않는다.
|
||||
|
||||
## 1. 현재 상태와 파일 지도
|
||||
|
||||
| 항목 | 경로 | production 포함 |
|
||||
| --- | --- | --- |
|
||||
| 선택/금지/예산 SSOT | `config/recipes/frontend-capability-recipes.json` | 정책만 |
|
||||
| catalog JSON schema | `schemas/config/frontend-capability-recipes.schema.json` | 아니오 |
|
||||
| TypeScript port | `recipes/frontend-capabilities/contracts.ts` | 아니오 |
|
||||
| fake/unavailable | `recipes/frontend-capabilities/fake-adapters.ts` | 아니오 |
|
||||
| contract test | `tests/recipes/optional-capability-contracts.test.ts` | 아니오 |
|
||||
| file/IndexedDB/OPFS/Cache 심층 계약 | `recipes/frontend-capabilities/browser-file-storage-contracts.ts` | 아니오 |
|
||||
| 심층 deterministic fake | `recipes/frontend-capabilities/browser-file-storage-fakes.ts` | 아니오 |
|
||||
| 심층 contract test | `tests/recipes/browser-file-storage-contracts.test.ts` | 아니오 |
|
||||
| browser data current-status ledger | `docs/architecture/browser-data-capability-completion-ledger.md` | 문서만 |
|
||||
| 심층 설계/ADR | `docs/architecture/browser-file-and-origin-storage.md`, `decisions/VD-11-browser-file-and-origin-storage.md` | 문서만 |
|
||||
| client cache scope/persistence 설계 | `docs/architecture/client-cache-and-storage.md`, `decisions/VD-13-client-cache-scope-and-persistence.md` | 문서만 |
|
||||
| realtime/Web Push/Polling 설계와 reference runtime | `docs/architecture/realtime-events-web-push-and-bounded-polling.md`, `decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`, `src/adapters/realtime`, `src/adapters/web-push` | composition 전에는 tree-shaken |
|
||||
| transfer/CDN 설계/ADR | `docs/architecture/presigned-transfer-and-image-cdn.md`, `decisions/VD-12-presigned-transfer-and-image-cdn.md` | 문서만 |
|
||||
| Range/background 결정 | `docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md` | 문서만 |
|
||||
| storage lifecycle/migration 결정 | `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md` | 문서만 |
|
||||
| transfer composition/Image provider 결정 | `docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md` | 문서만 |
|
||||
| REST/GraphQL/Connect/gRPC-Web·Protobuf/REST Gateway·Schema·Mapper·Server State 설계 | `docs/architecture/api-contract-schema-mapper-and-server-state.md`, `docs/architecture/protobuf-browser-transport-and-rest-gateway.md`, `decisions/VD-23-api-transport-selection-and-rest-execution.md`, `decisions/VD-24-runtime-schema-and-boundary-mapper.md`, `decisions/VD-25-server-state-cache-lifecycle.md`, `decisions/VD-26-persisted-graphql-operation.md`, `decisions/VD-27-grpc-web-unary-and-server-stream.md`, `decisions/VD-29-connect-web-and-browser-protobuf-runtime.md`, `decisions/VD-30-protobuf-contract-and-rest-gateway.md` | 문서만 |
|
||||
| provider-neutral Browser RPC V3 계약/port/runtime | `src/contracts/browser-rpc.ts`, `src/application/ports/browser-rpc`, `src/adapters/browser-rpc`, `tests/unit/browser-rpc` | composition 전에는 tree-shaken |
|
||||
| API contract/server-state 복구 runbook | `docs/operations/api-contract-and-server-state-recovery.md` | 문서만 |
|
||||
| storage 복구 runbook template | `docs/operations/browser-file-storage-recovery.md` | 문서만 |
|
||||
| transfer/CDN 복구 runbook template | `docs/operations/browser-transfer-recovery.md` | 문서만 |
|
||||
| browser-native reference runtime | `src/adapters/browser-files`, `src/adapters/browser-transfer`, `src/adapters/storage/indexeddb`, `src/adapters/storage/opfs`, `src/adapters/cache-storage` | composition 전에는 tree-shaken |
|
||||
| reference runtime browser evidence | `tests/browser-capabilities` | 아니오 |
|
||||
| 정적/번들 gate | `scripts/check-optional-recipes.ts` | build 도구 |
|
||||
| negative fixture | `scripts/check-optional-recipe-fixtures.ts` | 아니오 |
|
||||
| 완전 제거 gate | `scripts/test-optional-recipe-removal.ts` | 아니오 |
|
||||
| native reference runtime 제거 gate | `scripts/test-browser-file-storage-runtime-removal.ts` | 아니오 |
|
||||
|
||||
현재 `productionRuntimeDependencies`는 빈 배열이며 12개 recipe 모두 선택되지
|
||||
않았다. `recipes/`의 TypeScript example은 선택 시 복사하고 좁힐 출발점이고,
|
||||
`src/adapters`의 browser-native reference runtime은 공통 lifecycle·failure
|
||||
mechanism을 재사용할 수 있는 실제 구현이다. 제품 feature는 이 runtime에
|
||||
schema/codec/query와 dataset 정책을 주입하고 더 좁은 facade 뒤에서 조립한다.
|
||||
|
||||
### Reference runtime bundle budget
|
||||
|
||||
`referenceRuntime`이 있는 recipe는 catalog의 `sourceRoots`를 실제 budget entry로
|
||||
사용한다. gate는 중첩 root의 실행 가능한 source를 중복 제거하고 경로순으로
|
||||
정렬한 뒤, 모든 module을 하나의 synthetic entry에 포함한다. 이 entry는 Vite
|
||||
production mode, ES2022/ES module, esbuild minify로 build하며 tree-shaking을
|
||||
명시적으로 끈다. 따라서 synthetic consumer가 호출 여부를 알 수 없다는 이유로
|
||||
validation, quota, integrity, cleanup 같은 fail-closed guard가 예산에서 빠지지
|
||||
않는다. 생성된 모든 chunk/asset의 Node zlib gzip byte 합계를 catalog의
|
||||
`bundleBudgetGzipBytes`와 비교하고 결과와 SHA-256을
|
||||
`artifacts/quality/optional-recipes.json`에 기록한다.
|
||||
|
||||
이 synthetic build는 설치 크기 상한을 검증하기 위한 것이며 product bootstrap에
|
||||
runtime을 compose하지 않는다. 별도의 production manifest/module-inventory
|
||||
검사는 선택되지 않은 runtime source가 실제 `dist`에 없는지 계속 검증한다.
|
||||
|
||||
2026-07-28 기준 동일 설정의 `offline-indexeddb` 실측은 32,930 gzip bytes였다.
|
||||
기존 8,000 bytes 값은 bundle 측정 없이 선언된 값으로 실제 reference runtime과
|
||||
일치하지 않아 약 9% headroom을 둔 36,000 bytes로 교정했다.
|
||||
`service-worker-pwa` 10,000 bytes와 `file-transfer` 52,000 bytes는 현재 실측을
|
||||
수용하므로 유지한다. 이후 source가 예산을 넘으면 gate를 우회하거나 예산을
|
||||
자동 인상하지 않고, output artifact와 변경 이유를 검토해야 한다.
|
||||
|
||||
## 2. 어느 경계에 두는가
|
||||
|
||||
| capability 성격 | port 소유자 | adapter 방향 | concrete 위치 예 |
|
||||
| --- | --- | --- | --- |
|
||||
| application이 외부 결과를 요청 | application | outbound | `src/adapters/<capability>` |
|
||||
| URL/browser event가 의도를 전달 | application input | inbound | `src/presentation/adapters` |
|
||||
| React rendering behavior만 교체 | presentation | local facade | `src/presentation/<capability>` |
|
||||
| feature 전용 protocol | feature application | in/out 분리 | `src/features/<name>/adapters` |
|
||||
|
||||
WebSocket 연결 생성, reconnect와 credential attachment는 outbound다. 수신 JSON
|
||||
검증과 application input 호출은 inbound다. Service Worker update event,
|
||||
BroadcastChannel event도 같은 원칙을 적용한다. generated DTO와 vendor SDK
|
||||
type은 facade 밖으로 노출하지 않는다.
|
||||
|
||||
## 3. 12개 recipe 선택표
|
||||
|
||||
| recipe | 설치하는 경우 | 설치하면 안 되는 경우 | 핵심 fallback |
|
||||
| --- | --- | --- | --- |
|
||||
| realtime | foreground ordered event 또는 duplex protocol이 확정됨 | focus refetch/manual refresh가 충분하거나 ordering·replay owner 없음 | bounded polling 또는 stale UI |
|
||||
| offline/IndexedDB/OPFS | structured offline data/queue 또는 large local binary가 제품 요구 | credential, partition/retention/recovery 미정, HTTP cache로 충분 | read-only/online-only 또는 승인된 bounded Blob |
|
||||
| Service Worker/Cache Storage | install/offline shell 또는 public HTTP representation cache owner 승인 | auth/private/opaque cache, update/rollback UX 없음 | hosting cache 기반 network app |
|
||||
| file/picker/download | selection/preview/upload/download와 bounded memory/integrity 요구 | backend 재검증 없음, whole-buffer large file, long-lived credential URL | native input + authorized direct download |
|
||||
| generated API | versioned source와 drift CI가 있음 | DTO가 domain/UI로 노출됨 | typed request builder + schema |
|
||||
| feature flag | rollout/kill switch owner와 default 있음 | authorization에 사용 | typed local default |
|
||||
| Web Worker | profiler가 main-thread 병목을 증명 | 단순 network I/O | chunked/deferred execution |
|
||||
| multi-tab | 비민감 event 동기화가 필요 | server가 conflict authority | focus 시 authoritative refresh |
|
||||
| browser permission | user gesture 기반 기능 필요 | boot 요청, denied UX 없음 | manual input/instruction |
|
||||
| client workflow | cross-page client-only state가 실재 | query/server state 복제 | URL/local/context/query |
|
||||
| large data UI | 실측 scale이 budget 초과 | pagination으로 충분, a11y 미정 | accessible pagination |
|
||||
| analytics/error sink | provider·consent·retention 승인 | arbitrary payload/redaction 우회 | bounded local diagnostics |
|
||||
|
||||
정확한 failure matrix, security/privacy, gzip budget과 제거 순서는 JSON catalog가
|
||||
SSOT다. 문서와 catalog가 다르면 gate가 검사하는 catalog를 우선 고치고 이 표도
|
||||
같이 갱신한다.
|
||||
|
||||
Web Push target은 현재 13번째 설치 recipe가 아니다. 기존 `realtime`,
|
||||
`service-worker-pwa`, `browser-permission`의 인접 경계를 조합해야 하는 별도
|
||||
`NOT_SELECTED` capability다. 실제 선택 전 VD-10 amendment와 machine-readable
|
||||
catalog에 permission, subscription/backend provider, worker handler, notification
|
||||
policy와 removal source를 명시하며, foreground realtime이 선택됐다는 이유로
|
||||
Web Push를 함께 설치하지 않는다.
|
||||
|
||||
## 4. 공통 구현 순서
|
||||
|
||||
1. 문제를 vendor 이름이 아닌 capability와 측정값으로 기록한다.
|
||||
2. catalog의 trigger와 forbidden 조건을 모두 검토한다.
|
||||
3. project owner, security/privacy reviewer, gzip budget과 재검토 날짜를 VD-10
|
||||
amendment에 기록한다.
|
||||
4. existing URL/local/context/query/application port로 해결되지 않는지 확인한다.
|
||||
5. 필요한 contract만 `recipes`에서 해당 application/presentation 경계로 복사해
|
||||
실제 payload와 failure union으로 좁힌다.
|
||||
6. concrete SDK는 `src/adapters/...` 또는 local presentation facade adapter에서만
|
||||
import한다.
|
||||
7. composition root가 concrete adapter를 주입한다. page/use case가 constructor를
|
||||
직접 호출하지 않는다.
|
||||
8. fake, unavailable, timeout/cancel, cleanup, malformed input, redaction과
|
||||
integration test를 작성한다.
|
||||
9. runtime config schema, dependency inventory/approval, SBOM, bundle budget,
|
||||
browser support와 runbook을 갱신한다.
|
||||
10. 실제 provider integration과 negative behavior가 통과한 뒤에만 catalog 상태를
|
||||
별도 project catalog에서 `INSTALLED`로 바꾼다.
|
||||
|
||||
## 5. capability별 필수 검증
|
||||
|
||||
### Realtime
|
||||
|
||||
- runtime schema로 envelope/version/event ID/sequence/timestamp를 검증한다.
|
||||
- reconnect는 exponential backoff 상한, visibility/offline 상태, auth refresh와
|
||||
resume token expiry를 정의한다.
|
||||
- duplicate/out-of-order는 domain use case에 전달하기 전에 정책화한다.
|
||||
- route unmount/logout에서 unsubscribe하고 heartbeat timer를 종료한다.
|
||||
- SSE는 active document의 one-way stream, WebSocket은 duplex protocol,
|
||||
Web Push는 Service Worker가 받는 background notification hint, Polling은
|
||||
visible/finite HTTP scheduling policy로 분리한다.
|
||||
- 기본 event 효과는 query namespace invalidation과 authoritative refetch다.
|
||||
gap, cursor expiry와 queue overflow에서는 delta 적용을 중단하고 snapshot으로
|
||||
복구한다.
|
||||
- Web Push permission은 user action에서만 요청하고 subscription endpoint/key와
|
||||
payload를 storage, URL, telemetry나 application state에 노출하지 않는다.
|
||||
- detailed status, target contract와 구현 work package는
|
||||
[Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)을
|
||||
따른다. 현재 concrete runtime과 product selection은 없다.
|
||||
|
||||
### IndexedDB/OPFS
|
||||
|
||||
- 기존 동기식 preference `StoragePort`에 넣지 않고 feature-specific async
|
||||
repository와 large-object port를 사용한다.
|
||||
- raw database/transaction/store/index/schema version은 adapter 밖으로 노출하지
|
||||
않는다. request success가 아니라 transaction complete 이후에만 성공이다.
|
||||
- DDL schema와 record codec version을 분리하고 schema upgrade는 additive,
|
||||
data migration은 resumable bounded batch로 실행한다.
|
||||
- versionchange/blocked/future schema를 read-only/online-only 상태로 드러내며
|
||||
자동 reload와 database 삭제를 금지한다.
|
||||
- OPFS는 immutable bytes만 소유하고 IndexedDB journal이
|
||||
`PREPARING -> FILES_READY -> COMMITTED -> CLEANED` commit authority를 가진다.
|
||||
- quota, corrupt row/manifest, migration checkpoint, worker crash, storage eviction,
|
||||
N-1 rollback을 fixture와 실제 browser에서 검증한다.
|
||||
|
||||
### Cache Storage/Service Worker
|
||||
|
||||
- Cache Storage는 public HTTP representation 전용이며 application repository나
|
||||
query cache가 아니다.
|
||||
- auth, cookie-dependent, private, personal, no-store, opaque, redirect, 206을
|
||||
거절하고 query/Vary exact match를 보존한다.
|
||||
- candidate 전체의 type/size/integrity가 검증된 뒤에만 release를 활성화하고
|
||||
verified previous release를 rollback용으로 유지한다.
|
||||
- stale worker loop를 막고 unregister와 parsed owned-cache cleanup을 별도 lifecycle로
|
||||
검증한다.
|
||||
|
||||
### File/Blob/picker/download, presigned transfer와 generated API
|
||||
|
||||
- native File/Blob/handle은 transient adapter vault 안에 두고 application에는
|
||||
opaque ref, untrusted metadata와 bounded range/chunk만 전달한다.
|
||||
- native input을 baseline으로 두고 system picker는 user activation 안에서만
|
||||
progressive enhancement한다. dismissal은 failure가 아니다.
|
||||
- upload는 client MIME을 신뢰하지 않고 count/size/signature/server rejection,
|
||||
resumable session, part checksum/idempotency, quarantine을 다룬다.
|
||||
- presigned URL은 application에 raw URL로 노출하지 않고 in-memory identity
|
||||
capability로 보관한다. method/resource 또는 session-part/offset/length/checksum,
|
||||
expiry, origin/path/query/header를 정확히 묶고 data-plane fetch는 credential,
|
||||
redirect, referrer와 cache를 fail-closed 정책으로 제한한다.
|
||||
- resume는 IndexedDB checkpoint만 신뢰하지 않고 server status와 다시 선택한
|
||||
source의 part digest를 대조한다. checkpoint에는 URL/query/signed header/token을
|
||||
저장하지 않으며 explicit abort가 불명확하면 reconcile 전까지 유지한다.
|
||||
- progress는 unknown total을 허용하며 navigation/unmount에서 AbortSignal로
|
||||
취소한다. server upload session은 별도 abort/TTL cleanup이 필요하다.
|
||||
- 큰 download는 single `Uint8Array`/Blob이 아니라 browser handoff 또는
|
||||
backpressure stream을 사용하고 handoff와 confirmed save를 구분한다.
|
||||
- Image CDN은 raw transform URL builder가 아니라 opaque asset과
|
||||
composition-registered preset으로만 responsive descriptor를 만든다. immutable
|
||||
revision, format/width/pixel/decode/cache/expiry와 CDN origin을 검증한다.
|
||||
- object URL은 explicit lease로 만들고 replacement/unmount에서 revoke한다.
|
||||
- generated code는 facade 뒤 DTO이며 runtime response schema와 contract drift
|
||||
gate를 유지한다.
|
||||
|
||||
### Flag/worker/multi-tab/browser
|
||||
|
||||
- flag unknown/unavailable/stale에서 명시적 typed fallback을 사용하고 access
|
||||
control로 사용하지 않는다.
|
||||
- worker는 task ID/generation/cancel을 사용해 stale result를 폐기하고 crash를
|
||||
normalized failure로 바꾼다.
|
||||
- multi-tab은 source/event/version으로 self-echo와 duplicate를 막고 payload를
|
||||
비민감 invalidation hint로 제한한다.
|
||||
- browser permission은 user gesture에서만 요청하고 denied/dismissed/unsupported를
|
||||
서로 다른 UX 결과로 처리한다.
|
||||
|
||||
### Client workflow/large data/analytics
|
||||
|
||||
- workflow store는 server entity/collection을 복제하지 않고 query key나 ID 참조만
|
||||
보관한다. logout/reset/version mismatch 정책을 테스트한다.
|
||||
- virtualization은 profiler와 production-like row count로 정당화하며 keyboard,
|
||||
focus restoration, screen reader와 stale row identity를 검증한다.
|
||||
- analytics는 essential diagnostics와 consent-required event를 분리하고 closed
|
||||
event/attribute registry, pre-queue redaction, sampling, bounded queue와
|
||||
retention을 적용한다.
|
||||
|
||||
## 6. 검증 명령
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types:recipes
|
||||
corepack pnpm test:recipes
|
||||
corepack pnpm test:browser-capabilities
|
||||
corepack pnpm build
|
||||
corepack pnpm check:optional-recipes
|
||||
corepack pnpm check:optional-recipe-fixtures
|
||||
corepack pnpm test:optional-recipe-removal
|
||||
corepack pnpm test:browser-file-storage-removal
|
||||
```
|
||||
|
||||
negative gate는 cleanup 누락, unselected dependency, local adapter 밖 vendor
|
||||
import, credential localStorage/URL/telemetry 경로, workflow store의 server-state
|
||||
복제, production source의 recipe import와 선택 전 reference runtime composition을
|
||||
거절한다. removal gate는 recipe와 recipe test를 삭제한 임시 사본에서 base
|
||||
typecheck, architecture, test와 build를 실행한다.
|
||||
별도 native-runtime removal gate는 browser file/storage source와 전용 test,
|
||||
catalog metadata를 제거한 임시 사본에서 typecheck, architecture, 전체 base test,
|
||||
build와 optional catalog 검사를 다시 실행한다.
|
||||
|
||||
## 7. 제거 체크리스트
|
||||
|
||||
1. 신규 호출과 background 작업을 중지한다.
|
||||
2. subscription, worker, channel, media track, observer를 cleanup한다.
|
||||
3. persisted store/cache/event queue의 migrate 또는 purge 정책을 실행한다.
|
||||
4. composition registration과 runtime config를 제거한다.
|
||||
5. concrete adapter, facade/port와 vendor dependency를 제거한다.
|
||||
6. dependency baseline, SBOM과 bundle baseline을 갱신한다.
|
||||
7. typecheck/test/build, production bundle absence와 도메인 기능 fallback을
|
||||
검증한다.
|
||||
|
||||
provider 장애 시 fake로 바꾸어 production을 PASS 처리하지 않는다. 문서화된
|
||||
unavailable fallback만 사용하고 provider가 필수인 promotion은
|
||||
`FAIL_UNVERIFIED` 또는 blocked 상태로 유지한다.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Architecture overview
|
||||
|
||||
This Mermaid view is a repository-local implementation projection. The
|
||||
`PASS_SCOPED` reviewer evidence applies to the canonical draw.io diagram named
|
||||
in `review-ledger.json`, not automatically to edits in this file.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Bootstrap[bootstrap / composition root] --> Presentation[presentation]
|
||||
Bootstrap --> Adapters[adapters]
|
||||
Presentation --> Shell[app shell and route surfaces]
|
||||
Shell --> Providers[session and theme providers]
|
||||
Presentation --> Application[application]
|
||||
Adapters --> Application
|
||||
Application --> Domain[domain]
|
||||
Contracts[contract registries] --> Bootstrap
|
||||
Contracts --> Adapters
|
||||
Contracts --> Presentation
|
||||
```
|
||||
|
||||
The enforced dependency rule points inward: presentation calls application use
|
||||
cases, adapters implement application ports, and only the composition root
|
||||
selects concrete adapters. Contract registries are the named source for routes,
|
||||
API operations, environment values, storage keys, errors, queries, telemetry,
|
||||
and release tokens. Installed feature contributions extend those registries
|
||||
without making the generic application or router import a concrete feature
|
||||
implementation.
|
||||
|
||||
In ports-and-adapters terms, `presentation` is the current inbound adapter and
|
||||
`adapters` contains the current outbound implementations. The production
|
||||
composition exposes an application input API to React while keeping concrete
|
||||
output ports inside application closures:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Driver[User, route, browser event] --> Inbound[React inbound adapter]
|
||||
Inbound --> Input[Application input API]
|
||||
Input --> UseCase[Use cases]
|
||||
UseCase --> Output[Application output ports]
|
||||
Output --> Outbound[HTTP, auth, storage, query, telemetry adapters]
|
||||
Bootstrap2[Composition root] -. selects and injects .-> Input
|
||||
Bootstrap2 -. selects and injects .-> Outbound
|
||||
```
|
||||
|
||||
The executable route tree is mounted only after runtime configuration and
|
||||
release-manifest coherence pass. `ApplicationProvider` receives the composed
|
||||
application API; concrete session, storage, telemetry, diagnostics, and release
|
||||
ports are not returned to feature pages. TanStack Query is isolated behind the
|
||||
presentation query adapter, while its provider remains React infrastructure.
|
||||
The module-augmented feature registry gives each installed feature a closed ID
|
||||
and exact input type.
|
||||
|
||||
Route contracts/codecs, the route-input provider, and lazy runtime modules are
|
||||
separate modules so feature pages do not import the router that loads them.
|
||||
Expected application failures use the shared `Result`/`AppFailure` contract.
|
||||
The architecture gate analyzes TS/TSX imports independently of
|
||||
dependency-cruiser, rejects local JavaScript-family specifiers and executable
|
||||
JavaScript-family files, and fails on unresolved imports, parse failures,
|
||||
forbidden layer edges, or cycles. The remaining project-owned work includes
|
||||
selecting and verifying real hosting, identity, telemetry, vulnerability and
|
||||
signing providers, plus backend/provider/browser/operations evidence for each
|
||||
selected optional capability; the repository does not fabricate that evidence.
|
||||
Use the following
|
||||
documents for implementation details and project integration work:
|
||||
|
||||
- [Frontend platform capability review](./frontend-platform-capability-review.md)
|
||||
- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
|
||||
- [API contract, Schema, Mapper, and Server State](./api-contract-schema-mapper-and-server-state.md)
|
||||
- [Protobuf browser transports and REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
|
||||
- [Backend API and Server State handoff contract](./backend-api-and-server-state-contract.md)
|
||||
- [TypeScript, state, and data flow](./typescript-state-and-data-flow.md)
|
||||
- [Routing, pages, and patterns](./routing-pages-and-patterns.md)
|
||||
- [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)
|
||||
- [Browser file and origin storage](./browser-file-and-origin-storage.md)
|
||||
- [Client cache and storage](./client-cache-and-storage.md)
|
||||
- [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)
|
||||
- [Presigned transfer and Image CDN](./presigned-transfer-and-image-cdn.md)
|
||||
- [Server file capability infrastructure](./server-file-capability-infrastructure.md)
|
||||
- [Frontend platform implementation roadmap](./frontend-platform-implementation-roadmap.md)
|
||||
@@ -0,0 +1,757 @@
|
||||
# Presigned transfer, resumable upload, streaming download and Image CDN
|
||||
|
||||
이 문서는 presigned URL, multipart/resumable upload, streaming download와
|
||||
Image CDN을 브라우저 애플리케이션에 넣을 때의 control plane/data plane 경계,
|
||||
무결성, 재개, 만료, 캐시와 복구 계약을 정의한다.
|
||||
|
||||
현재 구현된 개별 reference runtime은 실제 browser `fetch`와 bounded byte
|
||||
stream을 사용한다. 제품별 endpoint, bucket, CDN vendor, asset schema와
|
||||
authorization owner는 아직 조합하지 않는다. 이 개별 runtime은
|
||||
`AVAILABLE_NOT_COMPOSED`이고, Range/composition/provider 같은 후속 delta는 아래
|
||||
표처럼 다른 상태다. 어느 경로도 임의의 URL이나 object key를 application caller가
|
||||
직접 전달하는 범용 HTTP facade가 아니다.
|
||||
|
||||
## 0. 현재 구현과 목표 상태
|
||||
|
||||
이 문서에서 “설계됨”, “reference source가 있음”, “제품에 조합됨”과 “target
|
||||
browser에서 보장 가능함”은 서로 다른 사실이다. primary current status는
|
||||
`NOT_SELECTED`, `DESIGNED_NOT_IMPLEMENTED`, `AVAILABLE_NOT_COMPOSED`, `COMPOSED`,
|
||||
`PLATFORM_LIMITED` 다섯 값 중 하나만 사용한다. production evidence와 traffic
|
||||
admission은 이 status와 별도 축이다.
|
||||
|
||||
| capability | primary current status | 현재 있는 것 | 남은 목표 |
|
||||
| --- | --- | --- | --- |
|
||||
| whole-object Presigned GET/part PUT | `AVAILABLE_NOT_COMPOSED` | strict BFF response validation, in-memory identity vault, bounded GET/PUT executor | top-level wire version, 실제 endpoint/auth/revocation/provider contract |
|
||||
| multipart/resumable upload | `AVAILABLE_NOT_COMPOSED` | server-authoritative session flow, part retry, IDB CAS checkpoint, Web Lock, cross-tab abort signal | non-destructive pause, safe inventory/retention sweep, 실제 BFF/storage/scan |
|
||||
| whole-object foreground streaming download | `AVAILABLE_NOT_COMPOSED` | `200` stream, length/SHA-256, picker save, bounded object URL와 browser handoff mechanism | strategy selector, browser-managed capability issuer와 제품 save UX |
|
||||
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | VD-14 production design | port/runtime/checkpoint, `206/200/412/416`, seek/truncate 또는 OPFS staging |
|
||||
| Image CDN policy/verification engine | `AVAILABLE_NOT_COMPOSED` | opaque asset/preset, responsive descriptor, P-256, static-image probe | descriptor HTTP provider/refresh, renderer, 실제 BFF/CDN |
|
||||
| app-managed background download | `NOT_SELECTED` | VD-14 경계와 금지 조건 | 제품이 별도 선택한 지원 browser에서만 optional 구현 |
|
||||
| cross-browser background-download guarantee | `PLATFORM_LIMITED` | browser-managed handoff fallback | 공통 baseline으로 구현 완료를 선언하지 않음 |
|
||||
| app-managed background upload | `NOT_SELECTED` | pause/foreground resume와 명시적으로 분리 | durable source staging/worker auth를 가진 별도 protocol |
|
||||
| cross-browser background-upload guarantee | `PLATFORM_LIMITED` | foreground checkpoint resume fallback | worker lifetime/source permission을 공통 보장하지 않음 |
|
||||
|
||||
현재 runtime은 테스트 전용 mock이 아니라 실제 browser API를 호출하지만, 위
|
||||
`남은 목표`가 구현됐다는 뜻은 아니다. 특히 foreground whole-object streaming
|
||||
증거를 Range resume나 app-managed background download 증거로 재사용하지 않는다.
|
||||
Range와 background download의 상세 상태 머신, destination, fallback, rollout과 완료 기준은
|
||||
[VD-14](./decisions/VD-14-resumable-download-and-background-transfer.md)가
|
||||
소유한다. top-level runtime composition, account teardown, Image descriptor
|
||||
provider/refresh와 safe presentation projection은
|
||||
[VD-16](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md)이
|
||||
소유한다.
|
||||
|
||||
## 1. 경계와 topology
|
||||
|
||||
```text
|
||||
feature use case
|
||||
-> feature-specific transfer facade
|
||||
-> BFF/Web API control plane
|
||||
- authorization
|
||||
- upload session / download capability
|
||||
- resource metadata and lifecycle authority
|
||||
-> browser transfer data plane
|
||||
- capability validation
|
||||
- bounded fetch / stream / part transfer
|
||||
- integrity and cancellation
|
||||
-> object/file storage or CDN
|
||||
- bytes only
|
||||
|
||||
image use case
|
||||
-> ImageDeliveryPort
|
||||
-> backend/CDN-issued immutable asset descriptor
|
||||
-> policy-validated responsive candidates
|
||||
-> presentation-safe <picture>/<img> attributes
|
||||
```
|
||||
|
||||
브라우저는 S3, MinIO, GCS, Azure Blob의 관리자 credential, bucket policy, object
|
||||
key 생성 규칙이나 signing key를 소유하지 않는다. control plane이 짧은 수명의
|
||||
제한된 capability를 발급하고, data plane은 그 capability가 묶은 정확한
|
||||
operation과 bytes만 전송한다.
|
||||
|
||||
## 2. 공통 capability 규칙
|
||||
|
||||
Presigned URL은 단순 URL이 아니라 bearer capability다. 구현은 적어도 다음
|
||||
binding을 하나의 immutable snapshot으로 검증해야 한다.
|
||||
|
||||
- protocol/version과 opaque capability ID
|
||||
- `DOWNLOAD` 또는 특정 upload session/part operation
|
||||
- logical resource/session ID
|
||||
- exact HTTP method
|
||||
- HTTPS와 composition allowlist에 속한 origin/path
|
||||
- capability가 발급한 exact query와 signed request headers
|
||||
- expected media type, byte range 또는 exact part offset/length
|
||||
- hard maximum bytes와 선택한 checksum algorithm/digest
|
||||
- upload 성공 response의 exact status, `Content-Length`,
|
||||
`expectedResponseByteLength`와 opaque receipt header
|
||||
- 발급·만료 시각과 composition의 더 짧은 lifetime ceiling
|
||||
- single-use가 필요한 경우 원자적인 server/provider consumption
|
||||
|
||||
### 2.1 Wire version 목표
|
||||
|
||||
현재 multipart DTO는 `PRESIGNED_MULTIPART_V1`을 exact하게 포함하지만, whole-object
|
||||
presigned capability response에는 top-level transfer protocol literal이 아직
|
||||
없다. strict exact-key schema만으로 현재 payload drift는 막지만, 호환되지 않는
|
||||
wire 변경과 구 client drain을 명시적으로 운영하기에는 부족하다.
|
||||
|
||||
향후 presigned control-plane envelope에는 다음 literal을 추가한다.
|
||||
|
||||
```text
|
||||
protocol = PRESIGNED_TRANSFER_V1
|
||||
```
|
||||
|
||||
- request/response, vault registration과 executor consumption에서 exact match한다.
|
||||
- 알 수 없는 값, 누락과 newer version은 fail-closed한다.
|
||||
- Range는 이 literal에 암묵적으로 섞지 않고
|
||||
`RANGE_RESUMABLE_DOWNLOAD_V1` 별도 protocol을 사용한다.
|
||||
- Image descriptor HTTP envelope도 `IMAGE_CDN_DESCRIPTOR_V1`로 versioning한다.
|
||||
- version을 추가하기 전까지 현재 구현 상태는 계속
|
||||
`AVAILABLE_NOT_COMPOSED`이며 wire-version 목표가 구현됐다고 기록하지 않는다.
|
||||
|
||||
URL의 query와 signed headers는 credential material로 취급한다. persistence,
|
||||
checkpoint, diagnostics, analytics, error message, referrer와 application state에
|
||||
넣지 않는다. runtime caller가 URL/query/header를 조립하거나 capability의
|
||||
method, origin, byte limit과 expiry를 늘릴 수 없다.
|
||||
|
||||
Data-plane `fetch` 기본값은 다음과 같다.
|
||||
|
||||
```text
|
||||
credentials = omit
|
||||
redirect = error
|
||||
referrerPolicy = no-referrer
|
||||
cache = no-store
|
||||
mode = same-origin or explicitly approved CORS
|
||||
```
|
||||
|
||||
cross-origin object storage/CDN은 exact origin allowlist, CORS method/header,
|
||||
노출할 response header와 CSP `connect-src`/`img-src` 계약이 있어야 한다.
|
||||
redirect를 따라가며 signed query를 다른 origin으로 전달하지 않는다.
|
||||
|
||||
## 3. Presigned URL
|
||||
|
||||
### 3.1 Control plane
|
||||
|
||||
실제 발급은 feature-specific BFF adapter가 소유한다. composition은 presigned
|
||||
capability 발급 endpoint를 HTTPS absolute URL 하나로 고정해 factory에 한 번
|
||||
주입한다. application caller는 endpoint를 고르거나 path/query를 조립할 수 없다.
|
||||
공통 runtime은 이 고정 endpoint와 strict envelope만 알고 provider-specific
|
||||
signing DTO는 알지 않는다.
|
||||
|
||||
권장 흐름:
|
||||
|
||||
```text
|
||||
browser -> authenticated BFF capability request
|
||||
BFF -> resource/session authorization and metadata lookup
|
||||
BFF -> object service signer
|
||||
BFF -> opaque bound capability
|
||||
browser -> validated direct data-plane request
|
||||
```
|
||||
|
||||
발급 endpoint의 `2xx`만으로 authorization을 추론하지 않는다. runtime schema가
|
||||
전체 capability를 검증하고, data request 시에도 object storage/BFF가 method,
|
||||
expiry, size/checksum 조건을 강제해야 한다. presigned URL은 underlying credential
|
||||
revocation이나 server policy 때문에 표기된 expiry보다 일찍 무효화될 수 있다.
|
||||
|
||||
upload part capability 요청에서 전달되는 `requestBindingSha256`,
|
||||
`uploadBindingSha256`와 part digest는 authorization proof가 아니다.
|
||||
`UPLOAD_PART` capability binding은 `protocol: PRESIGNED_MULTIPART_V1`을 exact하게
|
||||
포함한다. BFF는 `sessionId`로 server-owned session을 다시 읽고 protocol,
|
||||
subject/purpose/state/expiry와 part plan을 authorization한 뒤 canonical binding을
|
||||
직접 재계산해야 한다. client digest를 그대로 신뢰하거나 단순 echo해서 signed
|
||||
URL을 발급하면 안 된다.
|
||||
|
||||
### 3.2 Consumption
|
||||
|
||||
- capability를 async 대기하는 동안 caller 입력과 dependency method를 snapshot한다.
|
||||
- 사용 전에 만료뿐 아니라 최소 잔여 lifetime도 검사한다.
|
||||
- 만료/403은 임의 retry가 아니라 control plane의 새 capability 발급으로 복구한다.
|
||||
- ambiguous network failure 뒤 upload part를 새 bytes로 덮어쓰지 않는다.
|
||||
- client-side single-use map은 UX 최적화일 뿐이다. cross-tab/replay authority는
|
||||
server 또는 composition-owned atomic consumer다.
|
||||
- signed response의 URL, query, raw header와 exception text를 관측성에 남기지 않는다.
|
||||
|
||||
## 4. Multipart/resumable upload
|
||||
|
||||
이 reference runtime의 기본 모델은
|
||||
`PRESIGNED_MULTIPART_V1` capability 기반 ordered multipart protocol이다. 이
|
||||
literal은 모든 upload control-plane request/response, session과 durable
|
||||
checkpoint에서 일치해야 하며 다른 값이나 누락은 fail-closed한다. 특정 S3 DTO를
|
||||
application port로 노출하지 않으며 tus 같은 offset protocol을 선택하면 별도
|
||||
protocol/version과 wire adapter가 동일한 상위 session contract를 구현한다.
|
||||
|
||||
### 4.1 Session contract
|
||||
|
||||
Control plane이 소유하는 operation:
|
||||
|
||||
1. `create`: authorization, purpose, declared bytes/media와 source binding을 확인
|
||||
2. `status/list parts`: server-authoritative session/part state 반환
|
||||
3. `issue part capability`: exact session, part number, offset, length와 checksum binding
|
||||
4. `complete`: ordered part receipt와 checksum을 검증
|
||||
5. `abort`: server upload를 중단하고 orphan cleanup을 예약
|
||||
|
||||
browser HTTP adapter는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`의
|
||||
closed operation set을 composition-owned fixed HTTPS endpoint map에 연결한다.
|
||||
모두 bounded `POST application/json`이고 caller가 URL을 제공하지 못한다.
|
||||
upload control endpoint response는 exact URL/status/content type과
|
||||
request/response byte cap, deadline, `Retry-After` ceiling을 검증한다. part
|
||||
capability는 앞 절의 별도 fixed BFF endpoint를 redirect 금지로 사용하고 strict
|
||||
response envelope 및 request/response의 `UPLOAD_PART` binding protocol이
|
||||
`PRESIGNED_MULTIPART_V1`인지 대조한다.
|
||||
|
||||
Session은 적어도 opaque ID, protocol version, exact total bytes, media type,
|
||||
part size, part count, concurrency ceiling, checksum algorithm과 expiry를 묶는다.
|
||||
part number는 1부터 연속적이어야 하고 마지막 part를 제외한 part length는
|
||||
고정한다.
|
||||
|
||||
`PRESIGNED_MULTIPART_V1`의 canonical digest 계약은 다음과 같다. 각 field는
|
||||
아래 순서의 UTF-8 line으로 직렬화하고 SHA-256 lowercase hex를 사용한다.
|
||||
|
||||
```text
|
||||
fingerprint.digestHex =
|
||||
SHA-256(
|
||||
"SHA-256-PARTS-V1"
|
||||
fingerprint.byteLength
|
||||
fingerprint.partSizeBytes
|
||||
fingerprint.partCount
|
||||
"{partNumber}:{offset}:{byteLength}:{checksumSha256}" for each ordered part
|
||||
)
|
||||
|
||||
requestBindingSha256 =
|
||||
SHA-256(
|
||||
"RESUMABLE-UPLOAD-BINDING-V1"
|
||||
uploadKey
|
||||
purpose
|
||||
mediaType
|
||||
fingerprint.algorithm
|
||||
fingerprint.digestHex
|
||||
fingerprint.byteLength
|
||||
fingerprint.partSizeBytes
|
||||
fingerprint.partCount
|
||||
)
|
||||
|
||||
uploadBindingSha256 =
|
||||
SHA-256(
|
||||
"RESUMABLE-UPLOAD-SESSION-BINDING-V1"
|
||||
requestBindingSha256
|
||||
sessionId
|
||||
fingerprint.algorithm
|
||||
fingerprint.digestHex
|
||||
fingerprint.byteLength
|
||||
fingerprint.partSizeBytes
|
||||
fingerprint.partCount
|
||||
)
|
||||
```
|
||||
|
||||
각 괄호 안의 항목은 실제로 줄바꿈 하나로 연결하며 마지막 빈 line은 추가하지
|
||||
않는다. BFF는 create에서 받은 선언을 server policy/session snapshot과 함께
|
||||
보관하고, part capability 발급 때 그 snapshot으로 request/upload digest와 exact
|
||||
part offset/length/checksum/idempotency를 재계산한다. complete에서는 server
|
||||
ledger의 ordered part set으로 fingerprint digest도 다시 계산한다. digest 일치는
|
||||
input binding의 무결성 신호일 뿐 subject authorization, session ownership 또는
|
||||
session state 검사를 대체하지 않는다.
|
||||
|
||||
### 4.2 Browser transfer
|
||||
|
||||
- source는 bounded `readRange(offset, length)`를 제공한다.
|
||||
- part bytes 하나와 digest 계산에 필요한 copy만 메모리에 둔다.
|
||||
- `partSize × concurrency × copyFactor`가 composition memory ceiling을 넘으면
|
||||
시작 전에 거절한다.
|
||||
- 각 part는 exact offset/length와 SHA-256을 계산한 뒤 capability를 발급받는다.
|
||||
- retry는 동일 session/part/offset/length/checksum/idempotency binding에만 허용한다.
|
||||
- 429/모든 `5xx`/network retry는 composition의 bounded attempt, bounded
|
||||
`Retry-After`와 abortable backoff 안에서만 수행한다.
|
||||
- 만료/authorization failure는 최대 정책 범위 안에서 capability를 재발급한다.
|
||||
- upload response의 opaque receipt/ETag를 whole-file digest로 해석하지 않는다.
|
||||
- PUT capability는 성공 status, receipt header와
|
||||
`expectedResponseByteLength`를 묶는다. runtime은 exact `Content-Length`를
|
||||
확인하고 response body를 hard cap과 동일 deadline 안에서 끝까지 bounded
|
||||
drain한 뒤에만 receipt를 성공으로 채택한다. `204`는 expected response bytes가
|
||||
`0`일 때만 허용하고 `Content-Length` 부재를 0으로 정규화한다.
|
||||
- complete 전 server-authoritative status와 local receipt를 reconcile한다.
|
||||
- complete 성공은 scan 완료가 아니라 `QUARANTINED`다.
|
||||
|
||||
### 4.3 Resume와 checkpoint
|
||||
|
||||
Checkpoint에는 다음만 저장할 수 있다.
|
||||
|
||||
- schema version, exact `PRESIGNED_MULTIPART_V1`, revision과 lifecycle state
|
||||
- opaque upload key, session ID와 `requestBindingSha256`
|
||||
- `SHA-256-PARTS-V1` fingerprint의 total bytes, part size/count와 digest
|
||||
- session expiry와 server concurrency ceiling
|
||||
- 완료 part의 number/offset/length/checksum/opaque receipt
|
||||
- 마지막 reconciliation 시각
|
||||
|
||||
Presigned URL, query, signed header, bearer token, file name, local path, account ID와
|
||||
raw backend error는 저장하지 않는다.
|
||||
위에 명시한 SHA-256 fingerprint/part checksum과 bounded opaque part receipt는
|
||||
서버 reconcile에 필요한 비권한성 checkpoint field이므로 예외적으로 해당 account
|
||||
partition에만 보존한다. raw provider ETag를 임의로 저장하는 것이 아니며 이
|
||||
필드들도 diagnostics, telemetry, ticket 또는 application-facing 결과에는
|
||||
노출하지 않는다.
|
||||
|
||||
resume 시 checkpoint만 신뢰하지 않는다.
|
||||
|
||||
1. 사용자가 다시 선택한 source의 exact byte length와 source binding을 검사한다.
|
||||
2. server status/list-parts를 authoritative하게 읽는다.
|
||||
3. 완료되었다고 주장하는 각 part의 local bytes를 다시 bounded hash하여
|
||||
server checksum/receipt와 대조한다.
|
||||
4. 불일치하면 해당 session을 complete하지 않고 abort/restart 또는 사용자 복구로
|
||||
전환한다.
|
||||
5. 새 part만 업로드한 뒤 전체 ordered set을 다시 reconcile한다.
|
||||
|
||||
`GET_STATUS`가 HTTP `404` 또는 `410`을 반환하거나 decoded status가
|
||||
`NOT_FOUND`/`EXPIRED`이면 해당 session은 terminal이다. runtime은 CAS revision을
|
||||
확인해 checkpoint를 제거하고 새 session으로 restart하거나
|
||||
`EXPIRED_RESOURCE/RESTART`를 반환한다. 사라진 session의 checkpoint를 다음
|
||||
invocation까지 반복해서 붙잡지 않는다. abort의 `404/410`도 checkpoint를
|
||||
정리하고 application에는 `ORPHANED`로 닫는다.
|
||||
|
||||
한 session은 cross-tab mutation lock으로 직렬화한다. lock은 correctness의 유일한
|
||||
근거가 아니며 server idempotency와 part CAS가 최종 authority다.
|
||||
|
||||
명시적 abort는 같은 runtime의 controller뿐 아니라 strict
|
||||
`RESUMABLE_UPLOAD_CANCEL_V1` BroadcastChannel을 통해 같은 origin의 다른
|
||||
runtime에도 opaque upload key의 ephemeral cancel 신호를 보낸다. 수신 runtime은
|
||||
진행 중 fetch/read/backoff의 AbortSignal을 먼저 중단해 Web Lock을 내보내고,
|
||||
abort 요청 runtime이 lock 안에서 durable checkpoint를 `ABORT_PENDING`으로
|
||||
바꾼 뒤 server abort/reconcile을 실행한다. 이 메시지는 session ID, capability,
|
||||
signed URL이나 receipt를 포함하거나 저장하지 않으며 server state authority가
|
||||
아니다. BroadcastChannel이 없으면 correctness는 유지되지만 abort는 caller가
|
||||
정한 bounded deadline 아래 다른 context의 lock 해제를 기다린다.
|
||||
|
||||
사용자 cancel은 현재 browser work 중지이고 server abort와 다르다. 명시적 abort를
|
||||
요청했는데 결과가 불명확하면 checkpoint를 즉시 성공으로 삭제하지 않고 다음
|
||||
reconcile에서 server 상태를 확인한다. backend는 만료된 orphan multipart를
|
||||
정리하는 TTL job을 가져야 한다.
|
||||
|
||||
application-facing upload 성공값은 `state`, opaque `resourceId`, `byteLength`,
|
||||
`replayed`만 반환한다. control-plane 검증에 사용한 `sessionId`,
|
||||
`requestBindingSha256`와 file fingerprint는 public outcome에 노출하지 않는다.
|
||||
|
||||
### 4.4 아직 구현되지 않은 pause와 checkpoint lifecycle 목표
|
||||
|
||||
현재 `ResumableUploadPort`는 `upload()`와 server-side `abort()`만 제공한다.
|
||||
caller가 자신의 `AbortSignal`을 중단하면 committed checkpoint가 남아 다음
|
||||
`upload()`에서 resume할 수 있지만, 이것은 명시적 pause protocol이 아니다. 다른
|
||||
tab의 active upload를 non-destructive하게 pause하는 API도 없으며 현재 cross-tab
|
||||
cancel signal은 explicit server abort를 준비하기 위한 신호다.
|
||||
|
||||
향후 pause를 선택하면 다음을 별도 version으로 구현한다.
|
||||
|
||||
```text
|
||||
pause(uploadKey)
|
||||
-> active read/fetch/backoff cancel
|
||||
-> RESUMABLE_UPLOAD_PAUSE_V1 ephemeral cross-context signal
|
||||
-> per-key mutation lock
|
||||
-> exact revision CAS to PAUSED
|
||||
-> in-memory part capability retirement
|
||||
-> no server multipart abort
|
||||
|
||||
resume through upload()
|
||||
-> PAUSED checkpoint validation
|
||||
-> server-authoritative status
|
||||
-> local completed-part re-hash
|
||||
-> CAS to ACTIVE
|
||||
-> missing parts only
|
||||
```
|
||||
|
||||
현재 checkpoint `schemaVersion: 1`의 state는 `ACTIVE | ABORT_PENDING`뿐이다.
|
||||
`PAUSED`를 durable state로 추가한다면 unknown-old-writer behavior와 migration을
|
||||
정한 `schemaVersion: 2`가 필요하다. 기존 schema에 필드를 몰래 추가하지 않는다.
|
||||
|
||||
현재 checkpoint store도 single-key `read/CAS/remove`와 account partition 전체
|
||||
삭제만 제공한다. abandoned upload가 같은 `uploadKey`로 다시 열리지 않으면 local
|
||||
checkpoint를 retention 기준으로 자동 발견·정리하지 못한다. 향후 admin lifecycle은
|
||||
다음을 제공한다.
|
||||
|
||||
- account partition 안에서 cursor 기반 bounded safe-summary inventory
|
||||
- maximum age, count와 logical-byte budget
|
||||
- expired/terminal candidate의 server status 재확인
|
||||
- active lock/lease와 `ABORT_PENDING`을 무조건 삭제하지 않는 분류
|
||||
- checkpoint와 owned local staging이 있다면 같은 cleanup journal로 처리
|
||||
- cleanup response loss, blocked database와 CAS conflict reconciliation
|
||||
- logout/account deletion용 exact partition maintenance authority
|
||||
|
||||
inventory는 file name, path, account/resource/session ID, digest, receipt와 capability를
|
||||
application이나 operator UI에 반환하지 않는다. 제품 resume UI가 display metadata를
|
||||
필요로 하면 제품 repository가 opaque `uploadKey`와 별도로 소유한다.
|
||||
|
||||
이 pause/inventory/retention 항목은 현재 **설계 목표이며 구현 완료가 아니다**.
|
||||
app-managed background upload도 이 항목에 포함되지 않는다. worker upload는 source
|
||||
bytes의 durable staging, worker auth/reissue, version drain과 platform support가
|
||||
승인된 별도 optional capability다.
|
||||
|
||||
## 5. Streaming download
|
||||
|
||||
Streaming download는 server resource를 전체 `Blob`/`ArrayBuffer`로 materialize하지
|
||||
않고 `Response.body`를 closed-result byte source로 변환한다.
|
||||
|
||||
- response는 200, non-opaque, non-redirect이고 body가 있어야 한다.
|
||||
- capability의 media type, content encoding 정책과 response header가 일치해야 한다.
|
||||
- native chunk는 configured output chunk ceiling으로 다시 분할한다.
|
||||
- 누적 bytes가 expected/hard maximum을 넘으면 즉시 reader를 cancel한다.
|
||||
- EOF에서 expected bytes보다 작으면 truncated failure다.
|
||||
- integrity-required policy는 vetted incremental verifier를 사용하고 destination
|
||||
close 전에 digest를 확인한다.
|
||||
- first failed chunk 뒤에는 더 이상 bytes를 노출하지 않는다.
|
||||
- abort/consumer early return에서 reader를 cancel하고 lock/capability lease를
|
||||
해제한다.
|
||||
- byte source는 기본 one-shot이며 두 번째 stream 소비를 거절한다.
|
||||
|
||||
File System Access save picker가 있으면 user activation 안에서 destination을 먼저
|
||||
열고 stream을 쓴다. close와 integrity verification이 끝난 경우만 `SAVED`다.
|
||||
anchor/navigation은 `BROWSER_HANDOFF`이며 disk write 완료를 의미하지 않는다.
|
||||
save picker가 없는 browser의 whole-Blob fallback은 별도 small-artifact hard cap
|
||||
아래에서만 허용한다.
|
||||
|
||||
Range 기반 resumable download는 이 streaming contract와 다른 capability다.
|
||||
도입하려면 validator-bound `Range`, `206 Content-Range`, destination seek/truncate,
|
||||
ETag/If-Range와 final whole-object integrity를 별도 계약으로 추가한다. 단순히
|
||||
partial bytes를 기존 파일 뒤에 append하지 않는다.
|
||||
|
||||
현재 presigned provider는 `Range` request header를 금지하고 GET success를
|
||||
`200`으로 고정하며 executor는 `Content-Range`를 거절한다. save destination도
|
||||
순차 writable만 제공한다. 따라서 이 문단은 구현 설명이 아니라 미구현 경계를
|
||||
뜻한다.
|
||||
|
||||
목표 `RANGE_RESUMABLE_DOWNLOAD_V1`은 다음을 모두 포함한다.
|
||||
|
||||
- immutable generation 또는 strong validator와 representation binding
|
||||
- 비권한성 account-partitioned checkpoint와 CAS offset
|
||||
- exact `206`, Range 무시/validator mismatch의 `200`, mode가 계약한 `412`,
|
||||
`416` 상태 머신
|
||||
- capability 재발급 뒤 representation binding 재검증
|
||||
- seek/truncate destination 또는 journaled OPFS staging
|
||||
- durable segment commit 뒤에만 checkpoint offset 전진
|
||||
- 완료 뒤 destination 전체 재읽기와 whole-object SHA-256
|
||||
- partial count/byte/age retention, crash/logout/account-switch cleanup
|
||||
- system picker 미지원 환경의 browser-managed handoff/server-generation fallback
|
||||
|
||||
상세 불변조건과 promotion gate는
|
||||
[VD-14](./decisions/VD-14-resumable-download-and-background-transfer.md)를 따른다.
|
||||
Range의 primary current status는 `DESIGNED_NOT_IMPLEMENTED`다.
|
||||
|
||||
### 5.1 아직 구현되지 않은 strategy selector
|
||||
|
||||
현재 download strategy는 composition-registered file policy 하나에
|
||||
`BROWSER_MANAGED`, `PROMPT_AND_STREAM` 또는 `BOUNDED_OBJECT_URL`로 정적으로
|
||||
고정된다. source kind, exact size와 실제 picker/seek/OPFS 지원을 입력으로 안전한
|
||||
fallback을 선택하는 공통 headless selector는 없다.
|
||||
|
||||
목표 selector는 user-agent 문자열이 아니라 capability probe를 사용한다.
|
||||
|
||||
| 조건 | 목표 결정 |
|
||||
| --- | --- |
|
||||
| server file, 탭 종료 뒤 계속 필요 | `BROWSER_MANAGED_HANDOFF` |
|
||||
| verified foreground save, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` |
|
||||
| Range resume 필수, destination 계약 충족 | `RANGE_RESUMABLE_FOREGROUND` |
|
||||
| 작은 generated artifact | `BOUNDED_OBJECT_URL` |
|
||||
| 큰 generated artifact, picker 미지원 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` |
|
||||
| background download가 선택되고 OPFS만 지원 | `APP_MANAGED_BACKGROUND_DOWNLOAD`; app-private staging 후 foreground export |
|
||||
|
||||
위 값은 application-level execution plan이다. 현재 file-delivery primitive에는
|
||||
VD-16의 고정 mapping으로만 투영한다. source kind
|
||||
`BROWSER_MANAGED_RESOURCE`를 strategy로 사용하거나 `PROMPT_AND_STREAM`을
|
||||
Range plan으로 재사용하지 않는다.
|
||||
|
||||
picker 미지원 때문에 unbounded Blob ceiling을 올리지 않는다. browser-managed
|
||||
handoff는 disk save나 integrity 완료를 관찰할 수 없으므로 계속
|
||||
`BROWSER_HANDOFF`다. 이 selector와 picker 미지원 failure normalization도 현재
|
||||
구현돼 있지 않다.
|
||||
|
||||
## 6. Image CDN
|
||||
|
||||
### 6.1 Asset와 preset
|
||||
|
||||
application은 raw source URL이나 arbitrary transform query 대신 opaque asset
|
||||
reference와 composition-registered preset reference를 전달한다. backend/CDN
|
||||
descriptor는 다음을 묶는다.
|
||||
|
||||
- opaque asset ID와 immutable asset revision
|
||||
- public/private delivery class
|
||||
- source pixel dimensions와 안전 판정을 통과한 raster media type
|
||||
- named preset와 exact crop/fit intent
|
||||
- rendition별 format, natural width/height, URL와 expiry
|
||||
- private rendition이면 server-issued capability binding
|
||||
|
||||
CDN은 임의 external source URL을 transform parameter로 받지 않는다. backend
|
||||
asset registry가 quarantine/scan을 통과한 source object만 CDN asset ID로
|
||||
promotion한다.
|
||||
|
||||
### 6.2 Policy validation
|
||||
|
||||
Composition policy가 소유하는 값:
|
||||
|
||||
- application origin과 그 origin과 다른 allowed HTTPS CDN origins/path prefix
|
||||
- named preset와 allowed widths/DPR/formats
|
||||
- max natural/output width·height·pixels와 decoded/encoded bytes
|
||||
- crop/fit, quality와 static-raster 제한
|
||||
- maximum candidates와 minimum private URL lifetime
|
||||
- maximum concurrent capability verification
|
||||
- public/private cache, referrer와 credential policy
|
||||
|
||||
runtime은 caller가 preset의 width, DPR, quality나 format ceiling을 늘리지 못하게
|
||||
한다. SVG/HTML/data/blob/javascript URL과 active/unknown media type은 기본
|
||||
거절한다. 현재 protocol은 static raster만 지원하므로 animated format은 항상
|
||||
거절하며, 도입하려면 별도 frame/decode budget protocol이 필요하다.
|
||||
Composition이 전달하는 hard limit은
|
||||
`IMAGE_CDN_IMPLEMENTATION_CEILINGS`보다 항상 작거나 같아야 한다. 이 값은 제품
|
||||
기본값이 아니라 adapter-owned 절대 상한이며 intrinsic/source/output pixel,
|
||||
decoded/encoded byte, candidate, URL, capability lifetime와 동시 cryptographic
|
||||
verification guard를 구성 실수로 해제하지 못하게 한다.
|
||||
|
||||
private capability의 signature는 versioned preset binding ID의 허용 집합을
|
||||
묶는다. CDN/BFF는 그 ID를 server-owned immutable preset registry에서 조회하고
|
||||
요청 query의 width/height/DPR/fit/format/quality가 registry가 산출한 exact
|
||||
candidate인지 다시 계산해 불일치 요청을 거절해야 한다. 브라우저가 만든 query,
|
||||
binding digest 또는 signature 문자열을 단순히 echo하거나 query 자체를
|
||||
authorization proof로 취급하지 않는다.
|
||||
|
||||
capability policy의 `acceptedKeyIds`는 bounded unique overlap set이며 현재
|
||||
signing key를 고르는 selector가 아니다. verifier의 immutable public-key
|
||||
registry는 runtime 생성 시 이 집합 전체를 포함해야 하고, descriptor의 단일
|
||||
`signature.keyId`는 policy와 verifier 양쪽에 exact membership이 있어야 한다.
|
||||
rotation은 새 public key와 old/new overlap policy 배포, client 채택 확인,
|
||||
backend signer 전환, `maxCapabilityLifetimeMs + maxClockSkewMs`와 client rollout
|
||||
기간 경과, old key 제거 순서를 따른다. 유출 key는 overlap 절차 대신 backend
|
||||
revocation과 runtime 재조합/강제 rollout 대상으로 다룬다.
|
||||
|
||||
browser probe는 encoded body를 hard cap 안에서 읽은 직후 native decoder 호출
|
||||
전에 PNG/JPEG/WebP/AVIF header/container metadata를 파싱한다. 선언된
|
||||
width/height, pixel 수와 decoded-byte ceiling을 먼저 확인하고 APNG/WebP
|
||||
animation, AVIF sequence/derived image와 ambiguous/malformed container를
|
||||
fail-closed한다. 이 pre-decode 검사가 통과한 static raster만
|
||||
`createImageBitmap`으로 실제 dimensions를 재검증한다.
|
||||
|
||||
### 6.3 Responsive descriptor
|
||||
|
||||
width descriptor를 쓰는 candidate는 모두 양의 고유 width를 가지며 오름차순으로
|
||||
정렬한다. 같은 source set에서 `w`와 `x` descriptor를 섞지 않는다. `sizes`는
|
||||
registry가 승인한 layout token에서 결정하고 arbitrary presentation 문자열을
|
||||
CDN query에 넣지 않는다.
|
||||
|
||||
반환값은 presentation-safe descriptor다.
|
||||
|
||||
- fallback `src`, intrinsic width/height
|
||||
- ordered format별 `srcset`
|
||||
- registry-owned `sizes`
|
||||
- `loading`, `decoding`, `fetchPriority`
|
||||
- `referrerPolicy=no-referrer`
|
||||
- application과 분리된 CDN origin의 asset은 `crossOrigin=anonymous`
|
||||
|
||||
private signed image는 expiry 전에 실제 load가 시작될 수 있는 eager/priority
|
||||
정책만 사용하거나 load 직전에 새 descriptor를 발급한다. 오래된 signed URL을
|
||||
DOM, persisted state, telemetry 또는 query cache에 장기 보관하지 않는다.
|
||||
private delivery는 `PRIMARY_REQUIRED` browser probe가 필수이며 이를 `NONE`으로
|
||||
낮출 수 없다. probe는 `credentials: omit`, redirect 금지, exact response URL과
|
||||
`Cache-Control: no-store`를 실제 response에서 확인한다.
|
||||
실제 `<img crossorigin="anonymous">`는 same-origin일 때 cookie를 보낼 수
|
||||
있으므로 registry는 CDN origin이 composition의 application origin과 같으면
|
||||
생성 단계에서 거절한다. private CDN 응답은 cookie나 ambient authorization에
|
||||
의존하지 않는다.
|
||||
|
||||
### 6.4 CDN cache와 invalidation
|
||||
|
||||
- public rendition은 asset revision을 URL에 포함하고
|
||||
`public, max-age=..., immutable`로 제공한다.
|
||||
- content가 바뀌면 purge에 의존해 같은 URL을 재사용하지 않고 revision을 바꾼다.
|
||||
- format은 URL에서 명시하거나 `Vary: Accept` 계약과 cache key를 정확히 맞춘다.
|
||||
- private rendition은 짧은 expiry와 필수 `no-store`를 쓴다.
|
||||
- CDN cache hit 여부는 authorization이나 asset safety proof가 아니다.
|
||||
|
||||
image probe의 단일 bounded deadline은 response header fetch, streamed body read와
|
||||
native decode 전체를 포함한다. timeout/cancel/error 시 reader를 cancel하고,
|
||||
abort 뒤 늦게 resolve한 response body도 cancel하며 늦게 생성된 `ImageBitmap`도
|
||||
즉시 `close()`한다.
|
||||
|
||||
Image CDN runtime의 `close()`는 terminal/idempotent다. application teardown,
|
||||
logout, account/tenant partition 변경 또는 runtime 교체 시 composition owner가
|
||||
한 번 호출한다. runtime lifetime signal은 진행 중 capability verification과
|
||||
probe를 중단하고, accepted capability WeakMap은 새 WeakMap으로 교체되어 기존
|
||||
reference를 즉시 revoke하면서 strong reference를 남기지 않는다. 닫힌 runtime은
|
||||
accept/resolve를 `UNAVAILABLE`로 거절하며 재개하지 않고 새 runtime을 조합한다.
|
||||
|
||||
### 6.5 아직 구현되지 않은 descriptor provider와 refresh
|
||||
|
||||
현재 Image CDN runtime은 trusted gateway가 이미 strict하게 decode했다고 가정한
|
||||
`BackendIssuedImageAsset` 또는 composition-owned public descriptor를
|
||||
`runtime.assets`에 전달받는다. opaque asset/preset validation, P-256 signature,
|
||||
responsive URL 생성과 browser probe는 구현돼 있지만 BFF에서 private descriptor를
|
||||
가져오는 concrete HTTP provider는 없다.
|
||||
|
||||
향후 `IMAGE_CDN_DESCRIPTOR_V1` provider는 다음 계약을 가진다.
|
||||
|
||||
- composition-owned fixed HTTPS BFF endpoint
|
||||
- caller가 전달하는 값은 opaque product asset reference와 registered intention뿐
|
||||
- authenticated control-plane request, redirect 금지와 no-store
|
||||
- exact final response URL/status/content type/content length
|
||||
- fatal UTF-8 bounded JSON body와 unknown-field rejection
|
||||
- issuer, asset/revision, dimensions, delivery class, preset binding ID,
|
||||
issued/expiry와 P-256 signature의 exact schema
|
||||
- request deadline, caller/runtime abort와 late response-body cleanup
|
||||
- descriptor와 raw backend body를 query cache, persistence, log와 telemetry에
|
||||
저장하지 않음
|
||||
|
||||
private descriptor가 minimum remaining lifetime 아래로 내려가면 presentation
|
||||
runtime이 기존 signed URL을 임의 연장하지 않는다. product-owned facade가 같은
|
||||
opaque asset/intention에 대해 single-flight reissue를 수행하고, 새 descriptor를
|
||||
다시 signature/registry/probe 경계에 통과시킨다. asset revision, preset binding,
|
||||
issuer 또는 account scope가 달라지면 기존 reference를 폐기하고 새 결과로
|
||||
교체한다. logout, tenant switch, key compromise와 kill switch에서는 refresh를
|
||||
중단하고 runtime을 `close()`한다.
|
||||
|
||||
P-256 key overlap과 runtime close는 현재 구현돼 있지만 dynamic key-set fetch,
|
||||
revocation epoch/list, descriptor auto-refresh와 backend scope revoke는 구현돼
|
||||
있지 않다. 정상 key rotation은 composition의 immutable old/new registry 교체로,
|
||||
긴급 회수는 backend revoke, runtime close와 forced rollout으로 처리한다.
|
||||
|
||||
presentation-safe descriptor를 실제 `<picture>/<source>/<img>`에 적용하는 renderer도
|
||||
현재 공통 runtime 범위에는 없다. renderer primitive는 raw URL override를 받지 않고
|
||||
descriptor 속성만 투영할 수 있지만, `alt`, placeholder, error/retry, SSR/preload와
|
||||
analytics는 제품 presentation이 소유한다.
|
||||
|
||||
## 7. Failure와 recovery
|
||||
|
||||
| 조건 | 결과 | 복구 |
|
||||
| --- | --- | --- |
|
||||
| capability expired/revoked | `EXPIRED_RESOURCE` | 새 capability 발급 |
|
||||
| method/origin/path/binding mismatch | `POLICY_REJECTED` | 요청 재구성 금지 |
|
||||
| part status conflict | `CONFLICT` | server reconcile |
|
||||
| part checksum mismatch | `INTEGRITY_FAILED` | 같은 bytes 재검증 후 retry/abort |
|
||||
| response overrun/truncation | `INTEGRITY_FAILED` | destination abort, 새 download |
|
||||
| session missing/gone | `EXPIRED_RESOURCE` | checkpoint 폐기 후 새 session |
|
||||
| image preset/URL/pixel violation | `POLICY_REJECTED` | 안전한 placeholder/original policy |
|
||||
| image capability verification concurrency ceiling | `LIMIT_EXCEEDED` | 진행 작업 종료 대기 또는 runtime 부하 조사 |
|
||||
| closed Image CDN runtime 사용 | `UNAVAILABLE` | 새 runtime composition |
|
||||
| network/429/모든 5xx | `UNAVAILABLE` | bounded retry/backoff |
|
||||
|
||||
관측성에는 operation, safe outcome, byte/part/candidate bucket, retry bucket과
|
||||
failure code만 기록한다. capability ID, URL, query, asset/resource/session ID,
|
||||
file name, digest, raw ETag, receipt와 backend message는 log/diagnostics/telemetry에
|
||||
기록하지 않는다. 앞 절의 strict checkpoint allowlist만 durable 예외다.
|
||||
|
||||
## 8. 조합 조건
|
||||
|
||||
현재 `src/adapters/browser-transfer/index.ts`는 개별 presigned, upload와 Image CDN
|
||||
factory를 export할 뿐 이들을 하나의 lifecycle과 account partition으로 묶는
|
||||
top-level composition factory를 제공하지 않는다. browser file runtime, capability
|
||||
vault, upload runtime과 Image CDN runtime은 각자 `dispose()`/`close()`를 가지지만
|
||||
logout, account switch와 partial cleanup을 하나의 admission fence 아래 실행하는
|
||||
owner도 아직 없다.
|
||||
|
||||
제품 composition 전에 반드시 정할 것:
|
||||
|
||||
- fixed BFF capability endpoint, closed upload endpoint map과 runtime schema
|
||||
- `PRESIGNED_TRANSFER_V1` 및 `IMAGE_CDN_DESCRIPTOR_V1` rollout/drain 계획
|
||||
- `PRESIGNED_MULTIPART_V1` canonical binding 재계산과 server-side session lookup
|
||||
- same-origin proxy 또는 cross-origin CORS/object-storage topology
|
||||
- per-account/partition Web Lock namespace와 ephemeral BroadcastChannel cancel
|
||||
namespace, 미지원 환경의 bounded abort fallback
|
||||
- per-operation maximum bytes, part size/count/concurrency와 retry budget
|
||||
- upload success status/receipt header/`expectedResponseByteLength`와 response cap
|
||||
- checksum algorithm과 full/composite 의미
|
||||
- quarantine scan/promotion/status protocol
|
||||
- checkpoint classification, account scope, retention과 logout handling
|
||||
- application과 분리된 CDN origin, versioned preset exact 재계산,
|
||||
format/pixel/decoded-byte/cache/CSP contract, key rotation,
|
||||
verification concurrency와 runtime/probe deadline
|
||||
- download save/handoff UX와 partial destination recovery
|
||||
- browser matrix, fault injection, orphan cleanup과 CDN rollback runbook
|
||||
|
||||
이 값이 없으면 runtime factory를 bootstrap에 넣지 않는다. 선택하지 않은 runtime은
|
||||
production module inventory와 removal gate로 기본 bundle에서 제외한다.
|
||||
|
||||
### 8.1 목표 top-level composition
|
||||
|
||||
향후 공통 factory의 책임은 dependency를 편리하게 묶는 것보다 구성 시 불변조건과
|
||||
teardown 순서를 한 곳에서 강제하는 것이다.
|
||||
|
||||
```text
|
||||
BrowserTransferComposition
|
||||
product facade references
|
||||
strict runtime config snapshot
|
||||
browser file runtime
|
||||
browser-managed capability provider/vault
|
||||
presigned provider/vault/executor
|
||||
upload control transport/runtime/checkpoint admin
|
||||
download strategy selector
|
||||
optional Range runtime/checkpoint/destination registry
|
||||
image descriptor provider/runtime
|
||||
account lifecycle fence
|
||||
readiness/compatibility result
|
||||
safe observer
|
||||
kill switches
|
||||
close()
|
||||
```
|
||||
|
||||
browser-managed handoff mechanism에는 synchronous resolver seam이 있지만, 실제
|
||||
BFF에서 capability를 발급받아 user activation 전에 in-memory identity vault에
|
||||
준비하는 concrete provider는 현재 없다. 목표 provider는 fixed endpoint, strict
|
||||
versioned response, safe receipt와 exact resource/media/extension/length/digest/expiry
|
||||
binding을 검증하고 raw href는 resolver 내부에만 둔다. handoff 시점에는 async
|
||||
발급을 시작하지 않고 이미 준비된 exact identity만 synchronous consume한다.
|
||||
|
||||
composition의 `close()`는 terminal/idempotent하고 다음 순서를 보장한다.
|
||||
|
||||
1. 신규 issue/upload/download/image resolve admission을 닫는다.
|
||||
2. active foreground work와 worker/channel을 abort한다.
|
||||
3. in-memory signed capability와 image reference를 revoke한다.
|
||||
4. writer, reader, Web Lock, BroadcastChannel과 runtime을 close한다.
|
||||
5. upload/Range checkpoint와 owned staging을 정책에 따라 reconcile한다.
|
||||
6. logout/account deletion이면 maintenance authority로 exact partition cleanup을
|
||||
실행한다.
|
||||
7. blocked/ambiguous cleanup을 성공으로 표시하지 않고 safe recovery 결과로 남긴다.
|
||||
|
||||
primary status가 `COMPOSED`여도 completion ledger의 `RuntimeHealth`,
|
||||
`PromotionEvidence`와 `TrafficAdmission`은 별도다. config schema, endpoint/auth,
|
||||
account partition, actual provider evidence, cleanup owner와 browser fallback 중
|
||||
하나라도 없으면 `TrafficAdmission=DISABLED`,
|
||||
`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 fail-closed한다.
|
||||
|
||||
### 8.2 Contract harness 목표
|
||||
|
||||
현재 unit test와 Playwright route interception은 reference runtime의 failure와
|
||||
browser API behavior를 검증하지만 reusable BFF/provider conformance suite는 아니다.
|
||||
`server-file-capability-infrastructure.md`의 provider contract matrix도 설계이며 실제
|
||||
S3/MinIO/GCS/Azure/CDN adapter에 실행되는 source는 이 repository에 없다.
|
||||
|
||||
향후 harness는 같은 versioned fixture를 다음 네 등급에 실행한다.
|
||||
|
||||
| 등급 | 증명 범위 |
|
||||
| --- | --- |
|
||||
| deterministic fake | state machine, canonical binding과 failure mapping |
|
||||
| intercepted browser route | Fetch/CORS/stream/abort와 native destination |
|
||||
| emulator/container | provider SDK, multipart와 response header wiring |
|
||||
| actual staging provider | 실제 product/API/version/region의 constraint 강제 |
|
||||
|
||||
필수 frontend/BFF contract:
|
||||
|
||||
- `PRESIGNED_TRANSFER_V1` exact request/response와 unknown version rejection
|
||||
- expiry, wrong method/resource/range/part/header/query/origin rejection
|
||||
- URL/query/header/error/telemetry redaction
|
||||
- capability reissue와 server revocation
|
||||
- upload create/status/part/complete/abort, response-loss와 orphan reconcile
|
||||
- pause/checkpoint inventory가 구현될 경우 v1→v2 migration과 old-writer drain
|
||||
- browser-managed capability preload/consume와 `BROWSER_HANDOFF` truth
|
||||
- `IMAGE_CDN_DESCRIPTOR_V1`, signature/key overlap, expiry/reissue와 revocation
|
||||
- CDN preset exact recomputation, private no-store와 public immutable cache
|
||||
- Range를 구현할 경우 VD-14의 `200/206/412/416` 및 destination crash matrix
|
||||
|
||||
actual provider evidence에는 adapter/provider/config/contract version, artifact
|
||||
digest, environment/region, pass/fail/skip, fault result, waiver/owner와 expiry를
|
||||
기록한다. required case skip, expired evidence와 contract/config 변경은 production
|
||||
promotion을 막는다. fake나 emulator success를 actual provider evidence로
|
||||
승격하지 않는다.
|
||||
|
||||
## 9. 표준·vendor 참고
|
||||
|
||||
- [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)
|
||||
- [Fetch Standard](https://fetch.spec.whatwg.org/)
|
||||
- [RFC 9110 HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html)
|
||||
- [File System Standard](https://fs.spec.whatwg.org/)
|
||||
- [VD-14 resumable download와 background download](./decisions/VD-14-resumable-download-and-background-transfer.md)
|
||||
- [VD-16 browser transfer composition과 image delivery](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md)
|
||||
- [HTML responsive images](https://html.spec.whatwg.org/multipage/images.html)
|
||||
- [tus resumable upload protocol](https://tus.io/protocols/resumable-upload)
|
||||
- [Amazon S3 presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)
|
||||
- [Amazon S3 multipart upload](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html)
|
||||
@@ -0,0 +1,806 @@
|
||||
# Protobuf browser transport와 REST Gateway
|
||||
|
||||
- 상태: production design accepted, product/provider selection pending
|
||||
- 기준일: 2026-07-28
|
||||
- 범위: gRPC-Web, Connect-Web, Connect protocol, Protobuf contract/codegen,
|
||||
REST/JSON Gateway
|
||||
- 현재 source 상태: provider-neutral Browser RPC V3 계약·application port·공통
|
||||
unary/server-stream lifecycle runtime은 `AVAILABLE_NOT_COMPOSED`;
|
||||
vendor runtime/dependency/proto/descriptor/proxy는 없음
|
||||
- 관련 결정:
|
||||
- [VD-23 API transport selection과 REST execution](./decisions/VD-23-api-transport-selection-and-rest-execution.md)
|
||||
- [VD-24 Runtime schema와 boundary mapper](./decisions/VD-24-runtime-schema-and-boundary-mapper.md)
|
||||
- [VD-25 Server state cache lifecycle](./decisions/VD-25-server-state-cache-lifecycle.md)
|
||||
- [VD-27 gRPC-Web unary와 server stream](./decisions/VD-27-grpc-web-unary-and-server-stream.md)
|
||||
- [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md)
|
||||
- [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md)
|
||||
- backend handoff:
|
||||
[Backend API와 Server State contract](./backend-api-and-server-state-contract.md)
|
||||
- 운영 절차:
|
||||
[API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md)
|
||||
|
||||
|
||||
## Installed binding snapshot과 stream cleanup bound (R-01, R-04, R-05, R-06)
|
||||
|
||||
- `installBrowserRpcContractBindings()`가 registry를 **parse → validate →
|
||||
install** 순서로 처리한다. own data descriptor만 읽어 exact key set으로
|
||||
null-prototype frozen snapshot을 만들고, 그 snapshot을 검증한 뒤 설치한다.
|
||||
getter/accessor, extra key, symbol key, malformed descriptor, revoked proxy는
|
||||
composition-time `TypeError`이며 getter는 호출조차 되지 않는다. runtime과
|
||||
transport call은 이후 snapshot만 읽으므로 validation 이후 registry mutation이
|
||||
replay policy·deadline·byte ceiling·transport selection을 바꿀 수 없다.
|
||||
- server stream 종료는 transport iterator에 lifecycle authority를 위임하지
|
||||
않는다. commit/admission generation은 즉시 fence하고 listener는 바로 해제하며,
|
||||
`iterator.return()`은 cleanup **요청**으로서 bound 안에서만 기다린다. 끝나지
|
||||
않은 cleanup은 관찰만 유지되고(unhandled rejection 없음) application generator는
|
||||
bound 안에 종료된다. cleanup rejection은 이미 선택된 application failure를
|
||||
덮지 않는다.
|
||||
- WebSocket text frame은 allocation 전에 admission한다. UTF-16 code unit 길이가
|
||||
이미 cap을 넘으면 encoder를 만들지 않고 거절하고, 나머지는 early exit하는
|
||||
code-point 누적으로 센다. valid surrogate pair는 4 bytes, lone surrogate는
|
||||
`TextEncoder`와 동일하게 replacement 3 bytes다.
|
||||
- clock/fence collaborator 예외는 Result 경계를 벗어나지 않는다. clock 실패는
|
||||
`SERVER_FAILURE/RPC_RUNTIME_DEPENDENCY_FAILED`, capture 실패는
|
||||
`SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`, `isCurrent` 실패는
|
||||
fail-closed로 canonicalize하며 listener/timer는 단일 exit path에서 정확히 한 번
|
||||
해제한다.
|
||||
|
||||
Browser RPC는 여전히 `AVAILABLE_NOT_COMPOSED`다. 선택된 Connect/gRPC-Web
|
||||
transport는 enqueue-time `maxBufferedBytes`, raw/decompressed ceiling,
|
||||
cancel/closed receipt, terminal framing, target browser와 load behavior를
|
||||
별도로 증명해야 조립할 수 있다 (R-07).
|
||||
|
||||
## 1. 먼저 축을 분리한다
|
||||
|
||||
네 이름은 같은 종류의 대안이 아니다.
|
||||
|
||||
| 축 | 선택지 | 소유하는 것 |
|
||||
| --- | --- | --- |
|
||||
| contract/serialization | Protobuf binary, ProtoJSON | message/service schema, field number, presence, codegen |
|
||||
| browser RPC transport | Connect protocol, gRPC-Web | HTTP framing, media, status/error, timeout, stream terminal |
|
||||
| browser client runtime | Connect-Web, official grpc-web 또는 승인 runtime | Fetch/XHR, generated client, interceptors, cancellation |
|
||||
| HTTP exposure | curated REST BFF, generated gRPC-Gateway, Envoy JSON transcoder | resource URL, method, JSON/status/cache/CORS contract |
|
||||
|
||||
예를 들어 Connect-Web은 같은 generated service descriptor로 Connect protocol과
|
||||
gRPC-Web transport를 모두 만들 수 있다. 반대로 REST Gateway는 Protobuf service를
|
||||
ProtoJSON HTTP API로 노출할 수 있지만, 그것이 자동으로 좋은 browser REST
|
||||
contract가 된다는 뜻은 아니다.
|
||||
|
||||
한 semantic operation은 frontend registry에서 정확히 하나의 active wire
|
||||
profile에 binding한다. runtime이 media type을 보고 Connect/gRPC-Web/REST를
|
||||
추측하거나 장애 시 다른 protocol로 같은 command를 자동 replay하지 않는다.
|
||||
|
||||
## 2. 현재 상태
|
||||
|
||||
| capability | current status | 근거 | promotion 필요 |
|
||||
| --- | --- | --- | --- |
|
||||
| Browser RPC V3 공통 계약/runtime | `AVAILABLE_NOT_COMPOSED` | exact operation/profile/schema/mapper/encoder/transport join, unary retry·total deadline·abort, bounded server-stream·idle deadline·terminal, generation fence와 unavailable adapter test | selected generated client를 감싸는 protocol transport, raw-byte cap과 actual provider/browser conformance |
|
||||
| Protobuf schema/codegen | `DESIGNED_NOT_IMPLEMENTED` | `.proto`, `buf.yaml`, descriptor, runtime dependency 없음 | authenticated schema source와 owner |
|
||||
| Connect-Web client runtime | `DESIGNED_NOT_IMPLEMENTED` | `@connectrpc/*`, `@bufbuild/protobuf` 없음 | selected service, provider와 bundle budget |
|
||||
| Connect protocol unary | `DESIGNED_NOT_IMPLEMENTED` | endpoint/profile/fixture 없음 | exact JSON 또는 binary profile |
|
||||
| Connect protocol server stream | `DESIGNED_NOT_IMPLEMENTED` | stream runtime/proxy evidence 없음 | bounded stream protocol과 browser evidence |
|
||||
| gRPC-Web unary/server stream | `DESIGNED_NOT_IMPLEMENTED` | VD-27만 존재 | runtime/proxy/descriptor와 conformance |
|
||||
| REST Gateway reference contract/harness | `DESIGNED_NOT_IMPLEMENTED` | native REST reference만 있고 transcoder/fixture 없음 | selected kind의 deterministic/provider conformance |
|
||||
| product REST Gateway composition | `NOT_SELECTED` | route/provider/owner 없음 | curated BFF 또는 selected transcoder의 제품 승인 |
|
||||
| browser client/bidi stream | `PLATFORM_LIMITED` | request streaming을 target browser 공통 계약으로 보장 못함 | 다른 transport/application protocol |
|
||||
| product traffic | `NOT_SELECTED` | operation/provider/owner 없음 | product ADR와 traffic admission |
|
||||
|
||||
이 문서가 추가돼도 dependency나 generated source를 기본 bundle에 넣지 않는다.
|
||||
공통 runtime source가 생긴 뒤에도 같은 원칙을 유지한다. 공통 runtime은
|
||||
Connect/gRPC-Web wire를 직접 decode하지 않고 selected transport가 반환한
|
||||
bounded message/terminal/failure만 처리하므로, 실제 Connect-Web 또는 gRPC-Web
|
||||
adapter가 구현됐다는 증거가 아니다.
|
||||
|
||||
## 3. 기본 선택
|
||||
|
||||
### 3.1 권장 순서
|
||||
|
||||
1. 기존 REST가 제품 의미와 운영 요구를 충족하면 REST를 유지한다.
|
||||
2. backend가 Protobuf-first이고 browser RPC가 필요하면 Connect-Web +
|
||||
Connect protocol을 우선 평가한다.
|
||||
3. backend가 gRPC-Web만 노출하거나 기존 Envoy/gRPC-Web conformance 자산을
|
||||
재사용해야 하면 Connect-Web의 gRPC-Web transport 또는 official grpc-web
|
||||
runtime 중 하나를 고정한다.
|
||||
4. public HTTP API, CDN/conditional cache, 링크 가능한 resource URL,
|
||||
broad HTTP tooling이 핵심이면 curated REST BFF를 우선한다.
|
||||
5. `google.api.http` annotation만으로 제품 REST 의미를 온전히 표현할 수 있을
|
||||
때만 generated gRPC-Gateway/Envoy transcoding을 선택한다.
|
||||
|
||||
### 3.2 선택 matrix
|
||||
|
||||
| 요구 | 기본 후보 | 주의 |
|
||||
| --- | --- | --- |
|
||||
| 내부 web UI + Protobuf-first backend + unary | Connect-Web/Connect | JSON/binary를 operation profile로 고정 |
|
||||
| 내부 web UI + 기존 gRPC-Web gateway | selected gRPC-Web runtime | runtime별 streaming capability가 다름 |
|
||||
| bounded server→browser stream | Connect server stream 또는 gRPC-Web server stream | idle/total/queue/sequence/resume 필수 |
|
||||
| public/resource-oriented HTTP API | curated REST BFF | Protobuf service shape를 그대로 노출하지 않음 |
|
||||
| 단순 proto HTTP annotation과 broad JSON client | generated REST Gateway | ProtoJSON/status/error semantic fixture 필요 |
|
||||
| HTTP cache/ETag/Range/file download | REST/BFF | RPC transcoder로 억지로 만들지 않음 |
|
||||
| browser client/bidi | WebSocket/WebTransport/별도 session API | Connect protocol 자체 지원과 browser 지원을 혼동 금지 |
|
||||
| 기존 REST backend뿐임 | REST 유지 | Protobuf/gateway를 미래 대비로 추가하지 않음 |
|
||||
|
||||
Connect가 항상 gRPC-Web보다 우월하거나 REST Gateway가 수동 REST보다 항상
|
||||
저렴하다고 가정하지 않는다. actual provider, proxy, browser, bundle,
|
||||
observability와 조직 운영 비용을 evidence로 비교한다.
|
||||
|
||||
## 4. 공통 application 경계
|
||||
|
||||
```text
|
||||
presentation
|
||||
-> feature application input
|
||||
-> semantic gateway port
|
||||
-> installed operation registry
|
||||
-> REST adapter
|
||||
-> Connect adapter
|
||||
-> gRPC-Web adapter
|
||||
-> transport decode
|
||||
-> semantic runtime schema
|
||||
-> boundary mapper
|
||||
-> immutable application projection
|
||||
```
|
||||
|
||||
금지:
|
||||
|
||||
```text
|
||||
page/use-case -> generated service client
|
||||
page/use-case -> protobuf message or descriptor
|
||||
page/use-case -> transport/base URL/metadata
|
||||
generated message -> TanStack cache
|
||||
raw Connect/gRPC error -> presentation
|
||||
REST gateway DTO -> domain without runtime schema/mapper
|
||||
```
|
||||
|
||||
application port는 `listResources`, `createResource`, `watchJob` 같은 의미를
|
||||
표현한다. `callRpc(service, method, bytes)`나 `executeProto<T>()`를 노출하지 않는다.
|
||||
|
||||
## 5. Protocol-neutral operation과 exact binding
|
||||
|
||||
```text
|
||||
ApiOperationContractV3
|
||||
semanticOperationId
|
||||
owner
|
||||
semantics = QUERY | COMMAND | SERVER_STREAM
|
||||
protocol = REST | CONNECT_HTTP | GRPC_WEB
|
||||
replayPolicy
|
||||
idempotencyKeyPolicy
|
||||
authProfileId
|
||||
csrfProfileId
|
||||
deadlineProfileId
|
||||
retryProfileId
|
||||
requestSemanticSchemaId
|
||||
responseSemanticSchemaId
|
||||
mapperId
|
||||
serverStateProfileId | null
|
||||
dataClassification
|
||||
compatibility
|
||||
protocolBinding
|
||||
```
|
||||
|
||||
현재 source에서는 설치된 REST V2 registry를 위험한 union migration으로 바꾸지
|
||||
않고 `BrowserRpcOperationV3` sibling registry를 추가했다. 제품 operation이
|
||||
선택되면 composition root가 semantic feature gateway 뒤에서 REST 또는 Browser
|
||||
RPC 중 정확히 하나를 bind한다. use-case가 두 registry를 보거나 runtime fallback을
|
||||
결정하지 않는다.
|
||||
|
||||
Connect/gRPC-Web binding:
|
||||
|
||||
```text
|
||||
BrowserRpcOperationV3 + BrowserRpcProviderProfile
|
||||
providerId
|
||||
clientRuntimeId
|
||||
clientRuntimeVersion
|
||||
transportRuntimeKind = CONNECT_WEB_FETCH | OFFICIAL_GRPC_WEB_XHR | CUSTOM_FETCH_FRAMED
|
||||
clientApiKind = PROMISE | ASYNC_ITERABLE | CALLBACK_STREAM
|
||||
protocolRevision
|
||||
wireProfileId
|
||||
encoding = PROTO_BINARY | PROTO_JSON
|
||||
fullyQualifiedService
|
||||
method
|
||||
rpcKind = UNARY | SERVER_STREAM
|
||||
requestMessageId
|
||||
responseMessageId
|
||||
descriptorArtifactId
|
||||
descriptorDigest
|
||||
errorProfileId
|
||||
corsProfileId
|
||||
compressionProfileId
|
||||
deadlineDialect
|
||||
retryOwner = FRONTEND_ADAPTER | EDGE_PROXY | NONE
|
||||
maxRequestBytes
|
||||
maxHeaderBytes
|
||||
maxHeaderFields
|
||||
maxMessageBytes
|
||||
maxResponseMessages
|
||||
maxTotalResponseBytes
|
||||
idleDeadlineMs | null
|
||||
```
|
||||
|
||||
구현 경로는 `src/contracts/browser-rpc.ts`,
|
||||
`src/application/ports/browser-rpc`, `src/adapters/browser-rpc`다. 공통 profile은
|
||||
runtime identity/digest, fixed base URL, descriptor digest, allowed procedure,
|
||||
auth/CSRF/CORS/error/deadline/retry owner와 raw-byte ceiling owner까지 고정한다.
|
||||
`maxHeaderBytes`, media/trailer parsing과 실제 raw queue ceiling은 공통 coordinator가
|
||||
추측하지 않고 selected wire transport가 VD-27/VD-29에 따라 추가로 집행한다.
|
||||
|
||||
REST Gateway binding:
|
||||
|
||||
```text
|
||||
RestGatewayBindingV1
|
||||
providerId
|
||||
gatewayKind = CURATED_BFF | GRPC_GATEWAY | ENVOY_TRANSCODER
|
||||
gatewayRuntimeId
|
||||
gatewayRuntimeVersion
|
||||
httpRuleArtifactId
|
||||
httpRuleDigest
|
||||
method
|
||||
relativePathTemplate
|
||||
requestProjection
|
||||
protoJsonProfileId | null
|
||||
responseEnvelopeProfileId
|
||||
statusErrorProfileId
|
||||
cacheConditionalProfileId
|
||||
```
|
||||
|
||||
registry는 다음을 boot/build 전에 거절한다.
|
||||
|
||||
- protocol/profile/runtime/provider tuple 불일치
|
||||
- descriptor 또는 HTTP rule digest 불일치
|
||||
- caller-provided endpoint, method, metadata 또는 message type
|
||||
- `SERVER_STREAM`인데 ordinary Query profile 사용
|
||||
- browser client/bidi method
|
||||
- Connect와 gRPC-Web decoder의 runtime auto-negotiation
|
||||
- runtime kind와 deadline/cancel/status API가 맞지 않는 client API 조합
|
||||
- frontend와 edge proxy가 동시에 retry owner인 profile
|
||||
- gateway kind가 다른 두 route의 last-write-wins collision
|
||||
- JSON exposure인데 binary-only compatibility gate만 통과
|
||||
- non-replayable command retry/fallback
|
||||
- hard ceiling보다 큰 message/frame/deadline/URL
|
||||
|
||||
## 6. Protobuf contract source
|
||||
|
||||
### 6.1 Source of truth
|
||||
|
||||
```text
|
||||
authenticated Proto/Buf module
|
||||
-> immutable source commit/module digest
|
||||
-> lint
|
||||
-> breaking comparison
|
||||
-> FileDescriptorSet
|
||||
-> deterministic codegen
|
||||
-> generated artifact digest
|
||||
-> operation/schema/mapper binding
|
||||
-> release contract set
|
||||
```
|
||||
|
||||
runtime endpoint에서 latest schema를 받아 build하지 않는다. schema update는
|
||||
review 가능한 explicit workflow이며 source, dependency lock, descriptor,
|
||||
plugin/runtime와 generated output digest를 함께 보존한다.
|
||||
|
||||
### 6.2 Evolution
|
||||
|
||||
- field number 재사용/renumber 금지
|
||||
- 삭제 field number와 JSON name reserve
|
||||
- package/service/method full name 안정성
|
||||
- enum zero value와 unknown numeric policy
|
||||
- proto3 optional/edition presence를 명시
|
||||
- oneof absence/unknown case 처리
|
||||
- map ordering을 identity/digest로 사용하지 않음
|
||||
- unknown field가 binary↔JSON conversion에서 보존되지 않을 수 있음을 반영
|
||||
- Timestamp/Duration range와 nanos
|
||||
- int64/uint64의 JS/ProtoJSON projection
|
||||
- bytes와 repeated/nesting ceiling
|
||||
|
||||
JSON을 한 곳이라도 사용하면 binary wire compatibility만으로 충분하지 않다.
|
||||
최소 `WIRE_JSON` breaking category를 요구한다. generated SDK의 import/source
|
||||
compatibility를 외부 소비자가 의존하면 `PACKAGE` 또는 `FILE`을 선택한다.
|
||||
Buf gate와 별도로 domain meaning, authorization, default/presence, pagination,
|
||||
revision과 idempotency semantic diff를 수행한다.
|
||||
|
||||
### 6.3 ProtoJSON profile
|
||||
|
||||
operation은 다음을 고정한다.
|
||||
|
||||
```text
|
||||
ProtoJsonProfileV1
|
||||
emitDefaultValues
|
||||
useProtoFieldNames
|
||||
enumEncoding = NAME | NUMBER
|
||||
ignoreUnknownFields
|
||||
int64Projection
|
||||
bytesProjection
|
||||
wellKnownTypePolicy
|
||||
```
|
||||
|
||||
runtime/library default에 맡기지 않는다. 기본 방향:
|
||||
|
||||
- request unknown field reject
|
||||
- response는 generated decoder 뒤 known semantic projection만 mapper에 전달
|
||||
- int64/uint64는 decimal string 또는 bounded application type
|
||||
- bytes는 base64 decode 전후 byte ceiling
|
||||
- non-finite float는 domain 승인 없으면 거절
|
||||
- Timestamp/Duration은 generated decode 성공 뒤 semantic range 재검증
|
||||
- `Any`는 allowlisted type URL만
|
||||
|
||||
ProtoJSON은 ordinary JSON schema의 임의 union을 대체하지 않으며 binary보다
|
||||
evolution 보장이 약하다.
|
||||
|
||||
## 7. Deterministic codegen과 공급망
|
||||
|
||||
선택 branch는 최소 다음을 고정한다.
|
||||
|
||||
```text
|
||||
buf.yaml
|
||||
buf.lock
|
||||
buf.gen.yaml
|
||||
schema/module digest
|
||||
protoc or Buf version
|
||||
plugin name/version/revision/digest
|
||||
@bufbuild/protobuf version
|
||||
@connectrpc/connect version
|
||||
@connectrpc/connect-web version
|
||||
Buf image digest
|
||||
FileDescriptorSet digest
|
||||
canonical HttpRule manifest digest
|
||||
generated OpenAPI digest when selected
|
||||
generated output digest
|
||||
```
|
||||
|
||||
CI:
|
||||
|
||||
- format/lint/breaking
|
||||
- dependency lock과 source provenance
|
||||
- clean checkout generate diff 0
|
||||
- generated directory 수동 수정 금지
|
||||
- generated import boundary
|
||||
- descriptor↔generated symbol exact join
|
||||
- generated runtime↔runtime compatibility matrix
|
||||
- canonical HttpRule/OpenAPI semantic diff; custom option을 Buf breaking에 위임하지 않음
|
||||
- N/N-1 fixture
|
||||
- license/SBOM/vulnerability/secret scan
|
||||
- production bundle inventory와 budget
|
||||
- capability removal 뒤 generated/runtime/proxy reference 0
|
||||
|
||||
generated files는 `src/generated` 같은 전역 public API가 아니라 selected
|
||||
feature adapter-private root에 둔다. 여러 feature가 공유하는 service라도 좁은
|
||||
platform adapter facade만 재사용한다.
|
||||
|
||||
## 8. Connect-Web
|
||||
|
||||
Connect-Web은 browser client runtime이다. `createConnectTransport()`는 Connect
|
||||
protocol, `createGrpcWebTransport()`는 gRPC-Web protocol을 사용한다. 같은 package를
|
||||
쓴다고 두 wire protocol이 호환되거나 자동 failover 가능한 것은 아니다.
|
||||
Connect-ES v2 generation은 `protoc-gen-es`가 message와 service descriptor를
|
||||
함께 생성하며 과거 Connect 전용 generator를 신규 toolchain에 넣지 않는다.
|
||||
|
||||
### 8.1 Connect unary
|
||||
|
||||
```text
|
||||
validated semantic input
|
||||
-> generated request
|
||||
-> bounded encode (ProtoJSON or binary)
|
||||
-> POST fixed /package.Service/Method
|
||||
-> bounded response/error decode
|
||||
-> generated message
|
||||
-> semantic validation
|
||||
-> mapper
|
||||
-> generation fence
|
||||
```
|
||||
|
||||
Connect unary는 bare Protobuf/JSON body와 의미 있는 HTTP status를 사용한다.
|
||||
success content type과 selected encoding을 exact 검증한다. error는 non-2xx JSON
|
||||
Connect error profile로만 decode하며 intermediary HTML/JSON을 Connect error로
|
||||
추측하지 않는다.
|
||||
|
||||
stock runtime이 unary body를 whole JSON/ArrayBuffer로 읽는 selected version이면
|
||||
interceptor가 raw-byte ceiling을 대신한다고 기록하지 않는다. edge의 encoded/
|
||||
decompressed cap과 platform-owned bounded Fetch transport 또는 해당 exact
|
||||
runtime의 overflow conformance가 있어야 production evidence가 닫힌다.
|
||||
|
||||
GET은 다음을 모두 만족할 때만 별도 profile로 허용한다.
|
||||
|
||||
- protobuf method `idempotency_level = NO_SIDE_EFFECTS`
|
||||
- public/non-sensitive bounded request
|
||||
- URL byte ceiling과 canonical encoding
|
||||
- header/credential/preflight 정책
|
||||
- exact CDN/browser cache key, `Vary`, ETag와 retention
|
||||
- request URL이 log/history/referrer에 노출돼도 허용되는 classification
|
||||
|
||||
인증/private query의 기본은 POST다.
|
||||
|
||||
### 8.2 Connect server stream
|
||||
|
||||
Connect streaming은 framed `application/connect+proto|json`이고 HTTP status
|
||||
`200` 안의 final EndStream envelope가 RPC error/trailer authority다.
|
||||
|
||||
decoder는:
|
||||
|
||||
- incremental 5-byte envelope prefix
|
||||
- flag/compression/declared length
|
||||
- per-message/decompressed/total/count ceiling
|
||||
- exactly one final EndStream envelope
|
||||
- data after EndStream, missing EndStream와 truncation
|
||||
- bounded error/detail/trailer projection
|
||||
- idle + total deadline
|
||||
- reader cancel/release와 bounded consumer queue
|
||||
|
||||
를 검증한다. network EOF는 success가 아니다. stock Connect-Web runtime을
|
||||
사용하면 exact package version이 이 state machine을 얼마나 집행하는지
|
||||
conformance로 증명하고, 부족한 hard ceiling은 proxy 또는 custom Transport가
|
||||
소유한다. selected stock runtime의 streaming output compression은
|
||||
`IDENTITY_ONLY`로 고정하고 지원하지 않는 compressed envelope를 광고하지 않는다.
|
||||
|
||||
### 8.3 Interceptor policy
|
||||
|
||||
interceptor가 허용되는 책임:
|
||||
|
||||
- registry-owned auth/CSRF patch
|
||||
- remaining deadline/timeout
|
||||
- fixed low-cardinality diagnostics
|
||||
- exact safe retry coordinator
|
||||
- safe error normalization
|
||||
|
||||
금지:
|
||||
|
||||
- arbitrary URL/service/method rewrite
|
||||
- raw message/error logging
|
||||
- caller metadata passthrough
|
||||
- operation semantic retry를 transport가 임의 결정
|
||||
- cache write와 domain mapping
|
||||
|
||||
transport/client는 runtime/scope 단위로 재사용하되 base URL/profile이 다른
|
||||
provider 사이에서 공유하지 않는다.
|
||||
|
||||
interceptor array의 선언 순서가 아니라 실제 onion execution order를 manifest와
|
||||
test에 보존한다. remaining total deadline이 0 이하면 `timeoutMs=0`을 넘기지 않고
|
||||
호출 전에 local deadline failure로 닫는다.
|
||||
|
||||
## 9. gRPC-Web
|
||||
|
||||
상세 frame/status state machine은 VD-27을 따른다.
|
||||
|
||||
- browser는 native gRPC/HTTP2 transport를 직접 사용한다고 가정하지 않는다.
|
||||
- official `grpc-web` runtime은 XHR와 runtime-owned frame/status decoder,
|
||||
`ClientReadableStream.cancel()`을 사용한다. raw `ReadableStream` frame parser와
|
||||
`AbortSignal`을 이 경로의 frontend 보장으로 기록하지 않는다.
|
||||
- custom Fetch runtime만 `fetch`/`AbortController`/incremental raw-frame decoder
|
||||
profile을 사용한다. 두 runtime은 `transportRuntimeKind`로 분리한다.
|
||||
- gRPC-Web response trailer는 body trailer frame 또는 trailers-only response
|
||||
header에서 해석한다.
|
||||
- `grpc-status`가 있으면 그것이 authoritative다. 없으면 native gRPC의 공식
|
||||
HTTP→gRPC fallback mapping으로 internal status를 만들며 HTTP success만으로
|
||||
RPC success를 판정하지 않는다.
|
||||
- official `grpc-web` JavaScript runtime은 binary unary와 text unary/server
|
||||
streaming capability를 구분한다.
|
||||
- Connect-Web gRPC-Web transport를 선택하면 별도 `clientRuntimeId`와 그 runtime의
|
||||
conformance matrix를 사용한다.
|
||||
- text/base64와 binary를 같은 byte budget으로 취급하지 않는다.
|
||||
- text decoder는 browser chunk 하나를 base64 entity 하나로 가정하지 않고,
|
||||
중간 padding이 있는 연속 base64 entity를 처리해야 한다.
|
||||
- Envoy `grpc_web` filter 또는 selected gateway의 exact version/config를
|
||||
provider profile에 binding한다.
|
||||
- Envoy profile은 filter order, upstream HTTP/2, route/idle/max-stream timeout,
|
||||
gRPC timeout offset, buffering/flush, header/message ceiling과 local reply
|
||||
mapping을 고정한다. server stream에는 default route timeout을 그대로 쓰지 않는다.
|
||||
- retry owner는 정확히 하나다. server stream replay/hedge는 금지하고, frontend와
|
||||
Envoy retry를 동시에 켜지 않는다.
|
||||
|
||||
gRPC-Web과 Connect stream은 terminal envelope가 서로 다르다. 공통
|
||||
`ReadableStream` helper를 재사용할 수 있어도 decoder/state machine을 합치지 않는다.
|
||||
|
||||
## 10. REST Gateway
|
||||
|
||||
### 10.1 세 종류를 분리한다
|
||||
|
||||
`CURATED_BFF`
|
||||
|
||||
- browser/resource contract를 별도로 설계
|
||||
- REST envelope, status, ETag/304/412, idempotency, pagination과 CORS를 직접 소유
|
||||
- 내부에서 gRPC/Connect service를 호출할 수 있으나 public DTO는 별도 mapper
|
||||
|
||||
`GRPC_GATEWAY`
|
||||
|
||||
- `google.api.http` annotation에서 reverse proxy와 optional OpenAPI를 생성
|
||||
- proto request field를 path/query/body로 projection
|
||||
- ProtoJSON과 gRPC status mapping을 exact profile로 고정
|
||||
- initial reference 후보는 unary만이며 REST streaming은 별도 ADR 없이는 선택하지 않음
|
||||
|
||||
`ENVOY_TRANSCODER`
|
||||
|
||||
- descriptor와 `google.api.http` annotation으로 proxy filter가 JSON↔gRPC 변환
|
||||
- filter/runtime config와 descriptor를 coherent artifact로 배포
|
||||
- application-specific envelope/cache/idempotency 의미는 별도 filter/BFF 없이는
|
||||
자동 생성되지 않음
|
||||
|
||||
한 route는 하나의 gateway kind만 소유한다.
|
||||
|
||||
현재 reference REST의 `{ success, data|error, meta }` envelope와
|
||||
`200|201`, 향후 `204/304/412` 의미는 direct generated gateway의 기본 출력과
|
||||
같지 않다. 따라서 기존 reference operation은 curated BFF를 유지한다. direct
|
||||
gateway는 ProtoJSON/HTTP rule/status/error 자체를 새 versioned public contract로
|
||||
승인한 operation에만 적용한다.
|
||||
|
||||
### 10.2 Automatic transcoding의 한계
|
||||
|
||||
HTTP annotation은 path/method/body projection을 정의하지만 다음을 자동으로
|
||||
완성하지 않는다.
|
||||
|
||||
- frontend의 strict success/failure envelope
|
||||
- product authorization와 existence hiding
|
||||
- idempotency store/effect certainty
|
||||
- CursorPage snapshot 의미
|
||||
- ETag/If-None-Match/If-Match와 revision CAS
|
||||
- CDN/private cache policy
|
||||
- domain error vocabulary와 validation detail redaction
|
||||
- file Range/streaming download/upload
|
||||
- browser-compatible server event/reconnect protocol
|
||||
- OpenAPI와 runtime response/error rewrite의 자동 coherence
|
||||
|
||||
따라서 현재 reference REST envelope에 generated gateway를 바로 연결할 수 없다.
|
||||
gateway adapter가 exact envelope/status를 제공하거나 frontend에 새 operation
|
||||
contract를 versioned로 추가해야 한다.
|
||||
|
||||
### 10.3 REST mapping
|
||||
|
||||
- resource name/path는 stable API meaning을 가져야 한다.
|
||||
- path field를 body/query에도 중복 projection하지 않는다.
|
||||
- unbound fields의 query mapping과 repeated/nested encoding을 fixture로 고정한다.
|
||||
- `body: "*"`는 query surface와 HTTP semantics를 숨길 수 있어 default 금지다.
|
||||
- additional binding collision과 ambiguous path template를 build에서 거절한다.
|
||||
- `.proto` annotation을 기본 source of truth로 삼고 external service config를
|
||||
병용하면 override precedence와 두 artifact digest를 고정한다.
|
||||
- `generate_unbound_methods`는 기본 금지하며 GET/DELETE body, path field type,
|
||||
path unescape mode와 PATCH FieldMask behavior를 fixture로 고정한다.
|
||||
- Buf generic breaking check가 custom HTTP option의 제품 의미까지 증명한다고
|
||||
간주하지 않는다. canonical route manifest와 generated OpenAPI의 method/path/
|
||||
body/query/additional-binding diff를 별도 gate로 검사한다.
|
||||
- `response_body` projection은 전체 response schema와 mapper binding을 별도로
|
||||
갖는다.
|
||||
- ProtoJSON JSON name/default/enum/int64/unknown policy를 고정한다.
|
||||
- request/response body와 URL ceiling은 transcoder 앞/뒤 모두 적용한다.
|
||||
|
||||
server-stream을 JSON array로 buffer해 반환하는 transcoder 동작을 realtime
|
||||
stream으로 간주하지 않는다. SSE/NDJSON/streaming JSON이 필요하면 별도 protocol
|
||||
ADR과 framing/content type/terminal contract를 만든다.
|
||||
|
||||
## 11. Auth, CSRF와 CORS
|
||||
|
||||
same-origin BFF를 권장한다. cross-origin이면 protocol별 exact profile이 필요하다.
|
||||
|
||||
Connect:
|
||||
|
||||
- POST와 optional GET
|
||||
- `Content-Type`, `Connect-Protocol-Version`, `Connect-Timeout-Ms`,
|
||||
selected compression/auth headers
|
||||
- custom unary trailer를 expose할 때 `Trailer-<Name>`
|
||||
|
||||
gRPC-Web:
|
||||
|
||||
- POST
|
||||
- `Content-Type`, `Grpc-Timeout`, `X-Grpc-Web`, `X-User-Agent`,
|
||||
selected auth headers
|
||||
- `Grpc-Status`, `Grpc-Message`, `Grpc-Status-Details-Bin` expose
|
||||
|
||||
REST Gateway:
|
||||
|
||||
- operation별 method/header/media
|
||||
- bearer 또는 cookie+CSRF profile
|
||||
- conditional/idempotency header allow/expose
|
||||
|
||||
공통:
|
||||
|
||||
- exact allow-origin, credential mode와 `Vary`
|
||||
- wildcard credential 금지
|
||||
- redirect login/HTML response 금지
|
||||
- Origin/Fetch Metadata/CSRF를 cookie unsafe method에서 검증
|
||||
- generated client/caller가 arbitrary metadata를 제출하지 못함
|
||||
- auth attach 뒤 endpoint/path/method/body digest 불변
|
||||
|
||||
## 12. Deadline, cancellation, retry와 command
|
||||
|
||||
```text
|
||||
total logical deadline
|
||||
= auth attach
|
||||
+ transport attempts/backoff
|
||||
+ body/frame read/decompression
|
||||
+ generated decode
|
||||
+ semantic validation
|
||||
+ mapper
|
||||
```
|
||||
|
||||
- Connect-Web call은 AbortSignal과 bounded timeout을 받는다.
|
||||
- local deadline과 wire timeout 중 더 짧은 값만 사용한다.
|
||||
- stream은 idle/total deadline 둘 다 갖는다.
|
||||
- abort가 backend command rollback을 의미하지 않는다.
|
||||
- generated/runtime interceptor retry와 Query retry를 중복하지 않는다.
|
||||
- `SAFE | IDEMPOTENT | KEYED_COMMAND`의 exact evidence가 있는 operation만 retry한다.
|
||||
- keyed command는 protocol과 무관하게 backend atomic idempotency/reconcile이
|
||||
필요하다.
|
||||
- gateway의 header/message mapping은 idempotency key를 전달할 뿐 durable
|
||||
dedupe, receipt, retention과 reconcile을 구현하지 않는다.
|
||||
- browser abort는 이미 commit된 command의 rollback 증거가 아니다. edge→upstream
|
||||
cancel/deadline 전파와 backend cooperative cancellation을 staging에서 검증한다.
|
||||
- protocol 장애를 이유로 Connect↔gRPC-Web↔REST command를 자동 replay하지 않는다.
|
||||
- read fallback도 새 logical operation으로 시작하고 old result를 cache에 쓰지 않는다.
|
||||
|
||||
## 13. Status와 error mapping
|
||||
|
||||
common safe vocabulary에 mapping하되 wire authority를 섞지 않는다.
|
||||
|
||||
| source | authoritative outcome |
|
||||
| --- | --- |
|
||||
| Connect unary | HTTP status + bounded Connect JSON error |
|
||||
| Connect stream | HTTP admission + final EndStream envelope |
|
||||
| gRPC-Web | HTTP admission + terminal grpc-status source |
|
||||
| REST Gateway | selected HTTP/envelope/problem profile |
|
||||
|
||||
mapping은 `UNAUTHENTICATED/AUTH_REQUIRED`, `PERMISSION_DENIED/FORBIDDEN`,
|
||||
`NOT_FOUND`, `CONFLICT/ABORTED`, validation, rate limit, unavailable,
|
||||
deadline/cancel을 공통 `AppFailure`로 투영한다. raw message, arbitrary Any/detail,
|
||||
metadata/trailer와 vendor error는 application/log에 전달하지 않는다.
|
||||
|
||||
같은 semantic backend error라도 transport별 status body가 다를 수 있으므로
|
||||
conformance suite가 최종 `AppFailure`와 retry/effect certainty가 같은지 검증한다.
|
||||
|
||||
## 14. Server State와 cache
|
||||
|
||||
- query key는 semantic application input에서만 파생한다.
|
||||
- service/method/protobuf bytes/REST URL을 key에 넣지 않는다.
|
||||
- generated message/Connect response를 cache하지 않고 mapper output만 admission한다.
|
||||
- transport retry가 있으면 Query retry는 off다.
|
||||
- scope/generation mismatch result는 폐기한다.
|
||||
- Connect GET/CDN cache, browser HTTP cache와 TanStack cache owner를 operation별로
|
||||
하나씩 명시한다.
|
||||
- stream event는 ordinary query result가 아니다.
|
||||
- finite stream aggregate는 terminal success 뒤 atomic commit한다.
|
||||
- long-running stream은 bounded reducer 또는 invalidate-only hint를 사용한다.
|
||||
|
||||
## 15. Security와 privacy
|
||||
|
||||
- descriptor/generated code는 신뢰된 source에서만
|
||||
- fixed provider/service/method/route
|
||||
- message/body/frame/decompressed/collection/depth ceiling
|
||||
- recursive schema와 Any type allowlist
|
||||
- frontend generated validation을 backend authorization으로 간주 금지
|
||||
- raw payload, ProtoJSON, debug stringifier, metadata/trailer/cursor/revision log 금지
|
||||
- request/response message에 credential을 넣지 않음
|
||||
- query cache/persistence에 generated message 없음
|
||||
- gateway가 unknown field/duplicate JSON key를 어떻게 처리하는지 fixture로 고정
|
||||
|
||||
## 16. Observability
|
||||
|
||||
허용:
|
||||
|
||||
```text
|
||||
semantic operation ID
|
||||
protocol/client runtime/provider profile ID
|
||||
descriptor/http-rule artifact version
|
||||
HTTP status group / safe RPC code
|
||||
attempt/duration/message-size/count bucket
|
||||
stream terminal/idle/gap/overflow bucket
|
||||
cache admission outcome
|
||||
```
|
||||
|
||||
금지:
|
||||
|
||||
- service request/response content
|
||||
- protobuf debug JSON/string
|
||||
- metadata/trailer/error message/detail
|
||||
- URL query/GET message
|
||||
- credential/idempotency/cursor/revision
|
||||
- resource/account identifier actual value
|
||||
|
||||
protocol별 metric을 비교할 때 semantic operation ID를 join key로 쓰고 raw
|
||||
service/method를 high-cardinality label로 사용하지 않는다.
|
||||
|
||||
## 17. Test와 provider evidence
|
||||
|
||||
### 17.1 Deterministic
|
||||
|
||||
- proto format/lint/breaking/descriptor/codegen digest
|
||||
- enum/oneof/presence/int64/time/bytes/unknown-field fixture
|
||||
- Connect unary JSON/binary success/error/media/status
|
||||
- Connect stream partial prefix, length, compression, EndStream, truncation
|
||||
- gRPC-Web binary/text frame/trailer/status matrix
|
||||
- REST annotation path/query/body/response mapping
|
||||
- ProtoJSON default/name/enum/int64/null/unknown behavior
|
||||
- retry/deadline/cancel/auth/idempotency
|
||||
- scope/generation/cache admission
|
||||
- generated import/bundle/removal
|
||||
|
||||
### 17.2 Actual provider/browser
|
||||
|
||||
- selected Connect/gRPC-Web server or Envoy/gateway version
|
||||
- exact CORS/preflight/auth/CSRF
|
||||
- HTTP/1.1/2 proxy buffering and stream flush
|
||||
- media, compression, terminal status/trailer preservation
|
||||
- message/body/time limit
|
||||
- Chromium/Firefox/WebKit cancel/stream/backpressure
|
||||
- REST Gateway N/N-1 and OpenAPI/descriptor coherence
|
||||
- browser abort→gateway context→upstream cancel/deadline propagation
|
||||
- HttpRule route manifest/OpenAPI/runtime response rewrite coherence
|
||||
- kill switch, rollback and dependency/proxy removal drill
|
||||
|
||||
memory fake/MSW만으로 provider conformance를 주장하지 않는다.
|
||||
|
||||
## 18. Rollout
|
||||
|
||||
```text
|
||||
product operation selected
|
||||
-> schema/provider/gateway owner
|
||||
-> immutable proto + descriptor
|
||||
-> codegen and semantic mapper
|
||||
-> provider-neutral adapter/fake
|
||||
-> actual gateway/browser conformance
|
||||
-> AVAILABLE_NOT_COMPOSED
|
||||
-> bootstrap TrafficAdmission=DISABLED
|
||||
-> COMPOSED
|
||||
-> read-only shadow
|
||||
-> canary
|
||||
-> enabled
|
||||
```
|
||||
|
||||
- 처음에는 safe unary read 하나만 선택한다.
|
||||
- shadow result는 UI/cache에 쓰지 않는다.
|
||||
- Connect와 gRPC-Web을 동시에 canary하지 않는다.
|
||||
- server stream은 unary와 별도 gate다.
|
||||
- command는 backend idempotency/reconcile 뒤 별도 gate다.
|
||||
- REST fallback은 사전 등록된 read operation만 새 logical query로 실행한다.
|
||||
- rollback은 frontend/generated descriptor/gateway/backend를 coherent set으로 한다.
|
||||
|
||||
## 19. Removal
|
||||
|
||||
1. 신규 call/stream admission을 닫는다.
|
||||
2. query/stream cancel, command effect reconcile.
|
||||
3. cache/reducer/invalidation listener를 clear한다.
|
||||
4. operation/schema/mapper/provider profile을 제거한다.
|
||||
5. generated source, descriptor/proto input과 codegen config를 제거한다.
|
||||
6. Connect/gRPC runtime dependency와 proxy/transcoder route를 제거한다.
|
||||
7. backend method/HTTP binding은 N/N-1 client window 뒤 retirement한다.
|
||||
8. bundle, SBOM, lockfile, proxy config와 source reference 0을 증명한다.
|
||||
|
||||
## 20. 구현 work package
|
||||
|
||||
| 순서 | package | exit |
|
||||
| --- | --- | --- |
|
||||
| PB-01 | schema governance | authenticated module, Buf lint/breaking, descriptor/digest |
|
||||
| PB-02 | generated boundary | deterministic TS generation, private imports, mapper |
|
||||
| PB-03 | Connect unary | exact JSON/binary profile, error/deadline/cancel |
|
||||
| PB-04 | gRPC-Web unary | selected runtime/proxy/frame/status conformance |
|
||||
| PB-05 | bounded server stream | queue/idle/total/terminal/sequence/resume |
|
||||
| PB-06 | REST Gateway | selected kind, HTTP annotation/ProtoJSON/error/cache fixture |
|
||||
| PB-07 | operations | browser/provider evidence, canary/kill/rollback/removal |
|
||||
|
||||
PB-03~06은 제품에서 선택한 branch만 구현한다. “미래 대비”로 모두 설치하지 않는다.
|
||||
|
||||
## 21. 완료 기준
|
||||
|
||||
- [ ] Protobuf, client runtime, wire protocol과 gateway 축이 분리돼 있다.
|
||||
- [ ] 한 operation은 한 active transport/provider profile만 가진다.
|
||||
- [ ] descriptor/codegen/runtime/gateway artifact가 release digest에 binding된다.
|
||||
- [ ] generated type이 adapter 밖으로 나오지 않는다.
|
||||
- [ ] ProtoJSON과 binary compatibility policy가 각각 닫혀 있다.
|
||||
- [ ] Connect unary/stream과 gRPC-Web decoder가 서로의 terminal 규칙을 섞지 않는다.
|
||||
- [ ] REST Gateway가 envelope/cache/idempotency를 자동 제공한다고 가장하지 않는다.
|
||||
- [ ] client/bidi streaming을 browser 공통 capability로 표시하지 않는다.
|
||||
- [ ] actual proxy와 세 browser evidence가 있다.
|
||||
- [ ] command fallback/replay가 backend effect certainty를 우회하지 않는다.
|
||||
- [ ] coherent rollback과 complete removal drill이 통과한다.
|
||||
|
||||
## 22. 규범·공식 근거
|
||||
|
||||
- [Connect protocol reference](https://connectrpc.com/docs/protocol/)
|
||||
- [Connect-Web protocol selection](https://connectrpc.com/docs/web/choosing-a-protocol/)
|
||||
- [Connect-Web code generation](https://connectrpc.com/docs/web/generating-code/)
|
||||
- [Connect and gRPC-Web CORS](https://connectrpc.com/docs/cors/)
|
||||
- [gRPC-Web protocol delta](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md)
|
||||
- [Official grpc-web runtime](https://github.com/grpc/grpc-web)
|
||||
- [Envoy gRPC-Web filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_web_filter.html)
|
||||
- [gRPC HTTP status fallback mapping](https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md)
|
||||
- [Envoy timeout configuration](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html)
|
||||
- [Protocol Buffers language guide](https://protobuf.dev/programming-guides/proto3/)
|
||||
- [ProtoJSON format](https://protobuf.dev/programming-guides/json/)
|
||||
- [Buf breaking changes](https://buf.build/docs/breaking/)
|
||||
- [gRPC-Gateway introduction](https://grpc-ecosystem.github.io/grpc-gateway/docs/tutorials/introduction/)
|
||||
- [gRPC-Gateway customization](https://grpc-ecosystem.github.io/grpc-gateway/docs/mapping/customizing_your_gateway/)
|
||||
- [Google API HTTP annotation](https://github.com/googleapis/googleapis/blob/master/google/api/http.proto)
|
||||
- [Envoy gRPC-JSON transcoder](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
# Imported scoped diagram review evidence
|
||||
|
||||
This ledger entry consumes the canonical evidence already recorded by the
|
||||
`ca-skeleton-frontend-operational-contract` project note. It does not claim
|
||||
review of the repository-local Mermaid projections or of the complete
|
||||
production deployment topology.
|
||||
|
||||
- Reviewer: `wiki-diagram-reviewer`
|
||||
- Standard: `rules/diagram-standards.md` v2
|
||||
- Canonical report:
|
||||
`docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md`
|
||||
- Canonical report SHA-256:
|
||||
`b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29`
|
||||
|
||||
| Canonical diagram | SHA-256 | Score | Verdict | Reviewed scope |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| `raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio` | `c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0` | 100 | PASS | Clean Architecture compile-time dependency ownership |
|
||||
| `raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio` | `9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b` | 100 | PASS | immutable static assets and mutable `/config.json` delivery |
|
||||
|
||||
The canonical report explicitly limits this `PASS_SCOPED`: it does not verify
|
||||
the complete release/rollback topology, the implementation topology, or live
|
||||
hosting state.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"status": "PASS_SCOPED",
|
||||
"reviewer": "wiki-diagram-reviewer",
|
||||
"standard": "rules/diagram-standards.md v2",
|
||||
"evidenceReport": {
|
||||
"repoPath": "docs/architecture/review-evidence.md",
|
||||
"upstreamCanonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
|
||||
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
|
||||
},
|
||||
"reviews": {
|
||||
"overview": {
|
||||
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio",
|
||||
"sha256": "c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0",
|
||||
"score": 100,
|
||||
"verdict": "PASS",
|
||||
"thresholdSatisfied": true,
|
||||
"scope": "Clean Architecture compile-time dependency ownership"
|
||||
},
|
||||
"staticDelivery": {
|
||||
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio",
|
||||
"sha256": "9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b",
|
||||
"score": 100,
|
||||
"verdict": "PASS",
|
||||
"thresholdSatisfied": true,
|
||||
"scope": "immutable static assets and mutable /config.json delivery"
|
||||
}
|
||||
},
|
||||
"note": "`repoPath` is this repository's copy and must resolve. `upstreamCanonicalPath` and every `reviews[*].sourcePath` name the reviewing workspace, not this tree; they are provenance labels and are deliberately not resolvable here. `canonicalSha256` is what binds the two, and the gate checks it appears in `repoPath`."
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
# 라우팅, 페이지 템플릿, 재사용 패턴
|
||||
|
||||
> **정본 안내 (non-authoritative for runtime capability decisions)**
|
||||
>
|
||||
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
|
||||
> Service Worker, offline command와 Background Sync의 구현 결정은
|
||||
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
|
||||
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
|
||||
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 도메인과 무관하게 다음을 바로 구현할 수 있는 기준을 제공한다.
|
||||
|
||||
- typed route와 안전한 URL
|
||||
- session, permission, feature flag guard
|
||||
- lazy chunk의 loading/error/recovery
|
||||
- route 이동 시 focus, scroll, 취소, dirty form 처리
|
||||
- list, detail, form, status 등 공통 page template
|
||||
- page controller와 application input use case의 연결
|
||||
- 프론트엔드에서 반복 사용하는 설계 패턴
|
||||
|
||||
## 2. 현재 상태와 구현 기준
|
||||
|
||||
RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
|
||||
|
||||
- route registry가 path와 access policy를 소유한다.
|
||||
- contract에서 Data Router route object와 navigation을 생성한다.
|
||||
- runtime map이 route component를 lazy import하고 codec을 연결한다.
|
||||
- 앱 셸과 보호 route, not-found surface가 있다.
|
||||
- route heading focus와 비동기/render error boundary가 있다.
|
||||
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
|
||||
- `src/presentation/templates`가 standard/collection/detail/form/status slot,
|
||||
landmark와 responsive layout을 제공한다.
|
||||
- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping,
|
||||
duplicate submit과 dirty route blocker를 소유한다.
|
||||
- reference feature의 list/detail/create/status route가 네 template variation을
|
||||
production composition에서 실행한다.
|
||||
|
||||
platform route 계약, `src/features/installed-feature-contracts.ts`,
|
||||
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
|
||||
negative fixture가 함께 검사한다. params/search codec, loading/error surface,
|
||||
access, title, navigation, chunk ID는
|
||||
`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
|
||||
manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
|
||||
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
|
||||
|
||||
route ID와 parsed input type은 `route-contract.ts`, parse/canonical URL 생성은
|
||||
`route-codecs.ts`, React context/provider/hook은 `route-input.tsx`가 소유한다.
|
||||
router는 codec 결과를 provider에 넣고 lazy feature page는 좁은
|
||||
`useRouteInput()`만 소비한다. 이 분리로 feature runtime이 자신을 load하는
|
||||
`app-router.tsx`를 역참조하지 않으며, 같은 유형의 TypeScript 순환 의존은
|
||||
architecture graph fixture가 거절한다.
|
||||
|
||||
template는 데이터를 가져오지 않는다. reference page controller가 route input과
|
||||
application input을 query/form facade에 연결하고, template에는 render할 slot과
|
||||
안전한 callback만 전달한다. 이 분리는
|
||||
`page-templates-own-layout-only` dependency rule과 forbidden import fixture가
|
||||
검증한다.
|
||||
|
||||
## 3. React Router mode 결정
|
||||
|
||||
[React Router 공식 mode 설명](https://reactrouter.com/start/modes)과
|
||||
[Data Mode custom setup](https://reactrouter.com/start/data/custom)은
|
||||
Declarative, Data, Framework Mode를 구분한다.
|
||||
|
||||
저장소에는 현재 React Router `7.18.1`이 고정돼 있다. 구현 브랜치는 공식 문서의
|
||||
동일 버전 API를 기준으로 하고 router version upgrade를 Data Mode 구조 변경과
|
||||
같은 브랜치에 섞지 않는다. 위 공식 링크의 기본 표시 버전이 바뀌면 version
|
||||
selector를 `7.18.1`로 맞춰 확인한다.
|
||||
|
||||
| mode | 선택 조건 | 이 저장소에서의 판단 |
|
||||
| --- | --- | --- |
|
||||
| Declarative | React composition과 외부 data layer가 route data를 소유 | RP-04 이전 기준선 |
|
||||
| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | VD-03으로 채택하고 RP-04에서 구현 |
|
||||
| Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 |
|
||||
|
||||
채택한 결정:
|
||||
|
||||
- client-only SPA와 TanStack Query/application use case를 유지한다.
|
||||
- `createBrowserRouter`와 `RouterProvider` 기반 Data Mode를 사용한다.
|
||||
- Data Mode를 선택하는 이유는 route object, navigation blocker, scroll
|
||||
restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로
|
||||
서버 상태를 다시 소유하기 위해서가 아니다.
|
||||
- loader/action을 추가할 때는 TanStack Query/application input을 prefetch하거나
|
||||
호출하는 한 가지 소유 경로만 사용한다.
|
||||
- 같은 데이터를 route loader와 TanStack Query가 각각 가져오지 않는다.
|
||||
- SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지
|
||||
않는다.
|
||||
|
||||
결정 근거와 rollback 경계는
|
||||
`docs/architecture/decisions/VD-03-react-router-data-mode.md`에 고정한다.
|
||||
Data Mode를 사용할 수 없는 프로젝트만 별도 ADR과
|
||||
`NavigationLifecycleAdapter`를 구현한다.
|
||||
|
||||
## 4. route 계약과 runtime map
|
||||
|
||||
### 4.1 두 종류의 레지스트리
|
||||
|
||||
직렬화 가능한 contract와 React implementation을 분리한다.
|
||||
|
||||
```ts
|
||||
export const routeContracts = {
|
||||
home: {
|
||||
id: "home",
|
||||
path: "/",
|
||||
access: "public",
|
||||
navigation: "primary",
|
||||
titleKey: "route.home.title",
|
||||
loadingSurface: "page",
|
||||
errorSurface: "page",
|
||||
chunkId: "home",
|
||||
},
|
||||
resourceDetail: {
|
||||
id: "resourceDetail",
|
||||
path: "/examples/resources/:resourceId",
|
||||
access: "authenticated",
|
||||
navigation: "hidden",
|
||||
titleKey: "route.resourceDetail.title",
|
||||
loadingSurface: "detail",
|
||||
errorSurface: "detail",
|
||||
chunkId: "reference-resource-detail",
|
||||
},
|
||||
} as const satisfies RouteContractRegistry;
|
||||
```
|
||||
|
||||
```tsx
|
||||
export const routeRuntime = {
|
||||
home: {
|
||||
Component: lazy(() => import("../pages/home-page")),
|
||||
paramsCodec: emptyParamsCodec,
|
||||
searchCodec: emptySearchCodec,
|
||||
},
|
||||
resourceDetail: {
|
||||
Component: lazy(() => import("../features/resources/resource-detail-page")),
|
||||
paramsCodec: resourceDetailParamsCodec,
|
||||
searchCodec: resourceDetailSearchCodec,
|
||||
},
|
||||
} satisfies Record<RouteId, RouteRuntime>;
|
||||
```
|
||||
|
||||
요구 사항:
|
||||
|
||||
- contract key와 `id`가 다르면 typecheck 실패
|
||||
- contract에는 함수, component, schema instance처럼 직렬화 불가능한 값을 넣지 않음
|
||||
- runtime map에는 실제 lazy component와 codec/guard만 둠
|
||||
- contract의 모든 route가 runtime에 있고 runtime의 모든 key가 contract에 있음
|
||||
- navigation은 contract에서 파생
|
||||
- build chunk manifest와 `chunkId` 대응을 검증
|
||||
- public runtime config나 server가 route component 이름을 임의 지정할 수 없음
|
||||
|
||||
### 4.2 params와 search codec
|
||||
|
||||
URL은 외부 입력이다. page에서 `useParams()` 결과를 cast하지 않는다.
|
||||
|
||||
```ts
|
||||
const resourceDetailParamsSchema = z.object({
|
||||
resourceId: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
const resourceListSearchSchema = z.object({
|
||||
q: z.string().trim().max(100).catch(""),
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
sort: z.enum(["updated-desc", "name-asc"]).catch("updated-desc"),
|
||||
});
|
||||
```
|
||||
|
||||
path params와 search params는 입력 형태와 serialization 규칙이 다르므로 같은
|
||||
interface로 뭉치지 않는다. 각각 parse와 serialize를 제공한다.
|
||||
|
||||
```ts
|
||||
interface PathParamsCodec<T> {
|
||||
parse(
|
||||
input: Readonly<Record<string, string | undefined>>,
|
||||
): Result<T, RouteInputFailure>;
|
||||
serialize(value: T): Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface SearchParamsCodec<T> {
|
||||
parse(input: URLSearchParams): Result<T, RouteInputFailure>;
|
||||
serialize(value: T): URLSearchParams;
|
||||
}
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
- route input parse 실패와 backend 404를 구분한다.
|
||||
- 알 수 없는 search key를 보존할지 제거할지 route별로 선언한다.
|
||||
- default value를 URL에 항상 쓸지 생략할지 codec이 결정한다.
|
||||
- array/date/boolean encoding을 feature마다 다르게 만들지 않는다.
|
||||
- navigation link도 codec 기반 builder를 사용한다.
|
||||
- query key에는 parsed value만 사용한다.
|
||||
- 검색어·식별자를 telemetry에 기록하기 전에 sensitivity policy를 적용한다.
|
||||
|
||||
### 4.3 route object 생성
|
||||
|
||||
하나의 factory가 다음을 조합한다.
|
||||
|
||||
```text
|
||||
route contract
|
||||
+ runtime component/codecs
|
||||
+ access guard
|
||||
+ feature flag guard
|
||||
+ suspense surface
|
||||
+ render/chunk error surface
|
||||
+ title/focus/scroll behavior
|
||||
-> executable route object/tree
|
||||
```
|
||||
|
||||
JSX에서 route별 `<Route>`를 다시 나열하지 않는다. nested layout이 필요한 경우
|
||||
contract에 parent ID를 두고 cycle/orphan/duplicate path를 registry gate에서
|
||||
검사한다.
|
||||
|
||||
## 5. guard와 권한
|
||||
|
||||
### 5.1 guard 순서
|
||||
|
||||
권장 순서:
|
||||
|
||||
1. runtime/bootstrap readiness
|
||||
2. route 존재와 URL parse
|
||||
3. feature flag
|
||||
4. session readiness
|
||||
5. authentication
|
||||
6. coarse client permission hint
|
||||
7. route component
|
||||
8. server authorization result
|
||||
|
||||
client guard는 UX 최적화일 뿐 보안 경계가 아니다. API/BFF가 항상 최종 권한을
|
||||
검사한다.
|
||||
|
||||
### 5.2 guard 결과
|
||||
|
||||
```ts
|
||||
type GuardDecision =
|
||||
| { kind: "allow" }
|
||||
| { kind: "redirect"; to: SafeLocation; reason: RedirectReason }
|
||||
| { kind: "render"; surface: "auth-required" | "forbidden" | "not-found" };
|
||||
```
|
||||
|
||||
- redirect에는 origin route와 bounded return URL을 사용한다.
|
||||
- 외부 redirect는 allowlist를 거친다.
|
||||
- 동일한 route 쌍을 반복하는 redirect loop를 차단한다.
|
||||
- session이 아직 resolving이면 forbidden으로 단정하지 않는다.
|
||||
- server가 403을 반환하면 client claim을 신뢰해 화면을 계속 보여 주지 않는다.
|
||||
|
||||
## 6. navigation lifecycle
|
||||
|
||||
### 6.1 loading
|
||||
|
||||
loading surface를 route metadata에 연결한다.
|
||||
|
||||
| surface | 사용 |
|
||||
| --- | --- |
|
||||
| shell | 초기 앱 셸 진입 |
|
||||
| page | 새로운 전체 페이지 |
|
||||
| collection | table/list 구조 유지 |
|
||||
| detail | metadata/content 구조 유지 |
|
||||
| form | 필드 layout 구조 유지 |
|
||||
| inline | 부분 action |
|
||||
|
||||
cached data가 있으면 full-page skeleton으로 교체하지 않고 refreshing indicator를
|
||||
사용한다. `prefers-reduced-motion`에서 skeleton animation을 줄인다.
|
||||
|
||||
### 6.2 error와 lazy chunk recovery
|
||||
|
||||
route error boundary는 다음을 구분한다.
|
||||
|
||||
- render/programmer error
|
||||
- dynamic import/chunk load error
|
||||
- application `AppFailure`
|
||||
- URL parse failure
|
||||
- not found
|
||||
|
||||
chunk recovery 순서:
|
||||
|
||||
1. 현재 build ID와 release manifest를 확인한다.
|
||||
2. 새 manifest가 확인되고 같은 build에 대해 reload하지 않았다면 한 번만 reload한다.
|
||||
3. 같은 failure가 반복되면 reload loop를 막는다.
|
||||
4. 안전한 support surface와 trace/build ID를 표시한다.
|
||||
5. recovery 결과를 redacted diagnostics에 기록한다.
|
||||
|
||||
custom fallback을 넘겨 retry/reset 기능을 잃지 않게 한다. boundary는
|
||||
`location.key` 또는 route ID가 바뀌면 적절히 reset된다.
|
||||
|
||||
### 6.3 focus와 scroll
|
||||
|
||||
- route 성공 후 `main`의 page heading에 programmatic focus
|
||||
- mouse 사용자가 불필요한 focus ring을 보지 않게 할 수는 있지만 keyboard focus
|
||||
indication을 전역으로 제거하지 않음
|
||||
- modal/drawer가 닫히면 opener에 focus 복원
|
||||
- backward/forward navigation은 저장한 scroll 복원
|
||||
- 새 primary route는 top으로 이동
|
||||
- hash target은 fixed header offset과 focus 가능 여부를 처리
|
||||
- screen reader용 route title/live announcement는 중복 발표를 피함
|
||||
|
||||
### 6.4 취소와 dirty form
|
||||
|
||||
- route 이동 시 진행 중 query signal을 취소한다.
|
||||
- mutation은 취소 안전성이 명확할 때만 취소한다.
|
||||
- dirty form blocker는 browser unload와 in-app navigation을 구분한다.
|
||||
- 성공 저장 후 blocker를 해제한 다음 이동한다.
|
||||
- autosave가 있는 form은 pending/failed 상태를 별도로 알린다.
|
||||
- confirm dialog는 공통 accessible primitive를 사용한다.
|
||||
|
||||
## 7. 페이지 템플릿
|
||||
|
||||
template은 데이터를 가져오거나 application을 호출하지 않는다. 슬롯, landmark,
|
||||
focus target, responsive layout, 상태 위치만 소유한다.
|
||||
|
||||
### 7.1 `StandardPageTemplate`
|
||||
|
||||
슬롯:
|
||||
|
||||
- breadcrumb 또는 back link
|
||||
- title, description, status badge
|
||||
- primary/secondary actions
|
||||
- notices
|
||||
- content
|
||||
- contextual aside
|
||||
|
||||
작은 화면에서 action wrapping 순서와 heading hierarchy를 보장한다.
|
||||
|
||||
### 7.2 `CollectionPageTemplate`
|
||||
|
||||
슬롯과 상태:
|
||||
|
||||
- title/actions
|
||||
- search/filter/sort toolbar
|
||||
- active filter summary와 reset
|
||||
- result count
|
||||
- table/list/card view
|
||||
- pagination 또는 load-more
|
||||
- initial loading, refreshing, empty-first-use, empty-filtered, error
|
||||
- bulk selection/action
|
||||
|
||||
URL이 filter, sort, page를 소유한다. template은 query 상태를 직접 읽지 않는다.
|
||||
|
||||
### 7.3 `DetailPageTemplate`
|
||||
|
||||
- breadcrumb/back
|
||||
- title/status/actions
|
||||
- summary metadata
|
||||
- main sections
|
||||
- related/context aside
|
||||
- loading/not-found/forbidden/error
|
||||
- destructive action confirmation 위치
|
||||
|
||||
식별자가 바뀔 때 이전 entity 내용과 새 loading 상태를 혼동하지 않게 key/reset
|
||||
정책을 명시한다.
|
||||
|
||||
### 7.4 `FormPageTemplate`
|
||||
|
||||
- title/description
|
||||
- error summary
|
||||
- field groups
|
||||
- optional aside/help
|
||||
- sticky 또는 normal action bar
|
||||
- submit/cancel
|
||||
- submitting/saved/conflict/unavailable
|
||||
- dirty navigation confirmation
|
||||
|
||||
template은 특정 form vendor를 import하지 않는다.
|
||||
|
||||
### 7.5 `StatusPageTemplate`
|
||||
|
||||
다음 변형을 제공한다.
|
||||
|
||||
- unauthenticated
|
||||
- forbidden
|
||||
- not found
|
||||
- unavailable
|
||||
- offline
|
||||
- maintenance
|
||||
- unexpected
|
||||
|
||||
각 변형은 heading, 짧은 설명, 안전한 primary/secondary action, 선택적 trace ID를
|
||||
갖는다. raw stack/response를 표시하지 않는다.
|
||||
|
||||
### 7.6 선택 template
|
||||
|
||||
다음은 project 필요가 있을 때 추가한다.
|
||||
|
||||
- `SettingsPageTemplate`
|
||||
- `DashboardGridTemplate`
|
||||
- `SplitPaneTemplate`
|
||||
- `WizardTemplate`
|
||||
- `FullScreenTaskTemplate`
|
||||
|
||||
## 8. page controller 패턴
|
||||
|
||||
page를 세 부분으로 나눈다.
|
||||
|
||||
```text
|
||||
route adapter
|
||||
parses URL and guard context
|
||||
↓
|
||||
controller hook
|
||||
invokes application query/mutation and maps UI events
|
||||
↓
|
||||
page view
|
||||
renders template and design-system components
|
||||
```
|
||||
|
||||
예:
|
||||
|
||||
```tsx
|
||||
export function ResourceListRoute() {
|
||||
const input = useRouteInput();
|
||||
return <ResourceListController input={input} />;
|
||||
}
|
||||
|
||||
function ResourceListController({ input }: ResourceListControllerProps) {
|
||||
const controller = useResourceListController(input);
|
||||
return <ResourceListPage controller={controller} />;
|
||||
}
|
||||
```
|
||||
|
||||
router의 parse/invalid-route boundary가 성공한 `ParsedRouteInput`만 provider에
|
||||
넣고, page controller는 그 context를 바로 읽는다. 따라서 controller hook은
|
||||
조건부로 호출되지 않는다.
|
||||
|
||||
controller가 소유하는 것:
|
||||
|
||||
- parsed route input을 application input으로 변환
|
||||
- query/mutation state
|
||||
- pagination/filter/navigation event
|
||||
- retry/refresh/action callbacks
|
||||
- view model projection
|
||||
|
||||
view가 소유하는 것:
|
||||
|
||||
- semantic markup
|
||||
- template/component 조립
|
||||
- focus target
|
||||
- 사용자의 local-only interaction
|
||||
|
||||
controller가 소유하지 않는 것:
|
||||
|
||||
- HTTP URL 조립
|
||||
- credential
|
||||
- transport DTO parse
|
||||
- 도메인 invariant
|
||||
- raw vendor SDK
|
||||
|
||||
## 9. 권장 패턴 카탈로그
|
||||
|
||||
| 패턴 | 적용 위치 | 쓰는 이유 | 오용 |
|
||||
| --- | --- | --- | --- |
|
||||
| Ports and Adapters | application 외부 경계 | 정책과 기술 교체 분리 | 모든 작은 UI library에 port 생성 |
|
||||
| Command/Query | application input | 읽기/변경 의도와 정책 분리 | CQRS 인프라를 필요 없이 도입 |
|
||||
| Gateway | output port | 외부 데이터 capability 표현 | `get/post` 범용 HTTP를 application에 노출 |
|
||||
| Anti-Corruption Mapper | outbound feature adapter | DTO 변화가 core로 전파되지 않게 함 | 단순 object spread로 타입만 바꿈 |
|
||||
| Result | 예상 실패 | 실패 종류와 처리 경로를 닫음 | programmer error까지 모두 Result로 숨김 |
|
||||
| Controller/View | inbound React | data lifecycle과 markup 분리 | 거대한 hook 하나에 모든 feature 로직 집중 |
|
||||
| Strategy | retry/cache/auth recovery | 정책 교체와 테스트 가능성 | 설정 한 줄도 interface로 과도 추상화 |
|
||||
| Observer/External Store | session/theme/realtime | React 외부 소유 상태 구독 | server state를 다시 external store에 복제 |
|
||||
| State Machine | 복잡한 workflow | 유효 전이와 보상 명시 | 단순 modal open에 도입 |
|
||||
| Headless/Compound Component | 복합 UI | behavior와 style/slot 분리 | vendor primitive를 제품 전역에 직접 노출 |
|
||||
| Adapter Facade | icon/form/i18n vendor | React inbound 내부 vendor 교체 경계 | application port로 승격 |
|
||||
| Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 |
|
||||
| Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 |
|
||||
|
||||
RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며
|
||||
실제 document title, navigation, loading/error/access surface는
|
||||
`route.<ROUTE_ID>.title|navigation` typed catalog key를 해석한다. route params,
|
||||
search, backend message를 translation key로 조립하지 않는다. locale 변경은
|
||||
현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만
|
||||
다시 렌더링한다.
|
||||
|
||||
패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative
|
||||
architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다.
|
||||
|
||||
## 10. 새 route/page 추가 recipe
|
||||
|
||||
1. feature public 경계와 route ID를 정한다.
|
||||
2. serializable route contract를 등록한다.
|
||||
3. params/search Zod schema와 bidirectional codec을 작성한다.
|
||||
4. safe URL builder를 export한다.
|
||||
5. lazy page module과 runtime map entry를 추가한다.
|
||||
6. session/permission/flag guard를 선언한다.
|
||||
7. 적절한 page template을 선택한다.
|
||||
8. controller hook을 application input API에 연결한다.
|
||||
9. loading, empty, refreshing, error, auth, forbidden, not-found를 결정한다.
|
||||
10. title/message key, focus, scroll, chunk ID를 연결한다.
|
||||
11. 다음 검증을 추가한다.
|
||||
- contract/runtime map type completeness
|
||||
- codec round-trip/property cases
|
||||
- guard decision unit
|
||||
- page component state
|
||||
- query/mutation integration
|
||||
- keyboard/focus/axe
|
||||
- direct URL, back/forward, refresh E2E
|
||||
- chunk failure recovery가 필요한 route의 E2E
|
||||
12. registry, type, architecture, component, integration, E2E gate를 실행한다.
|
||||
|
||||
## 11. 금지 패턴
|
||||
|
||||
- page 안에서 raw `fetch`, storage, auth SDK, telemetry SDK 호출
|
||||
- `useParams()`/`URLSearchParams` 값을 cast만 하고 사용
|
||||
- route contract와 JSX route tree를 각각 수동 관리
|
||||
- protected route를 server authorization 대체 수단으로 취급
|
||||
- 모든 실패를 redirect 또는 full-page error로 처리
|
||||
- query data가 있는데 background error 때문에 내용을 제거
|
||||
- chunk load error에서 제한 없는 `location.reload`
|
||||
- route heading focus outline을 CSS로 무조건 제거
|
||||
- template이 data fetching 또는 feature-specific copy를 소유
|
||||
- generic `BasePage` prop 하나에 모든 layout variation을 boolean으로 추가
|
||||
|
||||
## 12. 완료 기준
|
||||
|
||||
- 모든 route ID가 contract와 runtime map에서 compile-time 완전성을 가진다.
|
||||
- params/search parse와 URL serialize가 같은 codec을 사용한다.
|
||||
- route metadata가 loading/error/chunk/title/navigation 행동에 실제 연결된다.
|
||||
- redirect와 chunk reload loop가 차단된다.
|
||||
- route 이동 시 query 취소, focus, scroll, dirty policy가 검증된다.
|
||||
- collection/detail/form/status reference page가 template을 사용한다.
|
||||
- page view가 application input 외의 외부 capability를 직접 호출하지 않는다.
|
||||
- 새 route recipe와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user