---
title: 'Valet Key Pattern'
source: 'https://academia.sh/en/courses/resilience-patterns/valet-key-pattern'
course: 'Resilience and Reliability'
language: en
updated: '2026-08-23T07:01:30+00:00'
license: 'CC BY-SA 4.0'
---

# Valet Key Pattern

Time-limited authorization for direct resource access: taking proof bytes off the service's path drops per-transfer bytes from 80,000 to 512, the held-slot pool saturating at 64 and dropping 28 transfers when the store slows down, the open authorization window growing linearly with key duration, and scope narrowing's payoff being non-linear.

The claim check pattern took the proof out of the queue, but the whole path still runs through
the service: the carrier uploads the proof to the service, the service writes it to the store,
and when the recipient requests the proof the service reads it back and transfers it to the
client. The 40,000 bytes removed from the queue still pass through twice on the on-demand path.

The **valet key** pattern grants the client authorization narrow and short-lived enough to reach
the resource directly, letting data flow around the service. The Authentication and Authorization
course established the signed link, scope, and time-limited token; the verification mechanics are
not repeated here. This lesson measures three things: the bytes that **do not pass** through the
service, the **authorization window** it opens, and the **cost** of narrowing scope.

## Getting the Byte Off the Service

**DD12 — delivery proof view rate 0.02.** Rationale: proof is read only on dispute or audit, not on
every shipment; its sensitivity already enters the measure as a rate. **DD13 — proof transfer
pool 64 slots**, with a failure scenario: **the proof store slows down**, response time rising
from 1 round to 20 rounds and lasting 80 rounds. Round duration is this course's DD6 assumption
(0.25 seconds), so the slowdown lasts 20 seconds, taken twice a month.

The rig is an in-process model: there is no store, network, or client; a round is an abstract
step, a slot is a counter, and bytes are a counted quantity.

```js
// valet/path.mjs — in-process model of the two paths for proof transfer.
// There is no real store, network, or client: a round is an abstract step, a slot is a counter, bytes are counted.

export const PROOF = 40_000, KEY = 512;   // DD8 proof bytes; DD14 valet key response

// path: "proxy" = bytes pass through the service; "valet" = the service only issues keys.
// Each round: incoming transfers request a slot, the store holds the slot for its duration, then releases it.
export function run({ rounds, arrivals, pool, normalDuration, failureDuration, failureStart, failureEnd, path }) {
  const held = [];
  const s = { accepted: 0, rejected: 0, peakSlots: 0, serviceBytes: 0, slotTurns: 0, keys: 0 };
  let remainder = 0;
  for (let t = 0; t < rounds; t += 1) {
    for (let i = held.length - 1; i >= 0; i -= 1) if (held[i] <= t) held.splice(i, 1);
    const duration = t >= failureStart && t < failureEnd ? failureDuration : normalDuration;
    remainder += arrivals;
    const n = Math.floor(remainder);
    remainder -= n;
    for (let i = 0; i < n; i += 1) {
      if (path === "valet") { s.accepted += 1; s.keys += 1; s.serviceBytes += KEY; continue; }
      if (held.length >= pool) { s.rejected += 1; continue; }
      held.push(t + duration);
      s.accepted += 1; s.serviceBytes += PROOF * 2;      // received from the client, written to the store
    }
    s.peakSlots = Math.max(s.peakSlots, held.length);
    s.slotTurns += held.length;
  }
  return s;
}
```

```js
// valet/measure.mjs — held slots and dropped transfers for both paths when the proof store slows down
import { run, PROOF, KEY } from "./path.mjs";

const V3 = 400_000, V8 = 3, DAY = 86_400;         // K01: daily shipments, peak multiplier
const DD12 = 0.02;                                // proof view rate
const ROUND_S = 0.25, ROUNDS = 320, POOL = 64;    // DD6 round duration; DD13 pool
const uploadRate = (V3 / DAY) * V8, readRate = ((V3 * DD12) / DAY) * V8;
const rate = uploadRate + readRate;
const arrivals = rate * ROUND_S;

console.log(`peak proof transfer ${rate.toFixed(4)}/s (upload ${uploadRate.toFixed(4)}, ` +
  `read ${readRate.toFixed(4)}; DD12 = ${DD12})`);
console.log(`${ROUNDS} rounds x ${ROUND_S} s = ${(ROUNDS * ROUND_S).toFixed(0)} s; ${arrivals.toFixed(4)} transfers per round`);
console.log(`DD13: store response rises from 1 round to 20 rounds, lasting 80 rounds (${(80 * ROUND_S).toFixed(0)} s)`);

console.log(`\n${"path".padEnd(8)}${"state".padEnd(10)}${"accepted".padStart(10)}${"rejected".padStart(10)}` +
  `${"peak slots".padStart(12)}${"avg. slots".padStart(12)}${"service MB".padStart(12)}`);
for (const path of ["proxy", "valet"])
  for (const [label, failureDuration, failureStart, failureEnd] of
    [["healthy", 1, ROUNDS, ROUNDS], ["slowdown", 20, 80, 160]]) {
    const r = run({ rounds: ROUNDS, arrivals, pool: POOL, normalDuration: 1,
      failureDuration, failureStart, failureEnd, path });
    console.log(`${path.padEnd(8)}${label.padEnd(10)}${String(r.accepted).padStart(10)}` +
      `${String(r.rejected).padStart(10)}${String(r.peakSlots).padStart(12)}` +
      `${(r.slotTurns / ROUNDS).toFixed(2).padStart(12)}${(r.serviceBytes / 1e6).toFixed(3).padStart(12)}`);
  }
console.log(`\nbytes passing through the service per transfer: proxy ${PROOF * 2}, valet ${KEY} ` +
  `(ratio ${((PROOF * 2) / KEY).toFixed(1)}x)`);
```

```
peak proof transfer 14.1667/s (upload 13.8889, read 0.2778; DD12 = 0.02)
320 rounds x 0.25 s = 80 s; 3.5417 transfers per round
DD13: store response rises from 1 round to 20 rounds, lasting 80 rounds (20 s)

path    state       accepted  rejected  peak slots  avg. slots  service MB
proxy   healthy         1133         0           4        3.54      90.640
proxy   slowdown        1105        28          64       18.65      88.400
valet   healthy         1133         0           0        0.00       0.580
valet   slowdown        1133         0           0        0.00       0.580

bytes passing through the service per transfer: proxy 80000, valet 512 (ratio 156.3x)
```

These numbers belong to the **measurement** class and come from an in-process model; their inputs
are K01's computed values and this lesson's assumptions.

## The Slowing Store's Spread Into the Service

The healthy rows give the quiet difference between the two paths: in eighty seconds, the proxy
path passes 90.640 MB through the service, the valet path 0.580 MB. That is 80,000 bytes against
512 bytes per transfer, a 156.3x ratio. The proxy path's average held slots is 3.54 — the product
of the arrival rate and the service duration — small next to the 64-slot pool on a healthy day.

The slowdown row makes the real distinction. When the store's response time stretches to twenty
times its normal length, average held slots rise to 18.65, the peak to 64 — the pool
**saturates** — and 28 transfers are rejected. Accepted transfers drop from 1133 to 1105. On the
valet path, under the same slowdown, held slots are 0, rejected 0, accepted 1133: because the
service never touches the proof's bytes, the store's slowdown does not show up in the service's
pool. The client still talks to a slow store, but it is the one waiting; **the blast radius stays
outside the service.** A saturated pool affects not only the proof transfer but every other job
sharing that pool; separating pools is the bulkhead pattern's question, from this course's Fault
Isolation topic.

## Scope and Duration

The pattern's counterpart is an authorization, measured on two axes: how long it stays open and
how many objects it opens.

**DD14 — valet key duration 300 seconds.** Rationale: a proof upload or view is a single user
action and does not take minutes; the duration is kept long enough to leave room for a retry. Its
sensitivity is given at 60 and 3600 seconds. **DD15 — a key's leak probability is 10⁻⁶**; a key
can end up in a log file, browser history, or a shared link. Both are assumptions.

```js
// valet/scope.mjs — narrowing scope's effect on key issuance, authorization window, and exposure
const V3 = 400_000, V8 = 3, V12 = 730, DAY = 86_400;   // K01: shipments, peak multiplier, retention
const SELLERS = 4000, EDGE_PEAK = 513.89;              // K01 computed value: seller count, edge peak
const DD14 = 300, DD15 = 1e-6;                         // key duration (s), key leak probability
const DAILY = V3 / SELLERS;                            // daily shipments per seller = 100

// Scope: [label, daily key issuance, objects one key can reach]
const SCOPE = [["single object", V3, 1], ["seller-day prefix", SELLERS, DAILY],
  ["seller bucket", SELLERS, DAILY * V12]];

console.log(`DD14 key duration ${DD14} s, DD15 leak probability ${DD15}; ` +
  `${V3.toLocaleString("en-US")} proofs a day, ${SELLERS} sellers, ${DAILY} shipments per seller`);
console.log(`\n${"scope".padEnd(18)}${"keys/day".padStart(13)}${"peak keys/s".padStart(16)}` +
  `${"of K01 edge".padStart(12)}${"open window".padStart(14)}${"surface".padStart(9)}` +
  `${"object-sec/day".padStart(18)}`);
for (const [label, issuance, surface] of SCOPE) {
  const peak = (issuance / DAY) * V8, openWindow = peak * DD14;
  const exposure = issuance * DD15 * surface * DD14;
  console.log(`${label.padEnd(18)}${issuance.toLocaleString("en-US").padStart(13)}` +
    `${peak.toFixed(4).padStart(16)}${`${((100 * peak) / EDGE_PEAK).toFixed(3)}%`.padStart(12)}` +
    `${openWindow.toFixed(1).padStart(14)}${surface.toLocaleString("en-US").padStart(9)}` +
    `${exposure.toFixed(1).padStart(18)}`);
}

console.log(`\n${"DD14 sensitivity".padStart(17)}${"single-object open".padStart(20)}` +
  `${"seller-day open".padStart(17)}${"bucket object-sec".padStart(19)}`);
for (const s of [60, 300, 3600]) {
  const t = (V3 / DAY) * V8 * s, g = (SELLERS / DAY) * V8 * s;
  console.log(`${`${s} s`.padStart(17)}${t.toFixed(1).padStart(20)}${g.toFixed(1).padStart(17)}` +
    `${(SELLERS * DD15 * DAILY * V12 * s).toFixed(0).padStart(19)}`);
}
const [t1, g1, k1] = SCOPE.map(([, u, y]) => u * DD15 * y * DD14);
console.log(`\nbucket -> seller-day: exposure falls ${(k1 / g1).toFixed(0)}x, ` +
  `key issuance unchanged`);
console.log(`seller-day -> single object: exposure changes ${(g1 / t1).toFixed(2)}x, ` +
  `key issuance rises ${(V3 / SELLERS).toFixed(0)}x`);
console.log(`worst case is not the same: objects opened by one leaked key ` +
  `${SCOPE.map(([label, , y]) => `${label} ${y.toLocaleString("en-US")}`).join(", ")}`);
```

```
DD14 key duration 300 s, DD15 leak probability 0.000001; 400,000 proofs a day, 4000 sellers, 100 shipments per seller

scope                  keys/day     peak keys/s of K01 edge   open window  surface    object-sec/day
single object           400,000         13.8889      2.703%        4166.7        1             120.0
seller-day prefix         4,000          0.1389      0.027%          41.7      100             120.0
seller bucket             4,000          0.1389      0.027%          41.7   73,000           87600.0

 DD14 sensitivity  single-object open  seller-day open  bucket object-sec
             60 s               833.3              8.3              17520
            300 s              4166.7             41.7              87600
           3600 s             50000.0            500.0            1051200

bucket -> seller-day: exposure falls 730x, key issuance unchanged
seller-day -> single object: exposure changes 1.00x, key issuance rises 100x
worst case is not the same: objects opened by one leaked key single object 1, seller-day prefix 100, seller bucket 73,000
```

## Narrowing Scope's Payoff Is Not Linear

The table separates narrowing scope into two steps that do not resemble each other.

**Stepping down from the bucket scope to the seller-day prefix gains 730x** and costs nothing: key
issuance is 4000 a day in both, the open window 41.7 keys in both. Expected exposure drops from
87,600 object-seconds to 120.0. The narrowing is free, with no reason to skip it.

**Stepping down from the seller-day prefix to the single object does not change the expected
exposure at all** — both rows show 120.0 object-seconds — but it raises key issuance 100x: 400,000
a day instead of 4000, 13.8889 keys/s at peak instead of 0.1389. That is 2.703 percent of K01's
513.89 requests/s at the edge. The expected value holds because the arithmetic cancels: scope
narrows a hundredfold, issued keys rise a hundredfold, and the product stays constant.

The decision can still favor the single object on other grounds: the **worst case** is not equal.
A single leaked key opens 1 object under the single-object scope, 100 under the seller-day
prefix, 73,000 under the bucket scope. Expected exposure is the same, but the tail is not, and
the largest damage a single event can carry spans three orders of magnitude.

On the duration axis, the result is simpler: the open window grows linearly with DD14. Shortening
the key duration to 60 seconds drops the single-object scope's concurrently valid keys from
4166.7 to 833.3; stretching it to 3600 seconds raises it to 50,000. Under the bucket scope, an
hour-long key produces 1,051,200 object-seconds a day. **Shortening the duration pays off
linearly at every scope; narrowing the scope pays off only at the first step.**

## The Numbers for Two Days

**The failure-free day's cost has two items.** The service issues a key for every byte it does not
carry: 400,000 signings a day under the single-object scope, 13.8889 requests/s at peak, 2.703
percent of the edge rate. And a permanently open authorization window is carried — under the
single-object scope, 4166.7 keys are concurrently valid for the 300-second duration. The proxy
path has no such window; access is bounded by the request itself.

**The failing day's gain is isolation.** When the proof store slows down twentyfold, the proxy
path saturates its pool at 64 slots and drops 28 transfers in eighty seconds; on the valet path,
dropped transfers are 0, held slots 0. The difference is not a speed difference, it is a spread
difference: on the proxy path, the store's failure turns into a resource problem for the service;
on the valet path, it stays with the client. The bytes that do not pass through the service total
90.060 MB in eighty seconds (90.640 − 0.580), and those bytes are the source of the held slots
during the failure.

## Summary

- Bytes passing through the service per transfer are 80,000 on the proxy path and 512 on the
  valet path; a 156.3x ratio, 90.640 MB against 0.580 MB over an eighty-second run.
- When the store slows down twentyfold, the proxy path's average held slots reach 18.65, the peak
  64 (the pool saturates), and 28 transfers are dropped; on the valet path, held slots are 0,
  dropped 0.
- The failure-free day's cost is key issuance: 400,000 signings a day under the single-object
  scope, 13.8889 requests/s at peak, 2.703 percent of K01's edge rate.
- The open authorization window is linear with duration: at DD14 = 60/300/3600 seconds,
  833.3 / 4166.7 / 50,000 keys are concurrently valid under the single-object scope.
- Narrowing scope's first step is free: bucket → seller-day drops expected exposure from 87,600
  to 120.0 object-seconds and does not change key issuance.
- The second step does not change the expected value (120.0 → 120.0) but raises key issuance
  100x; the justification is the worst case — objects opened by one leaked key are
  73,000 / 100 / 1.

## Next Step

The proof path now rests on three decisions: where the load stops, when the claim check is
deleted, and what scope and duration the key is valid for. All three numbers are embedded in code
as constants: lifetime, key duration, pool size. If a failure calls for shortening the key
duration or closing the proof path entirely, the only way
is a redeploy, and a redeploy draws from the outage budget's planned share. The next lesson takes
these settings out of the application: how long a change takes to reach every copy, the cost of a
cached read, the behavior when the store itself goes down, and the blast radius of a value set
wrong.
