Compare commits

..
214 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 5434760ddf fix: keep the fixture evidence test about preservation, and stop scanning worktrees
The release-evidence test asserted that every artifact a candidate is assembled
from survives the copy, which quietly assumed the checkout had already run the
release chain. It holds in this repository and fails in a product repository
that has not, where `artifacts/performance/bundle.json` simply does not exist
yet — a fact about the checkout, not about the copier. It now asserts that
whatever release evidence is present is preserved, and that at least one thing
was, so it cannot pass by finding nothing to check.

Vitest also walked `.worktrees/`. A git worktree inside the repository is a
different checkout of a different branch; running its tests against this
checkout's config produces failures that belong to neither and cost real time
to attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:30:54 +09:00
DongHyeonkaandClaude Opus 5 9ca5c3f668 fix: say why a sandbox failed to launch, and observe a run instead of an instant
The cgroup test read the live process tree with one `ps` per pid and asserted
while the provider was running. That was a race it used to win only because the
sandbox was slow; now a whole run finishes in a few hundred milliseconds and
`systemctl show` alone costs longer than the thing it describes. It records the
tree from `/proc` every 5ms and asserts on the recording once the run is over,
because the assertions were always about what the run contained.

That restructuring immediately paid for itself: the supervisor had been failing
to launch the sandbox at all, and the test was dying on the observation before
it ever checked the exit code.

It could not say why, because the supervisor consumed the child's output solely
to enforce a byte cap and then discarded it — `exit=1` and nothing else. It now
keeps the lines the sandbox tooling itself emits (`bwrap:`, `prlimit:`,
`systemd-run:`, `systemctl:`), which cannot carry provider credentials because
the provider command and its secrets travel in the args file. The failure now
reads:

  sandboxed external provider failed: exit=1; sandbox reported:
  bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

which is a host restriction — `kernel.apparmor_restrict_unprivileged_userns=1`
— reproducible in two lines of shell containing none of this repository's code,
and recorded in the ledger as such rather than carried as a product defect.

Suites that spawn processes, build archives and sign evidence were given a 30s
budget. The 10s default is sized for pure-JS unit tests; raising it globally
would hide a genuinely hung test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:12:06 +09:00
DongHyeonkaandClaude Opus 5 711d61e73f feat: make product features a declared selection with a runtime kill switch
Which features a build contains was not a decision anybody could express. The
reference feature was spread directly into the route, API, schema, codec and
adapter registries, so shipping without it meant editing five files by hand and
hoping nothing still referred to it — and there was no way at all to take it out
of service on a running deployment. The removability gate proved the editing
worked; nothing made it a choice.

There is now one manifest. `VITE_PRODUCT_FEATURES` narrows it at build time and
`FEATURE_OVERRIDES` in the runtime document takes an installed feature out of
service without a rebuild. Every registry composes from the manifest, and a test
fails if a new one forgets to.

Both inputs are subtractive, and the vocabulary is what enforces it rather than
a check somewhere downstream: the override enum has no `ENABLED`, and a
build-time selection naming something the source tree does not declare is
refused instead of ignored. A configuration document that could name a feature
into existence would be a configuration document choosing which code runs.

Disabling is not just hiding. Withdrawing a route from navigation would leave a
typed deep link that still mounts the feature, so the router refuses it too and
answers with a surface that says the deployment switched it off. The platform
overview now distinguishes the three states an operator actually needs: serving,
switched off, and not in this build.

What this is not: an env var does not shrink the bundle. A static import cannot
be undone by a value, and making the import graph itself depend on a
configuration string is the thing §3.5 exists to prevent — measured, `none`
changes the output by 58 bytes. Physical removal remains FE-GATE-020's job, and
the code comments say so rather than implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:45:19 +09:00
DongHyeonkaandClaude Opus 5 0a97d235e4 docs: register the adapter kernel capacity guard in the inventory
The inventory gate caught its own case: a new file under `src/adapters` that no
ledger row named. `assertBoundedCapacity` moved out of the telemetry adapter so
diagnostics could stop importing it across a boundary, and the kernel now has a
row of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:54:39 +09:00
DongHyeonkaandClaude Opus 5 b23a1b80ca docs: record the operational contract review and what it left open
Nineteen findings across release admission, the provider sandbox, removability,
browser and visual evidence, architecture boundaries and documentation, each
named by defect rather than symptom, with the four removal fixtures' before and
after counts.

Three tests stay red and are recorded as such rather than claimed: the live
process-tree observation of a running sandbox, which now loses a race it used to
win only because the sandbox was slow, and two that time out at their 10s budget
under parallel load while passing in isolation. Lab performance produces
evidence for the first time and that evidence misses its budget; no budget was
moved to hide it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:52:33 +09:00
DongHyeonkaandClaude Opus 5 10a04d3695 fix: let a removal fixture actually build the thing it claims still builds
FE-GATE-020 proves a capability can be removed by rebuilding the whole project
without it. The fixture it built could not get that far, and the failures all
came from the fixture rather than from anything about removability.

It was not a repository. The supply-chain inventory is defined as the tracked
file set, so it asks `git ls-files` what the project contains; with no
repository to ask, generation failed and took every provider suite down with
it. It is now initialised on preparation and committed after the removal — not
before, or the index would still list the files the removal deleted.

It had no `.gitignore`, so once it did have a repository, every generated
artifact and every linked module landed in the index and the inventory refused
the fixture for tracked and generated paths colliding. It carries the ignore
rules now, and therefore records the same tracked set as the repository it was
copied from.

Each removal script kept its own copy-target list and they had drifted: the
reference-feature fixture omitted `playwright.capabilities.config.ts`, which the
inventory requires. There is one list now. It also gained the install and
workspace identity — `.npmrc`, the lockfile, the workspace file — without which
the fixture is a different project, and the release evidence a candidate is
assembled from, without which no candidate can be built at all.

A tracked root the removal deletes is no longer required of the result: the
optional-recipe fixture deletes `recipes/`, and the inventory policy demanded
it back. Roots that are gone are pruned from the fixture's policy.

Two smaller causes. A platform integration file asserted the reference feature's
own route ids, so removing the feature left it importing a deleted module —
typecheck, the test run, coverage and the residue scan all failed on that one
misplaced assertion, which now lives in the feature's test tree. And the
canonical exact-count authority was re-imposed on a contract the fixture
deliberately reduces, failing the fixture for the reduction it exists to prove;
`CI_CONTRACT_MODE` already marked those runs and is now honoured by default.

The reference-feature fixture goes from failing before its first assertion to
1,612 passing with one failure, and that one is the live process-tree
observation test already red on the main tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:34:28 +09:00
DongHyeonkaandClaude Opus 5 3ea3397691 fix: make the architecture and documentation rules say what is actually true
Three boundaries the layer contract declares had no executable rule behind
them, so the code drifted across all three while every gate stayed green.

`src/contracts` reached back up into `src/application` for the shared `Result`
carrier and the compatibility predicate. Neither package owned the shared
vocabulary and the dependency pointed both ways. Both now live in contracts —
the lower package — and application re-exports them, so no caller moves.

A concrete adapter was not supposed to depend on another concrete adapter, but
only adapter-to-presentation was enforced, and `diagnostics` imported a guard
out of `telemetry`. The guard belongs to neither, so it moved to the adapter
kernel. Stating the rule needed the checker to resolve `$1` in a `to` pattern
against the importing module's own directory; the alternative is one rule per
adapter group, which silently stops covering a group the moment one is added.

Product assembly leaks out of bootstrap: generic presentation reads the
installed-feature registries. That is a real refactor, so the rule freezes the
exact set of modules doing it today rather than pretending it is fixed — a new
edge fails. The two remaining open edges are named in the config, not silent.

Each rule was verified by introducing the violation it forbids and confirming
the gate rejects it.

The documentation drifted the same way. README and the manual accessibility
checklist both said six routes while ten were registered, which left the
platform overview and three reference-resource screens outside the declared
manual review scope without anyone deciding they should be. The scope is now
derived from the route registry by `verify:documentation`, so the sentence
cannot outlive the registry again. The review ledger also named a canonical
path that does not exist in this tree; it is upstream provenance, and it now
says so instead of looking like a broken repository reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:23:10 +09:00
DongHyeonkaandClaude Opus 5 7485cd86e4 fix: let the browser, visual and performance evidence describe the product again
Four browser-capability specs never reached the code they were named for. The
`PRESIGNED_TRANSFER_V1` envelope gained a top-level `protocol` field, and the
fixtures kept answering without it, so every capability was refused before any
object request was made: the download and part-upload success paths were
asserting against an empty transcript rather than exercising a real GET or PUT.
The fixtures now speak the protocol they claim to, and the part-deletion
expectation carries the physical effect the adapter reports.

A refused capability document also answered `recovery: NONE`, telling the
caller there was nothing to be done. The design record fixes this class of
refusal as re-issuable and the vault already answers `REISSUE_CAPABILITY` for
it, so the HTTP decoder disagreed with both. It now agrees.

Lab performance produced no evidence at all. Playwright matches accessible
names by substring, so the navigation entry "플랫폼 구성" also matched the home
page's "플랫폼 구성 보기" call to action; the locator resolved to two links and
the run died on a strict-mode violation before the first measurement. With an
exact match the metrics are collected, and they show the named-interaction
budget is missed on this machine — a real signal that was previously invisible.

The platform overview baseline was captured before the reference routes moved
from `integration-defined` to `session-required` and was never regenerated, so
the only visual gate that could catch a regression on that page was failing for
its own staleness. Regenerated after confirming the diff is exactly that label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:49:48 +09:00
DongHyeonkaandClaude Opus 5 dfb7734674 fix: run the provider sandbox and admit a release to a named environment
The provider sandbox never ran. bubblewrap 0.9.0 stops parsing an `--args`
file at the first non-option and never hands the remainder back, so the
command written into that file was silently dropped: bwrap printed its usage
text, exited 1, and the provider produced no evidence at all. The options
still travel in the args file — that is what keeps host paths and credentials
out of `/proc/<pid>/cmdline` — but the command now rides on real argv, and
`encodeProviderBwrapInput` refuses a `--` so the drop cannot come back.

The scope wrapper then could not exit. It read the supervisor's liveness pipe
through `fs`, which runs a blocking `read(2)` on a threadpool thread; the
supervisor holds that pipe open for the scope's whole life, so the read never
returned and closing the descriptor did not interrupt it. Once bubblewrap
finished the wrapper deadlocked in `process.exit`, the scope outlived the
provider, and a completed run was reported as a timeout kill. The channel is
now read through the event loop, so teardown is observable and terminal.

Creation modes were left to the ambient umask. `mkdir(mode)` and `open(mode)`
are requests the kernel subtracts the umask from, so a runner exporting a
restrictive umask produced directories it could not enter and handed `tar` a
file it could not re-open. Private modes are pinned instead of inherited.

Promotion cleanup deleted before it checked. Removals run through a pinned
descriptor, so a leaf substituted after validation had this promotion's exact
five destroyed first and the substitution reported afterwards, leaving a
half-emptied directory a retry could not tell from a completed one. The name
is re-bound to the inode before anything is removed, so the failure is total.

Separately, release coherence proved the artifacts agreed with each other but
never that they belonged where they were going: a build whose runtime document
said `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API is coherent with
itself and passed every gate. `public/` is copied verbatim into `dist/`, so
that local document shipped with every build regardless of what the build was
for. Runtime configuration now comes from a declared profile, and FE-GATE-027
refuses to admit an artifact to an environment it does not match — including
refusing an undeclared destination, so nothing is admitted by omission.

`REQUEST_TIMEOUT_MS` and `VITE_ROUTER_BASE_PATH` were validated and then
dropped: the V3 executor ran every operation on its contract's own deadline,
and Vite emitted root-absolute assets for a sub-path deployment. The timeout is
now a ceiling that may tighten a contract but never loosen one, and one base
path feeds the router, the Service Worker scope and the asset base together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:38:19 +09:00
DongHyeonkaandClaude Opus 5 a0fbafb77b test: stop asserting which of two equal deadlines won the RPC-02 race
The cleanup test sets `totalDeadlineMs` and `idleDeadlineMs` to the same
25ms and then asserted `RPC_TOTAL_DEADLINE_EXCEEDED`. Which of the two the
runtime reports depends on whether the clock had crossed the total
deadline by the time the idle wait expired, so under parallel load the
assertion was a coin flip — it passed alone and failed in a 27-file run.

A test that fails for a reason unrelated to its subject teaches a reader
to ignore it, which is the failure mode this whole review pass was about.
The subject here is that a throwing `return` accessor cannot replace the
outcome the runtime already selected and that cancellation still runs
exactly once, so the assertion now pins the terminal kind and accepts
either deadline code.

Confirmed by three consecutive 485-test runs of the same 27-file set that
previously reproduced the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:32:37 +09:00
DongHyeonkaandClaude Opus 5 8157ad4029 fix: make the ledger gate check the column it claims to check
Adversarial re-verification of the gate itself found three holes.

The disposition check matched the finding's verdict anywhere in the table
row, and every row also carries the prior verdict — so `| NS-01 | PARTIAL
| FIXED |` satisfied a receipt that said either. It now reads the
disposition from its own column, which is the check the gate was supposed
to be performing all along.

An evidence path only had to exist. A row could point at an unrelated
suite and look substantiated, so each evidence file must now name the
finding it is evidence for; rows the review labelled differently declare
their own markers rather than the check being loosened.

The file-transfer bundle budget was a number typed into prose next to a
number in config, which is exactly the evidence drift the cross-audit
raised. The gate now compares them.

Each hole was confirmed by breaking the input and watching the gate fail:
a disposition disagreement, a budget changed to 60,000, and NS-07 pointed
at the public cache suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:31:07 +09:00
DongHyeonkaandClaude Opus 5 d5e7f4127a chore: derive the remediation closure claim instead of authoring it
The ledger declared "All 38 are now FIXED" while six of those rows were
reproducibly partial. A summary sentence is cheap and a reviewer reads it
as evidence, so the claim is now derived from a machine-readable record:
`docs/operations/adapter-remediation-dispositions.json` carries each
finding's disposition and the test paths that hold it, and
`check:remediation-ledger` joins that file to the prose, verifies every
evidence path exists, and refuses a blanket closure sentence while any row
is still open.

The shared-abort gate had the same weakness in miniature: it passed when
at least one production file imported the primitive, so an unrelated
import satisfied it while Image and Resumable kept their own diverging
copies. It now requires the four named consumers to resolve their import
to the primitive itself, and prints the exact importer set rather than a
count.

`check:optional-recipes:source` was already failing before this work
(52,078 against a 52,000 budget) and the correctness code above pushed it
further. Duplicate abort mechanics were consolidated first — Image and
Resumable onto the shared primitive, four decoders onto one snapshot
helper — and the remainder is code the review asked for, so the budget is
reset to 54,600 against a measured 53,810 with that reasoning recorded,
rather than the failure being carried forward as if it were green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:26:27 +09:00
DongHyeonkaandClaude Opus 5 d7b35cfca3 fix: bound the activation marker in bytes and give a click one observer
The marker read added a whole chunk to a running total and compared the
total afterwards, so a corrupt body could hand activation a 1 MiB chunk
against a 257-byte ceiling. It now reads at most the remaining allowance —
through a BYOB reader where the source offers one, and by refusing an
oversized chunk before copying it otherwise. A declared oversize cancels
the body it refuses instead of leaving the stream open, and the reader
lock is released on every path.

The build generator and the runtime decoder shared only the extension
table, not the path grammar. The generator happily emitted
`/assets/bad@name-abcdefgh.js`, which the decoder then refused — a correct
build failing at install time. Both now use one exported canonical path
predicate and the generator decodes its own output before returning it.

The notification click handler emitted its terminal record from inside
`process` and again from the `waitUntil` wrapper, so an ordinary click was
counted twice. Worse, a late rejection downgraded `MAYBE_APPLIED` to
`NOT_APPLIED` — telling operators the click had definitely not been
applied when nobody knew that — and the late observation ran outside
`waitUntil`, so a worker shutdown lost the evidence. There is one
observation authority per click now, certainty is monotone, only an
explicit null window confirms `NOT_APPLIED`, and the bounded tail is owned
by `waitUntil` without extending the public deadline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:26:14 +09:00
DongHyeonkaandClaude Opus 5 39a4a973a8 fix: hold transfer inputs and raw transfer work to what was verified
The capability vault checked an issuer's registration and then read it
again to store it, including its nested header rows. A stateful issuer
could show an allowed header set to the forbidden-header check and hand
`Authorization` to the copy, so the vault stored — and the executor sent —
a credential no rule had ever seen. The registration and everything nested
in it is now snapshotted once, and only that snapshot is validated,
frozen and stored.

The upload control plane had the same shape one level down: a `sessionId`
that answered `session_01` to the regex and `../../unsafe` to the result
snapshot reached a success receipt.

Two lifetimes were also unowned. A download source lease that resolved
after the caller's abort never reached the holder, so nothing closed it
and its fetch reader and capability lease outlived the terminal result; a
compensator sharing the holder's close-once latch now closes it exactly
once. And `dispose()` proved quiescence from the wrapper registry alone,
so a provider that ignored its attempt deadline let teardown report a
drained runtime and close the checkpoint store while the provider was
still running. Raw provider promises are now their own registry and the
drain must prove both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:26:01 +09:00
DongHyeonkaandClaude Opus 5 aa8ac35600 fix: make Browser RPC and Realtime own the physical work they report on
A server stream's registration was pruned on any settled close receipt.
`waitClosed()` rejecting, throwing synchronously, or not returning a
promise at all was absorbed into a fulfilled `undefined`, so the runtime
opened a second physical stream for the same operation while the first was
still running against the server. Only a fulfilled, contract-shaped
receipt confirms closure now; every negative receipt keeps the operation
DRAINING.

Cleanup also read foreign state outside the result boundary. A throwing
iterator `return` accessor replaced the already selected timeout with a
native `TypeError` and skipped the rest of the teardown, and the exported
lease decoder threw on a hostile `Symbol.asyncIterator`. Both reads move
inside their own boundaries, and the positive-close subscription is
installed before any fallible cleanup.

Composition validated the caller's registries before snapshotting them, so
a hostile accessor ran twice during validation, and rows hiding fields
behind a prototype or a non-enumerable key installed. Transport results
were checked for allowed own keys only, so own `{ok,message,encodedBytes}`
plus a prototype `injected` was a success and a missing `message` reached
a permissive schema as `undefined`.

In Realtime the tracked task was registered after the collaborator
returned. An authority that re-entered `close()` from inside its own
invocation saw an empty registry and got `{ok:true}` while its effect was
pending. The task is now registered first and the collaborator is invoked
a microtask later. A `scheduleTimeout` that threw was worse: the caller's
own catch treated it as an apply failure and started a recovery beside the
still-running effect, and `close()` rejected with a native `TypeError`. An
uninstallable deadline now fails closed as an expired one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:25:48 +09:00
DongHyeonkaandClaude Opus 5 632b230c82 fix: report OPFS completion honestly and bound public cache staging
A durable write whose journal transaction could not be completed returned
plain success with `SUCCEEDED` telemetry. The payload was committed but
the transaction stayed `COMMITTED`, so the reconcile backlog and its quota
pressure grew while every caller was told the write had settled. That is
now a `RECONCILE` failure with the effect certainty preserved, and an
unfinished delete is observed `DEGRADED` rather than clean.

The worker seam lost causes in both directions. A bootstrap failure
answered every request with kind `CAPABILITIES`, so the gateway read a
kind mismatch and replaced the real `BLOCKED` or `QUOTA_EXCEEDED` with a
generic `UNSUPPORTED`; the envelope's correlation is now captured once at
the listener. On the client, the pending row and its timer were released
before the reply was decoded, so a trap that threw inside the decoder left
the public promise pending with nothing left to time it out, and a
throwing `requestId` getter produced a timeout instead of a prompt
protocol failure.

Public cache staging handed its signal to each `Request` and called that
ownership. A fetch that ignored it held the mutation lock forever, and a
digest that finished after the abort still wrote both the asset and the
activation marker — publishing a release nobody was waiting for. One
terminal owner now covers the whole staging body and every await
re-checks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:25:26 +09:00
DongHyeonkaandClaude Opus 5 df18349682 fix: validate the snapshot that installs, not the object that was shown
Three trust boundaries checked a caller's object and then read it again to
use it. Between those two reads an accessor or a Proxy can answer
differently, so the value that passed validation and the value that was
installed were not the same value.

A credential owner's answer was read field by field outside the auth
boundary: a throwing `kind` getter escaped into the transport catch and an
auth outage reached operators as `NETWORK_FAILURE`. Contract composition
validated a contribution and then copied it, so a policy that answered
10,000 to the ceiling check and 999,999 to the copy installed the second
value. The cursor runtime validated its profile once and re-read it on
every page, so raising `maxPages` after construction widened a cap that
had already been checked.

`src/contracts/exact-snapshot.ts` is the one descriptor-based decoder they
now share: every property is read exactly once, an accessor, a symbol, an
inherited or non-enumerable field and a throwing trap all resolve to a
typed failure, and validation runs on the owned copy.

Separately, the `responseBody: NONE` probe awaited a bare `read()`. The
deadline produced a bounded public result while the raw reader kept its
lease, so the body stayed locked and the outer compensator could not
cancel it. The probe now takes the operation lifetime and owns the cancel
and the lock release itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:25:12 +09:00
DongHyeonkaandClaude Opus 5 cc91fc6ae0 fix: settle a shared abort operation by observation, not by drain count
The primitive decided a raced outcome by draining a hard-coded four
microtasks and then asking whether the task had landed. That made the
answer depend on scheduling rather than on what was observed: a caller
abort could fix the terminal owner synchronously and a rejection later in
the same call stack still won the public result, so `race()` disagreed
with `terminal()` and the failure taxonomy a caller received depended on
microtask ordering.

Task settlement and the terminal event now share one settle-once state
machine. Whichever callback actually runs first owns the outcome; a value
that loses is compensated exactly once and a rejection that loses is
absorbed, so neither can surface late.

The three consumers that kept their own copies of these mechanics move
onto it. The Image probe and the Resumable fetch transport attached their
caller listener before installing the timer, so a scheduler that threw
rejected the public `probe()`/`execute()` promise natively and left the
listener on the caller's signal; both now close atomically inside their
own Result vocabulary and start no fetch. `snapshotAbortTimers` binds the
scheduler callables once at construction, so replacing a method after
composition can no longer change how work already in flight is bounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:24:53 +09:00
DongHyeonkaandClaude Opus 5 8d6d84bfcc docs: record every re-review finding as closed with its evidence
All 38 findings from the 2026-08-14 adapter re-review are now FIXED. The twelve
the first pass did not reach — RPC-RR-01, RT-RR-01 through RT-RR-04 and
TR-RR-01 through TR-RR-07 — each landed with a named adversarial test that
failed on the pre-fix source and passes on the landed one, verified by reverting
the source file and re-running.

The ledger also records the six contracts that changed shape and are therefore
breaking for an external implementor: the Browser RPC server-stream lease, the
optional recovery context on AuthSessionPort, the presigned registration
protocol version, the resumable dispose result and cleanup deadline, and the
already-landed required image resolve signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:23:34 +09:00
DongHyeonkaandClaude Opus 5 5a76f95291 fix: bound resumable teardown, image concurrency and delivery leases
TR-RR-06. dispose() now bounds its drain with a cleanupDeadlineMs from policy
and returns the result, so a non-cooperative mutation lock or provider can no
longer make teardown unbounded and an unproved drain is reported as still
CLOSING instead of closed over. The checkpoint store stays open in that case,
because something can still write to it. An abort is admitted physical work
like an upload, so it joins the tracked set rather than being stepped over.

TR-RR-07. The verification slot belongs to the raw verifier, not the wrapper.
Releasing it when the caller's wait expired let an abandoned verification keep
running while a new one was admitted, so repeated aborts produced more
concurrent physical work than the configured cap allows. The slot is now
released only once the raw tasks settle.

TR-RR-04. A presigned byte source owns a fetch reader and a capability lease and
its port requires close(); the delivery consumer never called it. The closeable
subtype is lost in the FileByteSource projection, so a holder keeps it from the
moment the lease exists and the outermost finally closes it exactly once — on
success, validation failure, writer failure and abort alike.

check:adapter-inventory now also fails if the shared abortable-operation
primitive has no production importers. It was safe to add only once the
presigned subsystems actually migrated onto it; a gate that fails CI for a
documented, unfixed defect reports the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:22:45 +09:00
DongHyeonkaandClaude Opus 5 46e067e555 fix: put presigned transfer work inside one owned abort scope
TR-RR-05. The shared abortable-operation primitive now distinguishes VALUE,
REJECTED and TERMINAL, so a collaborator's own rejection is no longer forged
into a cancellation and race() always names the same first owner terminal()
reports. The caller signal and scheduler are captured once, so replacing a
method after construction cannot change how an in-flight operation is bounded.
A scheduler that cannot install the deadline is itself terminal: previously it
released the caller listener and left no owner, which made every later abort
invisible. Late values are compensated exactly once.

Both presigned subsystems, which each carried their own copy of these
mechanics, are now projections of that primitive — giving it real production
importers rather than a shared helper nobody used.

TR-RR-01. close() on an active download aborts the scope instead of only
dropping listeners, so a fetch or read already in flight actually stops. The
consumer's stream signal joins the operation's ownership before any I/O begins,
so an already-aborted consumer no longer causes one network request first.

TR-RR-02. The upload abort scope is created before the digest, and the digest
races the caller and the deadline like every other step. A non-settling hash can
no longer hold put() open, and the vault claim and the network call happen only
after the owner is re-checked.

TR-RR-03. A capability registration is a versioned exact union validated at
registration time: the protocol version, HTTPS only, exact own-data fields, a
2xx expected status, and no ambient credential or cookie header — a presigned
URL carries its own authorization, and a session header alongside it would send
the user's credentials to that origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:06:46 +09:00
DongHyeonkaandClaude Opus 5 c0f53d1855 fix: track realtime physical work from invocation to settlement
RT-RR-01. An effect or recovery task was registered as retained only after its
public wait expired, so a close() that arrived first saw an empty set and
reported quiescence while the raw task was still running against the authority.
Tasks are now registered when they are created and removed when they settle;
DRAINING keeps its narrower meaning through a separate timed-out set.

RT-RR-02. Admission happened when an event was queued; execution is a second
decision. A queue entry admitted before the stream entered DRAINING no longer
starts running inside it. And an abandoned task may have applied part of its
effect, so the resume token it was based on is discarded and recovery is
required explicitly — the next ordinary event can no longer skip authoritative
recovery on the strength of state a timed-out effect may have invalidated.

RT-RR-03. close() cached the first timeout forever, so a writer that later
settled could never be proved quiescent and the retained registry could never be
pruned. Only an in-flight close is shared now, every writer a close fences is
retained until its tail actually settles, and the tail prunes itself. A second
close therefore converges to success once the writer finishes.

RT-RR-04. Checkpoint work joins writer tails in the physical-task registry from
invocation to settlement and is drained on the same terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:59:15 +09:00
DongHyeonkaandClaude Opus 5 a7390e3b3a fix: give Browser RPC server streams a cancellable lease and a DRAINING fence
RPC-RR-01. openServerStream returned a bare AsyncIterable, which gave the
runtime no way to stop the physical stream or to learn when it actually closed:
iterator.return() is a request a non-cooperative implementation may ignore. The
runtime could therefore time out, report the call finished, and admit a second
stream for the same operation while the first was still running against the
server.

The transport now returns a lease — streamId, frames, cancel(reason) and
waitClosed() — decoded from own data descriptors before the runtime registers
it, so an accessor cannot hand the registry one object and the cancellation
path another. The runtime registers the lease the moment the physical stream
exists, cancels exactly once on exit, and keeps the entry until waitClosed()
settles. A second stream for the same operation is refused as CONFLICT /
RPC_STREAM_DRAINING while that entry stands, and the refusal never reaches the
transport.

While updating the suites this also corrected two RPC-RR-02/RPC-RR-04 stream
tests that were passing for the wrong reason: they sent an input the request
schema rejects, so they never reached the fence or the frame decoder. With the
correct input the frame test fails on a permissive decoder, as it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:52:57 +09:00
DongHyeonkaandClaude Opus 5 250531aa43 fix: stop test fixtures from deleting the repository's dependencies
Four fixtures linked the installed dependencies into a throwaway root with a
single directory symlink at <fixture>/node_modules, then ran pnpm inside that
root. pnpm does not recognise the modules directory it finds there and purges
it; with CI=true it does so without a prompt. The purge followed the symlink and
deleted the repository's own node_modules mid-run, so a test suite uninstalled
the workspace it was running in. That is what produced the cascading,
file-unrelated failures a full test:unit run reported, and it happened twice
while running the suites for the adapter re-review.

scripts/lib/fixture-node-modules.ts replaces all four sites: node_modules is a
real directory whose entries are individual symlinks, so a recursive delete
unlinks the fixture's own links instead of walking through one link into the
shared tree. Resolution is unchanged.

tests/unit/fixture-node-modules.test.ts performs the exact recursive delete pnpm
performs and asserts the source tree survives, and check:adapter-inventory now
fails on any reintroduction of the directory-symlink form — verified by putting
the old line back and watching the gate reject it.

A full tests/unit + tests/integration run now leaves the dependencies intact.
removal-fixture, supply-chain and security-followup-archive, the three suites
that had to be excluded before, pass in that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:08:32 +09:00
DongHyeonkaandClaude Opus 5 af7f35058b docs: replace over-closed ledger rows with evidence-linked dispositions
GOV-01. The adapter inventory claimed 118/118 while the tree held 119 files, so
src/adapters/platform/abortable-operation.ts sat outside every review's
coverage without anything failing. The row is restored and
scripts/check-adapter-inventory.ts now diffs the document against
git ls-files src/adapters, so the count is an equality rather than a number
someone has to remember. The same gate pins that the Service Worker asset
generator reads the shared extension table instead of declaring its own.

GOV-02. The previous ledger closed rows as FIXED_NOT_RELEASED that the
re-review found partial. The new section is written the other way round: a row
reads FIXED only where a named adversarial test failed on the pre-fix source
and passes on the landed one, and the twelve findings this pass did not reach —
RPC-RR-01, RT-RR-01 through RT-RR-04 and TR-RR-01 through TR-RR-07 — are
recorded as NOT_STARTED with the reason each needs a lifecycle change rather
than a contained edit. None of them may be treated as closed and no capability
they cover may be promoted without its own evidence row.

The structural gate for the shared abortable-operation primitive is
deliberately not added yet: it still has zero production importers, and a gate
that fails CI for a documented, unfixed defect would report the wrong thing.

Also records the destructive test hazard found while running the suites:
scripts/lib/removal-fixture.ts and scripts/check-supply-chain-provider-fixtures.ts
symlink the real node_modules into a temp fixture root and run pnpm there, which
purges the repository's own dependencies through the symlink mid-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:22:44 +09:00
DongHyeonkaandClaude Opus 5 69cb7e35ca fix: decode resumable control-plane responses against hostile objects
TR-RR-08. Object.keys sees only enumerable own string keys, so a symbol or
non-enumerable extra field passed the exactness check unseen and the property
reads that followed invoked whatever accessor the sender installed — escaping
the Result contract as a native rejection out of a public method. Key exactness
is now checked against own property descriptors inside a catch, and each decode
runs within the adapter's failure boundary so a proxy trap becomes a typed
CORRUPT_DATA result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:17:00 +09:00
DongHyeonkaandClaude Opus 5 fb5b449031 fix: apply the recorded private Cache-Control matrix to image probes
TR-RR-09. The documented BT-IMG-02 contract requires a private response to
carry no-store and fail closed when any directive that describes cacheability
accompanies it. The probe checked only that no-store was present and public was
absent, and the suite pinned the contradictory "private, no-store" as a success.
Both now follow the recorded contract: only no-store and syntactically valid
unknown extensions are admitted, and public, private, immutable, max-age,
s-maxage, no-cache, must-revalidate and proxy-revalidate each fail closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:14:41 +09:00
DongHyeonkaandClaude Opus 5 efc577de63 fix: bound Service Worker marker reads and attribute native effects
SW-RR-01. The activation marker was read with response.text() whenever no
Content-Length was present, so a large or non-terminating body could consume
the whole activation step. It now reads through a bounded reader that stops one
byte past the ceiling, cancels its reader, applies a read deadline and decodes
UTF-8 fatally.

SW-RR-02. A matching nonce is not identity. An activation or reset result whose
event.source is null can no longer stand in for the expected worker; only a
strict identity match is admitted.

SW-RR-03. The build generator and the shared manifest decoder now read one
exported extension table, so .mjs and .png stop being emitted-then-refused.
.json is deliberately outside it: every JSON file in a build output is a control
document the generator already excludes, not a cacheable asset.

SW-RR-04. Both caches.open and cache.match are closed as a miss. Letting a
match rejection propagate rejected respondWith itself, so the entry never
reached its network fallback.

WP-RR-01. focus and openWindow now carry the certainty phase showNotification
already had — NOT_APPLIED, MAYBE_APPLIED, CONFIRMED — and an effect that lands
after the handler deadline is observed exactly once. The evidence never
authorizes a retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:10:41 +09:00
DongHyeonkaandClaude Opus 5 bd90e0c983 fix: keep Browser RPC collaborator input and output inside the contract
RPC-RR-02. The server-stream path captured the generation fence outside its
protected boundary and raceWithin invoked clock.sleep outside a promise
boundary, so a synchronous throw from either escaped the Result contract and
skipped the listener and timer release. Both now run inside the boundary, and
release moved to finally.

RPC-RR-03. The runtime snapshotted its transports only after validating the
caller's raw objects, which ran their accessors first. It now decodes the
registry from own data descriptors before anything reads it — refusing an
accessor without invoking it and rejecting extra, inherited and symbol-keyed
fields — and validates that snapshot. Every installed binding registry is a
read facade over a private store instead of a frozen Map whose set, delete and
clear still worked.

RPC-RR-04. Transport results and stream frames are decoded per union variant
from own data descriptors into new frozen values. A throwing getter, an
inherited or extra field, a symbol key, an unknown failure code and an
out-of-range retryAfterMs all close as protocol failures instead of escaping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:00 +09:00
DongHyeonkaandClaude Opus 5 6a8281a941 fix: make OPFS finalization and public cache repair failure-atomic
STO-RR-01. finalizePut re-acquired the origin mutation lease it was already
holding. A Web Lock is not reentrant, so an ordinary PUT stopped for good at
FINALIZE; it now calls the locked cleanup directly. A strict non-reentrant fake
lease manager pins one acquire and one release per finalization. The adapter no
longer reports a failed finalization as a plain write success either: the
journal row stays COMMITTED for reconciliation, but the caller is told the
write did not settle.

STO-RR-02. A failure raised while serving a validated request now carries that
request's kind. Defaulting every catch to CAPABILITIES made the client's own
expected-kind check reject genuine quota, integrity and abort failures as
protocol breaches and report them as UNSUPPORTED. Only an envelope the runtime
could not read still answers at protocol level.

STO-RR-03. The worker client decodes a response instead of adopting it: exact
own-data descriptors, the negotiated protocol version, the exact awaited kind,
a code inside the closed BrowserDataFailure set and a boolean retryable. An
accessor, a proxy trap, an inherited or extra field and an unknown code all
close the call as UNSUPPORTED rather than leaving it to time out.

STO-RR-04. A marker read that fails transiently is unknown, not damaged, so it
no longer deletes the candidate that may be serving traffic. Only a confirmed
corrupt or missing marker enters the repair path.

STO-RR-05. Staging never deletes a candidate it did not create. A repair
replaces exact entries in place, so a failed fetch leaves every healthy asset
and the active release usable; a candidate this call created is still removed
on failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:00:41 +09:00
DongHyeonkaandClaude Opus 5 ca210d3bc5 fix: align the legacy and optional network paths with V3 authority
LEG-01. AuthSessionPort.recover now takes the request's lifetime context, and
the raw recovery helper returns data only. The sign-out notification moved to
the site that adopts the result, so a recovery that answers after the deadline
or a caller abort is observed and discarded instead of logging the user out of
a request nobody is waiting on.

LEG-02. The V2 client shares V3's credential admission validator instead of
checking the allowed set alone. A bearer profile whose patch omits, empties,
duplicates or corrupts Authorization now fails closed with zero fetches rather
than dispatching an anonymous request under an authenticated profile.

OPT-NET-01. A cursor loader rejection is re-thrown exactly as it is with no
signal at all. Only a signal that has actually aborted classifies the outcome
as PAGINATION_ABORTED, so a real upstream failure stops being filed as a user
cancellation.

OPT-NET-02. defineMutationIntent and the V3 admission site now share the single
isValidIdempotencyKey authority, closing the drift that let a control character
through intent definition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:50:44 +09:00
DongHyeonkaandClaude Opus 5 f4bfdf0365 fix: close the live V3 authority findings from the adapter re-review
LIVE-01. A credential collaborator that returns UNAVAILABLE, throws, rejects
or answers off-contract is an outage of the auth integration, not evidence
about the user's session. Each of those now closes as AUTH_INTEGRATION_FAILURE
with zero fetches, so the composition root's logout path stays reserved for a
genuinely absent session. The synchronous and asynchronous failure sites share
one classifier.

LIVE-02 / LIVE-03. Object.freeze(new Map(...)) freezes the wrapper, not the
backing store, so an exported registry could still be cleared or replaced after
composition. Both the installed REST auth profile registry and the composed
HTTP/event lookups are now read facades over private stores, and every composed
row is an exact own-data snapshot that rejects accessors, inherited and
symbol-keyed fields.

LIVE-04. The total deadline now bounds the physical waits rather than being
checked between them: dispatch and response admission race the attempt signal,
the bounded reader takes that signal, and an abandoned operation is still
observed once so a late native rejection cannot surface unhandled. A body that
completes after the deadline or the caller owns the execution is no longer
admitted; a stale generation keeps its more specific SCOPE_FENCED verdict.

LIVE-05. DEADLINE is no longer treated as a caller-owned cancellation, so a
timeout reaches api.request.failed exactly once while caller, route, scope and
shutdown aborts stay excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:43:59 +09:00
DongHyeonkaandClaude Opus 5 3b481eb4cf docs: disposition every adapter review finding
All 64 finding IDs across the five reviews now carry an explicit state and none
remains NOT_STARTED: 51 fixed, 6 promotion-blocked by design, 4 deferred to the
multi-release wire migrations, 2 cohesion refactors not performed, and 1 browser
hypothesis unverified.

BT-UP-05 records that the extraction was attempted and reverted rather than
half-landed, with the specific reason: the five internal owners need a
shared-internals module for roughly forty types, constants and helpers to avoid
an import cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:33:47 +09:00
DongHyeonkaandClaude Opus 5 78f1bb273e fix: drain resumable upload teardown
BT-UP-06: close() previously aborted the lifetime and closed the checkpoint
store immediately, so a caller could not wait for an active operation's terminal
settlement and a late provider result could still race the store.

The runtime now tracks every admitted operation until it settles. close() stays
the compatibility facade that closes admission and starts the drain, while
dispose() returns that same single-flight promise: it aborts the operation
registry, awaits actual settlement, and only then closes the checkpoint store
and cancellation channel. lifecycle() exposes OPEN, CLOSING and CLOSED, and a
draining runtime refuses new admission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:27:59 +09:00
DongHyeonkaandClaude Opus 5 000a2581af fix: complete presigned capability and upload transport contracts
BT-PRE-02: add the top-level PRESIGNED_TRANSFER_V1 protocol literal to the
capability request and response. A missing, V0 or V2 envelope is closed as
POLICY_REJECTED before the vault registers anything. The server negotiates by
request shape; fields are never dual-emitted into a strict decoder, and the
nested PRESIGNED_MULTIPART_V1 binding protocol is unchanged.

BT-PRE-03: aborting a controller does not settle a fetch that ignores its
signal, so both presigned scopes now race the task, cancel a late response body
and survive a throwing scheduler without leaking the external abort listener.

BT-PRE-04: the vault owns its registration invariants, re-checking method,
href/origin/path agreement, embedded credentials, byte bounds, digest shape and
expiry, so a second issuer cannot register a weaker capability of the same type.

BT-PRE-05: decode each path segment once and require it to round-trip through
the canonical uppercase percent encoder, closing %2f, %5c, %252e%252e, mixed-case
escapes and encoded NUL while still admitting valid opaque UTF-8 segments.

BT-UP-02: inject and snapshot the upload transport clock and scheduler, so
Retry-After delta-seconds and HTTP-date resolve against the same captured now
and a clock rollback clamps to zero instead of producing a negative delay.

BT-IMG-01: make the image resolve() lifetime signal required, replacing the
hidden PRIMARY_REQUIRED preset precondition with a type-level one, and add the
negative typecheck fixture and gate that prove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:19:00 +09:00
DongHyeonkaandClaude Opus 5 976c8a8da4 fix: harden Service Worker activation and install lifecycle
SW-06: correlate activation, reset and drain replies by source object identity
against the captured waiting worker or controller, so an arbitrary same-origin
source cannot close this page's admission, and end a request immediately as
PROTOCOL_MISMATCH when the source is swapped instead of waiting for the drain
timeout. requestActivation() and resetOwnedCaches() are single-flight, so ten
concurrent callers share one nonce, listener and postMessage.

SW-07: an empty in-scope client set is vacuously drained rather than rejecting
a waiting worker when the requester already closed.

SW-08: isolate per-client postMessage failures. A client that cannot receive the
drain request fails immediately instead of holding pending state to the timeout,
skipWaiting() is the activation commit and its failure is a rejection, and the
accepted and reload notifications are sent afterwards as best effort.

SW-09: fence late install work. A fenced worker starts no new candidate work, a
late response body from a non-cooperative fetch is cancelled, a throwing digest
maps to a closed outcome, and a second exact delete of the owned candidate cache
is registered once the abandoned install settles - without extending the public
60s bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:03:07 +09:00
DongHyeonkaandClaude Opus 5 db52f02d73 docs: close the adapter remediation ledger with final evidence
Record the focused subsystem suites, repository gates and the exact status of
every gate that is not green, with attribution rather than assumption:

- ci-artifact-contract remains at its baseline 19 sandbox failures and is never
  claimed as green
- WebKit browser capabilities are UNVERIFIED because the engine cannot launch
  here; chromium passes
- the two runtime-removal fixtures fail on pre-existing reduced-fixture
  arithmetic in scripts/contracts/ci-gates.ts, which is unchanged since the
  baseline revision
- provider and staging rollback matrices are UNVERIFIED, not PASS

Also record what was deliberately not performed: the Task 17 extraction refactor
and the multi-release V2 wire rollouts, together with the in-repo prerequisites
that did land for each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:55:42 +09:00
DongHyeonkaandClaude Opus 5 f6098242be fix: bound migration commits and version the OPFS worker protocol
STO-06: the IndexedDB codec migration commit chain runs entirely inside
IndexedDB callbacks, so up to maxRows records could keep executing past the
caller's cooperative deadline. The monotonic budget is now re-checked before
each record's first write; a started record still completes atomically, the
checkpoint advances only to the last safe key, and a clock failure aborts the
transaction rather than committing an unbounded batch.

STO-07: every OPFS worker request and response now carries
OPFS_WORKER_PROTOCOL_VERSION = 2, responses echo their request kind, and the
client validates the envelope and failure shape strictly while remembering the
expected kind per pending request. A page/worker release mismatch or a reply for
a different operation closes as UNSUPPORTED instead of being decoded as a value
of the wrong shape. UNSUPPORTED is used deliberately: the closed browser-data
taxonomy has no INCOMPATIBLE code and none was invented.

SW-10 and the OPFS/Web Push V2 wire rollouts remain deferred: they are
expand/dual-read/drain/contract deployments across releases rather than a single
in-repo change. The ledger records them as DEFERRED_TO_MIGRATION.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:41:12 +09:00
DongHyeonkaandClaude Opus 5 fce8e046ea fix: bind Web Push mutations to exact authority
WP-01: a CAS receipt is only evidence when it names the expected key and the
exact next revision. Write and remove now share one validator, so a stale or
arbitrary repository receipt can no longer be packaged as a confirmed control.

WP-05: a pre-aborted command records the operation the caller requested instead
of always reporting an inspection.

WP-06: bounded fan-out is reported honestly. The subscriptionchange client
handoff and the notification cleanup both emit countBucket and truncated, and an
incomplete cleanup returns { complete: false } and is observed as DEGRADED
separately from revoke authority.

WP-07: the user-visible native notification effect is tracked through
NOT_APPLIED, MAYBE_APPLIED and CONFIRMED phases and surfaced as observation
evidence, never as retry authorization.

WP-02, WP-03 and WP-04 stay open: they need the V2 wire protocol with server
request-shape negotiation, which belongs to the versioned-migration task rather
than this correctness pass. The ledger records them as DEFERRED_TO_MIGRATION.

Web Push remains NOT_SELECTED and AVAILABLE_NOT_COMPOSED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:34:09 +09:00
DongHyeonkaandClaude Opus 5 58efe6ddbd fix: make Service Worker cache and removal outcomes truthful
SW-URL-01: canonicalize each generated root-relative manifest URL against the
registration scope once, re-check same-origin, and share that absolute identity
across install cache keys, fetch classification and cache lookup or delete.
Previously every verified asset fell through to the network.

SW-01: serve verified static requests only from the current release cache. A
CacheStorage-wide match could return a previous release's response for the same
URL while the delete targeted a cache that was never read. The worker scope
facade no longer exposes a wide match at all.

SW-02: cache reset deletes only names that parse as owned, so a foreign cache
sharing the ca-static-v1- prefix survives.

SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED.

SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED
map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a
later release cannot delete the worker while a registration or owned cache is
still present.

SW-05: add the runtime-neutral service-worker-static-manifest codec that owns
exact row keys, the extension and content-type allowlist, the root-relative URL
rule and the length-prefixed canonical bytes. The generator and the build gate
hash those same bytes, and the build gate now decodes and recomputes the set
digest instead of type-casting the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:25:11 +09:00
DongHyeonkaandClaude Opus 5 cc4e875c2d fix: bound browser transfer leases and effect reporting
BT-X-01: add the shared abortable-operation utility with golden tests for first
terminal owner, idempotent close, listener and timer cleanup under a throwing
scheduler, observed late rejection and late-handle compensation. It carries no
subsystem result taxonomy.

BT-PRE-01: make the presigned download lease lazy and single-start. open() now
validates, claims and consumes the capability without any network I/O; the
fetch, the transfer deadline and the expiry recheck happen at first stream
consumption. The source gained close(), which discards an unused lease with no
I/O and otherwise cancels the body and releases the scope exactly once.

BT-UP-01: require removeEventListener in the AbortSignal structural guard and
isolate release cleanup so a hostile facade cannot replace a typed terminal
result with a rejection.

BT-UP-03: deleteDatabase cannot be cancelled after dispatch, so a blocked
deadline now returns PENDING with effect UNKNOWN instead of a failure that reads
as NOT_APPLIED. A realm-scoped (factory, databaseName) registry prevents
recreating the partition until the native request settles.

BT-UP-04: reject non-finite and negative upload clocks as a dependency failure
instead of letting them bypass every capability expiry comparison.

BT-IMG-02: replace the naive Cache-Control quote stripping with a quote- and
escape-aware tokenizer, so max-age="60 or 60" is no longer read as 60 and a
comma inside a quoted extension is not a directive boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:13:54 +09:00
DongHyeonkaandClaude Opus 5 8f67974f68 fix: install bounded Browser RPC stream leases
R-04: install the RPC contract bindings as exact immutable snapshots. Registry
and row data are copied from own data descriptors into frozen null-prototype
maps before validation, so a getter is never invoked, extra and symbol keys and
malformed descriptors are composition-time TypeErrors, and the runtime reads
only the snapshot. A post-validation mutation can no longer change replay
policy, deadlines, byte ceilings or transport selection.

R-01: bound transport stream cleanup. The generation is fenced and listeners
released immediately, and iterator.return() is awaited only within a cleanup
bound, so a non-cooperative iterator cannot keep the application generator, its
listeners or the total deadline alive. Unresolved cleanup stays observed.

R-05: reject oversized WebSocket text frames before allocating an encoded copy
and count UTF-8 bytes incrementally with an early exit, matching TextEncoder for
surrogate pairs and lone surrogates.

R-06: canonicalise clock and generation-fence failures into the closed Result
taxonomy instead of letting them escape as native rejections, with listener and
timer cleanup on every exit path.

Browser RPC remains AVAILABLE_NOT_COMPOSED; R-07 transport evidence is still
required before composition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:00:15 +09:00
DongHyeonkaandClaude Opus 5 2f29ccbf1a fix: retain realtime work through draining
R-02: add an OPEN/DRAINING/CLOSED lifecycle orthogonal to freshness. Effect and
recovery authorities are now awaited under a deadline: on expiry the commit
capability is revoked and the work aborted, the caller gets a bounded
non-retryable IDLE_TIMEOUT, and the underlying task is retained rather than
dropped. A draining stream refuses new events and recovery, and close() returns
a Promise that succeeds only once every retained task actually settled,
reporting IDLE_TIMEOUT otherwise.

R-03: a handoff fail-close moves active, probe, quiescing and transition leases
into a retired-writer set before clearing their references, and close() waits on
current and retired writers together, so an abandoned non-cooperative writer can
no longer make teardown report a false success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:52:46 +09:00
DongHyeonkaandClaude Opus 5 c9e820aed5 fix: harden the legacy HTTP rollback path
N-06: export one idempotency-key authority from mutation-intent.ts and use it
in the V2 client. A caller-supplied key is validated before credentials, timers
and fetch, and an invalid value is rejected as VALIDATION_REJECTED /
IDEMPOTENCY_KEY_INVALID rather than trimmed, regenerated or dropped, so a keyed
command can no longer replay while sending no key.

N-07: bound the legacy credential wait by the existing attempt controller,
which already carries the total deadline and the caller signal, so a
non-cooperative owner cannot hold the request open and no extra timer is
introduced. The owner receives the operation context, and the failure follows
ownership: deadline to REQUEST_TIMEOUT, caller to REQUEST_ABORTED, and only a
genuine rejection to AUTH_INTEGRATION_FAILURE. None of these paths fetch.

N-08: readBoundedJson delegates to the common bounded reader, so cancel and
releaseLock throws stay isolated inside the closed result, and the V2
content-type mismatch now cancels the response body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:46:03 +09:00
DongHyeonkaandClaude Opus 5 4fe924ee0f fix: harden bounded state sidecars
N-05: the conditional-validator key was a colon join over components that may
themselves contain colons, so two distinct valid bindings could collide and one
definition's ETag could be prepared for another. The key is now a validated,
byte-bounded fixed tuple encoded with JSON.stringify.

N-09: capture localStorage exactly once and compare StorageEvent.storageArea
against that object identity, so a pulse from sessionStorage or any other area
is rejected instead of matching on key and value alone. The pulse key is
registered in the storage registry as CACHE_INVALIDATION_PULSE.

N-10: race loadPage against the caller signal and re-check before observing a
page, so a non-cooperative loader can neither hold loadAll forever nor have a
post-abort completion accumulated into a successful result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:35:48 +09:00
DongHyeonkaandClaude Opus 5 b893d95b36 fix: make public cache staging repairable
STO-03: reject at composition any policy that enables Vary variants while
stripping vary from the stored response allowlist, since every stored variant
would collide on the same cache key.

STO-04: extract one verifyReleaseCandidate authority shared by the stage fast
path and activation. A matching release marker is a claim, not evidence, so a
restage now re-verifies each entry, deletes only the owned candidate on a
mismatch and refetches. Abort or an unreadable candidate is never stage success
and never moves the active pointer.

STO-05: split the availability guard. Staging keeps the fetcher requirement
with ONLINE_ONLY recovery; activation, rollback and cleanup need only cache
storage and the mutation lock, so an offline rollback or quota-recovery cleanup
is no longer reported UNSUPPORTED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:28:22 +09:00
DongHyeonkaandClaude Opus 5 ba79060a83 fix: execute canonical browser download targets
Replace the boolean browser-managed target validator with
resolveBrowserManagedTarget, which returns the parsed canonical absolute URL,
and hand that exact value to the host. Previously the raw href was passed on,
so a relative target was re-resolved against document.baseURI and a hostile
<base> could send the navigation to an origin the policy never approved.

STO-08 stays UNVERIFIED: the capability spec does not exercise the system
picker, so the receiver-binding hypothesis is neither reproduced nor refuted
and no source change was made for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:23:46 +09:00
DongHyeonkaandClaude Opus 5 618da9abf5 fix: preserve OPFS recovery authority during cleanup
Repair the compensating half of the OPFS put saga.

The coordinator now owns a single abortPreparedPut() driven by a
composition-owned bounded signal instead of the caller's already aborted one,
and the worker client no longer issues a duplicate fire-and-forget abort.
Journal rows and budget reservations are released only after the physical
effect is confirmed CLEANED or ALREADY_CLEAN; a timeout, malformed response or
EFFECT_UNKNOWN keeps PREPARING/FILES_READY and returns OBJECT_RECONCILE.

New writes carry a transaction-unique physicalGenerationId through the staging
receipt, manifest path and prepared object, so a late compensation deletes only
its own transaction's directory even when a newer transaction legitimately
reuses the same logical generation. v1 paths, receipts and prepared objects stay
readable through the rollback window.

Abort and cleanup hold the origin mutation lease through physical deletion and
staging removal. A transaction that never reached staging returns ALREADY_CLEAN
without waiting for the lease, which would otherwise deadlock against the BEGIN
it is cancelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:21:58 +09:00
DongHyeonkaandClaude Opus 5 6d1e44f206 fix: terminate telemetry work on disposal
Give the best-effort telemetry adapter a terminal ACTIVE/DISPOSED lifecycle.
dispose() now removes the pagehide listener, clears the queue, invalidates
scheduled callback generations and aborts the in-flight sink; emit after
dispose is a no-op and a sink that ignores the abort cannot reschedule or
update post-dispose state. flush() joins the active delivery instead of
resolving early, and runtime infrastructure teardown disposes telemetry first.

Telemetry and diagnostics capacities are validated at construction against a
documented ceiling, so NaN or Infinity can no longer disable eviction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:05:43 +09:00
DongHyeonkaandClaude Opus 5 e06e4377ca fix: preserve command effect certainty across retries
Separate per-attempt physical state from the logical execution history. The
executor now keeps one monotonic certainty accumulator joined through
joinMutationEffectCertainty, records MAYBE_APPLIED at dispatch, and reads the
accumulator from every retry-loop fence, final-invariant, cancellation and
timeout return.

A retry-time scope fence landing between the loop-entry check and the
pre-dispatch invariant can no longer downgrade an already dispatched command to
NOT_STARTED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:01:48 +09:00
DongHyeonkaandClaude Opus 5 4e87bacdf3 fix: enforce installed HTTP auth profiles
Install the REST auth profile registry once at composition and make it the
single transport authority for V3. Contract composition now rejects an
unregistered authProfileId, so the executor never resolves a profile at
runtime.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:56:44 +09:00
DongHyeonkaandClaude Opus 5 67cc5b6d2c fix: restore V3 HTTP observability
Project one typed HttpExecutionObservation per logical V3 execution through a
closed composition-root projector: only registered diagnostic context keys and
bucketed values reach the sinks, and terminal non-abort failures now emit
exactly one api.request.failed telemetry event. Caller cancellation and scope
fencing record a diagnostic but never a failure event.

routeId becomes a required input at the installed operation-executor boundary
so the feature gateway's low-cardinality route identity survives to the sink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:42:51 +09:00
DongHyeonkaandClaude Opus 5 f7bec8274b docs: establish adapter remediation ledger
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:32:42 +09:00
DongHyeonka 4dc033cf33 refactor: adapter 구현중.. 2026-08-13 16:02:21 +09:00
DongHyeonka 30ceac23c1 fix: harden provider and promotion evidence 2026-08-02 16:28:24 +09:00
DongHyeonka 42ffb79997 fix: reject empty removal fixture scans 2026-08-02 15:02:57 +09:00
DongHyeonka f49d147b01 fix: harden CI evidence and removal contracts 2026-08-02 14:48:04 +09:00
DongHyeonka 1bb2cc4a20 refactor: generate CI workflow from gate contracts 2026-08-02 13:53:25 +09:00
DongHyeonka 777ce5c9ed docs: plan platform-owned frontend delivery 2026-08-02 13:50:40 +09:00
DongHyeonka 8565b96782 docs: define platform-owned frontend assurance delivery 2026-08-02 13:08:51 +09:00
DongHyeonka d2eb320936 test: harden HTTP scenario execution evidence 2026-08-02 11:36:27 +09:00
DongHyeonka e08d8c2dd8 docs: record HTTP deadline follow-up 2026-08-02 11:18:02 +09:00
DongHyeonka abdd90ad5d test: execute the HTTP scenario catalog 2026-08-02 11:17:28 +09:00
DongHyeonka 76bf9f1aa3 test: lock V8 coverage counter semantics 2026-08-02 10:12:34 +09:00
DongHyeonka 5cc6b8a51c docs: design V8 coverage counter contract 2026-08-02 09:44:13 +09:00
DongHyeonka e0373de4d9 docs: clarify counter-bearing module scope 2026-08-02 09:36:34 +09:00
DongHyeonka 5cecbb9820 refactor: align coverage counter provenance 2026-08-02 09:31:28 +09:00
DongHyeonka 6e05a35790 fix: reject empty coverage counters 2026-08-02 09:10:35 +09:00
DongHyeonka 8d6fbb97e9 fix: close coverage evidence races 2026-08-02 08:43:44 +09:00
DongHyeonka 67cd37659d fix: harden repository coverage evidence 2026-08-02 08:20:25 +09:00
DongHyeonka 5a73f7a1b5 fix: measure repository-wide risk coverage 2026-08-02 07:52:46 +09:00
DongHyeonka f487823442 fix: enforce exact local evidence defaults 2026-08-02 07:11:33 +09:00
DongHyeonka 1b4b0c2821 fix: recompute local promotion evidence 2026-08-02 07:02:39 +09:00
DongHyeonka 92e5cace5c fix: close immutable promotion trust gaps 2026-08-02 06:39:41 +09:00
DongHyeonka 7c5ed80407 fix: promote immutable verified release bundles 2026-08-02 06:08:06 +09:00
DongHyeonka 100a3bb6ba fix: make security fixtures fail closed 2026-08-02 05:40:58 +09:00
DongHyeonka 76d0ab0f62 fix: cover every tracked release input 2026-08-02 05:26:36 +09:00
DongHyeonka d6c98489ee fix: fail closed on release input discovery 2026-08-02 05:11:30 +09:00
DongHyeonka 381d5549e2 fix: preserve artifact writer failures 2026-08-02 04:46:33 +09:00
DongHyeonka c9f5887cac refactor: validate generated evidence artifacts 2026-08-02 04:33:01 +09:00
DongHyeonka 2c3cab2518 fix: select rollback artifact pairs atomically 2026-08-02 04:03:14 +09:00
DongHyeonka 172a26b8bd fix: fail closed in release drill verification 2026-08-02 03:54:58 +09:00
DongHyeonka 990603e24a fix: unify release runtime coherence verification 2026-08-02 03:38:49 +09:00
DongHyeonka 184bd98d92 fix: preserve reconciliation authorities 2026-08-02 03:15:22 +09:00
DongHyeonka d9afccdd60 fix: retain uncertain optimistic mutations 2026-08-02 02:42:56 +09:00
DongHyeonka 15645541b7 fix: reject credential idempotency headers 2026-08-02 01:33:07 +09:00
DongHyeonka fa2f699125 fix: reject invalid keyed mutation intents 2026-08-02 01:21:50 +09:00
DongHyeonka cbcc7b5ed7 fix: preserve logical mutation intent 2026-08-02 01:07:19 +09:00
DongHyeonka 53d181fbe4 fix: baseline invalidation registry contracts 2026-08-02 00:28:17 +09:00
DongHyeonka 0eb23875cb fix: harden invalidation registry governance 2026-08-01 23:59:11 +09:00
DongHyeonka 73a50426d6 fix: index many-to-many query invalidation 2026-08-01 23:17:09 +09:00
DongHyeonka 853c2e3f30 fix: align bound query keys with invalidation prefixes 2026-08-01 22:11:04 +09:00
DongHyeonka 92c3d438ab docs: plan refactoring review remediation 2026-08-01 21:59:19 +09:00
DongHyeonka a49c76b5b2 docs: define refactoring review remediation 2026-08-01 19:43:33 +09:00
DongHyeonka c6da03369c refactor: 리펙토링 2026-08-01 19:39:59 +09:00
DongHyeonka 9c959ea2a5 docs: plan release and boot integrity work 2026-08-01 15:15:37 +09:00
DongHyeonka 40c1870873 docs: define runtime integrity refactor design 2026-08-01 15:12:39 +09:00
donghyeon-ka 6c52cdb916 feat: 기능 추가 과정중 2026-07-30 15:58:20 +09:00
DongHyeonka d3ef801fe6 chore: 화면 상태 이미지 첨부 2026-07-30 15:56:55 +09:00
donghyeon-ka 3d810ef695 merge: optional frontend adapter recipes 2026-07-26 17:57:13 +09:00
donghyeon-ka 6c73b845bd feat: add optional frontend adapter recipes 2026-07-26 17:57:04 +09:00
donghyeon-ka 638f5f71bd merge: frontend supply chain verification 2026-07-26 17:38:00 +09:00
donghyeon-ka 8b4f875c1c feat: verify frontend supply chain 2026-07-26 17:37:51 +09:00
donghyeon-ka a64708f3de merge: test and registry evidence hardening 2026-07-26 17:15:32 +09:00
donghyeon-ka 98d4fd4960 feat: harden test and registry evidence 2026-07-26 17:15:26 +09:00
donghyeon-ka 3f634eb655 merge: diagnostics and telemetry runtime 2026-07-26 16:42:31 +09:00
donghyeon-ka 5173b6c8d6 feat: add diagnostics and telemetry runtime 2026-07-26 16:42:27 +09:00
donghyeon-ka 2fa0baa577 merge: internationalization message platform 2026-07-26 16:17:45 +09:00
donghyeon-ka 668bf05b48 feat: add internationalization message platform 2026-07-26 16:17:39 +09:00
donghyeon-ka b8c0444217 merge: design system platform 2026-07-26 15:49:13 +09:00
donghyeon-ka 13f28ef811 feat: add design system platform 2026-07-26 15:49:07 +09:00
donghyeon-ka e49d90f713 merge: form and page platform 2026-07-26 15:22:58 +09:00
donghyeon-ka b327d7370b feat: add form and page platform 2026-07-26 15:22:52 +09:00
donghyeon-ka fdcf0de5bf merge: removable reference feature vertical slice 2026-07-26 14:56:35 +09:00
donghyeon-ka c11be43f20 feat: add removable reference feature vertical slice 2026-07-26 14:56:34 +09:00
donghyeon-ka 980981bc86 merge: route and release recovery runtime 2026-07-26 14:26:39 +09:00
donghyeon-ka ce0040e407 feat: execute route and release recovery contracts 2026-07-26 14:26:39 +09:00
donghyeon-ka a33e93d4d4 merge: HTTP and query contract execution 2026-07-26 14:05:21 +09:00
donghyeon-ka ad55e21a3d feat: execute HTTP and query runtime contracts 2026-07-26 14:05:12 +09:00
donghyeon-ka 8aaaa033c0 merge: application boundary runtime 2026-07-26 13:52:43 +09:00
donghyeon-ka 2dda17cf19 feat: connect application input and output boundaries 2026-07-26 13:52:35 +09:00
donghyeon-ka 38ad69236b merge: TypeScript tooling foundation 2026-07-26 13:41:37 +09:00
donghyeon-ka 0fed35586a feat: establish TypeScript-aware frontend tooling 2026-07-26 13:41:23 +09:00
donghyeon-ka 1a1747c737 merge: frontend platform capability review 2026-07-26 02:09:38 +09:00
donghyeon-ka 68342e25ce docs: audit frontend platform capabilities 2026-07-26 02:09:06 +09:00
donghyeon-ka cb195f8773 merge: frontend performance route contract 2026-07-26 00:32:50 +09:00
donghyeon-ka 5a7a6c4ae5 test(performance): derive navigation target from route registry 2026-07-26 00:32:50 +09:00
donghyeon-ka 8ea825a8a1 merge: harden starter experience quality contract 2026-07-26 00:28:17 +09:00
donghyeon-ka 236909be64 test: harden starter experience quality contract 2026-07-26 00:28:09 +09:00
donghyeon-ka 68d9efbda3 merge: add persistent responsive color themes 2026-07-26 00:06:56 +09:00
donghyeon-ka 4cfbe5a29e feat: add persistent responsive color themes 2026-07-26 00:06:49 +09:00
donghyeon-ka bee5c158c6 merge: provide reusable UI and state galleries 2026-07-25 23:59:20 +09:00
donghyeon-ka 3581ead595 feat: provide reusable UI and state galleries 2026-07-25 23:59:12 +09:00
donghyeon-ka a8e3db1aec merge: assemble responsive app shell navigation 2026-07-25 23:52:41 +09:00
donghyeon-ka baeda39057 feat: assemble responsive app shell navigation 2026-07-25 23:52:34 +09:00
donghyeon-ka 0925d252d9 merge: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9a120e6d45 feat: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9200c80149 merge: require field gate inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka f7e8ef6ee4 fix: require external field evidence inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka e70b1a4ad9 merge: harden field performance evidence 2026-07-25 22:30:14 +09:00
donghyeon-ka 6b4b956d51 fix: authenticate field performance evidence context 2026-07-25 22:30:14 +09:00
donghyeon-ka 7d4daea23a merge: harden live hosting verification 2026-07-25 22:25:04 +09:00
donghyeon-ka c089e749d0 fix: require genuine live hosting evidence 2026-07-25 22:25:04 +09:00
donghyeon-ka 2d199a5a23 fix: retain complete manual accessibility evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 23c47a1eb9 merge: align accessibility gate evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 8a38805c01 merge: strengthen manual accessibility evidence 2026-07-25 22:21:23 +09:00
donghyeon-ka 2725c35c28 fix: require signed accessibility evidence per route 2026-07-25 22:21:23 +09:00
donghyeon-ka 50dc803f19 fix: consume canonical scoped diagram review evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka 976f444692 merge: align documentation readiness evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka c7191d7615 fix: gitkeep 파일 제거 2026-07-25 22:16:01 +09:00
donghyeon-ka a2a97ebcc7 fix: budget transitive initial JavaScript chunks 2026-07-25 21:46:07 +09:00
donghyeon-ka 28f5585a56 merge: complete bundle graph accounting 2026-07-25 21:46:07 +09:00
donghyeon-ka 4667cafa43 fix: validate complete immutable release surface 2026-07-25 21:45:02 +09:00
donghyeon-ka 6a0c60180c merge: complete immutable release verification 2026-07-25 21:45:02 +09:00
donghyeon-ka 18bea3a852 fix: verify hosting response content types 2026-07-25 21:43:55 +09:00
donghyeon-ka 72fd295556 merge: refresh hosting header verification contract 2026-07-25 21:43:55 +09:00
donghyeon-ka cc6cf29c79 merge: refresh bootstrap type fixture contract
# Conflicts:
#	package.json
2026-07-25 21:40:56 +09:00
donghyeon-ka 8198886dab fix: execute negative type fixture against source 2026-07-25 21:40:24 +09:00
donghyeon-ka c5e218d37a feat: orchestrate blocking frontend quality gates 2026-07-25 21:39:04 +09:00
donghyeon-ka 69d7e26a5b Merge branch 'feature-frontend-ci-quality-gates-contract' into develop 2026-07-25 21:39:04 +09:00
donghyeon-ka 6dd5b85c8c Merge branch 'feature-frontend-operational-runbook-contract' into develop 2026-07-25 21:30:41 +09:00
donghyeon-ka 75c3f5b08c feat: operationalize frontend incident runbooks 2026-07-25 21:30:40 +09:00
donghyeon-ka b1625252d5 feat: enforce web vitals performance budgets 2026-07-25 21:26:51 +09:00
donghyeon-ka 15ddb8d474 Merge branch 'feature-web-vitals-performance-budget-contract' into develop 2026-07-25 21:26:51 +09:00
donghyeon-ka eb37cbe8be feat: enforce coherent release and rollback contract 2026-07-25 21:22:35 +09:00
donghyeon-ka 9d40724ab2 Merge branch 'feature-frontend-release-cache-rollback-contract' into develop 2026-07-25 21:22:35 +09:00
donghyeon-ka b82bc73c6c Merge branch 'feature-frontend-contract-compatibility-governance' into develop 2026-07-25 21:19:20 +09:00
donghyeon-ka 89f3c69413 feat: govern frontend contract compatibility 2026-07-25 21:19:20 +09:00
donghyeon-ka 5ad6fb032e Merge branch 'feature-frontend-contract-registry-governance' into develop 2026-07-25 21:16:46 +09:00
donghyeon-ka 52f4896b63 feat: govern contract registries and snapshots 2026-07-25 21:16:46 +09:00
donghyeon-ka 3c347d40d8 Merge branch 'feature-frontend-browser-security-boundary-contract' into develop 2026-07-25 21:14:32 +09:00
donghyeon-ka 6f88915c7a feat: enforce browser security boundaries 2026-07-25 21:14:32 +09:00
donghyeon-ka 675603c3a2 Merge branch 'feature-frontend-build-bundle-supply-chain-contract' into develop 2026-07-25 21:13:13 +09:00
donghyeon-ka 4a3110974b feat: generate build and supply-chain evidence 2026-07-25 21:13:13 +09:00
donghyeon-ka 6db96b6ef5 feat: establish automated and manual accessibility gates 2026-07-25 21:11:23 +09:00
donghyeon-ka caf09ecd56 Merge branch 'feature-accessibility-baseline-contract' into develop 2026-07-25 21:11:23 +09:00
donghyeon-ka f6300c5d1d Merge branch 'feature-tailwind-design-token-styling-contract' into develop 2026-07-25 21:09:07 +09:00
donghyeon-ka 9a92c11792 feat: add Tailwind semantic design tokens 2026-07-25 21:09:07 +09:00
donghyeon-ka a023c3b645 Merge branch 'feature-sample-feature-slice-contract-fixture' into develop 2026-07-25 21:07:48 +09:00
donghyeon-ka c6b7a9b9bc feat: add removable sample vertical contract fixture 2026-07-25 21:07:48 +09:00
donghyeon-ka 04c4bad43c Merge branch 'feature-frontend-render-recovery-boundary-contract' into develop 2026-07-25 21:05:36 +09:00
donghyeon-ka c37d571eb3 feat: add layered render recovery boundaries 2026-07-25 21:05:36 +09:00
donghyeon-ka eb16c2ffe7 Merge branch 'feature-routing-navigation-guard-contract' into develop 2026-07-25 21:03:57 +09:00
donghyeon-ka b221453c15 feat: centralize routes and navigation guards 2026-07-25 21:03:57 +09:00
donghyeon-ka bfc642875c Merge branch 'feature-boundary-mapper-viewmodel-contract' into develop 2026-07-25 21:02:06 +09:00
donghyeon-ka a9a7db0231 feat: contain DTO mapping at the HTTP boundary 2026-07-25 21:02:06 +09:00
donghyeon-ka 3e126c0ddd Merge branch 'feature-async-ui-state-contract' into develop 2026-07-25 21:00:21 +09:00
donghyeon-ka 438dc11548 feat: model complete async UI surface states 2026-07-25 21:00:21 +09:00
donghyeon-ka 652e0250f3 Merge branch 'feature-frontend-observability-logging-trace-contract' into develop 2026-07-25 20:58:33 +09:00
donghyeon-ka bf8d69e285 feat: add redacted best-effort telemetry contract 2026-07-25 20:58:33 +09:00
donghyeon-ka d54eeff450 Merge branch 'feature-frontend-storage-registry-contract' into develop 2026-07-25 20:56:58 +09:00
donghyeon-ka 2c3eda4d6b feat: enforce classified browser storage registry 2026-07-25 20:56:58 +09:00
donghyeon-ka 0a3bf08308 Merge branch 'feature-server-state-caching-contract' into develop 2026-07-25 20:55:27 +09:00
donghyeon-ka 184eb67282 feat: add application-owned query cache contract 2026-07-25 20:55:27 +09:00
donghyeon-ka c570996a97 Merge branch 'feature-frontend-auth-session-integration-contract' into develop 2026-07-25 20:54:18 +09:00
donghyeon-ka 44414c5244 feat: integrate bounded external auth sessions 2026-07-25 20:54:18 +09:00
donghyeon-ka 37ca2c3172 Merge branch 'feature-frontend-error-classification-boundary-contract' into develop 2026-07-25 20:52:58 +09:00
donghyeon-ka 7f3569ce3c feat: normalize failures through a stable registry 2026-07-25 20:52:58 +09:00
donghyeon-ka a0ca15da65 Merge branch 'feature-runtime-schema-validation-contract' into develop 2026-07-25 20:51:27 +09:00
donghyeon-ka bf813f09ab feat: validate HTTP envelopes and payload schemas 2026-07-25 20:51:27 +09:00
donghyeon-ka 0934de44c7 Merge branch 'feature-api-client-response-envelope-contract' into develop 2026-07-25 20:49:50 +09:00
donghyeon-ka 5c36cd978c feat: add shared HTTP response and retry contract 2026-07-25 20:49:50 +09:00
donghyeon-ka b193feeddc Merge branch 'feature-frontend-env-runtime-config-contract' into develop 2026-07-25 20:46:07 +09:00
donghyeon-ka 6b51104010 feat: validate runtime configuration before mount 2026-07-25 20:46:06 +09:00
donghyeon-ka 7199d7d9b6 Merge branch 'feature-frontend-architecture-enforcement-lint-contract' into develop 2026-07-25 20:43:49 +09:00
donghyeon-ka f3e105f971 feat: enforce frontend architecture boundaries 2026-07-25 20:43:49 +09:00
donghyeon-ka 1b1fb9bac6 Merge branch 'feature-frontend-test-taxonomy-contract' into develop 2026-07-25 20:41:45 +09:00
donghyeon-ka 946cf407b0 test: establish frontend gate taxonomy 2026-07-25 20:41:44 +09:00
donghyeon-ka 0a0b7263b3 Merge branch 'feature-frontend-clean-architecture-layering-contract' into develop 2026-07-25 20:40:21 +09:00
donghyeon-ka d7480cbafd fix: make application port contracts checkJs-safe 2026-07-25 20:40:21 +09:00
donghyeon-ka 54d74fcf4a Merge branch 'feature-frontend-clean-architecture-layering-contract' into develop 2026-07-25 20:39:59 +09:00
donghyeon-ka 49b20bd557 feat: define clean architecture layers and ports 2026-07-25 20:39:59 +09:00
donghyeon-ka 7b35f6b551 feat: bootstrap Vite and pnpm toolchain contract 2026-07-25 20:39:06 +09:00
donghyeon-ka 24d7072d66 Merge branch 'feature-frontend-project-bootstrap-toolchain-contract' into develop 2026-07-25 20:39:06 +09:00
946 changed files with 258964 additions and 1 deletions
+225
View File
@@ -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"
}
}
}
+25
View File
@@ -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
+460
View File
@@ -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
+26
View File
@@ -0,0 +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
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
save-exact=true
+1
View File
@@ -0,0 +1 @@
24.14.0
+15
View File
@@ -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;
+123
View File
@@ -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.
+167 -1
View File
@@ -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
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+18
View File
@@ -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.
+18
View File
@@ -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.
+38
View File
@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"layers": {
"domain": {
"root": "src/domain",
"mayImport": ["src/domain"]
},
"application": {
"root": "src/application",
"mayImport": ["src/application", "src/domain", "src/contracts"]
},
"presentation": {
"root": "src/presentation",
"mayImport": ["src/presentation", "src/application", "src/domain", "src/contracts"]
},
"adapters": {
"root": "src/adapters",
"mayImport": ["src/adapters", "src/application", "src/domain", "src/contracts"]
},
"bootstrap": {
"root": "src/bootstrap",
"mayImport": ["src"]
}
},
"forbidden": [
["domain", "application"],
["domain", "presentation"],
["domain", "adapters"],
["domain", "bootstrap"],
["application", "presentation"],
["application", "adapters"],
["application", "bootstrap"],
["presentation", "adapters"],
["presentation", "bootstrap"],
["adapters", "presentation"],
["adapters", "bootstrap"]
]
}
+2807
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -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"
}
]
}
+513
View File
@@ -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"]
}
]
}
+35
View File
@@ -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"
}
}
}
+11
View File
@@ -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=()"
}
}
+18
View File
@@ -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"
}
]
}
+77
View File
@@ -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"
}
}
]
}
+93
View File
@@ -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"
}
}
}
+19
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+20
View File
@@ -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"
}
}
+20
View File
@@ -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": []
}
+24
View File
@@ -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"
}
+52
View File
@@ -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"
}
+130
View File
@@ -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": []
}
+22
View File
@@ -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"
]
}
]
}
+59
View File
@@ -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.
@@ -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 | 2502,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
+81
View File
@@ -0,0 +1,81 @@
# Clean Architecture layer contract
The import direction is `domain <- application <- presentation`; concrete
adapters implement application-owned ports and are assembled only in
`src/bootstrap`.
| Layer | Owns | May depend on |
| --- | --- | --- |
| `domain` | framework-neutral models and pure policies | domain siblings |
| `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.
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
{
"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 상태로 유지한다.
+79
View File
@@ -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
+22
View File
@@ -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.
+30
View File
@@ -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