Lesson 18 / 20
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.
Contents
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.
// 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; }
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.