Architecture Decision Record
Solution Architecture v1.0 · Storage Platform Architecture · 2026-09 · 31 views · open source, fully on-premises
Storage Tiering Service · Solution Architecture v1.0 · Storage Platform Architecture · 2026-09 · 31 views · open source, fully on-premises
The argument these decisions serve is summarised in the Architecture One-Pager.
Thirty-two decisions make up this architecture. Everything else on the thirty-one views is convention, and convention needs no defence. Each record opens with the question that forced a decision, says what was chosen and how it is built on hardware the organisation owns, lists the options that lost and why, and states what the choice costs. It then names the conditions under which a different organisation should choose differently, and explains why the choice should still be right after the products, the media and the team have changed. Read the one-pager first. Read a record when you want to argue with it.
Status of this document. This is a design, not a report on a running system. Prices, latencies, volumes and savings are targets or stated assumptions taken from the requirement or from planning estimates, and each is replaced by a measured value in the proof phase. Five product behaviours are relied on and must be confirmed before production depends on them: Ceph RGW's STS accepting SPIRE-issued JWT-SVIDs through AssumeRoleWithWebIdentity; RGW validating S3 additional checksums (CRC32C) on PUT in the deployed Ceph release; RGW bucket notifications delivering object and lifecycle events to Kafka without silent loss under load; EOS and CTA serving 2,000 concurrent stage requests through the WLCG Tape REST API; and Vitess VStream keeping a 9.2-billion-row analytical replica current while the catalogue takes 1,400 commits a second.
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 works on-premise: The concrete mechanism: which package, configured how, on whose hardware.
- 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
Read path and catalogue: What a read is allowed to depend on, where placement truth lives, and why stale answers are safe.
- ADR-01 · The read path never consults the tiering plane
- ADR-02 · The placement catalogue runs on Vitess over MySQL, sharded by tenant
- ADR-03 · Bounded-stale resolution, made safe by a 24-hour release hold
- ADR-04 · The catalogue decides, the tier is fact, and both can be rebuilt from storage
- ADR-05 · Callers see a recall class and a job handle, never a tier
Tiers and storage: What a tier is when the organisation owns the hardware, which software provides each rung, and how small objects are stored.
- ADR-06 · The tier ladder is signed, versioned configuration priced in internal transfer prices
- ADR-07 · Ceph RGW provides the disk tiers, one cluster set per tier, with lifecycle transitions disabled
- ADR-08 · Small objects are stored in single-tenant, single-key, self-describing packs
- ADR-09 · Compact a pack only when dead space passes half and minimum durations have expired
- ADR-10 · The archive rung is LTO tape behind EOS and CTA, driven through the WLCG Tape REST API
- ADR-11 · The ladder ends in a second custodian, in Phase 3, only for data kept as an obligation
Signal and classification: What counts as evidence of use, how it is collected cheaply, who classifies, and what happens when the signal goes quiet.
- ADR-12 · Every reader declares a class, and non-promoting classes change nothing
- ADR-13 · Telemetry is reduced at the source and stored in Kafka and ClickHouse
- ADR-14 · Classification is a bounded batch over an analytical replica of the catalogue
- ADR-15 · Declarative policy first; a predictive classifier earns its place in shadow
- ADR-16 · The platform classifies; the storage layer does not tier itself
- ADR-17 · Objects under 1 MB are classified and moved only as cohorts
- ADR-18 · Blind means no demotion: one canary drives both the brake and the alarm
Placement policy: The arithmetic every demotion must clear, what stops oscillation, where guards are enforced, and how a policy reaches production.
- ADR-19 · Every demotion clears a per-object break-even over the destination's minimum duration, recorded with its inputs
- ADR-20 · Hysteresis: 14-day dwell, a 3× promotion bar, and no promotion on a first read
- ADR-21 · Guards are enforced at commit against a per-tenant epoch, not at planning
- ADR-22 · Policy ships through shadow, dry run, rings and a hard movement cap, orchestrated by Temporal
Movement: How an object changes place without ever being lost, who does the work, and what a delete does to a move in progress.
- ADR-23 · Copy, verify, commit, release, with the catalogue row as the state machine
- ADR-24 · Movers are a Go fleet on budget leases that name dollars and requests
- ADR-25 · A delete wins over a movement, and offboarding is proven against storage
Recall and cost: How cold data comes back, who pays for it, and how the saving is counted so that it survives a finance review.
- ADR-26 · Recall is a Temporal job planned by pack and cartridge, and staging is not promotion
- ADR-27 · Retrieval budgets per tenant have a hard ceiling; legal authority is never refused and never free
- ADR-28 · Savings are net, against an all-hot counterfactual, attributed to decisions and reconciled
Security and operations: Who may move and who may delete, how holds change, how disagreement with storage is settled, and where it all runs.
- ADR-29 · Movement and deletion are different principals with fifteen-minute, one-tier credentials
- ADR-30 · Restrictive hold and pin changes apply at once; relaxing changes need two approvers
- ADR-31 · Reconciliation believes the tier for where bytes are and the catalogue for where they should be
- ADR-32 · Two data centres and a witness, with cross-site failover as a rehearsed runbook
Technology by capability
Every capability on the views, the package that provides it, a credible alternative, and the record that justifies the choice. Everything runs on hardware the organisation owns, with no managed service and no external dependency on any read path.
| Capability | Choice | Origin | Credible alternative | Why this one | Record |
|---|---|---|---|---|---|
| Placement resolution | Go resolver with a bounded in-process cache | This design | Direct catalogue queries from the file service | One dependency (vtgate); staleness made safe by the release hold | ADR-01 |
| Stale-safe reads | 24-hour release hold on every movement | This design | Primary-only reads; cache invalidation | Lets caches and replicas serve resolution without a data-loss path | ADR-03 |
| Placement catalogue | Vitess on MySQL 8.4, multicolumn vindex | Open source | TiDB; FoundationDB | Tenant sharding, cheap point reads, online resharding, CNCF governance | ADR-02 |
| Disk tiers | Ceph RGW, separate clusters per tier, multisite | Open source | MinIO; SeaweedFS | S3, STS, SSE-KMS and notifications at exabyte scale, with open governance | ADR-07 |
| Tier ladder | CUE in Git, signed with cosign | Open source | Hard-coded tier logic | Prices and minimum durations as reviewed, versioned data | ADR-06 |
| Small-object storage | Platform pack format, one tenant and key per pack | This design | tar; SeaweedFS volumes | Per-entry identity and checksum; readable without the platform | ADR-08 |
| Archive tier | EOS and CERN Tape Archive on LTO-10 | Open source | LTFS with own scheduler; dCache | Retrieval queues, drive sharing and repack already solved at scale | ADR-10 |
| Access telemetry | Apache Kafka with KRaft | Open source | Redpanda; NATS JetStream | Replayable partitioned log shared with CDC and decisions | ADR-13 |
| Aggregates and analytical replica | ClickHouse | Open source | Apache Druid; Trino on Iceberg | Rollups at ingest and fast scans over billions of rows | ADR-14 |
| Catalogue change data | Vitess VStream into Kafka | Open source | Nightly snapshot to Parquet | Shard-aware and minutes behind, with no load on primaries | ADR-14 |
| Classification and break-even | ClickHouse SQL and a Go evaluator | This design | Spark batch | No extra cluster; the arithmetic lives in one auditable place | ADR-19 |
| Predictive classifier (Phase 2) | LightGBM batch scoring in Python | Open source | XGBoost; survival models | Tabular features, fast scoring, importances an auditor can read | ADR-15 |
| Freshness gate and dead-man | Canary events, Prometheus, self-hosted Healthchecks | Open source | Alertmanager watchdog only | Brake and alarm read one signal; the watcher lives outside the estate | ADR-18 |
| Policy rollout and recall jobs | Temporal on PostgreSQL | Open source | Argo Workflows; job tables | Durable timers, signals and multi-day workflows | ADR-22 |
| Movers and budget leases | Go workers and a Go budget authority | This design | rclone; RGW sync modules | Conditional commits and money-denominated limits need catalogue-aware code | ADR-24 |
| Release and cleanup | Go release gate under a separate identity | This design | Deletes inside movers | The only delete permission, conditioned on committed state | ADR-23 |
| Holds, pins, budgets | PostgreSQL 17 with Patroni | Open source | Rows in Vitess | Small relational data with approval workflows | ADR-30 |
| Decision and movement records | Parquet on Ceph with S3 Object Lock | Open source | Kafka tiered storage | Immutable, cheap, and readable by any engine for three years | ADR-19 |
| Workload identity and storage credentials | SPIRE JWT-SVIDs exchanged at RGW STS | Open source | Static RGW keys rotated by OpenBao | Attested, 15-minute, one-tier credentials | ADR-29 |
| Encryption keys | OpenBao transit for RGW SSE-KMS | Open source | HashiCorp Vault | Per-tenant keys under Linux Foundation governance | ADR-29 |
| Human identity and approvals | Keycloak with WebAuthn | Open source | Dex; Authentik | Existing OIDC provider with strong second factors | ADR-30 |
| Drift detection | RGW bucket notifications and a rolling scan | Open source | Daily full scans | Minutes for common changes, 14 days for everything | ADR-31 |
| Compute platform | Kubernetes (RKE2) with Cilium, etcd witness | Open source | OKD; kubeadm | Separate serving and tiering clusters with room-aware scheduling | ADR-32 |
The decisions, and the alternatives that lost
Read path and catalogue
What a read is allowed to depend on, where placement truth lives, and why stale answers are safe.
ADR-01 · The read path never consults the tiering plane
Status: Accepted · Shown on views: 02, 07, 08, 11
What is a read allowed to depend on?
Context. Every product read has to find its object, so whatever answers that question sets a ceiling on the product's availability and a floor on its latency. The tiering plane (telemetry, classifier, planner, movers) is large, batch-shaped and changes weekly. If a read ever calls it, even as a fallback, the storage bill and the product's availability become the same number.
Decision. Placement resolution is a lookup in the committed catalogue and nothing else. It does not evaluate policy, call the classifier, check a hold or probe a tier. The serving plane (resolver, read router, placement API, recall API, catalogue) is a separate Kubernetes cluster with a 99.99% target; the tiering plane is another cluster with a 99.5% target and no component the serving plane calls.
How it works on-premise. The resolver is a Go service whose only network dependency is vtgate. Its deployment manifests have no service entries for Kafka, ClickHouse, Temporal or the policy service, and a CI check fails any change that adds one. Access events are written to a local bounded buffer and shipped asynchronously; if Kafka is down the buffer drops events, never reads.
| Option | Verdict | Reasoning |
|---|---|---|
| Catalogue lookup only, tiering plane fully separate | Chosen | The tiering plane can be switched off for a week with no effect on reads. Its availability target can be honest. |
| Resolve by probing tiers in order | Rejected | Needs no catalogue, but puts up to five storage calls on a miss and makes an archive probe part of a hot read. |
| Resolver consults policy for promote-on-read | Rejected | Convenient, and it puts the policy engine on every read. Promotion is decided later from telemetry instead. |
| Provider or storage-layer transparent tiering | Right elsewhere | Right when one storage system owns all tiers and its own metadata. Here tiers span Ceph clusters and tape. |
What it buys
- Classifier, planner and mover outages are cost events, never availability events
- The tiering plane can be redeployed, rewritten or paused without a change window for reads
- The read path is small enough to reason about completely
What it costs
- A cold read cannot promote itself synchronously; promotion waits for the next cycle
- Every read emits telemetry through a buffer that must be sized and watched
- The catalogue becomes the one component whose availability everything inherits
Choose differently when. A system where every tier is inside one storage product that already tracks class per object, and where the product's own metadata service is the read path anyway, gains nothing from a separate catalogue and should use the product's placement.
Why it holds up over time. The boundary is about dependency direction, not technology. Whatever replaces the classifier, the movers or even the storage tiers, the rule that reads depend only on committed placement stays correct and keeps the replacement low-risk.
Lesson. Put the system that decides on a different availability budget from the system that serves, and forbid the call between them rather than hoping nobody makes it.
ADR-02 · The placement catalogue runs on Vitess over MySQL, sharded by tenant
Status: Accepted · Shown on views: 08, 13, 22
Which open-source store can hold 9.2 billion placements, answer point reads at 1.4 million a second, and commit a conditional update with no data loss inside a data centre?
Context. The requirement asks for strong consistency, RPO 0 in a data centre, no cross-tenant transactions, 3.7 TB of rows before indexes, 34% growth a year, and a p99 of 8 ms. Every operation is either a point read by key or a single-row or single-tenant conditional write. There are no joins across tenants, no range scans on the hot path and no analytical queries, because those go to a replica (ADR-14).
Decision. Vitess on MySQL 8.4, 64 shards to start, keyed by a multicolumn vindex of tenant hash and object hash. Each shard has a primary and two semi-sync replicas in separate rooms of DC-A, and one asynchronous replica in DC-B. VTOrc handles in-DC failover. Commits are compare-and-set updates on row_version and guard_epoch within one shard.
How it works on-premise. MySQL runs on bare-metal NVMe servers, not in Kubernetes, managed by the Vitess operator's topology in etcd with the third etcd voter at the witness site. vtgate runs in the serving cluster. Resharding is done online with MoveTables and Reshard workflows as tenants grow. Backups use Vitess's built-in backup to a Ceph bucket that is not a placement tier.
| Option | Verdict | Reasoning |
|---|---|---|
| Vitess on MySQL | Chosen | Tenant sharding is its native model; proven for this exact kind of product at Slack and GitHub; CNCF graduated; point reads are as cheap as MySQL gets. |
| TiDB | Right elsewhere | Automatic region splitting and no shard key to design. Right for a team that will not own a sharding scheme; costs more per point read through its SQL and coprocessor layers. |
| FoundationDB with a record layer | Right elsewhere | Strict serialisability across keys and a strong record at Apple and Snowflake for metadata. Needs a layer built and owned in-house and skills that are hard to hire. |
| CockroachDB | Rejected | Technically suitable, but no longer released under an open-source licence, which rules it out for this stack. |
| Cassandra or ScyllaDB | Rejected | Excellent for point reads; conditional commits need lightweight transactions, which are slow and easy to misuse on the one write that must be exact. |
What it buys
- Single-shard transactions are exactly the shape of every commit
- Semi-sync across rooms gives RPO 0 in DC-A with plain MySQL operations knowledge
- Online resharding lets the catalogue grow with the corpus without a migration project
What it costs
- The shard key is a design decision the team owns forever
- 256 MySQL instances is a real fleet to patch and watch
- Cross-site failover is a runbook rather than automatic, by choice (ADR-32)
Choose differently when. Choose TiDB when the team cannot own a sharding scheme and point-read cost is not the constraint. Choose FoundationDB when cross-key transactions become necessary, for example if placements start spanning tenants.
Why it holds up over time. The catalogue's access pattern is key-value with conditional writes, so its schema moves to any store with that capability. MySQL's replication and storage engine have been stable for fifteen years, and Vitess's governance under the CNCF does not depend on one company's roadmap.
Lesson. Pick the store for the operations you actually run on the hot path, and send everything else to a replica built for it.
ADR-03 · Bounded-stale resolution, made safe by a 24-hour release hold
Status: Accepted · Shown on views: 11, 18, 31
How can resolution use caches and replicas when the requirement says placement must be strongly consistent?
Context. 1.4 million resolutions a second against primaries alone would need a much larger catalogue fleet, and the 4× burst would need more still. Caches and replicas are the obvious answer, and the obvious danger is a stale answer pointing at a location whose bytes were just deleted. The property that actually matters is not linearisable reads. It is that a resolved location always holds the bytes.
Decision. Sources are released no earlier than 24 hours after commit. Resolution may therefore use a 60-second in-process cache and replicas lagging less than 5 seconds, and falls back to the primary only when a row is not found. Commits remain serialisable on the primary. The design guarantees that every answer, however stale within 24 hours, names bytes that exist.
How it works on-premise. The movement row carries release_after = commit_time + 24 h. The release gate selects only rows past that time. vtgate routes resolver reads to replicas with a lag threshold of 5 seconds and to the primary on not-found. The resolver cache is a bounded LRU with a 60-second TTL and no negative caching.
| Option | Verdict | Reasoning |
|---|---|---|
| Stale reads, release held 24 h | Chosen | Safety comes from ordering, not from read consistency. The hold also turns every movement into a reversible one for a day. |
| Primary reads only | Rejected | Linearisable and several times the catalogue hardware, and the burst still lands on primaries that also take commits. |
| Cache with invalidation on commit | Rejected | Invalidation across 48 pods is a distributed protocol with its own failure modes, protecting against a problem the hold removes. |
| Release immediately after commit | Rejected | Saves one day of duplicate storage and makes every cache, replica and long stream a data-loss path. |
What it buys
- Most resolutions never reach a primary
- Reversing a bad movement within a day is a pointer change with no retrieval
- Long streams that started before a commit finish from the source
What it costs
- One extra day of storage for every moved byte, about \(250 to \)600 a day at planned volumes
- A newly committed placement may be read at its old location for up to 60 seconds
- The guarantee depends on the release gate honouring release_after, so the gate is tested harder than anything else
Choose differently when. Where storage is so expensive that a day of duplicates matters, or where movements are rare enough that primaries can serve every read, drop the hold and read from primaries.
Why it holds up over time. This is a property of ordering: no cache, replica or storage technology can break it as long as release waits longer than the longest staleness. Future caches or read paths only need to state their staleness bound and check it is under 24 hours.
Lesson. When readers can be stale, make the writer wait out the staleness before destroying anything they might still point at.
ADR-04 · The catalogue decides, the tier is fact, and both can be rebuilt from storage
Status: Accepted · Shown on views: 12, 30, 31
Where does placement truth live: in the catalogue, in the storage layer's own metadata, or in both?
Context. A catalogue-only design is fast and loses everything if the catalogue is lost. A storage-only design is rebuildable and puts a listing over nine billion objects on the read path. The requirement wants catalogue reads on the hot path, a rebuild within 30 hours, and drift detected rather than absorbed.
Decision. The catalogue is authoritative for where to read. The storage tiers are believed about where bytes physically are. The two are reconciled continuously (ADR-31). Everything needed to rebuild location is written into storage itself: object keys encode tenant, object and version, and every pack carries its own index plus a sidecar manifest.
How it works on-premise. Unpacked keys follow t/
| Option | Verdict | Reasoning |
|---|---|---|
| Catalogue authoritative, storage self-describing, reconciled | Chosen | Fast reads, a bounded rebuild, and disagreements surface as findings. |
| Catalogue only, opaque storage keys | Rejected | Smaller keys and nothing to scan, and a lost catalogue is a lost corpus. |
| Storage metadata only | Rejected | Rebuildable by definition, and puts storage listing and class metadata on every read. |
What it buys
- A full catalogue loss costs about 30 hours and the decision history, not objects
- Anyone with storage access and the key convention can find any object without this software
- Drift becomes measurable because both sides can be enumerated
What it costs
- Key conventions are a permanent contract; changing them means rewriting objects
- Pack manifests are extra objects to store and keep consistent
- A rebuild restores location but not why an object is where it is
Choose differently when. When a storage product offers a transactional, queryable metadata service with its own replication, and every tier lives in it, the product's metadata can be the truth and the catalogue a cache.
Why it holds up over time. Self-describing data outlives every index built over it. In ten years the catalogue may be a different database, but objects named by their identity and packs that list their contents will still be readable by whatever replaces it.
Lesson. An index should be something you can lose. Write enough into the data itself that the index can be rebuilt from it.
ADR-05 · Callers see a recall class and a job handle, never a tier
Status: Accepted · Shown on views: 10, 11, 20
What does a product know about where an object is, and what happens when bytes are not immediately available?
Context. If a product sees tier names it will build behaviour on them, and every ladder change becomes a product change. If a read of an archived object blocks until the bytes arrive, a four-minute or twelve-hour request sits on the read path. The requirement sets a 3-second synchronous ceiling and asks for synchronous-or-job to be decided.
Decision. Resolve returns one of three recall classes: immediate, delayed or scheduled. Immediate objects are read inline. For delayed and scheduled objects the caller receives, within 3 seconds, a job handle with an estimated availability time and a callback, never a hung request. Tier names, cluster names and vendor classes never appear in any product-facing response.
How it works on-premise. The recall class is a column of the ladder version, so the resolver maps tier to class from the placement row with no extra lookup. The recall API creates a Temporal workflow for the job and returns its id and the current per-tier estimate published by the quota planner.
| Option | Verdict | Reasoning |
|---|---|---|
| Recall classes with job handles | Chosen | Honest about physics, stable across ladder changes, and bounded on the read path. |
| Synchronous recall for every class | Rejected | Simplest for callers, and puts a four-minute request, and on the deep rung a multi-day one, on the read path. |
| Expose tiers to product teams | Rejected | Gives products more control and couples every product to the current storage estate. |
What it buys
- Adding or retiring a tier changes no product code
- No request is held open beyond 3 seconds
- Products can design a 'fetching' state against three stable classes
What it costs
- Every product that can read archived objects needs a fetching state and a callback
- Estimates must be accurate enough to be believed, so the quota planner is part of the contract
- A cold object that should have been immediate shows up as a product-visible delay, which is the point, and also a complaint
Choose differently when. If archived data is only ever read by batch tools, never by people, the job model can be the only model and immediate reads need no class at all.
Why it holds up over time. Recall classes describe what a caller experiences, which changes far less often than the media underneath. Tape, disk, a future glass or DNA archive: each maps to one of three classes, and the product never notices.
Lesson. Expose the service level a caller can plan around, not the mechanism that currently provides it.
Tiers and storage
What a tier is when the organisation owns the hardware, which software provides each rung, and how small objects are stored.
ADR-06 · The tier ladder is signed, versioned configuration priced in internal transfer prices
Status: Accepted · Shown on views: 09, 17, 26
What does 'price per GB', 'retrieval fee' and 'minimum storage duration' mean when the organisation owns the disks and the tapes?
Context. The requirement defines placement arithmetic in cloud terms. On-premises there is no invoice per retrieval, but the costs are real: erasure-coded reads across twelve disks, drive-hours and robot mounts, dead space in immutable packs and on tape until reclamation. If those costs are not priced, the classifier optimises against a ladder that says reads and deletes are free, and it will be wrong in exactly the ways that cost most.
Decision. The ladder is an ordered list of tiers, each with storage price per GB-month, retrieval price per GB and per mount, minimum duration, first-byte latency, recall class and durability. Prices are internal transfer prices published by the storage team, fully loaded over five years. Minimum durations equal each medium's reclaim cycle. The ladder is versioned, signed, and every placement records the version it was made under.
How it works on-premise. The ladder is a CUE file in Git, reviewed by the storage team and finance, signed with cosign, and loaded by the classifier, the break-even evaluator and the cost ledger. A new version is evaluated in shadow before it applies to new decisions. Existing placements are never re-evaluated merely because a price changed.
| Option | Verdict | Reasoning |
|---|---|---|
| Transfer-priced ladder as versioned configuration | Chosen | The arithmetic stays identical to the cloud version, and the prices have a named owner. |
| Treat on-prem storage as sunk cost; tier by capacity pressure | Rejected | Common, and it makes every read and delete look free until the next hardware order. |
| Hard-code tier behaviour in the classifier | Rejected | Faster to start, and every hardware change becomes a code change nobody reviews for cost. |
What it buys
- Placement decisions and the cost ledger agree because they read the same numbers
- Hardware refreshes and new media are configuration changes evaluated in shadow
- Storage team, finance and platform argue about one table instead of three spreadsheets
What it costs
- Transfer prices are estimates and will be disputed
- Two ledgers (placement-time price and current price) must be kept to explain year-on-year changes
- A wrong price produces systematically wrong decisions until corrected
Choose differently when. If the organisation later moves the corpus to a public cloud, the ladder is populated from the provider's price list instead, and nothing else in this record changes.
Why it holds up over time. Media prices halve every few years and tape generations change every two or three. A ladder that is data absorbs those changes without touching code, and the version stamp on every placement keeps old decisions explainable under the prices that justified them.
Lesson. If a cost is real but invisible, price it explicitly, or your optimiser will spend it for you.
ADR-07 · Ceph RGW provides the disk tiers, one cluster set per tier, with lifecycle transitions disabled
Status: Accepted · Shown on views: 09, 22, 30
Which open-source object store provides hot, warm and cold, and are they one cluster with storage classes or separate clusters?
Context. The disk tiers hold about 32 PB logical across NVMe replicas, HDD erasure coding and dense HDD, in two data centres. They need an S3 API, short-lived credentials, server-side encryption with an external KMS, bucket notifications and asynchronous replication between sites. Ceph RGW can express tiers as storage classes inside one zone and move objects between them with its own lifecycle rules, which is exactly the out-of-band movement the requirement calls a governance failure.
Decision. Ceph RGW for all disk tiers, with each tier as its own set of Ceph clusters, replicated to DC-B with RGW multisite. Movement between tiers is always a copy made by the platform, never an RGW storage-class transition. Lifecycle transition rules are disabled on every placement bucket and detected if added.
How it works on-premise. Hot clusters use NVMe OSDs with 3× replication; warm uses HDD with EC 8+3; cold uses 20 TB HDD with EC 12+4 and larger bucket index shards for pack objects. Each cluster is sized in cells of 10 to 15 PB so a cluster rebuild or upgrade has a bounded blast radius. RGW STS, SSE-KMS with OpenBao and Kafka bucket notifications are enabled on all of them.
| Option | Verdict | Reasoning |
|---|---|---|
| Ceph RGW, separate clusters per tier | Chosen | Mature at exabyte scale, open governance, S3 and STS compatible; tier failures and upgrades are isolated. |
| One Ceph cluster, tiers as storage classes | Rejected | Cheaper to run and allows in-place transitions, but one cluster failure takes every tier and transitions bypass the catalogue. |
| MinIO | Rejected | Fast and simple, but its community edition was cut back in 2025 and its roadmap belongs to one company. |
| SeaweedFS | Right elsewhere | Haystack-style volumes are excellent for small objects; right for a smaller estate that wants packing built in and accepts a smaller operator community. |
What it buys
- A warm-tier incident never affects hot reads
- Each tier upgrades on its own schedule and hardware generation
- No storage-side feature can move an object the catalogue did not decide to move
What it costs
- Every movement between tiers is a network copy rather than a metadata change
- More clusters to run, monitor and upgrade
- Ceph expertise is required at a depth the storage team must already have
Choose differently when. With a small corpus and a small storage team, one Ceph cluster with storage classes and a catalogue-driven transition API is simpler and the isolation is not worth the extra clusters.
Why it holds up over time. The platform uses Ceph only through the S3 API, STS and notifications. Any future S3-compatible store can replace one tier at a time by adding a ladder version, and the self-describing key convention (ADR-04) makes the migration a copy like any other movement.
Lesson. If a storage system can move data on its own, turn that off when something else is keeping the record of where data is.
ADR-08 · Small objects are stored in single-tenant, single-key, self-describing packs
Status: Accepted · Shown on views: 13, 15
Is the unit of tiering the object or the pack?
Context. 62% of objects are below 128 KB and the median is 240 KB. On erasure-coded HDD and on tape, per-object metadata, allocation overhead and requests cost more than the bytes. Packing fixes that and creates new problems: a cold read becomes a ranged read into a large object, a delete becomes dead space, and a mixed-tenant pack turns offboarding, encryption and legal disclosure into compaction events.
Decision. The object remains the unit of placement and every object keeps its own catalogue row. Below warm, objects under 1 MB are stored in packs. A pack holds exactly one tenant's objects under one encryption key, grouped by folder and upload time, and seals at 2 GB or after 7 days open. The pack format is the platform's own: entry headers with identity, length, key id and CRC32C, a footer index, and a sidecar manifest.
How it works on-premise. The pack builder streams members from hot into a multipart upload on the cold cluster, reads the sealed pack back and checks every entry, writes the manifest, then commits member placements in batches of 1,000 rows per shard transaction. On tape, a pack is one CTA file. Reads use a ranged GET for one member's offset and length.
| Option | Verdict | Reasoning |
|---|---|---|
| Own pack format, one tenant and one key per pack | Chosen | Offboarding, crypto-shredding and disclosure stay per tenant; the format is simple enough to read without this software. |
| Mixed-tenant packs | Rejected | Better fill for 180,000 small tenants, and a single tenant's deletion or key destruction becomes a compaction of everyone else. |
| tar or zip archives | Rejected | Familiar, but no per-entry checksum or key id in the header and no index designed for ranged reads. |
| Store small objects individually | Right elsewhere | Right when the median object is megabytes and tiers charge nothing per object. |
What it buys
- Millions of small-object requests become one pack write
- A pack can be verified and read without the catalogue
- Tenant-level deletion and key destruction never touch another tenant
What it costs
- Small tenants produce small, underfilled packs
- Deletes leave dead space that only compaction reclaims
- A pack format is code the platform owns for as long as packs exist
Choose differently when. A single-tenant corpus, or one where encryption and deletion are not tenant-scoped, should pack across tenants for density.
Why it holds up over time. A format with a header per entry, a checksum and an index at the end is the design of tar, Haystack and every log-structured store before them. It will be readable in twenty years with a hundred lines of code, which is the standard an archive format should meet.
Lesson. Pack along the boundaries along which you will later have to delete, encrypt and disclose.
ADR-09 · Compact a pack only when dead space passes half and minimum durations have expired
Status: Accepted · Shown on views: 15, 13
When is it worth rewriting a pack to reclaim the space deleted members left behind?
Context. Compaction reads every live member of a pack and writes them into a new one. On cold disk that is an erasure-coded read of up to 2 GB; on tape it is a mount, a read and a rewrite. It is the most expensive operation the platform performs, and a naive threshold turns the platform's own housekeeping into its largest retrieval bill.
Decision. A pack is compacted only when dead bytes exceed 50% of its size and the minimum duration of its live members has passed. Packs are cut by cohort so that most expire together, and a pack whose live bytes reach zero is deleted without compaction. On tape, compaction is CTA's repack of whole cartridges, scheduled with generation migrations.
How it works on-premise. Dead bytes are tracked per pack as deletes commit. A daily job lists packs over the threshold, prices compaction with the ladder's retrieval price and the break-even evaluator, and submits only those where reclaimed storage over the next minimum duration exceeds the compaction cost.
| Option | Verdict | Reasoning |
|---|---|---|
| Threshold of 50% dead plus break-even check | Chosen | Compaction happens only when it pays, and cohort packing makes whole-pack expiry the common case. |
| Low threshold, around 20% | Rejected | Keeps storage tidy and repeatedly re-reads live data to reclaim little. |
| Never compact | Rejected | Zero retrieval cost and unbounded dead space for long-lived tenants with steady deletion. |
What it buys
- The expensive operation runs only when it saves money
- Most packs disappear whole at the end of their cohort's life
- Tape compaction aligns with migrations that must happen anyway
What it costs
- Up to half of a pack's bytes may be dead for a long time
- Dead-space accounting must be exact to be trusted
- Tenants with churn-heavy folders produce packs that are costly to keep
Choose differently when. If storage becomes more expensive than reads, as with very dense but slow media where retrieval is cheap, lower the threshold.
Why it holds up over time. The rule is an inequality over ladder prices, not a constant. When prices change, the threshold effectively moves with them without anyone editing it.
Lesson. Housekeeping that reads data is still retrieval. Price it like any other decision.
ADR-10 · The archive rung is LTO tape behind EOS and CTA, driven through the WLCG Tape REST API
Status: Accepted · Shown on views: 09, 20, 21, 22
What provides a delayed-recall archive tier on-premises, with open-source software, at about 8 PB and growing?
Context. At archive prices only tape competes on-premises, and tape's hard problem is not writing but reading: queuing thousands of retrieval requests, ordering them by cartridge and position, sharing a handful of drives between interactive and bulk demand, and repacking cartridges across generations. Building that scheduler is where in-house tape projects fail.
Decision. The CERN Tape Archive manages the libraries, drives, queues and repack, with EOS as its disk buffer. The platform writes sealed packs into EOS, which archives them through CTA to two tape pools in two libraries in different data centres. Retrieval is requested through the WLCG Tape REST API. Drive reservations by demand class are CTA configuration derived from the ladder.
How it works on-premise. Two libraries with eight LTO-10 drives each, one per data centre. EOS provides about 500 TB of buffer on the cold Ceph hardware generation. The recall workflow submits stage requests per pack, polls for completion, and hands staged packs to unpack workers.
| Option | Verdict | Reasoning |
|---|---|---|
| EOS and CTA with the Tape REST API | Chosen | Built for exabyte-scale physics archives with the same retrieval problem; open source under CERN; the API is shared with dCache. |
| LTFS with an in-house scheduler | Right elsewhere | Simple, portable cartridges; right for a small archive, and the scheduler becomes the project. |
| Bareos or Bacula on tape | Rejected | Backup software, designed for restoring jobs, not for serving recalls of individual packs. |
| Deep HDD or spun-down disk instead of tape | Rejected | Faster recalls and roughly three to five times the cost per GB at this size. |
What it buys
- Retrieval ordering, drive sharing and repack come from a system that already does them at scale
- Two copies in two sites without a second product
- A standard API means dCache could replace EOS and CTA
What it costs
- CTA experience is rare outside research computing
- EOS is another storage system for the storage team to run
- Recall latency is minutes to hours and cannot be engineered away
Choose differently when. Below a few petabytes of archive, or without staff willing to run tape, deep HDD tiers in Ceph with spun-down pools are simpler and the cost difference is small.
Why it holds up over time. LTO has a published roadmap of roughly doubling capacity per generation, and CTA's repack moves data forward generation by generation. The platform only ever sees pack identifiers and the Tape REST API, so a change of tape software or media is invisible above the ladder.
Lesson. For tape, buy or adopt the scheduler. Writing to tape is easy; reading back fairly under contention is the product.
ADR-11 · The ladder ends in a second custodian, in Phase 3, only for data kept as an obligation
Status: Accepted · Shown on views: 09, 22, 27
Does the ladder end inside the organisation's own estate, or with an independent custodian?
Context. Two tape copies in two data centres protect against media and site loss, but not against a failure that affects the organisation's whole estate: a ransomware operator with administrative access, a control-plane bug that deletes across both sites, or a commercial or legal dispute. A second custodian adds independence and costs an operational estate of its own, a courier contract and a recall time measured in days.
Decision. A deep-archive rung is added in Phase 3: a third tape copy of packs that are older than seven years or under regulatory retention, exported from CTA and held in an independent vault. It is scheduled recall with a 72-hour contract and is priced in the ladder. Data that is kept for business value rather than obligation does not go there.
How it works on-premise. A dedicated CTA tape pool writes the third copy; cartridges are ejected monthly and shipped under the vault contract with a manifest of pack ids. The catalogue records the vault location as a placement; the reconciler checks it against the monthly vault inventory rather than by scanning tapes.
| Option | Verdict | Reasoning |
|---|---|---|
| Vault custodian for obligation data, Phase 3 | Chosen | Independence where it is legally or existentially needed, not for the whole corpus. |
| Second cloud as deep archive | Right elsewhere | Faster recall and no courier; right if the organisation already has a cloud agreement and accepts data leaving its estate. |
| End the ladder at on-site tape | Rejected | Cheapest, and leaves the whole archive exposed to one administrative domain. |
What it buys
- A copy exists that no administrator of this estate can delete
- The rung is priced and visible, so it is used deliberately
- Phase 3 timing lets tape operations mature first
What it costs
- Recall in days, and a contract to manage
- A monthly physical process that must be audited
- A third copy of obligation data adds cost that has no saving attached
Choose differently when. If the oldest data is a business asset read occasionally rather than a compliance obligation, a second cloud with hours-long recall is the better custodian.
Why it holds up over time. Separation of administrative domains is the one protection that does not weaken as software changes. Whatever runs this platform in ten years, a cartridge in a vault it cannot reach is still out of reach.
Lesson. Replicas protect against failure; custodians protect against the people and systems that manage the replicas.
Signal and classification
What counts as evidence of use, how it is collected cheaply, who classifies, and what happens when the signal goes quiet.
ADR-12 · Every reader declares a class, and non-promoting classes change nothing
Status: Accepted · Shown on views: 03, 14, 21
How does the platform stop a training job, a migration or an eDiscovery sweep from making the whole corpus look hot?
Context. An access log cannot tell a user opening a file from a pipeline reading every file. A single corpus-wide scan resets last-access for every object, and a classifier that believes it undoes months of demotions and then pays to redo them. The only party that knows why a read happened is the reader.
Decision. Resolve requires a reader class: interactive, sync-client, preview-render, batch-export, ml-training, ediscovery or migration. The class is bound to the caller's workload identity, so a pipeline cannot claim to be interactive. Batch-export, ml-training, ediscovery and migration are non-promoting: their reads are recorded and costed but never update the promoting aggregate that classification reads.
How it works on-premise. Allowed classes per SPIFFE identity are policy data; the resolver rejects a class the caller's identity may not use. Access events carry the class; ClickHouse materialised views maintain promoting and non-promoting aggregates separately.
| Option | Verdict | Reasoning |
|---|---|---|
| Declared class bound to identity | Chosen | The reader states intent, and identity keeps the statement honest. |
| Infer bulk reads from access patterns | Rejected | Needs no caller change, and misclassifies bursts of genuine use and slow scans alike. |
| Separate read endpoints for bulk callers | Rejected | Clear, and multiplies interfaces while still trusting the caller to use the right one. |
What it buys
- Scans and sweeps leave placement untouched
- Retrieval costs can be attributed to the kind of reader that caused them
- Tape access for training becomes an explicit authorisation rather than a side effect
What it costs
- Every calling team must change its client to declare a class
- Misconfigured identities surface as rejected reads during rollout
- Class definitions become a governed list that will be argued over
Choose differently when. A corpus read only by people through one application can infer class from the application and skip the declaration.
Why it holds up over time. New kinds of bulk reader will keep appearing, AI training being only the latest. Each becomes a new class in policy data rather than a new source of false heat.
Lesson. When the meaning of an event depends on intent, make the caller declare the intent and bind the declaration to identity.
ADR-13 · Telemetry is reduced at the source and stored in Kafka and ClickHouse
Status: Accepted · Shown on views: 12, 14
How does the platform collect access evidence for 1.4 million reads a second without telemetry becoming its largest cost?
Context. The requirement names telemetry as the platform's dominant controllable cost. A raw event per read at peak is far more than the 120 billion events a month the requirement budgets. Classification needs, per object or cohort and per class, the last access and the number of distinct access days in the window. It does not need every range read.
Decision. The resolver emits the first access per object, reader class and hour, with a count, and aggregates objects under 1 MB per folder cohort. Events go to Kafka and are rolled up by ClickHouse into raw events kept 90 days and aggregates kept three years.
How it works on-premise. A bounded in-process set per resolver pod, flushed every 60 seconds, provides deduplication. Kafka runs with KRaft on six brokers across rooms with replication factor 3. ClickHouse consumes through a Kafka engine table into ReplacingMergeTree for raw events and AggregatingMergeTree for aggregates, on a 12-node cluster.
| Option | Verdict | Reasoning |
|---|---|---|
| Deduplicate at source, Kafka to ClickHouse | Chosen | Cuts event volume by an estimated 15 to 25 times without losing any signal classification uses. |
| Log every read, aggregate downstream | Rejected | Maximum fidelity for a question nobody asks, at the platform's largest cost. |
| Kafka with Flink or Spark streaming | Rejected | Powerful, and one more distributed system for a rollup ClickHouse does at ingest. |
| Sampled events only | Right elsewhere | Right for classes where last access does not matter; wrong where one read in 400 days is the whole signal. |
What it buys
- Telemetry cost grows with distinct object-hours, not with read rate
- Classification reads compact aggregates instead of scanning events
- Ingest and aggregation scale independently
What it costs
- Per-read detail is gone, so read-count questions have hourly resolution
- A resolver pod crash loses up to 60 seconds of its events
- ClickHouse becomes a system the platform team must operate well
Choose differently when. If per-read forensics are a requirement, for example for security investigations, send full events to a separate cheap log store and keep this pipeline for classification.
Why it holds up over time. The signal is defined as last access and distinct days per class, which does not depend on the transport. Kafka and ClickHouse can be replaced by any log and any columnar store; the aggregate definitions are the lasting asset.
Lesson. Collect the signal the decision uses, at the resolution the decision uses, as close to the source as possible.
ADR-14 · Classification is a bounded batch over an analytical replica of the catalogue
Status: Accepted · Shown on views: 08, 16
Where does a classification cycle over 9.2 billion placements run, without degrading the store every read depends on?
Context. Classification joins every placement with its access aggregate, its holds and its dwell and minimum-duration dates. That is a full scan with joins. Run against the catalogue, it competes with 1.4 million reads a second. The requirement asks for the hot path and the analytical path to be physically separable, and for a cycle under 6 hours whose cost per object is published.
Decision. Catalogue changes stream through Vitess VStream into Kafka and into a ClickHouse replica of placement, movement and guard tables. Each cycle runs as SQL over the replica and the aggregates to produce candidates, and a Go evaluator applies break-even and guards. The cycle's compute cost and duration are published per run.
How it works on-premise. A Debezium-compatible VStream connector publishes row changes to Kafka topics per keyspace. ClickHouse applies them into ReplacingMergeTree tables keyed by object. The classifier job is a Kubernetes CronJob in the tiering cluster that writes decisions to Kafka and a plan to Temporal.
| Option | Verdict | Reasoning |
|---|---|---|
| CDC replica in ClickHouse | Chosen | Scans run on a store built for them, minutes behind the catalogue, with no load on it. |
| Scan Vitess read replicas | Rejected | No new system, and full scans on replicas that also serve resolution. |
| Nightly snapshot export to Parquet | Right elsewhere | Simpler, and a day stale. Right if cycles run daily and holds are always re-checked at commit, which they are here anyway. |
What it buys
- No classification query ever touches a store on the read path
- The replica also serves the cost ledger, reconciliation and ad hoc analysis
- Cycle cost is a measured number that can be traded against frequency
What it costs
- The replica lags the catalogue, so plans can be stale
- A CDC pipeline to operate and keep in sync
- A second copy of 3.7 TB of rows
Choose differently when. A corpus small enough to scan in minutes on one replica does not need a separate analytical store.
Why it holds up over time. Separating transactional from analytical access is one of the oldest stable patterns in data systems, and change data capture is how it is done without coupling the two. Staleness is made harmless by re-checking guards at commit (ADR-21).
Lesson. Let the analytical copy propose. Only the transactional store may permit.
ADR-15 · Declarative policy first; a predictive classifier earns its place in shadow
Status: Accepted · Shown on views: 16, 23, 27
Is placement decided by age-and-access rules or by a trained model?
Context. Rules are auditable, cheap and wrong at the margins in exactly the ways that cost retrieval fees. A model is better at the margins and cannot be explained to an auditor in a sentence. Regulated tenants may refuse placements nobody can justify per object, and nobody yet knows how much a model would actually save on this corpus.
Decision. The MVP uses declarative policies: per object class, an observation window, a minimum idle time, and the break-even inequality evaluated per object or cohort. A predictive classifier is introduced in Phase 2 in shadow only, scored against the declarative policy on realised break-even. It takes over for a class only after beating the rules for two consecutive 90-day scoring periods, and tenants may pin themselves to declarative policy.
How it works on-premise. Declarative policies are CUE evaluated by the classifier's SQL and Go evaluator. The Phase 2 model is a gradient-boosted classifier trained in Python on aggregate features exported to Parquet, scored in batch, with its outputs written as shadow decisions carrying the model version.
| Option | Verdict | Reasoning |
|---|---|---|
| Rules first, model in shadow, promoted on realised savings | Chosen | Auditable from day one, and a model has to prove itself in money before it moves anything. |
| Model from the start | Rejected | Possibly better at launch, with no baseline to prove it and no explanation for regulated tenants. |
| Rules forever | Right elsewhere | Right if the shadow comparison shows the model's marginal saving is smaller than its operating cost. |
What it buys
- Every placement in the first year can be explained in one sentence
- The model's value is measured rather than assumed
- Regulated tenants keep auditable placement permanently
What it costs
- Early margins are left on the table
- Two classifiers run side by side during Phase 2
- Promotion criteria must be defended when the model looks good but has not yet earned it
Choose differently when. If a similar corpus already has a proven model and scored history, start the model in shadow on day one and shorten the scoring period.
Why it holds up over time. Models will be retrained and replaced many times. The comparison framework (the same decisions, the same ladder, scored on realised money) is what persists, and it makes each replacement a measured decision.
Lesson. Ship the explainable baseline first, and let the clever version earn production by beating it in money.
ADR-16 · The platform classifies; the storage layer does not tier itself
Status: Accepted · Shown on views: 16, 30
Should the platform build its own classifier, or let the storage system's own tiering decide?
Context. The requirement's build-versus-buy question is Autoclass. On-premises its equivalents are RGW lifecycle transitions and commercial hierarchical storage managers. They need no telemetry pipeline. They also cannot see tenant, workspace, reader class or holds; they cannot be scored or overridden per tenant; and they move objects without the catalogue knowing.
Decision. The platform classifies. Storage-side lifecycle transitions are disabled on placement buckets (ADR-07). The decision is revisited in Phase 3 for a narrowly defined long tail if the per-object cost of the platform's own classification ever exceeds the saving it adds over an age-only rule for that tail.
How it works on-premise. RGW bucket policies forbid lifecycle transition configuration on placement buckets, and the reconciler treats a transition notification as a governance finding. A commercial HSM is not deployed.
| Option | Verdict | Reasoning |
|---|---|---|
| Own classifier with reader class and holds | Chosen | Sees what storage cannot, is scored per decision, and honours holds and pins by construction. |
| RGW lifecycle transitions by age | Rejected | Free to run, blind to reader class and holds, and moves objects the catalogue does not know about. |
| Commercial HSM (IBM Storage Scale, Quantum StorNext) | Rejected | Mature and proprietary, which the stack excludes; still blind to tenant context. |
| Storage tiering for an untenanted long tail | Deferred | Reconsidered in Phase 3 if measured overhead says so. |
What it buys
- Holds, pins and reader classes are honoured in every decision
- Every movement is attributable and reversible
- The classifier can be scored against a declared baseline
What it costs
- A telemetry pipeline and classifier to build and run
- The platform's own overhead must stay below what it adds
- No vendor to call when classification is wrong
Choose differently when. A corpus with no holds, no tenant-level pins and no bulk readers, where an age-only rule is nearly optimal, should let the storage layer tier itself.
Why it holds up over time. Context the storage layer cannot see (who reads, why, and under what legal constraint) is permanent to this kind of product. As long as that remains true, the platform's classifier has an edge no storage feature can copy.
Lesson. Buy the classifier when the information it needs lives inside the product you buy. Build it when the information lives in your application.
ADR-17 · Objects under 1 MB are classified and moved only as cohorts
Status: Accepted · Shown on views: 16, 17, 27
What is the smallest object worth tiering on its own?
Context. The per-object overhead target is \(0.0006 a year, covering catalogue share, telemetry, decisions and movement requests. Moving 1 GB from hot to cold saves about \)0.19 a year. Below roughly 3 MB at the target overhead, or about 1 MB at the platform's estimated real overhead, an object costs more to tier individually than it saves. Objects under 1 MB are an estimated 75% of objects and 4% of bytes.
Decision. The cohort threshold is 1 MB, derived from the published per-object overhead and revised when that figure changes. Objects below it are aggregated per folder in telemetry, classified per folder, priced as the pack they would form, and moved only as pack members. In the MVP, which has no packs, they stay where they are.
How it works on-premise. The threshold is a ladder-adjacent policy constant read by the resolver's event emitter, the classifier and the pack builder. Cohort keys are tenant, folder and upload month. A cohort decision writes one decision record naming its members' count and byte total, and each member's placement row carries that decision id.
| Option | Verdict | Reasoning |
|---|---|---|
| Cohorts below 1 MB, individual above | Chosen | Spends per-object machinery only where each object can repay it. |
| Classify every object individually | Rejected | Most precise, and most of the decisions lose money before they are made. |
| Never tier small objects | Deferred | The MVP position. Leaves about 7% of the target saving until packs exist. |
What it buys
- Telemetry, decisions and requests scale with folders and bytes, not with object count
- Movement budgets are spent where the money is
- The threshold is a published number that can be argued with
What it costs
- One hot object in a cold folder can hold back its cohort
- Folder structure becomes an input to placement quality
- About 7% of the saving waits for Phase 2
Choose differently when. When per-object overhead falls, or storage prices rise sharply, the threshold drops; at an overhead ten times smaller, individual classification of 100 KB objects pays.
Why it holds up over time. The threshold is computed from two published figures, overhead and tier price spread, so it moves as either changes. The rule 'do not spend more deciding than the decision can save' does not age.
Lesson. Publish your per-item overhead. It tells you which items are too small to be worth deciding about.
ADR-18 · Blind means no demotion: one canary drives both the brake and the alarm
Status: Accepted · Shown on views: 14, 25, 31
How does the platform know its access signal has stopped, and what does it do then?
Context. A stalled telemetry pipeline and a quiet corpus look identical: no events. A classifier running on stale aggregates sees everything as idle and demotes it, which the requirement names as the single most expensive failure mode. Monitoring that watches only for errors cannot see a pipeline that has stopped without failing.
Decision. A synthetic canary event is written into every Kafka partition every 60 seconds and aggregated like real events. The classifier's freshness gate refuses to produce demotion decisions when the newest canary visible in ClickHouse aggregates is older than 15 minutes. The same age is checked from the witness site by a self-hosted dead-man monitor that pages when it stops receiving pings. Promotion, recall and reads continue regardless.
How it works on-premise. A small Go job per partition emits canaries. A ClickHouse query exposes canary age as a Prometheus metric; the gate reads that metric, and a sidecar pings Healthchecks at the witness site only while age is under threshold. Demotion resumes after two consecutive fresh cycles.
| Option | Verdict | Reasoning |
|---|---|---|
| One canary, shared by gate and external monitor | Chosen | The brake and the alarm cannot disagree about whether the pipeline is alive. |
| Alert on consumer lag and errors | Rejected | Catches slow pipelines, and misses a producer that silently stopped sending. |
| Pause demotion when event volume drops | Rejected | Confuses holidays with outages and outages with quiet weeks. |
What it buys
- A blind classifier cannot demote
- The watcher does not depend on the estate it watches
- Freshness is one number with one definition
What it costs
- A canary pipeline to maintain and exclude from every business metric
- Demotion pauses during planned maintenance too
- Two clean cycles to resume adds up to 12 hours of lost demotion after an incident
Choose differently when. If demotion decisions were cheap to reverse, for example with no minimum durations and no retrieval charges, a stale classifier would be a nuisance and a simple lag alert would do.
Why it holds up over time. Dead-man signals are independent of the pipeline's technology. Replacing Kafka or ClickHouse means re-emitting the same canary, and the rule that absence of evidence halts irreversible action stays right.
Lesson. For any automation that acts on the absence of events, prove the events can still arrive before letting it act.
Placement policy
The arithmetic every demotion must clear, what stops oscillation, where guards are enforced, and how a policy reaches production.
ADR-19 · Every demotion clears a per-object break-even over the destination's minimum duration, recorded with its inputs
Status: Accepted · Shown on views: 06, 17, 24
What exactly must be true for a demotion to be allowed, and how is it checked later?
Context. Age rules demote objects that are about to be read or deleted. The requirement's figures imply heavy churn (210 TB a day ingested against 38 TB a day of net growth), so for young objects early deletion is often a larger risk than retrieval. A decision without recorded inputs cannot be audited, scored or reversed by population.
Decision. A demotion is allowed only if the storage saving over the destination's minimum duration exceeds expected retrieval cost at the predicted read probability, plus movement cost, plus expected early-deletion charge. The evaluation is per object or per cohort. Every decision records the policy version, the ladder version, the inputs, the predicted read probability, the break-even date, a confidence and the verdict, including refusals.
How it works on-premise. The Go evaluator reads candidate rows and ladder prices and writes decisions to a Kafka topic with acks from all in-sync replicas. ClickHouse keeps them queryable; hourly Parquet files with S3 Object Lock on a non-placement Ceph bucket keep them for three years, independently of the catalogue.
| Option | Verdict | Reasoning |
|---|---|---|
| Recorded per-object inequality over minimum duration | Chosen | Every bet has stated odds, and early deletion is priced rather than discovered. |
| Age threshold only | Rejected | Simple and auditable, and systematically wrong for periodic and short-lived classes. |
| Aggregate break-even per policy | Rejected | Right on average, and hides the populations where the policy loses money. |
What it buys
- Every retrieval and early-delete charge can be traced to a decision
- A policy version's moved population is one query
- Classifiers are scored on predicted against realised break-even
What it costs
- About 1 to 2 billion decision records a year to store and query
- Read and delete probabilities must be estimated, and bad estimates are recorded as bad
- Refusals add volume that has no direct saving
Choose differently when. Where there are no minimum durations and retrieval is free, the horizon disappears and a simple idle threshold is nearly as good.
Why it holds up over time. The inequality is independent of tiers, media and prices, all of which come from the ladder. Records carrying their inputs let a future team re-price old decisions under new assumptions.
Lesson. If a decision can cost money later, write down the odds it was made on at the time it was made.
ADR-20 · Hysteresis: 14-day dwell, a 3× promotion bar, and no promotion on a first read
Status: Accepted · Shown on views: 24, 20
Does a cold read promote the object, and what stops objects oscillating between tiers?
Context. Promoting on every cold read pays a retrieval fee and then throws away the saving that justified the demotion, often with an early-deletion charge on top. Never promoting means a genuinely re-warmed object pays retrieval forever. Documents reopened once for a meeting are the common case: bursty, then quiet.
Decision. Every object stays at least 14 days in a tier before any further movement. Promotion requires expected retrieval savings over the source tier's minimum duration to exceed three times the demotion margin. A single read never promotes on its own; the default trigger is reads by a promoting class on at least two distinct days within 30 days, evaluated at the next cycle. Rehydration for a recall is not promotion.
How it works on-premise. dwell_until is a placement column set at commit and checked in the commit transaction. Promotion candidates come from the same classification cycle, flagged by the aggregates' distinct-day counts. Tenants may configure the trigger per object class within bounds set by policy.
| Option | Verdict | Reasoning |
|---|---|---|
| Dwell, 3× bar, second distinct-day read | Chosen | Absorbs one-off reopenings and still catches objects that have genuinely warmed. |
| Promote on first cold read | Rejected | Best latency for repeat reads, and pays retrieval plus early deletion for every one-off. |
| Never promote | Rejected | Cheapest in movements, and a warmed corpus pays retrieval indefinitely. |
What it buys
- No object can flip tiers faster than every 14 days
- One-off reopenings cost one retrieval, not a round trip
- Promotion thresholds are data, measured against second-read probability
What it costs
- The second and third reads of a re-warmed object are still slow
- A 3× bar will sometimes leave an object cold that should be hot
- Users may notice a document that stays slow after they open it twice in one day
Choose differently when. If measured second-read probability after a first cold read is high, above about 40% within a week, promote on first read for that class.
Why it holds up over time. Hysteresis is how every control system avoids oscillation, and the bar and dwell are parameters that move with measured behaviour rather than constants that age.
Lesson. Make the move back harder than the move away, or the system will spend its savings changing its mind.
ADR-21 · Guards are enforced at commit against a per-tenant epoch, not at planning
Status: Accepted · Shown on views: 13, 16, 19
Where is 'no demotion below a hold's floor' actually enforced, given that plans are made from a replica minutes old and movements take minutes more?
Context. A hold can arrive at any moment between classification and commit. Checking holds only when planning leaves a window in which a movement planned before the hold commits after it. Writing a hold flag onto every affected placement row means updating up to hundreds of millions of rows for one custodian, which is slow exactly when speed matters.
Decision. Holds and pins are scope rows (workspace, folder, owner) stored beside the tenant's placements in the same Vitess shard, with a tenant_guard row holding guard_epoch. Adding or changing any scope increments the epoch. A movement records the epoch when it starts, and its commit is a single-shard transaction that fails if the epoch has changed or if the object falls under a scope forbidding the destination.
How it works on-premise. The commit is one transaction: read tenant_guard for update, compare the epoch, check matching scopes by the object's workspace, folder and owner columns, then update placement and movement. The policy service writes scope changes through the same shard routing.
| Option | Verdict | Reasoning |
|---|---|---|
| Per-tenant epoch checked in the commit | Chosen | No window between hold and enforcement, and one row written per hold. |
| Check holds at planning only | Rejected | Cheapest commit, and a hold placed during a movement is ignored. |
| Hold flag on every placement row | Rejected | Per-object precision, and a large hold becomes a long bulk update. |
What it buys
- A hold is effective against the next commit, typically within a second
- The mover needs no knowledge of holds
- Guards cannot drift from the replica used to plan
What it costs
- Any scope change aborts all of the tenant's in-flight movements, not only the affected ones
- Scope matching depends on workspace, folder and owner columns being correct in the catalogue
- Very active tenants with frequent pin changes see more wasted copies
Choose differently when. If a single tenant's holds change many times an hour, epochs per workspace rather than per tenant reduce wasted work at the cost of more guard rows to read.
Why it holds up over time. Optimistic concurrency with a version number is independent of database, language and storage. The rule that constraints are enforced where the state changes, not where the change was planned, holds for any future planner.
Lesson. Enforce a prohibition in the same transaction that would violate it.
ADR-22 · Policy ships through shadow, dry run, rings and a hard movement cap, orchestrated by Temporal
Status: Accepted · Shown on views: 04, 06, 23
How does a policy change that could move a hundred million objects reach production without becoming the incident?
Context. A tool that can relocate the corpus is a larger risk than the storage bill it exists to reduce. A dry run on a sample shows arithmetic, not behaviour: request cost, throughput and the populations a policy mis-models only appear when real objects move. A rollout also has to survive days of waiting between rings, restarts and human approvals.
Decision. Every policy version runs in shadow over the whole corpus, then a dry run copies a 1% stratified sample to scratch space without committing, then rings of 0.1%, 1%, 10% and 100% of eligible objects selected by stable hash. Each ring waits at least 72 hours and passes gates on re-read rate, wrong-tier rate, charge ratio and the gap to shadow projection. No policy moves more than 2% of objects or 1.5 PB in any 7-day window. One freeze flag stops all rings.
How it works on-premise. The rollout is a Temporal workflow per policy version with durable timers for ring waits, signals for approval and freeze, and activities that read gate metrics from ClickHouse. Plans are written to Kafka as batches for movers. Temporal runs on PostgreSQL in the tiering cluster.
| Option | Verdict | Reasoning |
|---|---|---|
| Shadow, dry run, rings, cap, in Temporal | Chosen | Each step answers a different question, and the workflow survives the days between them. |
| Dry run then full apply | Rejected | Fast, and the first real evidence of a bad policy is the whole corpus moving. |
| Argo Workflows or cron with state tables | Right elsewhere | Workable, with approvals, long waits and freezes hand-built around them. |
What it buys
- The blast radius of a bad policy is bounded before anyone looks
- Each ring produces measured evidence for the next
- A single signal halts everything
What it costs
- A full rollout takes at least two weeks
- Temporal is a system to run and upgrade
- The cap can delay a legitimately urgent policy, which must then be argued for explicitly
Choose differently when. For policies that only refuse or only promote, which cannot lose objects or incur early deletion, shadow and a single ring are enough.
Why it holds up over time. Progressive delivery with automated gates is how software, configuration and infrastructure changes are shipped safely, and it applies unchanged to whatever classifier or ladder comes next.
Lesson. Treat a policy that moves data like a deployment, including the cap on how much it may change at once.
Movement
How an object changes place without ever being lost, who does the work, and what a delete does to a move in progress.
ADR-23 · Copy, verify, commit, release, with the catalogue row as the state machine
Status: Accepted · Shown on views: 18, 22, 31
How does an object change place so that no failure at any instant leaves its only copy somewhere the catalogue does not name?
Context. A movement touches two storage locations, a database and possibly a second site, any of which can fail between steps. Workers die mid-copy. Commit acknowledgements get lost. Replication to the second data centre is asynchronous. The requirement sets object data RPO to zero and accepts paying twice to guarantee it.
Decision. Every movement is: mark in-flight in the catalogue with a movement id; copy to the destination with a checksum; verify; commit the new placement conditionally; and, no earlier than 24 hours later and only after confirming the destination exists in DC-B, release the source. The movement row records each state. A partial destination is garbage, never a state to repair. All steps are idempotent by movement id.
How it works on-premise. Movers write to the final destination key with the S3 CRC32C checksum header. Pack movements read back the whole pack. The release gate issues a HEAD against the DC-B zone before deleting the source in DC-A. A sweeper resumes movements stuck in copying for more than one hour, and abandoned ones are cleaned by the gate.
| Option | Verdict | Reasoning |
|---|---|---|
| Four-step protocol with delayed, replica-checked release | Chosen | At every instant the catalogue names at least one complete, verified copy in two sites. |
| Copy, commit and release in one step | Rejected | Half the duplicate storage, and a crash between commit and release, or a lagging replica, can lose data. |
| Two-phase commit across storage and catalogue | Rejected | Storage does not participate in transactions, so it cannot be made atomic anyway. |
What it buys
- No sequence of crashes can lose an object
- Any worker can resume any movement from its row
- Reads never notice a movement in progress
What it costs
- Every moved byte is stored twice for at least a day
- Orphaned copies are expected and must be found and cleaned
- The release gate is a correctness-critical service
Choose differently when. Nothing about the ordering should flip. The 24-hour hold can shorten where caches and replicas are not used on the read path.
Why it holds up over time. The protocol needs only conditional writes, object PUT and DELETE, and a way to check a replica. Those exist in every storage and database generation, so the safety argument outlives every product in this design.
Lesson. Never destroy the old copy until the record says the new one exists, and the new one really does, in every place it is supposed to.
ADR-24 · Movers are a Go fleet on budget leases that name dollars and requests
Status: Accepted · Shown on views: 08, 18, 21
Who does the copying, and what stops movement from overrunning its budget or starving user traffic?
Context. 900 TB and 120 million objects a day, 4× for a backfill, across Ceph clusters that also serve user reads and writes. At a 240 KB median, requests run out before bytes do. The requirement asks for a budget in currency and in requests per second, and for movement to yield to foreground traffic.
Decision. Movers are stateless Go workers consuming plan batches from Kafka. Before each batch a worker takes a five-minute lease from the budget authority naming bytes, requests and estimated cost against the daily budget and per-cluster request ceilings. Leases shrink automatically when Ceph foreground latency or queue depth crosses a threshold. Backfills run in a separate, lower-priority lease pool.
How it works on-premise. The budget authority is a small Go service with state in PostgreSQL and per-cluster token buckets fed by Prometheus metrics from RGW. Movers run on 24 dedicated nodes with 2 × 25 GbE in the tiering cluster, next to the storage network.
| Option | Verdict | Reasoning |
|---|---|---|
| Custom Go movers with leased budgets | Chosen | Catalogue-aware commits and money-denominated limits need code that understands both. |
| rclone or RGW sync modules | Rejected | Excellent copiers with no notion of conditional commits, holds or budgets. |
| Spark jobs for movement | Rejected | Scales copies well and turns per-object idempotent state into job-level retries. |
What it buys
- Movement spend is bounded per day in dollars and per second in requests
- User traffic takes priority automatically
- Workers are replaceable; all state is in the catalogue and Kafka
What it costs
- Mover code is correctness-critical and owned in-house
- Lease tuning interacts with storage performance and needs measurement
- A backfill can take much longer than planned when foreground load is high
Choose differently when. A corpus with few, large objects and generous request limits can use an off-the-shelf copier and a thin commit wrapper.
Why it holds up over time. Leasing a budget before spending it is independent of media and prices, which come from the ladder. The fleet can move to new hardware or a new language without changing the contract it keeps with the catalogue.
Lesson. Rate-limit automation in the unit that actually runs out, and let the thing it competes with take capacity back.
ADR-25 · A delete wins over a movement, and offboarding is proven against storage
Status: Accepted · Shown on views: 19, 31
What happens when an object is deleted mid-move, and how does the platform prove a departing tenant's data is gone?
Context. A movement that commits after a delete resurrects the object. A delete that removes only the catalogue row leaves a destination copy nobody knows about. Tenant offboarding cancels thousands of in-flight movements at once, incurs minimum-duration charges, and needs proof of completeness that a catalogue alone cannot give.
Decision. Deletes write a tombstone into the placement row. Commits against tombstoned rows fail, and the release gate deletes both the destination copy and, after the release hold, the source. Tombstones are kept 35 days. Offboarding sets the tenant's guard to frozen, lets in-flight movements fail, accepts and attributes minimum-duration charges, and is complete only when a tier scan finds no bytes under the tenant's prefixes.
How it works on-premise. Tombstones are written by the placement API under the delete identity. The release gate processes tombstones and abandoned movements from catalogue queries per shard. Offboarding runs as a Temporal workflow ending with a scan report signed and stored in the records archive.
| Option | Verdict | Reasoning |
|---|---|---|
| Tombstone plus scan-proven offboarding | Chosen | No resurrection, no forgotten copies, and completeness proven where the bytes are. |
| Delete row and bytes immediately | Rejected | Simple, and a concurrent commit can recreate the row pointing at a copy. |
| Prove offboarding from the catalogue | Rejected | Fast, and blind to exactly the orphans a tenant's lawyers would ask about. |
What it buys
- Deletes are final even under concurrent movement
- Offboarding produces evidence, not an assertion
- Orphan cleanup and delete cleanup share one path
What it costs
- 35 days of tombstones in the catalogue
- Offboarding waits for a scan
- Minimum-duration charges on offboarding are accepted rather than avoided
Choose differently when. If deletion is legally required within hours rather than days, crypto-shred the tenant's keys first and treat the scan as confirmation.
Why it holds up over time. Tombstones and verification by enumeration are how distributed stores have handled deletion for decades. The completeness proof depends only on the self-describing key convention (ADR-04).
Lesson. Prove that data is gone by looking where data lives, not where the index says it lived.
Recall and cost
How cold data comes back, who pays for it, and how the saving is counted so that it survives a finance review.
ADR-26 · Recall is a Temporal job planned by pack and cartridge, and staging is not promotion
Status: Accepted · Shown on views: 05, 20, 21
How do objects that cannot be read immediately come back, individually and fifty million at a time?
Context. Tape recall takes minutes to hours, requests arrive individually and in huge batches, retrieval must be ordered by cartridge and position, budgets can run out mid-job, and bytes already retrieved have already been paid for. The requirement also insists that rehydrated bytes being temporarily available is not a placement change.
Decision. Every non-immediate read or bulk request is a recall job in Temporal. The job resolves placements, groups members by pack and cartridge, prices the job, obtains authority, submits stage requests, unpacks members into a staging bucket with a TTL (48 hours by default, up to 7 days) and reports unread bytes at expiry. Placement rows are not changed; a separate promotion decision may follow.
How it works on-premise. Temporal workflows with child workflows per 10,000 packs. The quota planner holds per-tier drive reservations and publishes estimated recall time per class every minute. The staging bucket is on the hot Ceph cluster and uses RGW lifecycle expiration, which is allowed here because staging is not placement.
| Option | Verdict | Reasoning |
|---|---|---|
| Temporal jobs, pack and cartridge planning, TTL staging | Chosen | Durable across restarts and pauses, efficient on tape, and clean about what is placement and what is a copy. |
| Synchronous recall | Rejected | Simple for callers and puts hours on the read path. |
| Promote on recall | Rejected | Saves a second retrieval sometimes, and undoes the saving for every one-off read. |
What it buys
- A 50-million-object recall is one plan, not 50 million reads
- Paid-for retrievals survive budget pauses and worker loss
- Unread staging is visible and attributed
What it costs
- Callers must handle job handles and callbacks
- Staging capacity must be sized for peak recall
- Estimates can be wrong, and are published anyway
Choose differently when. If archive reads are rare and always single objects, a simple queue with a synchronous wait of a few minutes may be enough.
Why it holds up over time. The job model is independent of media. Faster archives shorten the estimate; slower ones lengthen it; the contract with callers is the same.
Lesson. Do not let a temporary copy change a permanent record.
ADR-27 · Retrieval budgets per tenant have a hard ceiling; legal authority is never refused and never free
Status: Accepted · Shown on views: 05, 21
Who pays for retrieval, who can see it coming, and what happens when a court order meets a budget?
Context. Absorbing retrieval centrally makes the platform's cost a function of behaviour it does not control. Charging tenants turns latency and cost into a product surface. This is an internal platform for one product, not a commercial storage service. A hard budget ceiling that blocks a legally required production is not acceptable; an unlimited legal channel becomes the way around every budget.
Decision. Retrieval is paid centrally and attributed per tenant, per reader class and per decision. Each tenant has a retrieval budget per month with a hard ceiling; exceeding it needs a named approver. A separate legal authority applies to recalls under a hold or court order: never refused for budget, approved by named counsel, charged to a legal cost line, and reviewed by security above one million objects.
How it works on-premise. Budgets and authorities are PostgreSQL rows managed through the policy service with Keycloak identities. The budget authority prices every recall before submission and pauses a running job at its ceiling with staged bytes kept.
| Option | Verdict | Reasoning |
|---|---|---|
| Central cost, tenant ceilings, separate legal authority | Chosen | Protects the platform from runaway exports without ever blocking a legal obligation. |
| Charge the reading tenant directly | Deferred | Right for a commercial storage product; Phase 3 tenant cost reporting prepares for it. |
| No budgets | Rejected | No friction, and one migration export can erase a month's saving. |
What it buys
- Retrieval storms cannot become unbounded bills
- Legal obligations never wait on finance
- Every retrieval dollar has an owner
What it costs
- Approver workflows add friction to legitimate large exports
- The legal channel must be audited to stay honest
- Budget sizing per tenant is guesswork until usage history exists
Choose differently when. If the platform is sold externally, retrieval should be a priced product feature with tenant-controlled limits.
Why it holds up over time. Separating ordinary spending authority from obligation-driven authority is an organisational pattern, not a technical one, and it stays correct whatever retrieval costs in future.
Lesson. Give a budget a hard ceiling, and give obligations a separate, audited door rather than a way through the ceiling.
ADR-28 · Savings are net, against an all-hot counterfactual, attributed to decisions and reconciled
Status: Accepted · Shown on views: 26, 25
How is the saving counted so that finance believes it?
Context. A saving with no counterfactual is an assertion. A gross saving ignores retrieval, early deletion, movement requests, telemetry and the platform's own compute. The platform's own cost target (8% of net saving, about $350,000 a year) is tighter than its hardware estimate in year one.
Decision. The ledger computes daily: the counterfactual bill with every byte at the hot price; the realised bill with each byte at the price of the ladder version it was placed under; tiering charges attributed to decisions; and the platform's own cost. It publishes net saving and platform cost ratio monthly and reconciles against the storage team's chargeback within ±2%. The year-one shortfall against the 8% target is reported, not hidden.
How it works on-premise. ClickHouse queries over the catalogue replica, movement outcomes and recall records, with platform hardware and power shares loaded from the storage team's asset ledger. Reports are Grafana dashboards and a monthly signed export to finance.
| Option | Verdict | Reasoning |
|---|---|---|
| Net against counterfactual, reconciled | Chosen | The number survives a finance review and shows where the saving leaks. |
| Capacity freed on hot tier | Rejected | Easy to show, and says nothing about the cost of freeing it. |
| Gross saving by tier | Rejected | Looks better every month, and hides a policy losing money. |
What it buys
- Finance and engineering use one number
- Leaks are attributed to policy versions and object classes
- The platform's own cost is on the same page as the saving
What it costs
- Reconciliation gaps must be investigated, not waved away
- Transfer prices will be disputed
- Year-one reports will show the platform over its cost target
Choose differently when. Nothing should flip the net and counterfactual principle. The reconciliation target moves if the storage team's own ledger is coarser.
Why it holds up over time. Counterfactual accounting outlives any tiering design: whatever replaces this platform can be compared on the same basis, which is exactly what the next decision-maker will need.
Lesson. Report a saving next to what would have happened without you, and after everything you cost.
Security and operations
Who may move and who may delete, how holds change, how disagreement with storage is settled, and where it all runs.
ADR-29 · Movement and deletion are different principals with fifteen-minute, one-tier credentials
Status: Accepted · Shown on views: 28, 29
How does a platform whose whole job is copying and releasing data avoid being able to destroy it?
Context. The mover fleet is large, busy and handles every tenant's data; it is the most likely component to be compromised or buggy. If it can delete, one bad release or one stolen token removes data at the speed movement is built for. The requirement forbids long-lived storage credentials in the fleet and one principal holding both move and delete.
Decision. Movers can only PUT into one named destination tier. Only the release gate, a separate deployment under a separate identity, can DELETE, and only sources whose destination is committed and replicated. Tenant deletes go through a third identity. All credentials are short-lived: SPIRE JWT-SVIDs exchanged at RGW STS for 15-minute credentials scoped to one tier and one operation. Encryption key ids are recorded per placement and preserved by every movement.
How it works on-premise. RGW roles per tier and operation, trusting SPIRE's OIDC discovery provider. RGW SSE-KMS uses OpenBao's transit engine with a key per tenant. The release gate runs in the serving cluster with a separate Kubernetes service account, separate SPIFFE id and separate change approvals.
| Option | Verdict | Reasoning |
|---|---|---|
| Separate principals, STS from SPIFFE, one tier per role | Chosen | No single identity can complete a destructive sequence, and nothing long-lived exists to steal. |
| One storage account for the platform | Rejected | Simplest, and the largest blast radius in the estate. |
| Per-service static RGW keys in OpenBao | Right elsewhere | Workable fallback if RGW STS with SPIRE fails in the proof, with hourly rotation. |
What it buys
- A compromised mover can copy but not destroy
- Stolen tokens expire in minutes
- Key association cannot silently change during a move
What it costs
- More roles and identities to manage
- STS and SPIRE become dependencies of movement (not of reads)
- Debugging access failures crosses three systems
Choose differently when. A single-tenant archive with no automated deletion could combine roles, but the separation costs so little that it rarely should.
Why it holds up over time. Separation of duties and short-lived, attested credentials are the direction of every security framework. SPIFFE and STS are standards, so storage and identity products can change without redesigning the separation.
Lesson. Give the busiest automation the least destructive permission, and let a small, boring service hold the dangerous one.
ADR-30 · Restrictive hold and pin changes apply at once; relaxing changes need two approvers
Status: Accepted · Shown on views: 19, 28
Who can change holds, pins and budgets, and with what checks?
Context. Removing a hold is the one action that lets the platform move something it must not move. Placing a hold is urgent and safe: delaying it creates exposure, and it can only prevent movement. A single approval flow for both either slows holds or weakens removals.
Decision. Changes that only restrict (adding a hold, raising a pin floor, lowering a budget) apply immediately from records management or an authorised operator. Changes that relax (removing or narrowing a hold, lowering a pin floor, raising a budget ceiling) need two named approvers from different roles and are written to the Object-Locked record before they take effect.
How it works on-premise. The policy service in PostgreSQL with Patroni holds scope rows and approval records; Keycloak with WebAuthn authenticates approvers. Records-management events are idempotent by reference. Every change is written to the audit topic and Parquet archive, then applied to the catalogue with an epoch bump.
| Option | Verdict | Reasoning |
|---|---|---|
| Asymmetric approval by direction of change | Chosen | Fast where fast is safe, careful where careful is necessary. |
| Dual approval for every change | Rejected | Uniform, and delays holds during exactly the window when data is at risk. |
| Single approval for every change | Rejected | Fast, and one compromised account can release a hold. |
What it buys
- Holds are effective within seconds
- No single person can release a hold
- Every relaxation is on record before it acts
What it costs
- Two approvers must be reachable for legitimate removals
- Classifying a change as restrictive or relaxing must itself be correct and tested
- The approval service becomes part of the compliance scope
Choose differently when. If holds are rare and always planned days ahead, uniform dual approval is simpler and costs nothing.
Why it holds up over time. The asymmetry follows from what each change can cause, not from any product, and remains correct as long as holds exist to prevent harm.
Lesson. Make safe changes fast and dangerous changes deliberate, and decide which is which by what the change can cause.
ADR-31 · Reconciliation believes the tier for where bytes are and the catalogue for where they should be
Status: Accepted · Shown on views: 30, 12
When the catalogue and storage disagree, which is right, and what happens next?
Context. Disagreement is inevitable: lost commits leave orphans, operators run tools, storage features get enabled, media fails. Absorbing drift silently hides defects. Believing only the catalogue sends reads to missing bytes; believing only storage loses the decision. Scanning nine billion objects is expensive, so frequency is a cost decision.
Decision. Bucket notifications feed near-real-time detection of changes the platform did not make. A rolling full scan covers every tier every 14 days, listing unpacked objects and reading pack manifests. Each disagreement is classified (orphan, out-of-band change, missing bytes, checksum mismatch, unknown prefix) with a fixed action per class. Unexplained drift is a defect ticket, never a silent correction.
How it works on-premise. RGW notifications to Kafka are consumed by the reconciler in the tiering cluster. Scans run as parallel list and manifest reads against Ceph, compared with the ClickHouse replica. Orphans are quarantined 35 days before the release gate deletes them.
| Option | Verdict | Reasoning |
|---|---|---|
| Notifications plus 14-day scan, classified drift | Chosen | Fast detection for common changes, bounded detection for everything, and every finding has a cause. |
| Scan only, daily | Rejected | Thorough, and roughly fourteen times the listing load for little added safety. |
| Trust the catalogue, repair on read errors | Rejected | Cheap, and finds problems when a user does. |
What it buys
- Out-of-band movement is visible within minutes
- Orphans are cleaned without risking in-flight commits
- Drift rates become a platform quality metric
What it costs
- A notification missed and not yet scanned can go unnoticed for up to 14 days
- Scan load on storage must be scheduled
- Classification rules need maintenance as new drift causes appear
Choose differently when. If notification delivery proves unreliable in the proof phase, shorten the scan interval and price the extra listing in the ladder.
Why it holds up over time. Two sources of truth with a defined arbiter per question is a pattern that survives replacement of either source, and the 14-day interval is a parameter priced like everything else.
Lesson. Decide in advance which system is believed about which fact, and treat any disagreement nobody can explain as a bug.
ADR-32 · Two data centres and a witness, with cross-site failover as a rehearsed runbook
Status: Accepted · Shown on views: 22, 31
Where does the platform run, and how does it survive the loss of a room or a site?
Context. The catalogue needs RPO 0 and a 60-second RTO in a site, and RPO 10 seconds with a 15-minute RTO across sites. Quorum systems need a third location. Automatic cross-site failover of a database on every product read path risks a split brain worse than a short outage. The storage tiers already replicate between the two data centres.
Decision. DC-A is primary, with three rooms as independent fault domains. Vitess primaries and two semi-sync replicas sit in different rooms, with VTOrc failover inside DC-A. DC-B holds async replicas, warm resolvers and the second Ceph zone and tape library. A small witness site holds the third etcd voter and the dead-man monitor. Promoting DC-B is a human-initiated, rehearsed runbook.
How it works on-premise. Kubernetes (RKE2) with Cilium in each data centre, separate clusters for serving and tiering. Rooms are exposed to schedulers as topology zones. The DC-B promotion runbook is rehearsed quarterly with a measured RTO recorded against the 15-minute target.
| Option | Verdict | Reasoning |
|---|---|---|
| Primary with rooms, warm secondary, witness, manual site failover | Chosen | Automatic where the failure is local and clear, deliberate where it is not. |
| Active-active across sites | Rejected | No site failover at all, and cross-site semi-sync on every commit plus conflict risk on the catalogue. |
| Automatic cross-site failover | Rejected | Faster RTO, and a network partition can promote DC-B while DC-A still serves. |
What it buys
- Room failures heal without people
- No automated path to a split-brain catalogue
- The witness keeps quorum and monitoring independent of both sites
What it costs
- A site loss is a 15-minute outage and needs an operator
- DC-B hardware is mostly idle
- Quarterly rehearsals take real time
Choose differently when. With three or more well-connected data centres, run Vitess primaries per region with cross-site semi-sync and automate site failover.
Why it holds up over time. Fault domains, quorum with a witness and human judgment for ambiguous site failures are stable patterns that do not depend on these products. Measured rehearsals keep the RTO a fact rather than a hope as the estate changes.
Lesson. Automate recovery from failures you can diagnose for certain, and rehearse the ones you cannot.
Every package used, in one table
Every open-source package named on the views, what it is, the job it does here, and what was considered instead.
| Package | What it is | What it does here | Considered instead |
|---|---|---|---|
| Vitess | Sharding and clustering system for MySQL, CNCF graduated | Placement catalogue routing, sharding, failover (VTOrc) and change data (VStream) | TiDB, FoundationDB |
| MySQL 8.4 | Relational database, long-term support release | Storage engine under every catalogue shard | PostgreSQL with Citus |
| Ceph RGW | S3-compatible object gateway over Ceph RADOS | Hot, warm and cold tiers, staging, records archive, STS and SSE-KMS | MinIO, SeaweedFS |
| EOS | Disk storage system from CERN | Disk buffer in front of the tape archive | dCache |
| CERN Tape Archive (CTA) | Tape archive and retrieval scheduler from CERN | Tape libraries, drives, retrieval queues, repack, two tape copies | LTFS with own scheduler |
| Apache Kafka | Distributed log, Apache Software Foundation | Access events, decisions, movement plans and outcomes, CDC, bucket notifications | Redpanda, NATS JetStream |
| ClickHouse | Columnar analytical database | Access aggregates, analytical catalogue replica, records query, cost ledger | Apache Druid, Trino on Iceberg |
| Temporal | Durable workflow engine | Policy rollout rings, recall jobs, offboarding | Argo Workflows |
| PostgreSQL with Patroni | Relational database with HA manager | Policy service, holds, pins, budgets, Temporal persistence | MySQL with Orchestrator |
| CUE | Configuration language with types and constraints | Tier ladder and placement policies | OPA Rego, JSON Schema |
| Forgejo | Self-hosted Git forge | Policy and ladder repositories, reviews, CI | GitLab CE |
| cosign | Artifact signing tool, Sigstore | Signs ladder and policy bundles | GPG signatures |
| SPIRE | SPIFFE workload identity implementation, CNCF graduated | Attested identities for movers, release gate and resolvers; OIDC for RGW STS | Kubernetes service account tokens |
| OpenBao | Secrets and key management, Linux Foundation fork of Vault | Per-tenant encryption keys for RGW SSE-KMS | HashiCorp Vault |
| Keycloak | Identity and access management, CNCF incubating | Operator and approver authentication with WebAuthn | Dex, Authentik |
| Kubernetes (RKE2) | Container orchestration distribution | Serving and tiering clusters | OKD, kubeadm |
| Cilium | eBPF networking and policy for Kubernetes | Network policy between serving and tiering workloads | Calico |
| etcd | Consistent key-value store, CNCF graduated | Vitess topology with a voter at the witness site | ZooKeeper |
| Prometheus, Thanos, Alertmanager | Metrics, long-term storage and alerting | SLOs, freshness, budgets, ring gates | VictoriaMetrics |
| Grafana | Dashboards | Operations and cost ledger views | Apache Superset |
| OpenTelemetry and Tempo | Tracing standard and trace store | Slow resolves, movement batches, recall jobs | Jaeger |
| Healthchecks | Self-hosted dead-man monitoring | External watcher for the telemetry canary at the witness site | Alertmanager watchdog |
| LightGBM | Gradient-boosting library | Phase 2 predictive classifier, in shadow first | XGBoost |