# Architecture Decision Record

*URL Shortener & Link Management Service · Solution Architecture v1.0 · Microsoft Azure · Platform Architecture · 2026-09 · 23 views · 16 architecture decision records*

The argument these decisions serve is summarised in the [Architecture One-Pager](architecture-one-pager).

Sixteen decisions make up this architecture. Everything else across the twenty-three views is convention, sizing or consequence. Each record states the forcing question, the context that makes it hard, what was decided, how it is realised on Microsoft Azure, the options weighed, what the choice buys and costs, the conditions that would flip it, why it should still be right in ten years, and the lesson worth carrying to a different system.

> **Status of this document.** This is a design, not a report on a running system. The rates, latencies, volumes, retentions and windows are the requirement's stated assumptions for a mid-size commercial link platform — 40,000 tenants, 12,000 branded domains, 12 billion stored links, 150,000 redirects per second — invented to be defensible and arguable rather than absent. They are to be replaced by measured telemetry before build, and four of them in particular are the first numbers real data should overturn: the 97% edge cache hit rate, which decides the cost per million redirects and the resolver fleet size; the 4% thirty-day working set, which decides whether the hot set is affordable at all; the 30-second edge cache TTL, which is simultaneously the hot-key absorber and the outer bound on the revocation promise; and the 18% bot share, which decides whether every customer-facing number is credible.

## How to read a record

- **Question:** The forcing question: why a decision was needed at all.
- **Context:** The requirement, the scale and the constraint that make it hard.
- **Decision:** What this architecture does, stated so it can be checked.
- **How it is realised on Google Cloud:** The concrete mechanism: which service or package, configured how, in which project.
- **Options weighed:** Chosen, rejected, deferred, or right elsewhere, with the reason for each.
- **Consequences:** What the choice buys and what it costs, both kept visible.
- **Choose differently when:** The conditions that would flip the decision for your system.
- **Why it holds up over time:** What keeps the decision right as scale, staff and technology change.
- **Lesson:** The principle that transfers beyond this platform.

## Decision map

**Planes and propagation**: Where the writer's involvement ends, and how a change reaches a PoP on the other side of the world.

- ADR-01 · The redirect plane reads a projection and never writes
- ADR-02 · Revocation travels on its own channel, ahead of the projection
- ADR-15 · Staleness bounds are published as contracts, not implied as immediacy

**Code and identity**: How a code is chosen, who guarantees it is unique, and why it can never be used twice.

- ADR-03 · Codes are random, unguessable and seven characters — not counter-derived
- ADR-04 · A code is identity, and is retired permanently rather than reused
- ADR-05 · Alias uniqueness is the only strongly consistent operation
- ADR-06 · The code namespace is per domain, not global

**The read path**: Where a lookup happens, what it is allowed to depend on, and what it returns.

- ADR-07 · Resolution happens in twelve regional resolvers, not at the PoP
- ADR-08 · A 302 with a 30-second cache lifetime, never a permanent redirect
- ADR-09 · The link table is partitioned by code hash, not by tenant

**Counting**: How a click becomes a number somebody bills on, without a write on the redirect path.

- ADR-10 · Clicks are collected from the edge access log, after the response has left
- ADR-11 · Full click events are shipped, with sampling reserved for pathological codes
- ADR-12 · Bots are classified and served, never blocked or silently counted

**Trust and tenancy**: Screening, terminal states, and the 12,000 domains the platform serves on customers' behalf.

- ADR-13 · Destinations are screened before issuance and re-screened afterwards
- ADR-14 · Four distinct terminal states, owned by the platform
- ADR-16 · Branded domains and their certificates are the platform's responsibility

## Technology by capability

Every capability on the twenty-three views, the Azure service chosen for it, the alternative that was weighed, and the reason. Azure was chosen for the package as a whole because it has appeared once in the practice's last ten deliveries while Google Cloud and AWS have carried three each — and because the topic's hard parts land on Azure primitives without a managed product making the architectural decisions: a globally replicated point-read store with native TTL, an anycast edge with per-domain managed certificates, and a high-cardinality append-only analytics store.

| Capability | Choice | Origin | Credible alternative | Why this one | Record |
|---|---|---|---|---|---|
| Edge termination and caching | Azure Front Door Premium — anycast, WAF, rules engine, 30 s response cache | Azure | Traffic Manager plus regional gateways | One anycast address set serving the shared domain and 12,000 branded domains with managed certificates, plus a response cache that carries the assumed 97% of traffic. DNS-based steering could not provide either. Also shaped by ADR-08, ADR-16. | ADR-07 |
| Branded domain certificates | Front Door managed certificates with Azure DNS verification | Azure | Customer-supplied certificates in Key Vault | At 12,000 domains, renewal is the availability risk. Managed issuance removes 12,000 expiry dates the platform would otherwise chase; customer-supplied certificates remain available for the enterprise tier that insists. | ADR-16 |
| Resolver compute | Azure Container Apps, twelve regions, KEDA-scaled on requests per second | Azure | Azure Functions on a consumption plan | Resolution is a warm, connection-heavy, latency-sensitive path with a 40 ms p99 budget; a container fleet holds that budget more predictably than per-request compute, and KEDA absorbs the assumed 4× burst. | ADR-07 |
| Link table | Azure Cosmos DB — single write region, read replicas in all read regions, native TTL | Azure | Azure SQL with geo-replication | The access pattern is a point read on a compound key at enormous cardinality, with expiry as a data property. Cosmos gives per-partition single-digit-millisecond reads, a change feed for propagation, and TTL for expiry in one component. Also shaped by ADR-09. | ADR-01 |
| Alias uniqueness | Cosmos DB conditional create (If-None-Match) in the write region | Azure | Multi-region writes with last-write-wins | A conditional create is a genuine compare-and-set. Multi-region writes would make the platform's one zero-tolerance guarantee probabilistic. | ADR-05 |
| Hot set cache | Azure Cache for Redis, zone-redundant, one per read region | Azure | Cosmos DB integrated cache | An explicit cache with measurable hit rate, eviction metrics and a pre-warm path; the integrated cache is easier and gives less control over exactly the number the cost model depends on. | ADR-07 |
| Revocation propagation | Azure Event Grid topic per region, 5 s pull into an in-process set | Azure | The same change feed as the projection | The 60-second bound needs a channel carrying only identifiers. Sharing the projection's path would couple a safety promise to the throughput of routine edits. | ADR-02 |
| Projection propagation | Cosmos DB change feed into a per-region projection worker | Azure | Dual-write from the management plane to each region | The change feed is replayable and ordered per partition, which is what makes the projection rebuildable in 30 minutes. Dual-write makes the writer responsible for twelve regions' availability. Also shaped by ADR-15. | ADR-01 |
| Click collection | Front Door access logs to Azure Event Hubs, 7-day retention | Azure | Resolver-emitted events | The log covers cached and uncached clicks alike, which is the only way a 30-second cache and an exact click count can coexist. Also shaped by ADR-11. | ADR-10 |
| Click analytics | Azure Data Explorer — raw 90 days, materialised views for rollups | Azure | Synapse or a lakehouse | High-cardinality append-only time series with interactive per-code queries is exactly ADX's shape; materialised views give rollups without a separate batch pipeline. | ADR-11 |
| Management plane | Container Apps in two regions, active-standby | Azure | Active-active in twelve regions | The write path is bound to a single write region anyway (ADR-05), so more management regions would add cost without adding availability. Also shaped by ADR-05. | ADR-01 |
| Screening and publishers | Azure Functions, event-driven | Azure | Long-running workers on the management plane | Screening and re-screening are bursty and idempotent; the periodic re-screen is a scheduled fan-out with no state of its own. | ADR-13 |
| Reputation verdicts | Three third-party URL reputation feeds | Third party | A single provider, or in-house classification | Three providers mean no single vendor outage or false-positive run decides the platform's behaviour; in-house classification is a different company's product. | ADR-13 |
| Identity | Microsoft Entra ID with workload identity; tenant-scoped API keys hashed in Key Vault | Azure | Connection strings and a self-managed key store | No credential in configuration anywhere, and key revocation without a redeployment. The resolver holds a read-only workload identity and nothing else. | ADR-01 |
| Audit and abuse evidence | Append-only store with an immutability policy — 400 days and 2 years | Azure | Rows in the operational database | An audit record that the application can update is not evidence, and an abuse decision has to be defensible after the customer has escalated. Also shaped by ADR-14. | ADR-13 |
| Observability | Azure Monitor for resources, ADX for the promise probes | Azure | Resource monitoring alone | Component health would have stayed green through every failure this platform actually fears. The wrong-destination and revocation probes are queries over ADX, alarmed on the published promise. Also shaped by ADR-05. | ADR-02 |
| Infrastructure and delivery | Bicep modules with GitHub Actions and OIDC federation | Azure + GitHub | Terraform with a service principal secret | Twelve read regions have to be identical by construction, and OIDC removes the last long-lived secret from the pipeline. | ADR-01 |

## The decisions, and the alternatives that lost

### Planes and propagation

*Where the writer's involvement ends, and how a change reaches a PoP on the other side of the world.*

#### ADR-01 · The redirect plane reads a projection and never writes

**Status:** Accepted  ·  **Shown on views:** 07, 08, 14

*Does serving a click touch the authoritative link table, or a derived copy of it that the click path cannot modify?*

**Context.** The tutorial architecture is one service and one database: look up the code, increment a counter, return a redirect. At a 500:1 read-to-write ratio it means the write store must be provisioned for the read rate, the redirect's availability becomes the availability of the click pipeline, and a bulk import competing for the same store slows down everybody's clicks. Every softer variant has the same defect in a smaller form — a click that writes anything synchronously, even asynchronously flushed from the same process, makes a printed link's behaviour depend on the health of a system it has no reason to depend on. And the failure is not graceful: the codes people are clicking most are exactly the ones whose writes are hottest.

**Decision.** The link table is written only by the management plane. The redirect plane reads a projection of it — an edge response cache, a regional hot set, an in-region read replica — plus a separately propagated deny-set, and holds no credential that could write anything. A click produces no synchronous write of any kind. The redirect plane must be able to serve for at least four hours with the entire management plane unavailable, learning nothing new but losing nothing already known.

**How it is realised on Google Cloud.** Cosmos DB with a single write region, read replicas in all twelve read regions, and the change feed as the only route from authoritative state into derived state. Resolvers run on Container Apps with a workload identity holding read-only data-plane permissions; there is no connection string and no write role to steal. Front Door caches the redirect for 30 seconds and forwards misses over Private Link to the nearest regional origin group.

| Option | Verdict | Reasoning |
|---|---|---|
| Read-only redirect plane fed by one-way propagation | Chosen | Makes redirect availability independent of the writer, caps the write store's provisioning at the real write rate, and makes the whole read tier disposable. Costs eventual consistency, which is why ADR-15 publishes the bounds. |
| One service reading and writing the link table on the click path | Rejected | Correct in every steady-state diagram and wrong at the first hot key or bulk import. Also makes the click path a write path, which is a security property nobody intended to give away. |
| Read from the authoritative store, write nothing | Rejected | Removes the write but keeps the coupling: a write-region incident or an RU exhaustion from a campaign import becomes a global redirect incident. |
| Strongly consistent reads in the redirect path | Right elsewhere | Right for a system where a stale read is a correctness violation — a payment authorisation, a feature entitlement. Here a stale read means a link points at last week's page for up to five minutes, which is a published property rather than a bug. |

**What it buys**

- A total management-plane outage costs zero redirects; creation fails visibly and clicks continue.
- The entire read tier is disposable and rebuildable in 30 minutes, so a regional incident is a capacity event rather than a data event.
- Provisioning the write store for the write rate rather than the read rate is the single largest cost saving in the design.

**What it costs**

- Two propagation channels to build, operate and reason about, with two published staleness bounds.
- A code exists authoritatively before it is resolvable everywhere, which is why a creation response carries a resolvable-within figure.
- Debugging is harder: the question 'why did this link resolve to that?' has to be asked of a projection first and the record second.

**Choose differently when.** Collapse the planes when read and write rates are comparable, the whole system fits in one region, and nobody has printed anything — an internal `go/` link service for a single company is in that category and should be built as one service with one table.

**Why it holds up over time.** This is the decision least tied to Azure and most likely to be eroded by a feature request. Nothing here depends on Front Door, Cosmos DB or Container Apps. What would break it is real-time click counts on the redirect response, or a 'resolve the latest destination, guaranteed' option sold to one large customer — which is why the boundary is drawn in views 08 and 14 rather than left as a convention.

> **Lesson.** When reads outnumber writes by three orders of magnitude, the read path should not be able to write, even if it would be convenient. Take the credential away and the temptation goes with it.

#### ADR-02 · Revocation travels on its own channel, ahead of the projection

**Status:** Accepted  ·  **Shown on views:** 13, 16, 20

*Does 'stop serving this code' use the same propagation path as 'this code now points somewhere else'?*

**Context.** Both are changes to a link, so one mechanism is the obvious answer. But they have different deadlines by a factor of five and different payloads by orders of magnitude. An edit carries a full record and can take five minutes; a kill carries an identifier and cannot. Sizing a single channel for the tighter deadline means paying that cost on every routine edit, and at 1.2 million creations a day that is not a rounding error. Worse, the urgent case is the one with a legal and reputational clock attached: a phishing link that keeps resolving for five minutes after an analyst pressed block is the platform's worst day, and 'we propagate all changes the same way' is not a defence anyone accepts.

**Decision.** Revocation is a separate, aggressively propagated channel carrying a compact set of codes that must stop resolving — killed, expired early, or blocked for abuse. Regional deny-sets pull deltas every 5 seconds and are matched before any cache layer, so a region with the delta stops serving regardless of what its hot set holds. Routine changes ride the change feed with a five-minute bound. Both bounds are published. A change classifier decides which channel a write takes, and misclassification is treated as a safety defect, not a latency one.

**How it is realised on Google Cloud.** Event Grid topic per region for the revocation delta, consumed into an in-process set in each resolver with a 5-second refresh and an age metric. The projection path is the Cosmos DB change feed into a per-region projection worker that upserts the Redis hot set. The 30-second Front Door cache TTL is what bounds the end-to-end worst case at 60 seconds, and a synthetic probe kills a real link every minute and measures when the last PoP stops serving it.

| Option | Verdict | Reasoning |
|---|---|---|
| A separate fast channel for stop instructions | Chosen | Buys urgency where it is needed and nowhere else. The payload is small enough to push everywhere in seconds, and the deny-set check is cheap enough to run before the lookup. |
| One channel sized for the 60-second deadline | Rejected | Simpler to build and operate, and pays the urgent cost on all 1.2 million daily creations. It also couples the safety promise to the throughput of the routine path — a change-feed backlog becomes a safety incident. |
| Cache purge per code at the edge on every revocation | Deferred | A real option for tightening the bound below 30 seconds, and a slower, less predictable fan-out than an expiry. Worth adding when a customer or a regulator needs a bound the TTL cannot give. |
| Delete the link record and let the negative cache take over | Rejected | Destroys the evidence, the appeal path and the click history, and makes an accidental block unrecoverable. A blocked link must remain a link. |

**What it buys**

- The safety promise is met by a mechanism sized for it, and can be stated as a number: 30 s p95, 60 s p99.
- Routine edits stay cheap, so the platform is not paying urgency tax on every campaign import.
- The deny-set is small enough to hold in process, which makes the check free relative to the lookup it precedes.

**What it costs**

- Two sources of truth on the read path, which must never disagree in the wrong direction. The deny-set therefore always wins and is checked first.
- A classifier that can mislabel a kill as an edit, which is a silent failure — hence the synthetic probe rather than a component health check.
- One more thing to rebuild after a regional restart, and the one whose absence is dangerous rather than merely slow.

**Choose differently when.** One channel is right when the urgent bound and the routine bound are the same, or when the write rate is low enough that paying urgency tax on everything is free. A platform doing a thousand creations a day should not build two channels.

**Why it holds up over time.** The two-bound structure should outlive the transport. Event Grid, the change feed and even the 5-second pull are implementation; what must survive is that stop instructions do not queue behind content updates, and that both bounds are numbers the platform says out loud.

> **Lesson.** When two operations on the same object have deadlines an order of magnitude apart, they are two mechanisms wearing one name. Separate them before someone sizes the cheap one for the expensive promise.

#### ADR-15 · Staleness bounds are published as contracts, not implied as immediacy

**Status:** Accepted  ·  **Shown on views:** 13, 16, 20

*Does the platform tell customers how long a change takes to take effect, or let them assume it is instant?*

**Context.** Every eventually consistent path in this design has a real bound: 60 seconds for a revocation, five minutes for a destination edit, ten seconds for a new link to be resolvable everywhere, one second in the creating region. A platform that says nothing is assumed to be instant, and then every support conversation is a negotiation, every abuse response is unaccountable, and every customer's expectation is set by whichever anecdote they heard first. Publishing a number invites arguments about the number, which is exactly the point: a number can be measured, challenged and improved, and a vague promise cannot.

**Decision.** Every eventual path states its bound in the product documentation, in the API response where relevant, and in the SLO set. Revocation 30 s p95 / 60 s p99; destination edit 5 minutes p99; new link resolvable globally 10 s p99, in-region 1 s p99; scheduled expiry effective within 60 s of the stated instant. Each bound has a synthetic probe measuring it end to end from outside, and a change to any bound is a product change with a release note, not a tuning adjustment.

**How it is realised on Google Cloud.** Probes run continuously and land in Azure Data Explorer: a synthetic link is killed every 60 seconds and its last-serving PoP measured, an edit is made and its global visibility timed, a new link's resolvability is sampled across regions. Alarms fire on the published promise rather than on component health, because every failure this platform fears looks healthy at the component level.

| Option | Verdict | Reasoning |
|---|---|---|
| Publish every bound, probe every bound | Chosen | Makes the design's central trade-off legible and auditable, and turns support answers into one sentence. |
| State nothing and aim to be fast | Rejected | Customers assume instant, the platform is judged against that, and no internal argument about caching can be settled. |
| Publish a single conservative number for everything | Rejected | Simplifies the documentation and discards the distinction that the whole two-channel design exists to create. |
| Offer strong consistency as a premium tier | Right elsewhere | Coherent for a configuration or entitlement service where staleness is a correctness bug. Here it would require the read path to coordinate globally, undoing ADR-01 for the customers who pay most. |

**What it buys**

- Support, sales and abuse response all use the same sentence, and it is checkable.
- Alarms test what is sold rather than what is running, which catches the silent failures.
- The 30-second cache TTL becomes a visible, arguable trade instead of a hidden setting.

**What it costs**

- Published numbers can be missed publicly, and a missed bound is now a breach rather than an anecdote.
- Probe infrastructure to build and keep honest, including synthetic links that must never be mistaken for customer data.
- Tightening a bound later is a product commitment, so the initial numbers need to be defensible rather than optimistic.

**Choose differently when.** Skip publication only where no external party can observe the staleness — an internal cache with a single consumer that is also the writer.

**Why it holds up over time.** The numbers will change and the practice should not. Publishing bounds is what keeps an eventually consistent system honest over a decade of staff turnover, because the next team inherits a contract rather than a folk belief.

> **Lesson.** An unstated bound is assumed to be zero. If your system is eventually consistent, the eventual part is a number, and it belongs in the documentation.

### Code and identity

*How a code is chosen, who guarantees it is unique, and why it can never be used twice.*

#### ADR-03 · Codes are random, unguessable and seven characters — not counter-derived

**Status:** Accepted  ·  **Shown on views:** 08, 15, 22

*Where does a generated code come from: a random draw, an encoded counter, a pre-allocated block, or a hash of the destination?*

**Context.** A counter encoded to base62 is the efficient answer: dense, collision-free, no read before write. It is also enumerable, and enumerability is a disclosure vulnerability rather than an aesthetic complaint — walking the space reveals every tenant's destinations, including the unlisted document, the internal deck and the pre-announcement landing page. A globally monotonic counter is also a coordination point on the write path, which is exactly the thing a multi-region creation story cannot afford. Hashing the destination deduplicates identical links for free, which some customers want and others consider a privacy breach: two tenants shortening the same URL must not be able to detect each other. A random draw needs a uniqueness check and a retry loop that gets worse as the table fills.

**Decision.** Generated codes are drawn at random from a base62 alphabet with a default length of 7 characters, giving roughly 3.5 × 10¹² codes against an assumed 12 billion stored and 1.2 million created per day. Allocation is a conditional create against the reservation index, retried on conflict. Sequential or otherwise enumerable codes are never issued on shared domains. Lengthening the default is the planned response to density, and the code length is therefore a per-domain configuration rather than a constant in the code.

**How it is realised on Google Cloud.** A cryptographically seeded random draw in the management plane, then a conditional create (`If-None-Match: *`) against the reservation container in Cosmos DB, keyed by `(domain, code)`. At the assumed density the expected retry rate is negligible; the retry count is a metric precisely because it is the leading indicator that 7 characters have stopped being enough.

| Option | Verdict | Reasoning |
|---|---|---|
| Random base62, 7 characters, conditional create | Chosen | Unguessable, no write-path coordination, and the retry cost is a function of density that can be monitored and answered by lengthening. |
| Encoded global counter | Rejected | Dense and cheap, enumerable, and a coordination point. Enumerability alone disqualifies it on a shared domain. |
| Pre-allocated per-region blocks | Deferred | Removes coordination entirely and is the right answer if creation must survive a regional partition. Deferred because ADR-05 already accepts refusing creation during failover, and blocks add per-region accounting for a case that has not been asked for. |
| Hash of the destination | Rejected | Free deduplication and a cross-tenant information leak: two tenants shortening the same URL would collide into one code and learn something about each other. |

**What it buys**

- A code discloses nothing about when it was created, who created it, or what exists next to it.
- Creation needs no global sequence, so the write path has one fewer coordination point than the counter design.
- Density is a monitored, answerable condition rather than a cliff.

**What it costs**

- A read-modify-write on allocation, which is why the uniqueness authority has to be strongly consistent (ADR-05).
- Seven characters are longer than a counter would need for the same population — the price of unguessability.
- Retry rates climb with density, so the code length is a parameter somebody must eventually change.

**Choose differently when.** Use a counter when the codes are not secret and never shared outside a trust boundary — an internal short-link service, or a system where every destination is already public by construction. Use a content hash when deduplication is an explicit product feature and single-tenant.

**Why it holds up over time.** The alphabet and the length will change; the property must not. 'A code reveals nothing about its neighbours' is what makes shortening safe for unlisted destinations, and it is the kind of property that is quietly lost when somebody adds sequential vanity codes for a launch.

> **Lesson.** An identifier that can be enumerated is an index of everything it identifies. Decide whether that index is something you are willing to publish before choosing how to generate the identifier.

#### ADR-04 · A code is identity, and is retired permanently rather than reused

**Status:** Accepted  ·  **Shown on views:** 12, 15, 21

*When a link is deleted or expires, may its code be issued again to someone else?*

**Context.** Reuse is tempting for exactly one reason: short, memorable aliases are scarce, and a customer who abandons `/spring` is holding an asset somebody else wants. But a short code's lifetime is not the platform's to decide. It is on a poster, in a printed programme, in an email thread from two years ago, in a QR code etched into a product. Reissuing it means that artefact now resolves to a stranger's destination — the worst outcome this platform can produce, worse than a dead link, because the clicker has no way to know it happened. The address space makes the trade absurd: at 7 characters there are roughly 3.5 × 10¹² codes and an assumed 12 billion in use, so reclamation recovers nothing that matters.

**Decision.** A code, once issued, is never reassigned to a different link and never reissued after deletion or expiry. The reservation record outlives the link record and carries a retired flag, so a claim against a retired code fails exactly as a claim against a live one does. A tenant deleting a link releases their quota, not the code. Destinations are mutable; codes are not.

**How it is realised on Google Cloud.** The reservation container is never deleted from. Link deletion marks the reservation retired and removes the link record; a subsequent conditional create fails against the retained reservation. Retired codes resolve to 404 through the negative cache rather than being absent from every store, so an enumerating client cannot distinguish 'never existed' from 'retired'.

| Option | Verdict | Reasoning |
|---|---|---|
| Permanent retirement, reservation outlives the link | Chosen | Makes the catastrophic outcome structurally impossible rather than unlikely, at a storage cost of a few tens of bytes per retired code. |
| Reuse after a quarantine period | Rejected | Any finite quarantine is shorter than a printed poster's life. It converts a structural guarantee into a bet on how long paper lasts. |
| Reuse only for codes that never received a click | Rejected | Superficially safe and wrong: a code printed for a campaign that has not launched yet has no clicks and is the most dangerous possible candidate for reuse. |
| Let the tenant decide | Rejected | The risk is borne by the clicker, who is not party to the decision. This is not a tenant-configurable property. |

**What it buys**

- A printed code can never resolve to somebody else's destination. The platform's worst failure mode is removed by construction.
- The uniqueness check has one rule, not one rule plus an age exception, which is what keeps the race test in view 19 simple enough to trust.
- Deletion becomes cheap and reversible in the only way that matters: the identity is still held.

**What it costs**

- The reservation store grows monotonically and is never compacted — an accepted, small, unbounded cost.
- Desirable aliases are permanently consumed, including by tenants who have left, and the platform must be willing to say no.
- Support will be asked for exceptions, and the answer has to be no every time, which only holds if the mechanism makes it impossible rather than discouraged.

**Choose differently when.** Reuse is defensible when every link is short-lived by contract and never leaves a digital channel the platform controls — a one-time password link, a session share expiring in an hour. The moment a code can be printed, retirement is the only safe answer.

**Why it holds up over time.** This is the promise that ages best and is most likely to be attacked by a future team looking at a table of valuable retired aliases with a revenue number next to it. It costs almost nothing to keep and cannot be recovered once broken, because the evidence arrives years later as somebody else's QR code.

> **Lesson.** Identifiers that escape into the physical world are permanent whether or not the system treats them that way. Decide which of your identifiers can be printed, and retire those forever.

#### ADR-05 · Alias uniqueness is the only strongly consistent operation

**Status:** Accepted  ·  **Shown on views:** 11, 12, 15

*How is `(domain, code)` guaranteed unique when creation happens concurrently, and what consistency does the rest of the system get?*

**Context.** Two clients asking for `go.acme.com/spring` in the same millisecond must produce exactly one winner and one clear conflict. A read-then-write cannot guarantee it. An eventually consistent store with last-write-wins conflict resolution actively hides the problem: both writers succeed, both are told they own the alias, and one of them silently loses their link later — which is worse than a rejection, because they have already sent the artwork to print. Meanwhile the read path needs the opposite property: a globally strongly consistent read on every redirect would turn a 40 ms budget into a cross-region coordination problem.

**Decision.** The reservation index is the single strongly consistent component in the architecture, written by conditional create in one write region. Exactly one writer wins; the loser receives a 409 naming the collision and is never given a substituted code. Everything else — the projection, the hot set, the edge cache, expiry by TTL — is eventually consistent, with its staleness published (ADR-15). Multi-region writes are refused: during a write-region failover, creation is unavailable for up to five minutes rather than risking two winners.

**How it is realised on Google Cloud.** A Cosmos DB container keyed by `(domain, code)` in a single-write-region account, written with `If-None-Match: *` so the insert is a genuine compare-and-set. The link record is written after the claim succeeds. Failover to the standby write region is explicit, and the management API returns a retryable error for creation throughout.

| Option | Verdict | Reasoning |
|---|---|---|
| Conditional create against a single-writer index | Chosen | A real compare-and-set with no coordination protocol to build. Costs creation availability during failover, which the requirement accepts in exchange for zero duplicate claims. |
| Multi-region writes with conflict resolution | Rejected | Keeps creation available everywhere and makes uniqueness probabilistic: last-write-wins picks a winner after both clients were told they had succeeded. |
| Read-then-write with a uniqueness index | Rejected | Works until two requests interleave, which at 1.2 million creations a day is daily rather than theoretical. |
| A dedicated consensus service for allocation | Right elsewhere | Right when allocation must survive a regional partition and cannot wait — a payment identifier mint, for instance. Here it is a whole distributed system added to avoid a five-minute creation outage. |

**What it buys**

- Duplicate ownership of a code is impossible, which is the platform's one zero-tolerance guarantee.
- The conflict is honest and immediate, so a customer discovers it before the artwork goes to print rather than after.
- Exactly one component needs strong consistency, so the other twenty-two views can be designed around eventual consistency without hedging.

**What it costs**

- Creation is unavailable during a write-region failover, for an assumed RTO of up to five minutes.
- Creation latency includes a cross-region round trip for clients far from the write region, which is why the 250 ms p99 is a creation figure and not a redirect figure.
- Bulk imports must be resumable, because a failover mid-import is a normal event rather than an exception.

**Choose differently when.** Multi-region writes become correct as soon as uniqueness stops being global — per-region namespaces, or codes that embed their origin region. If the product can accept `eu/spring` and `us/spring` as different links, this decision dissolves.

**Why it holds up over time.** The technology will change; the rule should not. 'Exactly one thing is strongly consistent, and it is named' is what keeps a distributed system arguable. Systems decay when the list of strongly consistent things grows quietly, one convenience at a time.

> **Lesson.** Find the single operation that cannot tolerate a conflict and give it the strongest guarantee you have. Then make everything else eventually consistent on purpose, and say so.

#### ADR-06 · The code namespace is per domain, not global

**Status:** Accepted  ·  **Shown on views:** 05, 10, 12

*Is `spring` one alias across the whole platform, or one per domain?*

**Context.** A global namespace is simpler: one key, one index, one uniqueness rule. It also means the first customer to claim `sale` takes it from every other customer forever, which is indefensible when 12,000 of those customers are paying for their own branded domain. Per-domain namespacing is what a branded-domain customer already assumes — they bought `go.acme.com` precisely so that its paths are theirs — and it multiplies the reservation index's key space by the number of domains, which is a scale question rather than a correctness one. The shared domain is the interesting case, because there the namespace really is contested and brand-adjacent aliases are a trust problem rather than a first-come-first-served one.

**Decision.** The namespace key is `(domain, code)`. Two tenants may each own `spring` on their own domains. On the shared domain a reserved-name deny-list applies — operational paths, single characters, profanity, and strings resembling well-known brands — and generated codes are always unguessable (ADR-03). Branded domains are first-class: same latency objective, same availability objective, same code space semantics.

**How it is realised on Google Cloud.** Reservation and link containers are both partitioned on domain with code as the row key, so a branded domain's claims and lookups are local to its partition, and a new domain adds partitions rather than contending for an existing one. Front Door serves all domains from the same anycast address set with per-domain managed certificates.

| Option | Verdict | Reasoning |
|---|---|---|
| Per-domain namespace | Chosen | Matches what a branded-domain customer already believes, and turns namespace scarcity into a shared-domain question only. |
| Global namespace | Rejected | Simpler index, unsellable product: a customer on their own domain cannot be told their alias is taken by a stranger. |
| Per-tenant namespace with domains as a display concern | Rejected | Breaks as soon as one tenant has two domains and wants the same alias on both — routine for a company running regional sites. |

**What it buys**

- Branded domains are genuinely first-class rather than a premium veneer over a shared namespace.
- Partitioning by domain keeps each customer's claims and reads local, which also bounds the blast radius of a hot domain.
- Namespace policy can differ between the shared domain and customer domains, which is where the real difference in risk is.

**What it costs**

- The reservation index is larger and its key is compound, which every component must handle consistently.
- Cache keys, metrics and quotas all carry the domain, so a bug that drops it becomes a cross-tenant defect rather than a miss.
- Domain deletion has to be handled explicitly: its codes retire with it and cannot be freed.

**Choose differently when.** A global namespace is right for a single-domain product — a social network's own link wrapper, where there is exactly one host and the operator owns every alias.

**Why it holds up over time.** Compound keys are hard to add later and easy to keep. A platform that starts global and grows branded domains has to migrate its uniqueness authority under live traffic, which is the kind of migration that produces exactly the duplicate-ownership defect ADR-05 exists to prevent.

> **Lesson.** Namespace boundaries follow ownership boundaries. If a customer can own the host, they own its paths, and the key has to say so from the first row written.

### The read path

*Where a lookup happens, what it is allowed to depend on, and what it returns.*

#### ADR-07 · Resolution happens in twelve regional resolvers, not at the PoP

**Status:** Accepted  ·  **Shown on views:** 09, 14, 18

*Does the lookup run at the edge against a replicated dataset, or in a regional service the edge forwards to?*

**Context.** The fastest design puts the whole link table at every PoP: no origin hop, uniform tail latency, and revocation as a data push. Twelve billion records at roughly 1 KB is around 12 TB, which is not a PoP-resident dataset, so in practice this means a hot subset at the edge — and that needs a programmable edge with a replicated key-value store. Azure does not offer one for general-purpose use: Front Door runs rules and caching, not arbitrary code against a replicated store. The honest options are therefore an edge response cache in front of regional resolvers, or origin-only resolution with CDN caching of the redirects themselves, which is cheapest and hardest to revoke.

**Decision.** Resolution runs in twelve stateless read regions. Front Door terminates TLS at the nearest PoP, applies WAF and bot rules, serves the 30-second response cache, and forwards a miss over Private Link to the nearest regional origin group. Each resolver walks an in-process LRU, a regional Redis hot set and an in-region Cosmos replica. Region count and placement are sized to hold the 40 ms p99 for the assumed traffic distribution, and are expected to change with real geography.

**How it is realised on Google Cloud.** Front Door Premium with latency-based origin selection across twelve Container Apps environments, each with a zone-redundant Azure Cache for Redis and an in-region Cosmos DB read replica. The 97% assumed edge hit rate means the regional tier is sized for the miss traffic, not the full rate — which is what makes twelve regions affordable.

| Option | Verdict | Reasoning |
|---|---|---|
| Edge response cache plus regional resolvers | Chosen | Uses what the platform actually offers, keeps the lookup in one place that can be reasoned about, and puts the cache where the traffic is. Costs a PoP-to-region hop on every miss. |
| Full link table replicated to every edge location | Rejected | Fastest and uniform, and a 12 TB distribution and consistency problem of its own. Not available on this platform in any case. |
| Hot subset in a programmable edge KV store | Deferred | The right answer the moment the platform offers a replicated edge store at a sane price, and the first place the architecture should move. Nothing above the read path needs to change when it does. |
| Origin-only with long-lived CDN caching of redirects | Rejected | Cheapest per redirect and incompatible with a 60-second revocation bound: the cache becomes the authority and cannot be corrected quickly. |

**What it buys**

- One lookup implementation, in one kind of place, with one set of metrics — rather than logic split between edge rules and a service.
- The regional tier is sized for misses, so the 97% hit rate converts directly into fleet size and cost.
- A read region is disposable: no unique state, so losing one is a capacity event.

**What it costs**

- A cold code pays the PoP-to-region hop, which is the click a customer tests with and complains about — hence pre-warming at creation.
- Twelve regions is twelve caches to warm and twelve deny-sets to keep fresh.
- The p99 depends on region placement assumptions that only real traffic can validate.

**Choose differently when.** Move the lookup to the edge as soon as a replicated edge key-value store is available and affordable, or if measured traffic turns out to be concentrated enough that the hot subset is small. Move it the other way — fewer, larger regions — if the miss rate is far below the assumption and placement is buying nothing.

**Why it holds up over time.** This is the decision in the package most tied to 2026 rather than to architecture, and it is designed to be replaced. What must survive the replacement is the boundary: whatever serves the lookup reads a projection and holds no write credential (ADR-01).

> **Lesson.** Name the decisions that are statements about this year's platform rather than about the problem. They are the ones to make replaceable, and the ones to revisit first.

#### ADR-08 · A 302 with a 30-second cache lifetime, never a permanent redirect

**Status:** Accepted  ·  **Shown on views:** 14, 16, 20

*What status code does a redirect carry, and how long may a browser or intermediary remember it?*

**Context.** A permanent redirect is the fastest thing the platform could possibly return: the browser never asks again, the platform never pays for the second click, and the destination inherits the link's search ranking — which is why SEO advice recommends it. It also makes the link uneditable and unkillable for every client that cached it, for as long as that client chooses to remember, which can be indefinitely. Since editing and killing are the product, and since clicks are a billing input, the fast answer is the wrong one. The real design space is the cache lifetime, and it is a single dial with safety on one side and cost on the other.

**Decision.** The redirect is a 302 with an explicit cache lifetime of 30 seconds at shared caches and no-store semantics for the terminal states. The 30 seconds is a design parameter, not a tuning knob: it is simultaneously the hot-key absorber, the dominant lever on cost per million redirects, and the outer bound on the revocation promise in ADR-02. Any proposal to change it must restate the revocation bound in the same sentence.

**How it is realised on Google Cloud.** Front Door caches the 302 for 30 seconds keyed on host plus code; the response carries `Cache-Control: max-age=30`. Blocked, expired and unknown responses are marked no-store so a terminal state is never held by a client. Clicks are still counted, because they are taken from the edge access log rather than from application code (ADR-10) — which is the only reason caching and analytics can coexist here.

| Option | Verdict | Reasoning |
|---|---|---|
| 302 with a 30-second shared-cache lifetime | Chosen | Keeps every link editable and killable within a stated bound, absorbs hot keys at the edge, and keeps counting exact because the count comes from the log. |
| 301 permanent redirect | Rejected | Fastest and cheapest, and it permanently forfeits both revocation and per-click counting for cached clients. A link platform that cannot kill a link is not a link platform. |
| 302 with no caching at all | Rejected | Tightest possible revocation and the loss of the mechanism that absorbs a viral code. Every click becomes an origin request at 150,000 per second. |
| 301 for links the customer marks permanent | Right elsewhere | Defensible for a pure site-migration tool, where SEO transfer is the product and nobody will ever repoint the link. It does not belong in a platform whose value is that the destination can change. |

**What it buys**

- Every link stays editable and killable within a published bound, for every client.
- The edge cache absorbs hot keys and carries the assumed 97% of traffic, which is what makes the cost target reachable.
- Terminal states are never cached, so a blocked or expired link cannot be resurrected by a stale client.

**What it costs**

- Every 30 seconds, a popular code costs an origin resolution — the price of remaining changeable.
- No SEO ranking transfer, which has to be explained to customers who have read otherwise and will ask for a 301.
- One number now carries three concerns at once, so it can only be changed by someone who understands all three.

**Choose differently when.** A permanent redirect is correct when the mapping genuinely cannot change and nobody needs per-click data — a retired domain's canonical move, or a published DOI-style identifier with a permanence policy.

**Why it holds up over time.** Status codes and cache directives are the most stable technology in the design; HTTP semantics have outlived every platform they ran on. The pressure on this decision will come from cost reviews looking at the cheapest line on the bill, which is why the 30 seconds is documented as three coupled promises rather than as a cache setting.

> **Lesson.** A cache lifetime on a redirect is not a performance setting. It is the upper bound on how quickly you can correct anything you have ever told a client.

#### ADR-09 · The link table is partitioned by code hash, not by tenant

**Status:** Accepted  ·  **Shown on views:** 11, 12, 18

*What is the partition key of the link table: the tenant, or the code itself?*

**Context.** Partitioning by tenant is the instinct of anyone who has built a multi-tenant system, and it makes tenant-scoped operations — list my links, export my campaign, delete my account — cheap. It also guarantees a hot partition, because tenants are not uniform: an assumed p99 tenant holds two million links, and the largest holds far more, while the redirect path reads by code and never by tenant. The read path's requirement and the management path's requirement point in opposite directions, and the read path outnumbers the other by 500:1.

**Decision.** The link table is keyed and partitioned on `(domain, code)`, so a point read by code hits exactly one partition and the distribution follows the hash rather than the customer. Tenant-scoped listing and search are served by a separate index built from the same change feed. Redirect-path cost and latency are never a function of how many links a tenant owns.

**How it is realised on Google Cloud.** Cosmos DB containers partitioned on domain with code as the row key; the by-tenant index is a separate container maintained by a change-feed consumer. A tenant with two million links therefore spreads across many physical partitions and cannot become a hot one.

| Option | Verdict | Reasoning |
|---|---|---|
| Partition by code within domain | Chosen | Makes the hot path a single-partition point read and the distribution independent of tenant size. Costs a second index for listing. |
| Partition by tenant | Rejected | Cheap listing, guaranteed hot partitions, and a redirect latency that varies with which customer owns the code. |
| Partition by tenant with a global secondary index by code | Rejected | Puts an index lookup on the 150,000-per-second path to avoid one on the 14-per-second path. |

**What it buys**

- Redirect cost and latency are independent of tenant size, so onboarding a very large customer is not a read-path event.
- Hot partitions can only come from genuinely hot codes, which the caches are designed to absorb.
- The write path spreads naturally across partitions during a bulk import.

**What it costs**

- A second index to build, maintain and reconcile, with its own lag.
- Tenant-scoped deletion is a fan-out rather than a partition drop.
- Per-tenant RU accounting is derived rather than physical, which makes cost attribution a calculation.

**Choose differently when.** Partition by tenant when the dominant access pattern is tenant-scoped — an analytics warehouse, a per-customer export service — or when tenants are small and uniform enough that no partition can get hot.

**Why it holds up over time.** Partition keys are the hardest thing in a data platform to change later, because changing one is a full migration under live traffic. Choosing it for the dominant read pattern rather than the convenient administrative one is a decision that pays for a decade.

> **Lesson.** Partition for the access pattern that happens most, not the one that is easiest to write a query for. Build an index for the other one.

### Counting

*How a click becomes a number somebody bills on, without a write on the redirect path.*

#### ADR-10 · Clicks are collected from the edge access log, after the response has left

**Status:** Accepted  ·  **Shown on views:** 14, 17, 20

*What records a click: the resolver that served it, or the edge that logged it?*

**Context.** Once the redirect is cached for 30 seconds (ADR-08), the resolver simply does not see most clicks — an assumed 97% of them never reach application code. Counting in the resolver would therefore make the click count a function of the cache hit rate, which is both wrong and invisible: the dashboard would look healthy and the numbers customers are billed on would silently drop as caching improved. The alternative is to treat the edge's own request log as the source of the count, which also removes the last reason for the redirect path to write anything.

**Decision.** One click event per redirect served is derived from the edge access log, emitted after the response has left the PoP, durably buffered before processing. The resolver's own log is a second, smaller stream used for cache-miss analysis and never for counting — one authority per number. A failure anywhere in the click path can never surface as a failed or slowed redirect.

**How it is realised on Google Cloud.** Front Door diagnostic logs stream to Event Hubs with seven-day retention as the durable buffer, giving an assumed 30-second RPO on click data. Processing classifies traffic, enriches, drops identifiers and appends to Azure Data Explorer. Daily reconciliation compares landed events against the edge's own request counters within ±0.5%.

| Option | Verdict | Reasoning |
|---|---|---|
| Count from the edge access log | Chosen | The only option compatible with caching the redirect. It also makes the redirect path genuinely write-free, which is the security property in ADR-01. |
| Count in the resolver | Rejected | Makes the count a function of the cache hit rate, and the error is invisible and moves in the direction that looks like success. |
| Count in the resolver and disable caching | Rejected | Exact counting at 150,000 origin requests per second, and the loss of the mechanism that absorbs a viral code. |
| Count client-side with a beacon | Rejected | A redirect has no page to run script on, and a platform that inserts an interstitial to count clicks has changed the product to suit its telemetry. |

**What it buys**

- Caching and accurate counting stop being in conflict, which is what makes the 30-second TTL affordable.
- The redirect path holds no write credential at all (ADR-01), because nothing on it needs one.
- An analytics outage is invisible to clickers and bounded by the buffer's retention rather than by anyone's reaction time.

**What it costs**

- The count depends on the edge log being complete and timely; a sampled or truncated log is a billing dispute rather than a monitoring gap.
- Click detail is limited to what the edge sees, so anything richer requires an enrichment step rather than an application field.
- Two log streams to keep straight, with a standing rule about which one counts.

**Choose differently when.** Count in the application when responses are not cacheable and the application is on the path for every request anyway — a signed download URL issuer, an authenticated API. There, the application log is the edge log.

**Why it holds up over time.** This will still be the right answer on any edge platform that produces a request log, and it is the reason two other decisions hold. The fragile version — counting in application code — looks identical on a dashboard, which is precisely why it needs to be written down as a decision rather than left as an implementation detail.

> **Lesson.** Put the meter where every request passes, not where your code happens to run. If caching can hide traffic from your counter, your counter is measuring your cache.

#### ADR-11 · Full click events are shipped, with sampling reserved for pathological codes

**Status:** Accepted  ·  **Shown on views:** 12, 17, 20

*Does the platform ship one event per click, or aggregate counters at the edge and ship rollups?*

**Context.** Edge aggregation is dramatically cheaper: a counter per code per minute instead of 150,000 rows per second. It also forecloses every question nobody thought to ask at design time — the fraud investigation that needs individual events, the customer dispute that needs a timeline, the bot reclassification that needs to be replayed over history. Sampling above a per-code threshold bounds cost on viral links, which is exactly where the customer is watching most closely and least willing to accept an estimate. And click data is a billing input here, which raises the bar on what can be reconstructed later.

**Decision.** One event per click is shipped and retained at full fidelity for 90 days, rolled up into daily aggregates for 25 months and monthly for 5 years. Totals and breakdowns are exact; unique-visitor figures are estimates with a stated ≤ 2% relative error and are labelled as estimates everywhere they appear. Adaptive sampling above a per-code threshold is deferred to Phase 3 and, if enabled, must restate the accuracy contract rather than quietly degrading it.

**How it is realised on Google Cloud.** Event Hubs to Azure Data Explorer, with rollups as materialised views rather than a separate batch pipeline. Traffic class is stored on the raw event, so a reclassification is a replay over retained data rather than a request to re-collect it. Raw retention is capped at 90 days because it is the dominant analytics cost line.

| Option | Verdict | Reasoning |
|---|---|---|
| Full events for 90 days, then rollups | Chosen | Keeps later questions answerable — fraud, disputes, reclassification — and caps the cost with retention rather than with fidelity. |
| Edge-aggregated counters only | Rejected | A fraction of the cost and no way to answer any question that was not anticipated, including the ones that arrive as legal requests. |
| Sampling above a per-code threshold from day one | Deferred | The right lever if viral codes turn out to dominate cost, and the wrong default: it degrades accuracy exactly where customers are looking hardest. |
| Full events retained indefinitely | Rejected | Storage grows without bound for questions that are almost never asked beyond a quarter, and increases the privacy surface for no product gain. |

**What it buys**

- Fraud, dispute and reclassification questions remain answerable for a quarter without re-collecting anything.
- Exact and estimated figures are separated and labelled, so a customer is never told an estimate is a count.
- Rollups make the common query cheap while the raw data stays available.

**What it costs**

- Raw retention is the dominant analytics cost line and must be defended at every cost review.
- A 90-day cliff means an investigation that starts late has less to work with, which has to be stated rather than discovered.
- Full-fidelity events carry a privacy obligation, met by dropping identifiers at ingestion (ADR-12's realisation).

**Choose differently when.** Aggregate at the edge when click data is a reporting nicety rather than a billing input, and when nobody will ever need to reconstruct an individual request. An internal link service is exactly that case.

**Why it holds up over time.** The 90-day figure will move. What should not is the separation between exact and estimated numbers, and the rule that enabling sampling means publishing a new accuracy contract. Quiet accuracy degradation is how analytics platforms lose credibility permanently.

> **Lesson.** Decide whether your telemetry has to answer questions you have not thought of yet. If it does, keep events and cap retention; do not keep counters and cap questions.

#### ADR-12 · Bots are classified and served, never blocked or silently counted

**Status:** Accepted  ·  **Shown on views:** 03, 17, 20

*What does the platform do with the assumed 18% of traffic that is unfurlers, scanners and prefetchers?*

**Context.** These clients are not attackers. A chat unfurler fetching a link so a user sees a preview, a mail provider scanning for malware, a security tool checking a destination — all of them are doing work the platform's customers actively want done. Blocking them breaks link previews everywhere, which customers experience as the platform being broken. But counting them as human clicks is worse in a subtler way: a campaign owner who sees three clicks before announcing anything concludes the platform is inventing numbers, and every figure loses credibility at once. The third failure mode is the dangerous one: classifying a genuine click as a bot discards it invisibly.

**Decision.** Bot traffic is classified at the edge, served normally, tagged on the click event, excluded from customer-facing counts and from tenant quotas, and retained in the raw data with its class. Classification tags and never blocks. Because the class is stored rather than applied as a filter at collection time, a reclassification can be replayed over retained history rather than requiring the data to be collected again.

**How it is realised on Google Cloud.** Front Door bot manager rules produce a classification that is carried into the access log and onto the click event. Customer-facing queries filter on traffic class; cost and billing reports carry the bot share explicitly, so a tenant whose links are hammered by scanners is visible rather than mysterious.

| Option | Verdict | Reasoning |
|---|---|---|
| Classify, serve, tag, exclude from counts | Chosen | Keeps previews working, keeps customer numbers credible, and keeps the decision reversible because the class is data rather than a filter. |
| Block bots at the edge | Rejected | Breaks the preview behaviour customers depend on, and produces support tickets that look like platform outages. |
| Count everything as a click | Rejected | Cheapest and destroys trust in every number the platform reports, starting with day-one campaign figures. |
| Drop bot events at collection | Rejected | Saves storage and makes a misclassification permanent: a genuine click discarded as a bot cannot be recovered. |

**What it buys**

- Link previews keep working, which is invisible when right and very visible when wrong.
- Customer-facing numbers survive contact with a launch day dominated by unfurlers.
- A classifier improvement can be applied retroactively, because the class is stored alongside the event.

**What it costs**

- Bot traffic is served and paid for — an assumed 18% of the redirect bill for traffic nobody is billed for.
- Two sets of numbers exist (with and without bots), and every report has to be explicit about which it shows.
- Classification accuracy becomes a product concern with its own false-positive cost.

**Choose differently when.** Blocking becomes correct when the traffic is genuinely abusive rather than functional — a scraper enumerating codes is handled by rate limiting in ADR-03's threat model, not by this decision.

**Why it holds up over time.** The specific classifier will be replaced repeatedly. What must survive is the separation of serving from counting, and the rule that classification is recorded rather than applied destructively. Systems that filter at collection time can never correct a classification error.

> **Lesson.** Classify, do not filter, when the classification might be wrong. Storing the label keeps the decision reversible; applying it at ingestion makes every mistake permanent.

### Trust and tenancy

*Screening, terminal states, and the 12,000 domains the platform serves on customers' behalf.*

#### ADR-13 · Destinations are screened before issuance and re-screened afterwards

**Status:** Accepted  ·  **Shown on views:** 06, 08, 16

*Is a destination checked once at creation, continuously, or not by the platform at all?*

**Context.** A short link hides its destination — that is the product, and it is also why phishing campaigns prefer shorteners to bare URLs. A one-time check at creation catches the naive case and misses the standard technique entirely: register a clean page, get it approved, distribute the link widely, then change the page. The destination the platform approved and the destination the clicker reaches are separated by however long the campaign takes to spread. Meanwhile the platform's shared domain is a single shared reputation: once mail providers and browsers blocklist it, every legitimate tenant's links stop working, and recovery takes weeks the business does not have.

**Decision.** Every destination is screened against multiple reputation feeds before a code is issued, and re-screened periodically thereafter for as long as the link is live. A bad verdict at creation refuses the link; a bad verdict afterwards blocks it through the revocation channel with a platform interstitial. Bulk creation from new or unverified accounts is rate-limited and challenged. Every block and every reinstatement records the feed, the score, the reviewer and the time, retained two years, with an appeal path that runs the same code as the block.

**How it is realised on Google Cloud.** Azure Functions call three third-party reputation feeds synchronously at creation and on a scheduled fan-out afterwards, prioritised by live click rate so the links actually being clicked are re-screened most often. A block publishes to the revocation channel (ADR-02) so it is effective globally within 60 seconds. Evidence lands in the append-only store with an immutability policy.

| Option | Verdict | Reasoning |
|---|---|---|
| Screen at creation and re-screen periodically | Chosen | Addresses the actual attack — approval followed by weaponisation — and makes the platform's response time a number rather than a hope. |
| Screen at creation only | Rejected | Catches the naive case and misses the standard one. It also gives a false sense of coverage, which is worse than none. |
| Screen at click time | Rejected | Strongest protection and incompatible with the read path: a synchronous third-party call would dominate the 40 ms budget and make redirects depend on a vendor's uptime. |
| No screening; respond to reports | Rejected | Cheapest and ends with the shared domain blocklisted, which takes every legitimate tenant down with it. |

**What it buys**

- The platform's domain reputation is defended continuously rather than after an incident.
- Response time to a flipped verdict is bounded by the revocation promise, so it can be stated to a regulator or a customer.
- Every decision is evidenced, which makes an appeal a process rather than an argument.

**What it costs**

- Three feed subscriptions, a re-screening budget proportional to live links, and false positives that block legitimate customers.
- Creation latency includes a synchronous feed call, which is part of the 250 ms p99.
- A human review queue with a rota — a staffing commitment, not just a component.

**Choose differently when.** Skip screening only when destinations cannot be attacker-controlled — an internal shortener over an allow-listed set of hosts. As soon as a stranger can supply the URL, screening is a requirement.

**Why it holds up over time.** Feeds and scores will change completely; the shape will not. Screen before issuance, re-screen while live, block through the fast channel, keep the evidence. A platform that treats abuse as an operational afterthought rediscovers this list under pressure.

> **Lesson.** If your system republishes content somebody else controls, the check at admission is the easy half. The hard half is noticing when what you admitted has changed.

#### ADR-14 · Four distinct terminal states, owned by the platform

**Status:** Accepted  ·  **Shown on views:** 04, 09, 21

*What does a code return when it is unknown, expired, deactivated or blocked — and whose page is it?*

**Context.** One 404 for all four is the simplest thing to build and tells the clicker nothing, which means every dead link becomes a support ticket for the customer and a mystery for the person holding the poster. A fully tenant-branded page for all four keeps the customer's experience intact and lets a blocked link masquerade as a working one, which is exactly the outcome an abusive tenant wants. There is also a real tension with enumeration: distinguishing 'never existed' from 'expired' tells a scanner which codes have been used. And a blocked link raises the question of whose reputation is on the page — the platform is making an accusation, so it should be the one making it.

**Decision.** Four states, four distinct responses: unknown returns 404, expired and deactivated return 410 with a tenant-configurable page, and blocked returns a platform-owned interstitial that names the reason and offers an appeal route to the owner. None is cached. The destination is never taken from the request — only from a stored record — so the platform can never be turned into an open redirect. A blocked link is never a silent 404 and never a silent redirect.

**How it is realised on Google Cloud.** The resolver's terminal-state selection runs last in the pipeline (view 09) so state can never collapse into a generic error. Unknown codes are negatively cached briefly, which makes enumeration cheap to serve, and retired codes look identical to never-issued ones from outside.

| Option | Verdict | Reasoning |
|---|---|---|
| Four distinct states, platform interstitial for blocks | Chosen | The clicker learns what happened, the owner learns why, and an accusation is made by the party making it. |
| One generic 404 for all four | Rejected | Simplest, and converts every expiry and every block into a support conversation with no information in it. |
| Tenant-branded pages for all four including blocks | Rejected | Lets a blocked link look alive and makes the platform's safety decision invisible to the person it protects. |
| Block by removing the record entirely | Rejected | Destroys the evidence and the appeal path, and makes an accidental block unrecoverable. |

**What it buys**

- A dead link is self-diagnosable by both the clicker and the owner.
- Blocked links carry a warning rather than a shrug, which is the difference between protecting someone and confusing them.
- The open-redirect class of vulnerability is structurally excluded, not filtered.

**What it costs**

- Four responses to build, test and keep consistent across twelve regions.
- Distinguishing states leaks a little existence information, mitigated by unguessable codes and rate limiting rather than by ambiguity.
- The interstitial is a platform-branded page in the middle of a customer's campaign, which some customers will dislike.

**Choose differently when.** A single generic response is defensible for an internal service where every clicker can ask the owner directly. It is never defensible when the clicker is a stranger who has no way to find out what happened.

**Why it holds up over time.** Status-code semantics outlive platforms. The pressure here will come from customers wanting full branding on every state, and the answer must remain no for blocks specifically, because the whole value of the interstitial is that it is not the accused party's page.

> **Lesson.** Error responses are part of the product for anyone who cannot ask you what went wrong. Distinguish the cases your users can act on differently.

#### ADR-16 · Branded domains and their certificates are the platform's responsibility

**Status:** Accepted  ·  **Shown on views:** 05, 10, 18

*Who owns verification, certificate issuance and renewal for 12,000 customer-owned domains?*

**Context.** A branded domain is what an enterprise customer is actually buying: `go.acme.com` rather than a shared host nobody trusts. That makes their domain part of the platform's serving path, and its certificate part of the platform's availability. At 12,000 domains, renewal is not an administrative task — it is 12,000 independent expiry dates, each of which takes one customer's entire link estate down when it lapses. Leaving it to the customer means the platform's most valuable tier has the least reliable links, and the outage is discovered by the customer's own users. There is also a trust question at verification: serving a host the platform has not proven the customer controls is an open invitation to hijack somebody else's traffic.

**Decision.** The platform verifies domain ownership before serving a domain, provisions and auto-renews its certificate, and surfaces verification and renewal state to the customer before expiry becomes an outage. A branded domain carries the same latency and availability objectives as the shared domain; it is not a slower or less reliable path. Customer-supplied certificates remain available for enterprises that insist, with the renewal obligation explicitly transferred and alarmed.

**How it is realised on Google Cloud.** Azure DNS-based verification of a TXT or CNAME record, then Front Door managed certificates with automatic renewal, all domains served from the same anycast address set. Renewal state is a first-class field with alarms at 30, 14 and 7 days, and a certificate approaching expiry without renewal pages somebody rather than emailing the customer.

| Option | Verdict | Reasoning |
|---|---|---|
| Platform-managed verification and certificates | Chosen | Removes 12,000 expiry dates from customers who will not track them, and keeps the branded path as reliable as the shared one. |
| Customer-supplied certificates only | Rejected | Cheaper and transfers an availability risk to the party least equipped to manage it, while the platform still gets the outage. |
| Serve branded domains on a separate, simpler path | Rejected | Makes the premium tier the slower tier, which inverts the value proposition. |
| Skip ownership verification for speed of onboarding | Rejected | Lets a customer claim a host they do not control, which is traffic hijacking with the platform as the instrument. |

**What it buys**

- A customer's links do not stop working because somebody forgot a renewal.
- Branded and shared domains share one serving path, so there is one set of latency and availability numbers rather than two.
- Ownership verification closes a hijacking vector before the first request is served.

**What it costs**

- Certificate lifecycle at 12,000-domain scale is a subsystem with its own failure modes and its own on-call load.
- Onboarding is slower because verification is a prerequisite, which sales will push back on.
- Customer-supplied certificates remain a supported exception, which means two paths to keep working.

**Choose differently when.** Leave certificates to the customer when there are tens of domains and each has a named technical owner — a handful of enterprise integrations rather than a self-service tier.

**Why it holds up over time.** Automated issuance and renewal will get easier, and the obligation will not move. Whoever serves the host owns its availability, regardless of who owns the name, and a platform that pretends otherwise learns this from a customer's incident review.

> **Lesson.** If you serve traffic on somebody else's domain, you own that domain's availability in practice. Take the renewal job rather than the support ticket.

## Every package used, in one table

Eleven terms that mean something specific in this package. Where a term is used loosely elsewhere in the industry, the definition here is the one the twenty-three views were drawn against.

| Package | What it is | What it does here | Considered instead |
|---|---|---|---|
| Code | The short string after the host — the `spring` in `go.acme.com/spring`. | Permanent identity. Issued once, never reassigned, never reissued after retirement. | Treating it as a lookup key that can be recycled, which eventually resolves a printed QR code to a stranger's site. |
| Destination | The URL a code currently resolves to. | Mutable state. Changing it is the product's central feature, bounded by the five-minute propagation promise. | Treating the destination as part of the link's identity, which makes every campaign change a new code and defeats the point of shortening. |
| Projection | The read-optimised, eventually consistent copy of the link table that the redirect path actually reads. | Disposable. Rebuildable from the link table in 30 minutes with no operator input. | Calling the hot set a cache in front of the database, which invites somebody to read through it and then write back. |
| Deny-set | A compact set of codes that must stop resolving now, propagated on its own channel and checked before any cache layer. | The mechanism behind the 60-second revocation promise. | Relying on cache invalidation, which couples the safety promise to the slowest purge in the fleet. |
| Reservation | The claim record for `(domain, code)`, strongly consistent, which outlives the link it once identified. | The uniqueness authority and the retirement ledger in one. | Deriving uniqueness from the link table, which frees a code the moment its link is deleted. |
| Terminal state | One of four distinct non-redirect outcomes: unknown (404), expired (410), deactivated (410), blocked (interstitial). | What a clicker can act on. Never collapsed into a single generic error. | One 404 for all four, which turns every dead link into a support ticket and every blocked link into a mystery. |
| Traffic class | The classification of a request as human, unfurler, scanner or unknown, decided at the edge. | Excluded from customer-facing counts and tenant quotas, retained in the raw data so a reclassification can be replayed. | Blocking bots, which breaks link previews in every chat client and mail scanner the platform's customers rely on. |
| Edge hit rate | The share of clicks answered by the 30-second response cache without reaching a resolver. | The dominant lever on both tail latency and cost per million redirects; assumed 97%, alarmed below 94%. | Treating it as a performance metric only, when it is the main line on the bill. |
| Propagation bound | The published maximum time for a change to take effect globally — 60 s for a revocation, 5 minutes for an edit. | A contract. Support answers, customer expectations and abuse response all derive from it. | Implying immediacy, which makes every support conversation a negotiation and every abuse response unaccountable. |
| Working set | The share of stored links that receive a click in a 30-day window; assumed 4%. | What makes a hot set affordable against 12 billion stored links. | Sizing the cache against the table, which prices the architecture out before it is built. |
| Click | One redirect served, recorded from the edge access log after the response has left. | The billing and reporting unit, reconciled daily to ±0.5% with a 0.05% loss budget. | A counter incremented by application code, which makes caching and accurate counting mutually exclusive. |
