Example language

Reference · 112 principles · 4 languages

Software Engineering Principles: from SOLID to the AI Era

Examples in modern Java, Rust, Go and Python (3.12+). Principles are grouped by axis; code-level ones get examples in all four languages, architectural ones a representative example. Where an API depends on the runtime version, the card says which.

Every principle carries a claim type — theorem, model, empirical law, heuristic, pattern, practice or editorial position — and a confidence level. That is what separates this reference from a list of “best practices”: Amdahl’s law (89) follows from arithmetic, USL (38) is fitted to your measurements, and “choose boring technology” is advice with nothing behind it — the page does not pretend these are the same thing. A card whose claims differ in kind lists the others separately, each with its own type. A theorem, model or empirical law must cite the primary source that establishes it — a spec behind an example does not count — and CI enforces that rather than the author’s good will.

Section XIII lists the pairs of principles that contradict each other and how to resolve each. Principle numbers are stable identifiers: new ones are appended to the numbering and placed in their topical section, so numbers within a section are not contiguous.

Scope: technical and engineering-economics principles, including organisational practices that directly shape architecture, the delivery model, operational risk or the interface of an internal platform (15, 76, 84, 85). Out of scope: practices that leave no trace in the system — postmortems, hiring, performance reviews.

All examples are fragments: real syntax, but not standalone programs — they lean on types and imports off-screen. Each card is labelled with its status; CI checks what can be checked statically.

  • Java
  • Rust
  • Go
  • Python 3.12+
  • / — filter
I

Code inside a process

  • heuristic
  • opinionated
  • fragment

Scope. ISP and DIP also yield testable failure modes, so the table’s verdicts are a ranking, not a proof that the other four are untestable.

Conflicts with: OCP ↔ YAGNI

Primary sources: Robert C. Martin — The Single Responsibility Principle (the actor-based formulation)

Other claims on this card, typed separately:

    • author’s position
    • opinionated

    The verdicts in the table, and “LSP is the only one worth examples”, are this page’s ranking, not a consensus and not a property of SOLID.

PrincipleGistVerdict
SRPOne reason to change (one stakeholder)Not operationalizable; rhetoric
OCPExtend with new code, not by editing old codeConflicts with YAGNI when applied prematurely
LSPA subtype is substitutable without breaking the contractThe only verifiable one; violations are real bugs
ISPA client does not depend on methods it doesn't usePortable anywhere; useful
DIPDependencies point toward the abstraction; the interface belongs to the consumerUseful, but degenerates into single-implementation interfaces

A “reason to change” is people, not requirements. Martin's later formulation: a module is responsible to one, and only one, actor — a group of people who originate requirements. A class that stores a shopping list and computes its cost violates SRP not because it “does two things”, but because different people own the list's contents and the pricing rules, and their requirements change independently. The actor is a criterion for where to cut, not a design driver: you don't create one class per stakeholder; you check the converse — that no single artifact serves two masters with mismatched change schedules.

None of this makes it operationalizable: granularity is arbitrary, the principle gives no stopping criterion for decomposition — a violation is recognized after the fact, and no design can be derived from it. The practical test is file history: commits from unrelated tasks by different people systematically converging on one class. The guard against the opposite extreme, splitting for the sake of splitting, is cohesion (13): what changes together lives together. The operationalizable replacement is information hiding (12): the question is not “how many responsibilities are here” but “what breaks outside if this internal decision is revisited”.

A contract has two layers. Signatures — which methods, which types — are checked by the compiler. Behavior — what a method promises to do — is checked by nobody but reviews and tests. LSP requires holding both: a subtype that matches the signatures but breaks the promise breaks everyone who wrote code against the supertype. The canonical example: Rectangle has setWidth and setHeight; Square, inheriting from it, must keep its sides equal — its setWidth(5) quietly changes the height too, and code that relied on “set the width, the height stays untouched” breaks precisely on the square. The compiler is happy; the contract is broken. The cure in the examples below is not to fix the inheritance but to remove it: a closed set of variants with no mutating setWidth (3), plus contract tests run against every implementation.

An LSP violation and its fix: the only SOLID principle that deserves examples.

Code example (fragment)
fragment
Java
// sealed instead of inheritance — the contract is closed and compiler-checked
sealed interface Shape permits Rect, Square {}

record Rect(double w, double h) implements Shape {}
record Square(double side) implements Shape {}

// Square extends Rectangle is impossible by construction — no mutating setWidth
Rust
// LSP is expressed through trait contracts; a violation is a panic instead of Err
trait Storage {
    fn get(&self, k: &str) -> Result<Option<Vec<u8>>, StorageErr>; // contract: never panics
}

// an impl that calls unwrap() inside get violates LSP,
// caught by code review + proptest
Go
// io.Reader's contract lives in its docs; an implementation that panics instead of
// returning an error violates LSP, even though the compiler is happy
type SafeReader struct{ inner io.Reader }

func (r SafeReader) Read(p []byte) (int, error) {
    n, err := r.inner.Read(p)
    if err != nil && err != io.EOF {
        return n, fmt.Errorf("read: %w", err) // not panic
    }
    return n, err
}
Python
# Protocol + a contract test run against every implementation
from typing import Protocol

class Storage(Protocol):
    def get(self, key: str) -> bytes | None: ...

def storage_contract_suite(impl: Storage):   # one test suite for all implementations
    assert impl.get("missing") is None       # not KeyError — that's the contract
  • pattern
  • established
  • fragment

A function returns a type that cannot be invalid by construction. Validation throws knowledge away; parsing keeps it in the type.

The everyday equivalent is connectors. A power plug does not fit a USB port not because someone inspects it at the port, but because the shape is different. Validation is a “do not insert here” sticker; parsing is a differently shaped connector. The first kind of error is caught at runtime, and only if nobody forgot the check; the second is never made at all. The same analogy carries over to the neighbouring cards: 3 — there are exactly as many connectors as there are meaningful variants, and an extra one cannot even be manufactured; LSP (1) — if the plug fits, the pins must carry the voltage promised on the label.

Code example (fragment)
fragment
Java
// record + compact constructor = smart constructor
public record Email(String value) {
    public Email {
        if (!value.matches(".+@.+\\..+")) throw new IllegalArgumentException(value);
    }
    public static Optional<Email> parse(String raw) {
        try { return Optional.of(new Email(raw)); }
        catch (IllegalArgumentException e) { return Optional.empty(); }
    }
}
// downstream, Email is always valid — no repeated checks
Rust
// newtype with a private field — the canonical form
pub struct Email(String);          // private field, can't be built from outside

impl Email {
    pub fn parse(raw: &str) -> Result<Self, EmailError> {
        raw.contains('@').then(|| Email(raw.to_owned())).ok_or(EmailError::Invalid)
    }
    pub fn as_str(&self) -> &str { &self.0 }
}
Go
// private field + constructor — the only way in
type Email struct{ value string }   // zero value is useless — on purpose

func ParseEmail(raw string) (Email, error) {
    if !strings.Contains(raw, "@") {
        return Email{}, fmt.Errorf("invalid email: %q", raw)
    }
    return Email{value: raw}, nil
}

func (e Email) String() string { return e.value }
Python
# frozen dataclass + factory; NewType for cheap cases
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Email:
    value: str

    def __post_init__(self):
        if "@" not in self.value:
            raise ValueError(f"invalid email: {self.value!r}")

# or pydantic: class Email(BaseModel): value: EmailStr — same thing, on steroids
  • pattern
  • established
  • fragment

Versions. Java example: records are final since Java 16, sealed since 17, record patterns and switch exhaustiveness checking since 21. Hence the “Java 21” label on the pane.

References: JEP 395: Records — JDK 16, JEP 409: Sealed Classes — JDK 17, JEP 440: Record Patterns — JDK 21, JEP 441: Pattern Matching for switch — JDK 21

Sum types instead of flag combinations. {isLoading, data, error} has 4 impossible states out of 8; a sum type has zero.

Why eight: three independent fields, each either present or absent — 2³. Four of them make sense: nothing started, loading, data arrived, error arrived. The other four — “loading and data at once”, “data and error”, “loading and error”, “everything at once” — are representable in the type, unreachable in meaning, and every code branch has to decide what to do with them. Usually it doesn’t, and the impossible state reaches the user. A sum type removes the question itself: there are exactly four states, because a fifth cannot be expressed.

Code example (fragment)
fragment
Java 21
// sealed + record + pattern matching
sealed interface FetchState<T> permits Loading, Loaded, Failed {}
record Loading<T>() implements FetchState<T> {}
record Loaded<T>(T data) implements FetchState<T> {}
record Failed<T>(Exception cause) implements FetchState<T> {}

String render(FetchState<String> s) {
    return switch (s) {                     // compiler demands exhaustiveness
        case Loading<String> l -> "spinner";
        case Loaded<String>(var data) -> data;
        case Failed<String>(var e) -> "error: " + e.getMessage();
    };
}
Rust
// enum is native; exhaustiveness comes for free
enum FetchState<T> {
    Loading,
    Loaded(T),
    Failed(anyhow::Error),
}

// match without a wildcard: add a variant and it won't compile until handled
Go
// no sum types; the closest substitute is a sealed interface + type switch.
// Honest assessment: exhaustiveness is checked by a linter, not the compiler
// (go-check-sumtype / exhaustive)
type fetchState interface{ isFetchState() }

type Loading struct{}
type Loaded struct{ Data []byte }
type Failed struct{ Err error }

func (Loading) isFetchState() {}
func (Loaded) isFetchState()  {}
func (Failed) isFetchState()  {}
Python
# union + match; mypy/pyright check exhaustiveness via assert_never
from dataclasses import dataclass
from typing import assert_never

@dataclass(frozen=True)
class Loading: pass

@dataclass(frozen=True)
class Loaded: data: bytes

@dataclass(frozen=True)
class Failed: cause: Exception

type FetchState = Loading | Loaded | Failed

def render(s: FetchState) -> str:
    match s:
        case Loading():      return "spinner"
        case Loaded(data):   return data.decode()
        case Failed(cause):  return f"error: {cause}"
        case _:              assert_never(s)   # static exhaustiveness
  • heuristic
  • context-dependent
  • fragment

An error is part of the signature, not a side channel for control flow. Exceptions break locality: any call can jump anywhere.

Code example (fragment)
fragment
Java
// checked exceptions: a historically failed attempt at the same thing;
// the modern form is a Result-like sealed type for domain errors
sealed interface ChargeResult permits Charged, Declined, GatewayDown {}
record Charged(String txId) implements ChargeResult {}
record Declined(String reason) implements ChargeResult {}
record GatewayDown(Duration retryAfter) implements ChargeResult {}

// exceptions remain for programming errors (NPE, IllegalState)
// — that is the correct split
Rust
// Result + the ? operator: the reference form of the approach
fn charge(card: &Card, amount: Cents) -> Result<TxId, ChargeError> {
    let token = tokenize(card)?;          // early return, the type stays honest
    gateway::charge(token, amount)        // ChargeError: an enum of every outcome
}

// thiserror for libraries, anyhow for applications: the settled boundary
Go
// (T, error) is the language idiom; the key is wrapping with context and errors.Is/As
func (s *Svc) Charge(ctx context.Context, c Card, amt Cents) (TxID, error) {
    tok, err := s.tokenize(ctx, c)
    if err != nil {
        return "", fmt.Errorf("tokenize card ****%s: %w", c.Last4, err)
    }
    id, err := s.gw.Charge(ctx, tok, amt)
    if err != nil {
        if errors.Is(err, gateway.ErrDeclined) { return "", ErrDeclined }
        return "", fmt.Errorf("gateway charge: %w", err)
    }
    return id, nil
}
Python
# exceptions are the language idiom; fighting it costs more than accepting it.
# Compromise: domain outcomes as values, infrastructure failures as exceptions
from dataclasses import dataclass

@dataclass(frozen=True)
class Declined: reason: str

@dataclass(frozen=True)
class Charged: tx_id: str

def charge(card: Card, amount: int) -> Charged | Declined:
    ...  # Declined is not an exception: it is an expected business outcome
    # ConnectionError stays an exception: it is a failure, not an outcome
  • pattern
  • context-dependent
  • fragment

Logic is pure functions with no I/O; a thin shell executes the effects. It kills mock hell: if a test contains when(repo.findById(...)), logic is tangled with I/O.

Code example (fragment)
fragment
Java
// core: a pure function, table-tested without a single mock
static List<LedgerEntry> settle(List<Trade> trades, FxRates rates, LocalDate day) { ... }

// shell: all I/O lives here, no logic
void settleDaily() {
    var trades = repo.tradesFor(today);          // I/O
    var rates  = fxClient.ratesFor(today);       // I/O
    var entries = Settlement.settle(trades, rates, today);  // pure
    repo.saveAll(entries);                       // I/O
}
Rust
// Rust nudges you here itself: a pure core needs neither async nor Send/Sync
fn settle(trades: &[Trade], rates: &FxRates, day: NaiveDate) -> Vec<LedgerEntry> { ... }

async fn settle_daily(db: &Db, fx: &FxClient) -> Result<()> {
    let (trades, rates) = tokio::try_join!(db.trades(today), fx.rates(today))?;
    db.save(settle(&trades, &rates, today)).await
}
Go
// the core is a package with no I/O imports; a deps linter enforces it
package settlement // imports only time and domain types

func Settle(trades []Trade, rates FxRates, day time.Time) []LedgerEntry { ... }
Python
# the I/O-free core is tested with hypothesis; the shell is 5 lines
def settle(trades: list[Trade], rates: FxRates, day: date) -> list[LedgerEntry]:
    ...  # no await, open(), requests — by construction

async def settle_daily(db: Db, fx: FxClient) -> None:
    trades, rates = await asyncio.gather(db.trades(TODAY), fx.rates(TODAY))
    await db.save(settle(trades, rates, TODAY))

Haskell turns this separation from discipline into law: every side effect is marked with the IO type, and the compiler won’t let you call it from a pure function — there, functional core / imperative shell is not a style but a consequence of the type system. (A precision along the way: monads are not “just for I/O”; Maybe, List, State are monads too, IO is merely the best known.) In Java/Go/Python the boundary is held by convention and a linter — which is what the examples show.

  • heuristic
  • context-dependent
  • fragment

A value that cannot be changed cannot be changed behind your back either: a whole class of “who else holds this reference” questions disappears, and sharing across threads no longer requires locks. The price is copying on change, and it is paid at the boundary: a collection a constructor accepts gets copied, otherwise the caller keeps a lever on your state.

The idea is old in functional programming and was brought to mainstream object-oriented code by Joshua Bloch’s Effective Java (“Minimize mutability”: a class should be immutable unless there is a very good reason to make it mutable). The data-structure side — sharing structure between versions instead of copying everything — goes back to Driscoll, Sarnak, Sleator and Tarjan’s “Making Data Structures Persistent” and to Okasaki’s Purely Functional Data Structures; it is why Clojure or Scala can “copy” a million-element map by allocating a few nodes. Pat Helland’s “Immutability Changes Everything” (CIDR 2015) shows the same idea at the scale of storage systems.

Code example (fragment)
fragment
Java
// record + List.copyOf; collections via immutable factories
public record Portfolio(String owner, List<Position> positions) {
    public Portfolio { positions = List.copyOf(positions); }  // defensive copy

    public Portfolio withPosition(Position p) {
        var next = new ArrayList<>(positions); next.add(p);
        return new Portfolio(owner, next);
    }
}
Rust
// immutability is the language default; mut is the explicit exception
let portfolio = Portfolio::new(owner, positions); // can't be changed without mut

// shared state: Arc<Portfolio> is cheap and safe precisely because
// its contents are immutable
Go
// the language has no immutability; by convention small value types are copied,
// and slices are copied explicitly in constructors
func NewPortfolio(owner string, ps []Position) Portfolio {
    return Portfolio{owner: owner, positions: slices.Clone(ps)}
}
Python
# frozen dataclass + tuple instead of list
@dataclass(frozen=True, slots=True)
class Portfolio:
    owner: str
    positions: tuple[Position, ...]

    def with_position(self, p: Position) -> "Portfolio":
        return replace(self, positions=(*self.positions, p))

In data infrastructure it is the default already. A Kafka log is append-only; a Parquet file in an Iceberg or Delta table is never rewritten in place — a commit writes new files and atomically swaps a pointer to a new snapshot. That is what makes time travel, concurrent readers without locks and a cheap rollback possible: the old snapshot simply still exists. Mutation moves to one place (the commit of the pointer) instead of being spread over every file.

Where it stops paying. Shallow immutability is a common false sense of safety: List.copyOf copies the list, not the Position objects in it, and a frozen dataclass holding a list is still mutable through that list. In hot loops over large arrays, copy-on-change costs allocation and GC pressure that data-oriented layouts (10) avoid by mutating in place. And Rust shows that the real hazard is shared mutation, not mutation as such: an exclusive &mut is safe because nobody else can see the change. The rule is “immutable when shared”, and the default leans that way because sharing is hard to see.

  • heuristic
  • opinionated
  • fragment

Scope. “An interface only once there are two implementations” is mechanical. A single implementation is justified when there is a genuine consumer-owned port, plugin boundary, security capability or stable external API. The criterion is a proven axis of change, not a class count.

Conflicts with: Local reasoning ↔ dataflow, Feature flags ↔ local reasoning

The metric: how many files you must open to understand a snippet. An abstraction appears on the third repetition, not the first. An interface with a single implementation costs navigation and buys no flexibility.

Code example (fragment)
fragment
Go
// The Go community says it outright: "accept interfaces, return structs" —
// the consumer declares the interface, and only once implementations >= 2

// ANTIPATTERN: an interface with a single implementation — two places to read
type UserService interface {
    QueryUser(ctx context.Context, id UserID) (User, error)
}
type userServiceImpl struct{ db *sql.DB }

// NORM: a direct call, one place to read
func (h *Handler) route(ctx context.Context, id UserID) (User, error) {
    return h.db.QueryUser(ctx, id)
}
Java
// ANTIPATTERN: an interface with exactly one implementation and one caller —
// +1 file, +1 jump while reading, zero substitutability in practice
public interface UserService { User find(UserId id); }
public final class UserServiceImpl implements UserService { ... }

// NORM: call the class directly. The interface arrives with the
// second implementation — or at the I/O boundary, where a test needs it (5)
public final class UserService { public User find(UserId id) { ... } }
Rust
// ANTIPATTERN: dyn for a single implementation — an indirect call
// at runtime and a jump across files while reading
struct Handler { store: Box<dyn Store> }

// NORM: a concrete type while there is one implementation. When a second
// appears, a generic: monomorphization gives flexibility at no runtime cost
struct Handler { store: PostgresStore }
Python
# ANTIPATTERN: an ABC hierarchy for a single subclass
class Store(ABC):
    @abstractmethod
    def get(self, key: str) -> bytes | None: ...

class PostgresStore(Store): ...     # the only implementation

# NORM: the class directly. When there are two, a Protocol: it needs no
# inheritance and is declared by the consumer, not the implementation (12)
class PostgresStore:
    def get(self, key: str) -> bytes | None: ...
  • practice
  • established
  • fragment

Precondition, postcondition, invariant — and the main thing that follows from them: who is at fault. A violated precondition is the caller's bug: it can occur in a correct program, is always checked, and is answered with an exception. A violated invariant is your own bug and a sign of already-corrupted state: you cannot carry on, hence assert or panic rather than a tidy error return (25).

The term and the discipline are Bertrand Meyer’s: Eiffel builds require, ensure and invariant into the language, and his “Applying ‘Design by Contract’” (IEEE Computer, 1992) and Object-Oriented Software Construction set out the method. The logic underneath is Hoare’s 1969 “An Axiomatic Basis for Computer Programming”: a triple {P} S {Q} says that if P holds before S, Q holds after. Meyer added the part that matters for teams — a contract is a division of responsibility. The caller guarantees the precondition, so the callee need not defend against it twice; the callee guarantees the postcondition, so the caller need not recheck it. His rule for subclasses — a precondition may only be weakened, a postcondition only strengthened — is the precise form of Liskov substitution (1).

Code example (fragment)
fragment
Java
// Objects.requireNonNull + explicit invariants in the constructor; assert for internal ones
public record Transfer(Account from, Account to, Cents amount) {
    public Transfer {
        Objects.requireNonNull(from); Objects.requireNonNull(to);
        if (amount.value() <= 0) throw new IllegalArgumentException("amount must be > 0");
        if (from.equals(to)) throw new IllegalArgumentException("self-transfer");
    }
}
Rust
// debug_assert! on internal invariants — free in release
fn apply(&mut self, entry: LedgerEntry) {
    debug_assert!(self.is_balanced(), "ledger invariant broken before apply");
    // ...
    debug_assert!(self.is_balanced(), "apply broke double-entry invariant");
}
Go
// TigerBeetle style — cheap invariant checks stay on in production
func (l *Ledger) Apply(e Entry) error {
    if l.debits != l.credits { // double-entry invariant, always on
        panic("ledger corrupted: unbalanced before apply") // corrupted state = crash-only
    }
    ...
}
Python
# assert for invariants (stripped by -O), exceptions for preconditions
def apply(self, entry: LedgerEntry) -> None:
    if entry.amount <= 0:
        raise ValueError("precondition: amount > 0")   # caller's bug
    assert self._is_balanced(), "invariant broken"     # my bug

The ledger example, and its costs. “Debits equal credits” is the invariant of a double-entry ledger; checking it before and after every apply turns silent corruption into a crash at the line that caused it, which is the whole point. TigerBeetle’s style guide keeps dense assertions on in production for that reason. Whether you can afford that is a measurement: an O(1) balance check is free, an O(n) scan of the ledger on every write is not. Know what your platform does by default — Java assert is off unless the JVM runs with -ea, Python strips assert under -O, Rust’s debug_assert! disappears in release — so a check that must always run is written as an ordinary if with a panic.

Where contracts end. Across a process or network boundary the caller is not a cooperating part of your program, so a precondition there is not “the caller’s bug” but input to parse (2, 32) and an error to return (4). A precondition the type system can carry should move into the type (3): a PositiveCents needs no runtime check at each call.

  • practice
  • established
  • fragment

Scope. Property-based testing and type-driven design cover different parts of the problem: the first searches the input space for a counterexample, the second shrinks the space itself by making some inputs unrepresentable (3). Neither answers what the correct result should be: the oracle is specified separately — as a property, a reference implementation or a metamorphic relation — and no number of runs makes up for its absence.

An example test checks the cases you thought of; a property test checks a claim that must hold for every input, and a generator hunts for the counterexample — and, once it finds one, shrinks it to a minimal one. Properties that pay off almost everywhere: roundtrip (parsing what was written yields the original), conservation (the sum does not change), agreement with a naive reference implementation. Types are the same idea one step earlier: what the compiler guarantees (3) no longer needs testing.

The method comes from Koen Claessen and John Hughes, “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs” (ICFP 2000): the programmer states properties, the library generates random inputs, and a failing input is shrunk to a small counterexample. Hypothesis (Python), jqwik (Java) and proptest (Rust) descend from it. Coverage-guided fuzzing (AFL, libFuzzer, Go’s built-in fuzzing) is the same loop with a different generator: instead of a distribution you describe, it mutates inputs that reach new code paths.

Code example (fragment)
fragment
jqwik
// a property instead of examples
@Property
void settlementConservesMoney(@ForAll("trades") List<Trade> trades) {
    var entries = Settlement.settle(trades, RATES, DAY);
    assertThat(sumDebits(entries)).isEqualTo(sumCredits(entries));
}
proptest
// roundtrip: the cheapest property, and the one that catches the most
proptest! {
    #[test]
    fn serde_roundtrip(cfg in any::<Config>()) {
        let json = serde_json::to_string(&cfg).unwrap();
        prop_assert_eq!(serde_json::from_str::<Config>(&json).unwrap(), cfg);
    }
}
Go fuzzing
// testing/quick is obsolete; the idiom is rapid or built-in fuzzing
func FuzzParseEmail(f *testing.F) {
    f.Fuzz(func(t *testing.T, raw string) {
        e, err := ParseEmail(raw)
        if err == nil && !strings.Contains(e.String(), "@") {
            t.Fatalf("parsed invalid email: %q", raw) // the parser accepted garbage
        }
    })
}
hypothesis
from hypothesis import given, strategies as st

@given(st.lists(trade_strategy()))
def test_settlement_conserves_money(trades):
    entries = settle(trades, RATES, DAY)
    assert sum_debits(entries) == sum_credits(entries)

The strongest form for backend code is a model. A stateful property test runs random sequences of operations — put, delete, compact, restart — against the real component and against a trivial model (a dict), and asserts after every step that they agree. That is how bugs that need five specific operations in a row get found: nobody writes that example by hand. Hypothesis ships this as RuleBasedStateMachine; the same idea at cluster scale is checking histories against a model (98).

Limits. A property test samples; it is not a proof, and a generator that never produces the dangerous shape (empty lists, duplicate keys, Unicode, boundary timestamps) passes forever. Weak properties (“does not throw”) catch little; the work is in finding a property that is strong and still easy to state, and for some domains — rendering, ranking, anything judged by taste — there is none, and examples remain the right tool. Pin the seed and keep found counterexamples as regression cases, or a failure seen once in CI will not come back on demand.

  • pattern
  • context-dependent
  • fragment

Conflicts with: DOD ↔ information hiding

Design driven by data layout: SoA instead of AoS, cache lines, batches instead of per-element processing. It runs against encapsulation — a deliberate trade-off for the hot path.

AoS (array of structs) is an array of records, the way a human writes them; SoA (struct of arrays) is one array per field. The difference shows when a loop reads one field across a million records: the CPU pulls memory in 64-byte lines, so AoS drags the whole record into cache and throws away almost the entire line, while SoA places exactly what is being read side by side. Hence the conflict with encapsulation (12): SoA takes the object apart and strips it of the right to hide its representation — which is why it is applied surgically, on a measured (37) hot path, not across the whole codebase.

Code example (fragment)
fragment
Java
// until Valhalla, objects mean pointers and cache misses.
// hand-rolled SoA + primitive arrays, or MemorySegment (FFM API)
final class Particles {           // instead of List<Particle>
    final float[] x, y, vx, vy;   // 4 dense arrays, SIMD-friendly

    void step(float dt) {
        for (int i = 0; i < x.length; i++) { x[i] += vx[i] * dt; y[i] += vy[i] * dt; }
    }
}
Rust
// SoA + iterators compile down to SIMD; #[repr(C)] controls layout
struct Particles { x: Vec<f32>, y: Vec<f32>, vx: Vec<f32>, vy: Vec<f32> }

impl Particles {
    fn step(&mut self, dt: f32) {
        for (x, vx) in self.x.iter_mut().zip(&self.vx) { *x += vx * dt; }
        for (y, vy) in self.y.iter_mut().zip(&self.vy) { *y += vy * dt; }
    }
}
Go
// same idea — slices of primitives instead of []*Particle
// (removes pointer chasing and GC pressure)
type Particles struct{ X, Y, VX, VY []float32 }
Python
# DOD = "get out of the interpreter" — numpy/arrow, vectorize instead of looping
import numpy as np

class Particles:
    def __init__(self, n): self.x, self.vx = np.zeros(n), np.zeros(n)
    def step(self, dt): self.x += self.vx * dt   # one C operation instead of n iterations

# polars/duckdb for tabular data — same principle one level up
  • pattern
  • established
  • fragment

Every resource has exactly one owner responsible for releasing it, and the release point is tied to scope, not to programmer discipline. Languages differ only in who enforces this: Rust checks ownership in the compiler; the rest provide a construct (try-with-resources, defer, with) and catch what was forgotten with a linter. A garbage collector does not fill this role: it manages memory, not file handles, connections, and locks, and the moment of finalization is undefined.

The idiom has a name from C++: Bjarne Stroustrup’s “resource acquisition is initialization” (RAII) — acquire in a constructor, release in the destructor, and let scope exit, including exit by exception, run the release. Rust turned the idiom into a checked rule: every value has one owner, ownership moves explicitly, borrows are tracked by the compiler, and Drop runs when the owner goes out of scope. Java’s try-with-resources, C#’s using, Go’s defer and Python’s with give the same release point without the compiler proof.

Code example (fragment)
fragment
Rust
// the reference: ownership in the type; the compiler proves no use-after-free
fn process(conn: Connection) -> Report { ... }
// took ownership: the caller can no longer use conn
Java
// try-with-resources + AutoCloseable; Cleaner for native resources
try (var conn = pool.acquire(); var tx = conn.begin()) {
    ...
} // closed in reverse order, guaranteed
Go
// defer right after acquire is the idiom; a linter catches a forgotten defer
conn, err := pool.Acquire(ctx)
if err != nil { return err }
defer conn.Release()
Python
# context manager; ExitStack for several resources
with pool.acquire() as conn, conn.begin() as tx:
    ...

How it fails in a service. A handler takes a connection from the pool, an exception skips the release, and the pool leaks one connection per error; under an error spike the pool empties and the service stops serving requests that have nothing to do with the original bug. A transaction that is never closed is worse: in PostgreSQL a session left “idle in transaction” keeps its snapshot, holds back vacuum and can keep row locks. Both are scope bugs, and both disappear when the release is tied to the block rather than to the happy path.

Where scope is the wrong shape. Some lifetimes are not lexical: a connection handed to a background task, a lease renewed across requests, a pool that lives as long as the process. Then ownership has to be transferred explicitly and the new owner named, not left to whoever last touched it. Traps of the idioms themselves: Go’s defer inside a loop runs at function exit, not at the end of the iteration, so a loop over files holds every descriptor until it returns; finalizers and Java’s Cleaner are a safety net for a forgotten release, never the release mechanism.

  • heuristic
  • established
  • fragment

Scope. What a data race is, what a happens-before edge guarantees, and what each primitive promises are language and library specifications (Go memory model, JLS chapter 17, the Rustonomicon, the ConcurrentHashMap and CPython documentation). The decision rule — name the invariant and give its whole read-and-write one atomic step — is engineering judgment built on them. The Python comment describes CPython: the free-threaded documentation calls the internal per-object locks a description of the current implementation, not a guarantee. The fragments were compiled and run locally (JDK 21, Go 1.27.1 with -race, Rust 1.98.1, CPython 3.14.7 with and without the GIL); the racy variants lost updates or loaded twice in those runs, which shows possibility, not frequency.

Versions. Java: ConcurrentHashMap.computeIfAbsent and LongAdder exist since Java 8; JLS chapter 17 is cited in its SE 27 edition. Go: atomic.Int64 needs Go 1.19+. Rust: AtomicU64::try_update is stable since 1.95 (the older fetch_update is the same operation; the 1.98 standard library source already carries its deprecation, effective 1.99, in favour of try_update). Python: the free-threaded build exists since 3.13; the fragment runs unchanged on both builds.

Primary sources: The Go Memory Model (version of June 6, 2022), The Java Language Specification, Java SE 27 Edition — Chapter 17, Threads and Locks, The Rustonomicon — Data Races and Race Conditions

References: The Rustonomicon — Send and Sync, PEP 703 — Making the Global Interpreter Lock Optional in CPython, Python documentation — Python support for free threading, Java SE 27 API — ConcurrentHashMap (computeIfAbsent), Go documentation — Data Race Detector, Announcing Rust 1.95.0 (atomic update / try_update stabilized)

Other claims on this card, typed separately:

Shared-memory correctness is two separate questions. Visibility: is there a happens-before edge (a lock, a volatile write, an atomic operation, a channel send) from the write to the read, or may the reader see a stale or half-built value? Atomicity: does every invariant-bearing sequence — read-modify-write, check-then-act — run as one step? A “thread-safe” collection answers the first question for each call and neither question for a pair of calls; freedom from data races, which the Go memory model, the JLS and the Rustonomicon each define, is necessary and not sufficient.

Code example (fragment)
fragment
Java 8+
final class SessionRegistry {
    private final ConcurrentMap<String, Session> sessions = new ConcurrentHashMap<>();
    private final Function<String, Session> loader;
    private volatile long hitsRacy;              // every write is visible to later reads...
    private final LongAdder hits = new LongAdder();

    SessionRegistry(Function<String, Session> loader) { this.loader = loader; }

    Session getRacy(String id) {
        hitsRacy++;                              // BUG: ...but read-add-write is three steps
        Session s = sessions.get(id);            // each call is thread-safe, the pair is not:
        if (s == null) {                         // two threads both miss, both load,
            s = loader.apply(id);                // and hand out different sessions
            sessions.put(id, s);
        }
        return s;
    }

    Session get(String id) {
        hits.increment();
        // FIX: check-then-act is one atomic map operation; concurrent callers for an
        // absent key wait for a single load. The loader must be short and must not
        // touch this map.
        return sessions.computeIfAbsent(id, loader);
    }
}
Go 1.19+
type Registry struct {
    mu       sync.Mutex
    sessions map[string]*Session // guarded by mu
    hits     atomic.Int64
    load     func(string) *Session
}

// BUG: no happens-before edge between goroutines touching the map. That is a
// data race (go test -race reports it; the runtime may abort with "concurrent
// map writes"), and even a race-free map would let two goroutines both miss.
func (r *Registry) GetRacy(id string) *Session {
    s, ok := r.sessions[id]
    if !ok {
        s = r.load(id)
        r.sessions[id] = s
    }
    return s
}

// FIX: one critical section spans the check and the act.
func (r *Registry) Get(id string) *Session {
    r.hits.Add(1) // a lone counter needs no lock, only an atomic step
    r.mu.Lock()
    defer r.mu.Unlock()
    s, ok := r.sessions[id]
    if !ok {
        s = r.load(id)
        r.sessions[id] = s
    }
    return s
}
Rust 1.95+
// Compiles: safe Rust rules out the data race, not the lost update.
fn withdraw_racy(balance: &Mutex<u64>, amount: u64) -> bool {
    let current = *balance.lock().unwrap(); // guard is dropped right here
    if current < amount {
        return false;
    }
    *balance.lock().unwrap() = current - amount; // BUG: writes back a stale read
    true
}

// FIX: one guard spans the check and the act.
fn withdraw(balance: &Mutex<u64>, amount: u64) -> bool {
    let mut guard = balance.lock().unwrap();
    if *guard < amount {
        return false;
    }
    *guard -= amount;
    true
}

// Lock-free: compare-and-swap in a loop; the closure re-runs on a lost race,
// so it must be pure. A plain number has no ABA problem; a recycled pointer does.
fn withdraw_cas(balance: &AtomicU64, amount: u64) -> bool {
    balance
        .try_update(Ordering::SeqCst, Ordering::SeqCst, |cur| {
            cur.checked_sub(amount)
        })
        .is_ok()
}
Python 3.13+ (GIL or free-threaded)
import threading


class Registry:
    def __init__(self, load):
        self._load = load
        self._sessions = {}
        self._hits = 0
        self._lock = threading.Lock()

    def get_racy(self, sid):
        self._hits += 1  # BUG: load, add, store; no build promises they stay together
        if sid not in self._sessions:  # check ...
            self._sessions[sid] = self._load(sid)  # ... act: two threads both load
        return self._sessions[sid]

    def get(self, sid):
        # FIX: one lock spans the counter and the check-then-act. The free-threaded
        # build locks each dict operation internally; that protects the dict,
        # never a sequence of operations on it.
        with self._lock:
            self._hits += 1
            session = self._sessions.get(sid)
            if session is None:
                session = self._sessions[sid] = self._load(sid)
            return session

Visibility. Without a happens-before edge the compiler and the CPU may reorder or cache, and the JLS gives the canonical surprise: a reader that sees a non-null reference is guaranteed the constructor’s value only for final fields; a plain field may still read as zero. Hence safe publication: make the object immutable (6), or hand the reference over through a synchronized channel — a lock, a volatile or atomic reference, a concurrent collection, sync.Once — and never let this escape from a constructor. Double-checked initialization on a plain flag is broken in Go for the same reason: seeing done == true says nothing about seeing the value it guards. In Go a race on a map, slice, string or interface can corrupt memory, not merely lose an update; in Java the damage stays within the language’s memory safety but the observed behaviour can be arbitrarily strange. Run go test -race in CI — it finds races that executions actually hit, not all of them.

Atomicity. The Rust pane compiles: Send/Sync and the borrow checker exclude data races, and the Rustonomicon states plainly that general race conditions remain the programmer’s problem. The Python pane is the same bug on both builds: the GIL never promised that += or check-then-act are atomic, it only made the interleaving rare, and PEP 703’s per-object locks protect one container operation, not a sequence. The decision rule: name the invariant, then give its whole read-and-write one critical section, one atomic primitive (computeIfAbsent, compare-and-swap), or one owner thread that the others message. This is write skew (90) inside a process; for a check-then-act across processes see 66, and for many callers loading the same key see 71.

The primitives carry their own traps. A CAS loop re-runs its update function after every lost race, so the function must be pure; and CAS compares values, not histories — a pointer freed and reused between the read and the swap passes the comparison (ABA), which is why lock-free structures over recycled memory need hazard pointers, epochs or tagged versions. Locks trade that for deadlock: whenever code holds two locks, acquire them in one global order, and never call unknown code (a callback, a loader, a logger that blocks) while holding one — the computeIfAbsent contract forbids exactly that for its mapping function.

II

Boundaries and coupling

  • heuristic
  • established
  • fragment

Versions. The module system is JDK 9+, and for modules on the module path strong encapsulation has applied from the start. The --illegal-access relaxation only concerned the JDK’s own internal packages: deny by default since JDK 16 (JEP 396), option ignored since JDK 17 (JEP 403).

Conflicts with: DOD ↔ information hiding

References: Module (Java SE 25 API), JEP 403 — Strongly Encapsulate JDK Internals

A module hides a decision that is likely to change. The criterion is testable: “what breaks if this decision is revisited”. SRP (1) is a blurry retelling of this principle.

A secret is a concrete decision: storage format, encoding, field order, algorithm. An interface that returns “a list of ASCII strings” has already leaked the encoding — moving to UTF-8 breaks every consumer. The same interface described as “grocery item names” would have survived that move unnoticed. The difference is not in the code but in what is declared to the outside.

Why Java needed a separate tier of visibility. Before the module system, public meant “visible to the whole classpath”: the language offered no way to hide a decision between packages of one library, and a helper class made public for a neighbouring package became, by Hyrum’s Law (16), someone else’s contract. exports adds a second tier: public and exported is API; public in a non-exported package does not exist from the outside. Nothing changes inside the module — packages still see each other’s public types, exported or not, and private/protected/public work as before; the module merely filters which part of the public surface is presented to the world. The file’s other directives have nothing to do with hiding: requires defines the dependency graph, opens selectively permits reflection, uses/provides wire up ServiceLoader.

Three words next to module path. Classpath is a list of locations on disk (directories and JARs) where the JVM may look for classes: launch configuration, not a mechanism, and it is flat — hence the whole story above. Module path is the same list, but with module-info read; only there does the second tier of visibility appear. ClassLoader is what finds bytecode by class name and loads it into the JVM, lazily, on first use; a custom loader is a unit of isolation (Tomcat gives one to each application, Flink to each job, child-first, so a job can bring its own library versions), and one class loaded by two loaders is two different types to the JVM: hence ClassCastException “between identical classes”. ServiceLoader means “find all implementations of an interface whose names I don’t know at compile time”: that is how JDBC finds drivers; uses/provides (88) is its contract in the module system, and before modules a text file in META-INF/services did the same job.

Code example (fragment)
fragment
Java
// module system (module-info.java) — hiding at the artifact level
module billing.core {
    requires transitive billing.model;  // visible to consumers: part of the API
    exports com.acme.billing.api;       // API only

    // com.acme.billing.internal is neither exported nor opened:
    // normal access is denied, and so is deep reflection (setAccessible, the
    // historical bypass of any modifier). Access can still be granted explicitly —
    // opens, Module.addOpens(), --add-opens: this is a default boundary,
    // not a physical impossibility.
}
Rust
// pub(crate) / private by default — hiding is built into the language
pub struct RateLimiter { /* fields are private */ }

pub(crate) mod internal;   // visible inside the crate, invisible to consumers
Go
// internal/ package — the compiler forbids imports from outside the subtree
// myservice/internal/ratelimit — physically unreachable from other modules
Python
# _prefix + __all__ convention; no enforced hiding — and that's honest
__all__ = ["RateLimiter"]   # the public contract is declared explicitly
  • heuristic
  • established
  • fragment

The only pair of design metrics that can actually be measured (afferent/efferent coupling, instability). In practice: dependency direction is checked in CI, not in the reviewer's head canon.

Both words come from Stevens, Myers and Constantine, “Structured Design” (IBM Systems Journal, 1974), which ranked kinds of coupling and cohesion and argued for low coupling between modules and high cohesion within them. The countable metrics are later: Robert C. Martin’s package metrics (afferent and efferent coupling, instability, abstractness) and the Stable Dependencies Principle — depend in the direction of stability.

What exactly is counted: Ca (afferent) — how many modules depend on you; Ce (efferent) — how many you depend on; instability I = Ce / (Ca + Ce). I = 0 — everyone depends on you and you depend on no one: expensive to change, but nobody breaks you. I = 1 — the reverse. The value is not in the numbers themselves but in the direction: the stable must not depend on the unstable, otherwise a change in the most volatile module propagates up the graph to all its readers. Cohesion is not measured with the same ruler — it is the question “does the module's content change for a single reason” (1), and it remains a judgment call.

Code example (fragment)
Java · ArchUnitfragment
// dependency rule as a test; definedBy matches on PACKAGE NAMES —
// ArchUnit reads bytecode, so the naming convention is this test's contract
@ArchTest
static final ArchRule layering = layeredArchitecture().consideringAllDependencies()
    .layer("domain").definedBy("..domain..")
    .layer("infra").definedBy("..infra..")
    .whereLayer("domain").mayNotAccessAnyLayer(); // domain knows nothing about infra

Go — depguard / go list -deps in CI. Rust — crates in a workspace (dependency graph = Cargo.toml, cycles impossible). Python — import-linter with a layers contract.

What the import graph does not see. Two services with no code dependency can be tightly coupled through a shared database table, a message format, a cache key layout or an assumption about ordering: change one column and both break, and no ArchUnit rule notices. Meilir Page-Jones’s connascence names these hidden kinds — of name, type, meaning, timing — and ranks them by how far apart the coupled pieces live. The practical rule: a dependency that crosses a team or a deploy unit must be visible as a contract (18, 74), because the graph will not show it. Cohesion, in turn, trades against DRY (43): pulling shared code into a common module raises coupling to it, and two copies that change for different reasons are more cohesive than one shared helper.

  • pattern
  • context-dependent
  • fragment

Conflicts with: DRY ↔ bounded contexts

No model is universal. Billing’s “Customer” and marketing’s “Customer” are different types; merging them produces a god table with 60 nullable columns.

Where the problem comes from. In an online store four departments say “order”, and each means something different: the warehouse means line items and shelf locations, accounting means the amount, tax and payment status, shipping means the address and box dimensions, support means the conversation with the customer. As long as this is a single Order class, it has to carry all four departments’ fields at once, and every consumer leaves three quarters of them empty — which is exactly how you end up with a table of sixty nullable columns. Worse than the columns is what gets glued together: the departments have independent reasons to change (1), and a change to the shipment format now passes through a type that tax calculation depends on.

What a boundary is. A bounded context is a region of the system inside which every word means exactly one thing, and on the way out of which meaning has to be translated. Billing’s “Customer” and marketing’s “Customer” are not two versions of one concept but two different concepts that happen to share a word: a river bank and a savings bank do not become one thing because their names match. Matching fields prove nothing — both have id, email, name, but they will change for different reasons and on different days. The criterion is the same as Parnas’s (12): the boundary follows a decision that gets revisited separately — and for the same reason DRY (43) does not apply across it.

Code example (fragment)
Rustfragment
// Different crates, different types; translation at the boundary is explicit

// crates/billing/src/lib.rs
pub struct Customer { pub id: CustomerId, pub payment_methods: Vec<PaymentMethod> }

// crates/crm/src/lib.rs
pub struct Customer { pub id: CustomerId, pub segments: Vec<Segment> }

// Anti-corruption layer: no From<crm::Customer> on purpose — mapping only through
// an explicit function carrying business decisions, not a mechanical one

Translation is a business decision, not field shuffling. The layer that performs it is called an anti-corruption layer: it exists so that another context’s concepts do not leak in. The absence of From<crm::Customer> in the example is deliberate — an automatic conversion pretends the types are the same, and the very first question, “does marketing consider a customer blocked for debt to be active?”, shows that they are not. Such a question is answered not by a mapper but by whoever owns the context.

Three mistakes at the door. A bounded context is not a microservice: boundaries of meaning are drawn in code, and four contexts live perfectly well in one process and one codebase; splitting them into services is a separate decision with a separate price (13). An identical set of fields is not proof of a single type but today’s coincidence, and the axis of change has not been established yet (42). A convenient Customer.from(...) is the most expensive of the three: it is not forbidden, but it creates the impression that translation is mechanical, and thereby hides the place where a decision had to be made.

  • empirical law
  • context-dependent
  • no code

Scope. This is a robust correlation with several mechanisms, not a proven law: ownership boundaries, coordination channels, release boundaries and team cognitive load all contribute, and each is managed differently. The inverse Conway maneuver works where the org structure can actually be changed; more often it is itself a consequence of budget, hiring and acquisition history.

Primary sources: Melvin Conway — How Do Committees Invent? (1968)

Architecture converges on the organization’s communication structure. Not advice but an observation: if you want a different architecture, restructure the teams first (the inverse Conway maneuver) — otherwise the old org chart will reassert itself in the interfaces.

The source is Melvin Conway, “How Do Committees Invent?” (Datamation, April 1968): organizations which design systems are constrained to produce designs which are copies of their communication structures. His argument is structural, not statistical: a design is split into parts before anyone knows the right split, each part goes to a group, and an interface between two parts can only be as good as the communication between the two groups. Fred Brooks named it “Conway’s law” in The Mythical Man-Month (1975). Later empirical work treats it as a hypothesis to test: Herbsleb and Grinter (ICSE 1999) found coordination breaking down exactly at cross-site interfaces, and MacCormack, Rusnak and Baldwin (Research Policy, 2012) found loosely coupled open-source organizations producing more modular code than tightly coupled commercial ones on matched products. Supportive, but correlational.

A data-platform example. Ingestion, transformation and serving belong to three teams; the result is three systems joined by two hand-offs — a raw bucket and a warehouse schema — and every change to an event needs three tickets. When the company reorganizes around domains (orders, payments) with one team owning an event from producer to served table, the pipeline gets cut the same way within a year, whether or not anyone redraws the architecture diagram. That is the inverse Conway maneuver, a name popularized by Thoughtworks and by Skelton and Pais’s Team Topologies (2019): pick the boundaries you want in the code (14), then give each boundary one owning team.

Limits. It is a tendency with friction, not a law of nature: a small team can build a well-modularized system, and a reorg does not refactor code by itself — it only changes which refactors become cheap. Misuse is reorganizing as a substitute for design: teams cut along the wrong seams produce the wrong seams faster. It trades against Brooks’s observations (76): every new team boundary is also a new communication cost. Not expressible in code; expressed in the org chart.

  • heuristic
  • established
  • fragment

Scope. Not every foreign type in a signature is a mistake. Standard-library types and de facto shared ecosystem types (context.Context, java.time, UUID) are cheaper to let through than to wrap — a wrapper around them becomes one more thing the consumer has to learn. The principle is about dependencies whose version you change, not about the foundation that someone else changes.

References: Cargo Book — SemVer Compatibility (what counts as a breaking change), JLS SE 25 — Modules (requires transitive, exports to, opens, uses/provides)

A type from someone else's library that ends up in your public signature becomes your contract: consumers compile against it, and your upgrade of that library is their breaking change. The leak is silent because no compiler asks “do you really want to expose this?”; per Hyrum's Law (16), the answer turns up when it is already too late to break anything.

The check is mechanical: list every type mentioned in the module's public surface. Anything that is not in your own code is someone else's contract you have silently taken on supporting. Then comes a choice of three, and it must be deliberate: wrap the foreign type in your own (the dependency then stays a secret, 12), declare the transitivity explicitly and accept it as part of the API, or remove the type from the signature. The worst option is the fourth, the default one: choosing nothing.

Code example (fragment)
fragment
Java
module billing.api {
    // I need it, consumers can't see it: an implementation detail
    requires com.fasterxml.jackson.databind;

    // a type from billing.model appears in my public signatures,
    // so consumers must be able to see it. transitive is an admission:
    // “billing.model compatibility is now my problem”
    requires transitive billing.model;

    exports com.acme.billing.api;
    exports com.acme.billing.spi to billing.plugins;  // qualified export

    // reflection is granted narrowly and by name, not to all comers
    opens com.acme.billing.dto to com.fasterxml.jackson.databind;

    // the extension point is declared, not found by scanning the classpath
    uses com.acme.billing.spi.TaxProvider;
    provides com.acme.billing.spi.TaxProvider with com.acme.billing.tax.DeTaxProvider;
}
Rust
// pub use: same trick, same price; a foreign type becomes your API,
// and a major bump of chrono becomes a major bump of your crate
pub use chrono::NaiveDate;

// a boundary instead of a leak: the newtype keeps chrono an implementation detail
pub struct SettlementDate(NaiveDate);

// a pub field of a pub struct is a contract too; non_exhaustive keeps
// the right to add fields without breaking consumers
#[non_exhaustive]
pub struct Settlement { pub date: SettlementDate }
Go
// Go has neither requires transitive nor re-exports: transitivity leaks
// through the signature, the only place it is visible at all. The consumer
// has to import lib/pq to inspect this error
func Charge(ctx context.Context, id string) (*pq.Error, error)

// the boundary: the foreign error is wrapped in ours; the contract is errors.Is
var ErrDuplicate = errors.New("billing: duplicate charge")

func Charge(ctx context.Context, id string) error {
    if err := insert(ctx, id); isUniqueViolation(err) {
        return fmt.Errorf("charge %s: %w", id, ErrDuplicate) // lib/pq stays inside
    }
    return nil
}
Python
# the same obligation, only declared by convention rather than by the compiler
from ._settlement import Settlement
from ._vendor_client import Client as Client  # redundant alias = “this is API” (PEP 484)

# a second, more explicit layer of the same statement: Client is promised publicly,
# so a vendor upgrade is our breaking change, not theirs
__all__ = ["Settlement", "Client"]

The flip side of the same principle is extension points. uses/provides declare a service in the module contract instead of scanning the classpath at startup: the implementation is plugged in late, but the fact of extensibility is declared early and visible in the artifact. The same choice comes up wherever there are plugins: the set of extension points is part of the public surface, and adding them is cheaper than removing them.

III

Evolution over time

  • heuristic
  • context-dependent
  • fragment

Scope. Despite the name, this is not a measured law: it is Hyrum Wright’s generalization from years of changing Google’s shared C++ libraries, and no study quantifies it. “With a sufficient number of users” is deliberately unquantified, so it holds as a design rule that scales with the number of consumers, not as a prediction for every API. Not every observable behavior becomes a dependency — the ones that do are cheap to notice and expensive to check against the spec. The practical takeaway is not “everything will break” but “whatever you neither pin down nor randomize, someone will pin down for you”.

Conflicts with: Contract testing ↔ provider velocity

Primary sources: Hyrum Wright — Hyrum’s Law (the statement and its origin)

References: The Go Programming Language Specification, Go Blog — Go maps in action (iteration order), OpenJDK — ImmutableCollections.java (SALT, order across JVM runs)

With enough consumers, every observable property of an API is part of the contract: JSON key order, latency, error message text.

Code example (fragment)
fragment
Go
// proverb level: don't let anyone observe what you don't promise
// map deliberately randomizes iteration order: the language defends against Hyrum's Law
for k := range m { ... } // order differs on every pass; nothing to rely on
Java
// same here: Set.of()/Map.of() randomize iteration order across JVM runs
// precisely so that nobody starts depending on it

Precision. The Go spec says map iteration order is unspecified and not guaranteed to be the same from one iteration to the next, not that it is “random and always different.” The practical conclusion is the same: you must not depend on it. But the word “randomizes” describes the implementation, not the language contract.

The mechanisms also differ in strength. The Go runtime picks random offsets every time an iterator is initialized: two iterations over the same unmodified map in one process yield different orders. Java computes SALT once at JVM startup (ImmutableCollections; the source comment names the goal outright: “iteration order will vary between JVM runs”): within a run the order is stable, across runs it differs. The contract is the same for both: order is unspecified. What differs is how insistently the implementation keeps you from relying on it, and that itself may change in the next release, which is the main argument for not relying on it.

  • pattern
  • established
  • fragment

Conflicts with: Canary ↔ schema migrations

References: Pydantic — Fields

The only correct protocol for changing a schema or an API under continuous deployment. Never “rename column” — always a sequence in which, at every step, old and new code run side by side.

The full cycle: 1) expand — a nullable column and compatible code; 2) dual write; 3) batched backfill with a checkpoint; 4) reconciliation of old and new values; 5) switching reads; 6) an observation period; 7) NOT NULL and the constraint; 8) contract — removing the old.

Code example (fragment)
fragment
SQL
-- 1. EXPAND: a nullable column with no default is a fast metadata-only change.
--    NOT NULL and a backfill at this step would take ACCESS EXCLUSIVE on the table.
ALTER TABLE users ADD COLUMN email_normalized TEXT;

-- 2. DUAL-WRITE is the application's job: both fields written in one transaction.

-- 3. BACKFILL in batches with a checkpoint, not a single UPDATE:
--    one big UPDATE = long locks, WAL volume blowup, bloat,
--    replica lag, and no way to interrupt the job.
WITH batch AS (
    SELECT id FROM users
    WHERE email_normalized IS NULL AND id > :checkpoint
    ORDER BY id LIMIT 5000 FOR UPDATE SKIP LOCKED
)
UPDATE users u SET email_normalized = lower(u.email)
FROM batch b WHERE u.id = b.id
RETURNING u.id;   -- the max returned id becomes the new :checkpoint

-- 4. VERIFY: there must be no mismatches; this gates the move to step 5
SELECT count(*) FROM users
WHERE email_normalized IS DISTINCT FROM lower(email);

-- 5-6. Reads switch via a flag (72); dual write stays on for the whole observation period.

-- 7. The constraint is added in two steps to avoid holding a long lock
ALTER TABLE users ADD CONSTRAINT users_email_norm_not_null
    CHECK (email_normalized IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_norm_not_null;

-- 8. CONTRACT: only once no live deployment still reads the old field
ALTER TABLE users DROP COLUMN email;
Python · pydantic v2
# Same for API fields: the deprecated field lives alongside; return both.
# The marker goes on the field itself, so it reaches JSON Schema and OpenAPI
class UserOut(BaseModel):
    email: str = Field(deprecated="Use email_normalized")
    email_normalized: str

Rollback differs at each step: before step 5 it is enough to stop writing to the new field; after it, switch reads back with the flag. After step 8 there is no rollback — it is a one-way door (20). That is why an observation period is needed between 5 and 8, measured in weeks of deployments, not in hours.

  • practice
  • context-dependent
  • fragment

Scope. Additive-only is a safe default policy, not a complete account of compatibility: that depends on the format, defaults, compatibility mode and the combination of reader/writer versions. Some removals with reserved and some type changes are wire-compatible while still being semantically breaking.

Conflicts with: Postel ↔ schema evolution

A schema shared by a writer and a reader that deploy separately is a contract with two directions: new readers must read old data (backward compatibility) and old readers must survive new data (forward compatibility). Protobuf and Avro make the safe moves explicit: add, never reuse a field number, reserved for the deleted ones — and a registry turns those rules into a check that fails the build.

All three rules follow from one fact: on the wire a field is identified by its number, not its name — the name lives only in the .proto and in generated code. Hence renaming a field is safe (same number), changing its number is equivalent to replacing the field, and an old reader skips an unfamiliar number as an unknown field — which is why adding is compatible. Hence also the worst case: if a deleted field’s number is given to a new field of a different type, an old reader will parse it with the old schema and silently get someone else’s data — not an error, but plausible garbage. reserved exists precisely so the compiler won’t let you do that; it is the same burned identifier as a principle’s retired number in this reference.

Code example (fragment)
fragment
Protobuf
message Order {
  reserved 3, 7;                 // deleted fields: numbers burned forever
  reserved "legacy_status";

  string id = 1;
  int64 amount_cents = 2;
  OrderStatus status = 4;        // adding is the only safe operation
}
Java
// a consumer must survive unknown fields (forward compat)
ObjectMapper mapper = JsonMapper.builder()
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // deliberately
    .build();
Rust
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]  // at a TRUST BOUNDARY: strictness;
struct AdminCommand { ... }    // on a service-to-service contract: the opposite,
                               // tolerance of new fields

Where the rules come from. They are not folklore but the formats’ own specifications: the Protocol Buffers language guide (“Updating a message type”) and the Avro specification’s “Schema Resolution” section, which defines exactly how a reader’s schema is matched against a writer’s. Avro has no field numbers — fields are matched by name — so its version of the rule is different: a field added for a new reader needs a default, or old records cannot be read. Confluent Schema Registry encodes the directions as compatibility modes (BACKWARD, FORWARD, FULL, and _TRANSITIVE variants that check against every earlier version, not just the last); the mode follows from who upgrades first — consumers first is backward, producers first is forward.

What a compatibility check cannot see. It checks shape, not meaning. Changing amount from cents to euros, or a timestamp from UTC to local time, passes every registry check and corrupts every reader; such a change is a new field with a new name, migrated with expand — migrate — contract (17). Some changes are wire-compatible and still lossy: widening int32 to int64 parses on both sides, but an old reader truncates large values. And the tolerance for unknown fields shown above belongs on service-to-service contracts; at a trust boundary, strictness wins (19, 32).

  • author’s position
  • opinionated
  • fragment

Conflicts with: Postel ↔ schema evolution

Primary sources: RFC 9413 — Maintaining Robust Protocols

“Be liberal in what you accept” gave us XSS, request smuggling, and protocols that cannot evolve. The modern position: strict validation at the boundary, rejection instead of guessing.

The principle is Jon Postel’s, from the early Internet specifications; RFC 793 (TCP, 1981) phrases it as “be conservative in what you do, be liberal in what you accept from others”. In a young network of independent implementations it helped them interoperate despite each other’s bugs. The costs showed up over decades. If receivers accept malformed input, senders never learn it is malformed, the bugs become the de facto protocol (16), and every new implementation has to reproduce them. Eric Allman, “The Robustness Principle Reconsidered” (ACM Queue, 2011), made the security case: liberal parsing is where attackers live.

Request smuggling is the textbook case. A front-end proxy and a back-end server each “tolerate” a request with both Content-Length and Transfer-Encoding, but resolve the ambiguity differently; the attacker’s bytes that the proxy saw as one request body, the back end reads as the start of a second request. Two lenient parsers that disagree are a vulnerability. The current HTTP/1.1 specification (RFC 9112) says such a message ought to be handled as an error.

An honest note on the title. RFC 9413 is titled “Maintaining Robust Protocols” and formally repeals nothing: it shows that liberal acceptance itself becomes a source of ossification and vulnerabilities, and recommends actively managing tolerance rather than banning it. “Repealed” is this page’s editorial position, not the text of the RFC.

Code example (fragment)
fragment
Python · pydantic v2
# strict mode at the external boundary
class PaymentIn(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")  # "123" will not become 123

    amount_cents: int
    currency: Literal["EUR", "USD"]
Go
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
// unknown field from an external client = 400, not a silent drop

Where tolerance still belongs. Strict is not the same as brittle. Ignoring unknown optional fields in a versioned schema is how producers evolve without breaking consumers (18) — that is designed extensibility, declared in the contract, not guessing. The line: tolerate what the specification says may vary; reject what it says is wrong, and parse into a typed value once, at the edge (2).

  • heuristic
  • established
  • config

Conflicts with: Retention as cost ↔ replay horizon

Decision speed is inversely proportional to the cost of rollback. Picking a logging library is a two-way door, settled in an hour. Picking a storage format, partitioning or ID scheme is a one-way door and requires an ADR.

The door metaphor is from Jeff Bezos’s 2015 letter to Amazon shareholders: Type 1 decisions are consequential and irreversible or nearly so, and deserve to be made slowly and carefully; Type 2 decisions are changeable, and should be made quickly by individuals or small teams. His warning runs both ways — an organization that grows tends to apply the heavyweight Type 1 process to everything, and becomes slow. The written record for the Type 1 ones is the Architecture Decision Record, proposed by Michael Nygard in “Documenting Architecture Decisions” (2011): a short file with context, decision, status and consequences, kept in the repository next to the code it constrains.

Code example (config)
ADRconfig
# ADR-014: Partition key for the orders topic

Status: accepted   Date: 2026-03-02

Context: repartitioning a Kafka topic in prod = consumer downtime
         + rebuilding Flink state stores

Decision: partition by customer_id, not by order_id

Consequences: hot keys for large B2B customers;
              mitigated with salted keys at >5% skew

Why the partition key is a one-way door: the key decides ordering guarantees and which consumer instance holds which state (92). Changing it means a new topic, dual writes or a replay (80), and rebuilding every stateful consumer — weeks, not a config change. The same holds for a primary-key scheme exposed in URLs, a public API field’s meaning (16), and anything written to long-lived storage in a format other systems read.

Turn one-way doors into two-way ones where you can. Expand–migrate–contract (17), a facade in front of storage, feature flags and versioned formats all lower the cost of rollback, and with it the process the decision needs. The misuse is the reverse: writing ADRs for library choices, which buries the few irreversible decisions among many reversible ones.

Shipping code and enabling behavior are two different events, separated by a flag. A canary / percentage rollout turns a release from a binary event into a controlled experiment with a rollback metric. A kill switch is a separate kind of flag: not an experiment but a breaker built in advance for degradation (see 55); it lives forever and is tested regularly.

The term “progressive delivery” was coined by James Governor of RedMonk in 2018 for the practices that sit between continuous delivery and full release: canaries, percentage rollouts, flags, automated analysis of the rollout’s metrics. The vocabulary of flags comes from Pete Hodgson’s “Feature Toggles (aka Feature Flags)” on martinfowler.com (2017), which separates release toggles, experiment toggles, ops toggles and permission toggles by how long they live and how dynamic they are — the kill switch above is an ops toggle, and it is the one kind meant to stay.

Code example (fragment)
Javafragment
// a typed flag, with owner and lifetime right in the declaration
@Flag(owner = "team-payments", removeBy = "2026-10-01", kind = RELEASE)
static final BoolFlag NEW_SETTLEMENT = flags.bool("settlement.v2", false);

if (NEW_SETTLEMENT.on(ctx.customerId())) { ... } // percentage by a stable hash of id,
                                                 // not random(), or the user
                                                 // flickers between versions

An honest assessment. Flag debt is real: every flag means 2n configurations, of which two get tested. A removal date is part of the definition of done; a flag older than two quarters with no decision is a process incident. The consequence for schemas: a canary means two versions of the code are always live at the same time, so expand — migrate — contract (17) goes from “good practice” to the only option.

Why flag hygiene is not pedantry. The SEC’s 2013 order against Knight Capital describes how, in August 2012, new code reused a flag that had once activated a retired function; one of eight servers had not received the new code, the flag woke the old function there, and the firm lost about $460 million in 45 minutes. A reused flag is the same hazard as a reused field number (18): a name whose old meaning is still alive somewhere.

Where it does not fit. A canary needs enough traffic to tell a regression from noise: a service with a hundred requests an hour gets nothing statistically useful from 1 %, and a staged rollout by time is more honest. Database migrations cannot hide behind a flag at all; they are made safe by being backward compatible (17). And mobile or on-premise clients cannot be rolled back once installed, so for them the flag, not the deploy, is the only rollback there is.

  • pattern
  • established
  • config

A big-bang rewrite violates Gall’s Law (41) on an industrial scale. Strangler fig: a facade in front of the legacy system, routes moved to the new system one at a time, the legacy dies by displacement. Every step is reversible (route back), progress is measurable (% of traffic), and the system keeps running the whole time.

The name is Martin Fowler’s, from his 2004 bliki entry “StranglerApplication” (later retitled “StranglerFigApplication”), after the fig vines he saw in Australian rainforest: they grow around a host tree until the tree dies and the fig stands on its own. The argument against the alternative is about risk and value: a rewrite delivers nothing until it reaches parity with a system whose behavior is only partly documented, while the old system keeps changing underneath it (76). Strangling delivers each migrated route as soon as it moves, and the parts nobody ever migrates turn out to be the parts nobody needed.

Code example (config)
Routingconfig
# routing IS the migration mechanism
routes:
  - match: { path: /invoices/* }   # migrated
    backend: billing-v2
  - match: { path: /* }            # everything else: legacy, for now
    backend: legacy-monolith

One route, done properly: put the facade in front first with no behavior change; then shadow /invoices/* — send each request to both, serve the legacy answer, and diff the two responses until the mismatch rate is near zero and every remaining difference is explained; then serve from the new system for a slice of tenants, then all; and only then delete the legacy code for that route. Each step has a metric and a rollback that is a routing change.

The most expensive part is not the routing but splitting the data: while both systems write to one database, the legacy isn’t dead, just hidden. A shared DB is a way station with a departure date; otherwise you’ve built a distributed monolith. Data moves by expand–migrate–contract (17) or change data capture (104), with one system owning writes at any time. Strangling also needs a seam: a batch job with no interface to intercept has to be cut at its inputs and outputs instead.

  • practice
  • context-dependent
  • fragment

Conflicts with: Contract testing ↔ provider velocity

An E2E environment with every service is the most expensive and least stable way to check compatibility. Consumer-driven contracts: the consumer publishes its expectations (which fields it reads, which statuses it handles), and the provider verifies them in its own CI. A breaking change is caught before deploy, on the provider side, with the name of the affected consumer. This operationalizes Hyrum’s Law (16): the contract is what consumers actually use, recorded in machine-readable form.

The pattern was described by Ian Robinson in “Consumer-Driven Contracts: A Service Evolution Pattern” (martinfowler.com, 2006): a provider’s obligations are the union of what its consumers actually rely on, and making those expectations explicit lets the provider evolve everything else freely. Pact, started at realestate.com.au, turned it into tooling: consumer tests run against a mock that records the interactions as a pact file, a broker stores the pacts per version, and the provider replays them against its real implementation. The broker’s can-i-deploy check answers the release question directly: is this provider version verified against every consumer version in production?

Code example (fragment)
Python · pact-stylefragment
# consumer expectation
pact.given("invoice 42 exists") \
    .upon_receiving("get invoice") \
    .with_request("GET", "/invoices/42") \
    .will_respond_with(200, body=Like({"number": "42", "total_cents": 12300}))

# the provider runs ALL published consumer contracts in its own CI;
# removing a field someone reads = a red build for the provider

Contracts check structure, not behavior — they complement the provider’s tests rather than replace them. A schema registry (protobuf + compatibility checks, 18) does the same for events; contract testing covers boundaries that have no registry (REST/JSON).

Where contracts fail. They protect only consumers who publish one; a consumer that never wrote a pact is invisible, which is Hyrum’s Law again. A public API with unknown clients cannot be contract-tested at all — there, versioning and an OpenAPI diff in CI do the job. Over-specified contracts are the common misuse: a consumer that asserts exact values instead of types and shapes (hence Like above) makes every provider change red, and teams learn to ignore the build.

  • practice
  • established
  • config

Conflicts with: Immutable infra ↔ stateful systems

References: Terraform — lifecycle meta-argument

IaC is not “provisioning scripts” but a declaration of desired state with detectable drift: a manual change in the console is a divergence between code and reality that plan must reveal and the process must forbid creating. terraform plan is a review artifact on a par with a code diff: what gets reviewed is not the HCL but the change plan (“destroy and recreate” on a database is what the reviewer is looking for). The state file is a database, with all that implies: locking, backups; renaming a resource without moved {} = destroy/create — a one-way door (20) disguised as a refactoring.

Code example (config)
Terraform · HCLconfig
# non-destructive refactor — a moved block, not “terraform got it from the name”
moved {
  from = aws_msk_cluster.kafka
  to   = module.streaming.aws_msk_cluster.kafka
}

# the invariant lives in code, not in the reviewer's head
lifecycle {
  prevent_destroy = true   # on stateful resources; a plan destroying them fails CI
}

What prevent_destroy does not do. It protects a resource only while that resource is described in the configuration: delete the resource block itself and the protection leaves with it, and the resource gets destroyed. So the protection cannot live in HCL alone. You need: policy-as-code that fails a plan with a destroy on stateful resources; CI that applies the saved plan rather than one recomputed at apply time; a check for deleted resource blocks in the diff; tested backups and restore drills; and an explicit break-glass procedure. Separately: drift is visible only within what the provider is able to read.

“Cattle, not pets” is a rule about how things change, not about the kind of resource: you can replace wholesale whatever is recoverable from code and replicas, while anything holding the only copy of some state requires explicit protocols for migration, restore and version compatibility. Replicated state with a quorum, or object storage, is managed declaratively just like compute; a unique machine with a local disk is not, whatever its tag says. Confusing the two is the standard way to lose state. Drift detection is not a quarterly audit but continuous (plan on a cron with an alert on a non-empty diff): drift found a month later has already grown dependencies — Hyrum (16) for infrastructure.

A zero-downtime migration (ID scheme, storage format, gitflow → trunk, platform) is not a “switch-over” but a pipeline: dual-run (both systems running) → shadow/compare (the new one gets a copy of traffic, discrepancies are measured) → gradual cutover with reversible routing → a coexistence period → contract (tearing down the old one). The progress metric is public (% of traffic / services / records on the new path), and — the most ignored part — the migration has an owner until the end: a migration that is 90% done is worse than one never started, because now there are two systems forever.

Code example (fragment)
Go · shadow-comparefragment
// ANTIPATTERN: a goroutine per request — unbounded concurrency,
// context cancelled right after the client reply, no timeout, no recover
go func() {
    nu, nerr := m.next.Get(ctx, id)          // ctx is already cancelled
    m.compare.Record(id, old, err, nu, nerr) // a panic here kills the process
}()

// CORRECT: the new implementation gets traffic but no vote —
// via a bounded queue, with its own context and timeout
func (m *Migrator) Get(ctx context.Context, id UserID) (User, error) {
    old, err := m.legacy.Get(ctx, id)
    m.shadow.Offer(job{id, old, err}) // non-blocking: if the queue is full,
    return old, err                   // the sample is dropped, client doesn't wait
}   // old one is still the source of truth; cutover when diff-rate < threshold
    // for N days straight, and that number is written down BEFORE the start

func (m *Migrator) shadowWorker() {
    for j := range m.shadow.Jobs() {
        func() {
            defer func() { _ = recover() }() // a worker panic doesn't crash the process
            ctx, cancel := context.WithTimeout(m.shadowBase, 200*time.Millisecond)
            defer cancel()
            nu, nerr := m.next.Get(ctx, j.id)
            m.compare.Record(j.id, redact(j.old), j.err, redact(nu), nerr)
        }()
    }
}

Honest assessment. Organizational migrations (gitflow → trunk, monorepo, platform) follow the same mechanics as technical ones: dual-run = both models are allowed, cutover = new services only on the new one, contract = the old one is switched off on a date announced in advance. “Voluntary and open-ended” is not a migration but a permanent doubling of maintenance. The cancellation criterion is also written before the start: a migration without a “when do we roll back” condition is a sunk-cost trap at production scale.

What the code does not show: shadowBase is not a context derived from the request but its own, carrying only the permitted correlation attributes and not a single user attribute; the shadow path needs a circuit breaker, otherwise degradation of the new system starts eating the old one’s resources; and the queue must have a dropped-samples metric — silently lost shadow traffic turns diff-rate into a number that measures itself.

IV

Failure as a normal mode

  • heuristic
  • established
  • fragment

Correctness can only be ensured at the endpoints; intermediate layers provide optimization, not a guarantee. Corollary: a broker's exactly-once ends at the broker's boundary — beyond it, deduplication must live in the consumer.

The precise statement. Kafka transactions provide true EOS in a read-process-write loop where input, output and offsets all live inside Kafka: the offset commit and the result write are atomic. The guarantee disappears exactly where an external sink appears (a database, a payment gateway, an email): it shares no transaction with the broker. So “exactly-once” is not marketing but a scope of applicability, outside which only E2E deduplication works.

Code example (fragment)
fragment
SQL · transactional inbox
-- The dedup marker and the business-state change go in ONE transaction.
-- Otherwise two consumers pass the check at once, or the process crashes
-- between the effect and the marker, and the event is applied twice.
BEGIN;

-- The effect is conditional on the claim: a duplicate inserts no row,
-- so `claimed` is empty and the UPDATE touches 0 rows.
WITH claimed AS (
    INSERT INTO consumed_events (event_id) VALUES (:event_id)
    ON CONFLICT DO NOTHING
    RETURNING event_id
)
UPDATE accounts SET balance = balance + :amount
WHERE id = :account_id AND EXISTS (SELECT 1 FROM claimed);

COMMIT;
Java · Kafka consumer
void handle(ConsumerRecord<String, OrderEvent> rec) {
    // Dedup and effect inside one storage transaction, not as two
    // calls around process(): the process can die in between.
    tx.run(session -> {
        if (!session.claimEvent(rec.value().eventId())) return; // INSERT .. ON CONFLICT
        applyBusinessChange(session, rec.value());
    });
    // offset is committed AFTER the transaction: a retry is safe, a loss is not
    consumer.commitSync();
}

Where this stops working. The scheme above requires the effect and the marker to live in the same transactional resource. If the side effect is external — a payment gateway, an email, a call to someone else's API — there is no shared transaction, and three options remain: an idempotency key on the external service's side, an outbox (68) followed by reconciliation, or an explicit reconciliation procedure. Deduplication in a separate store with a TTL is an optimization (“don't do the work twice”), not a correctness guarantee.

  • pattern
  • established
  • fragment

Conflicts with: Replay ↔ side effects

The idempotency key belongs in the API contract, not in client-side retry logic. Over a network a timeout does not tell you whether the operation happened, so every operation that can be retried must be safe to apply twice.

An operation is idempotent if applying it twice has the same effect as applying it once. HTTP defines this per method (RFC 9110, §9.2.2): PUT and DELETE are idempotent, POST is not. The problem it solves is the indeterminate outcome: the client sent “charge €40”, the connection dropped, and nothing on the client side can tell a lost request from a lost response. Retrying a non-idempotent call risks a double charge; not retrying risks a missing one. Message brokers have the same problem under another name: at-least-once delivery means every consumer sees duplicates, and “exactly-once” in practice means at-least-once delivery plus idempotent processing.

Code example (fragment)
fragment
Go
// Idempotency key comes from the client; result is cached atomically with the op
func (s *Svc) CreatePayment(ctx context.Context, key IdempotencyKey, req PaymentReq) (Payment, error) {
    return s.store.WithIdempotency(ctx, key, func(tx Tx) (Payment, error) {
        return s.doCreate(tx, req) // a retry with the same key returns the stored result
    })
}
Rust
// The type makes the requirement unbreakable: no key, no request
pub struct CreatePayment { pub idempotency_key: Uuid, pub amount: Cents }
PostgreSQL
-- same transaction as the payment itself; a unique key, not a read-then-write check
INSERT INTO idempotency (key, request_hash, response)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO NOTHING;

What makes it correct. The key is generated by the client once per logical operation and reused on every retry; Stripe’s Idempotency-Key header is the well-known public example, and an IETF draft (“The Idempotency-Key HTTP Header Field”) standardizes it — still a draft, not an RFC. The server stores the key and the response in the same transaction as the effect, enforced by a unique constraint; a separate “check, then write” races. A reused key with a different request body is an error, not a replay — hence the request hash. Keys expire after a window longer than any client’s retry horizon.

Limits. Idempotency covers the service’s own state. A side effect outside the transaction — an email, a call to a payment provider — needs its own key passed downstream, or an outbox (68). Idempotent is also not commutative: two different updates arriving out of order still need versioning or a fencing token (66).

  • pattern
  • established
  • fragment

Conflicts with: Backpressure ↔ availability

An unbounded queue turns a failure into a latency collapse (Little's law: L = λW; as the queue grows, response time grows at the same λ).

A buffer and backpressure are not alternatives on a single scale. Overload protection is assembled from four different things: a bounded buffer (absorbs a burst, but not a trend), a concurrency limit and admission control (decide how much work to accept at all), backpressure (pushes the pressure back to where the work came from), and load shedding (fast rejection where there is nowhere to push the pressure). The choice between the last two comes down to one question: can the producer slow down? An internal service on a gRPC stream can; a random user’s browser cannot, and for it the honest answer is 429 with Retry-After, not a thirty-second place in a queue. An end-to-end deadline and a retry budget (24) are part of the same design: without them, shed load comes back as retries and finishes off what you were protecting.

Code example (fragment)
fragment
Go
// bounded channel + rejection instead of a growing queue
select {
case jobs <- j:
case <-time.After(50 * time.Millisecond):
    return ErrOverloaded          // load shedding: a fast failure beats a slow success
}
Java
// Reactive Streams: backpressure in the contract; or a bounded executor
var pool = new ThreadPoolExecutor(8, 8, 0, SECONDS,
    new ArrayBlockingQueue<>(1_000),               // bounded!
    new ThreadPoolExecutor.CallerRunsPolicy());    // pressure back onto the producer
Rust · tokio
// bounded mpsc; send().await is itself the backpressure
let (tx, rx) = tokio::sync::mpsc::channel::<Job>(1_000);
if tx.try_send(job).is_err() { return Err(Error::Overloaded) }
Python · asyncio
# bounded queue; put() suspends the producer — that is a feature
queue: asyncio.Queue[Job] = asyncio.Queue(maxsize=1_000)
await queue.put(job)   # blocks when full — pressure flows back up the pipeline
  • pattern
  • established
  • fragment

Scope. Timeout, retry with jitter, circuit breaker and bulkhead are not interchangeable — they cover different failures: a response that is too slow, a one-off error, sustained degradation of a dependency, and failure spreading across pools. None of them replaces queue bounds and load shedding (23); a retry without a budget and an end-to-end deadline amplifies overload instead of easing it.

Retry without jitter is a distributed metronome: a synchronized crowd finishes off a recovering service.

Code example (fragment)
fragment
Rust · full jitter (AWS recipe)
async fn retry<T, E>(mut f: impl FnMut() -> BoxFuture<'static, Result<T, E>>) -> Result<T, E> {
    let base = Duration::from_millis(100);
    for attempt in 0..5 {
        match f().await {
            Ok(v) => return Ok(v),
            Err(_) if attempt < 4 => {
                let cap = base * 2u32.pow(attempt);
                sleep(Duration::from_millis(rand::random_range(0..cap.as_millis() as u64))).await;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}
Go · end-to-end deadline
// the context deadline is end-to-end; the caller sets the timeout, not the library
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()

resp, err := s.gw.Charge(ctx, req) // the whole stack below honors the deadline
Java · Resilience4j
// breaker + bulkhead by composing decorators
var decorated = Decorators.ofSupplier(() -> gateway.charge(req))
    .withCircuitBreaker(CircuitBreaker.ofDefaults("gw"))
    .withBulkhead(Bulkhead.of("gw", BulkheadConfig.custom().maxConcurrentCalls(20).build()))
    .withRetry(Retry.of("gw", RetryConfig.custom().intervalFunction(
        IntervalFunction.ofExponentialRandomBackoff()).build()))
    .decorate();
Python · tenacity
# exponential backoff + jitter, declared declaratively
from tenacity import retry, stop_after_attempt, wait_random_exponential

@retry(stop=stop_after_attempt(5), wait=wait_random_exponential(multiplier=0.1, max=3))
async def charge(req: PaymentReq) -> Payment: ...

What the example above is missing. It retries every error. A production policy distinguishes retryable errors (timeout, 5xx, overload) from non-retryable ones (validation, authentication, 4xx) — retrying the latter is pointless and harmful; honors Retry-After instead of its own backoff; is bounded by an end-to-end deadline, not by a sum of individual timeouts; is cancelled together with the context; has a per-client retry budget so that retries don’t make up most of the traffic; and never retries a non-idempotent operation without an idempotency key (22). Attempt counts and budget exhaustion are metrics; otherwise degradation is invisible.

  • practice
  • context-dependent
  • fragment

The only way to stop is to crash; recovery is tested by every deploy. Separate shutdown code goes unexecuted for years and therefore doesn’t work.

The source is George Candea and Armando Fox, “Crash-Only Software” (HotOS IX, 2003). Their observation: a system has two ways to stop — clean shutdown and crash — and must survive the second anyway, because power cuts and kernel panics do not ask. If it must recover from a crash, a separate clean-shutdown path is a second, rarely run, code path to the same place. Make crash the only stop and start the only recovery, and the recovery code runs on every restart, so its bugs are found in daylight. Their requirements for that: all important state lives in dedicated crash-safe stores (a database with a write-ahead log, not process memory), components are restartable on their own, requests carry timeouts and are retried — which needs idempotent operations (22). Erlang’s “let it crash” with supervisor trees is the same idea at the process level.

Code example (fragment)
Gofragment
// The orchestrator (k8s) kills pods constantly — so startup must be recovery
func main() {
    state := mustRecoverFromLog(walPath)   // startup == recovery, always

    // graceful shutdown is minimal: just release leadership / drain the LB;
    // correctness does NOT depend on it
}

A stream consumer example: it commits its offset only after the effect is durable, so a SIGKILL at any instruction means some messages are processed again on restart, never lost — and processing again is safe because the sink is idempotent. A test that kills the process at random points under load (77) checks the property directly.

A clarification. The thesis is that correctness must not depend on graceful shutdown. A clean shutdown in itself remains a useful optimization: draining connections and the load balancer, handing off leadership, reducing rework after a restart. Dropping it is not a consequence of crash-only but a separate decision. The cost is recovery time: a large log replayed on every start makes deploys slow, so checkpoints bound it.

  • empirical law
  • established
  • fragment

Scope. The 63 % figure is arithmetic under an independence assumption, not a measurement; real tails are partly correlated, so it is an idealized estimate of the effect. The measured part is Dean & Barroso’s Table 1 from a Google fan-out service: 10 ms at p99 for one leaf, 140 ms at p99 for all leaves.

Versions. StructuredTaskScope is still a preview in the current release: JDK 27 (GA 2026-09-15) previews it for the seventh time (JEP 533), and --enable-preview is required. The fragment uses the names introduced in JDK 26 (JEP 525), which JDK 27 keeps: anySuccessfulResultOrThrow() from JEP 505 became anySuccessfulOrThrow(). JDK 27 adds a third type parameter for the exception join() throws, and the built-in joiners now throw a checked ExecutionException, so the enclosing method must declare or handle it. The API is not final yet.

Primary sources: Jeffrey Dean, Luiz André Barroso — The Tail at Scale, Communications of the ACM 56(2), 2013

References: JEP 533: Structured Concurrency (Seventh Preview) — JDK 27, names kept, checked join() exception, JEP 525: Structured Concurrency (Sixth Preview) — JDK 26, JEP 505: Structured Concurrency (Fifth Preview) — JDK 25, previous names

Other claims on this card, typed separately:

Measure the tail, not the mean: when one user request fans out to many servers, it waits for the slowest of them, so a rare slow response on each server becomes a common slow response for users. Hedged requests attack the tail directly: send a duplicate to another replica once the first has been outstanding longer than the p95, and take whichever answers first.

The source is Jeffrey Dean and Luiz André Barroso, “The Tail at Scale” (Communications of the ACM, February 2013). Their point is that at Google’s scale latency variability cannot be engineered away component by component — background jobs, garbage collection, queueing and shared resources always produce some slow responses — so systems have to be tail-tolerant, the way fault-tolerant systems accept that parts fail. Besides hedging they describe tied requests (both replicas are told about each other and the second cancels once the first starts work), micro-partitioning for fast rebalancing, and putting slow machines on probation.

Code example (fragment)
Java · JDK 26–27 (structured concurrency preview, JEP 525/533)fragment
// hedged request on virtual threads
try (var scope = StructuredTaskScope.open(Joiner.<Reply>anySuccessfulOrThrow())) {
    scope.fork(() -> replica1.get(key));
    scope.fork(() -> { Thread.sleep(p95Latency); return replica2.get(key); }); // hedge
    return scope.join();  // first success wins, the other is cancelled
}

// On JDK 25 (JEP 505) the same joiner was named anySuccessfulResultOrThrow().
// On JDK 21 (JEP 453) the shape differs; the factories did not exist yet:
// try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Reply>()) {
//     scope.fork(() -> replica1.get(key));
//     scope.fork(() -> { Thread.sleep(p95Latency); return replica2.get(key); });
//     scope.join();
//     return scope.result();
// }

The arithmetic and the cost. With a fan-out of 100 independent calls, the probability of hitting at least one component's tail is ≈ 1 − 0.99¹⁰⁰ ≈ 63 %: the component's p99 becomes the user's ordinary experience. Hedging applies only to idempotent, cancellable operations (22) and buys latency with extra traffic, so it needs a budget, and the share of hedged requests belongs in the metrics.

What was measured and what was not. The 63 % figure is arithmetic under an independence assumption; real tails are partly correlated (one GC pause, one noisy neighbour hits many requests at once), so treat it as the shape of the effect, not a prediction. The measured part is the paper’s: in a Google benchmark reading 1,000 keys spread over 100 BigTable servers, hedging after a 10 ms delay cut the 99.9th-percentile latency from 1,800 ms to 74 ms while sending about 2 % more requests. Hedging does not help when the slowness is shared — an overloaded database behind every replica only gets twice the load — and it is the wrong tool for non-idempotent writes.

  • pattern
  • context-dependent
  • no code

Conflicts with: Paved road ↔ blast radius

The unit of failure is designed up front: customers are sharded into cells — complete, independent copies of the stack — and an incident hits one cell, not everyone. Shuffle sharding is a variant that reduces the overlap between customers combinatorially.

A cell has its own compute, its own database and its own queues; the only shared component is a thin router that maps a partition key (tenant, account) to a cell. A bad deploy, a poison message (81), a runaway tenant or a corrupted index stays inside the cell it reached, and deploys roll out cell by cell, so the first cell is the canary for the rest. The idea is the bulkhead (24) applied to the whole stack. AWS describes it in the Well-Architected guidance “Reducing the Scope of Impact with Cell-Based Architecture” and uses it for its own control planes.

Shuffle sharding is described by Colm MacCárthaigh in the Amazon Builders’ Library article “Workload isolation using shuffle-sharding”. Instead of fixed groups, each customer gets a random combination of workers. With 8 workers and 2 per customer there are 28 combinations; a customer whose requests crash their two workers takes down only the customers who share both — on average a small fraction, and clients that retry against the other workers of their shard see little effect. With plain sharding into 4 pairs, the same customer takes out a quarter of everyone.

The cost and the limits. Cells multiply fixed cost (N databases to patch, N dashboards), make cross-cell queries and cross-tenant features hard, and need a migration path for tenants that outgrow a cell. The router and the cell-assignment table are the new single point of failure, so they are kept minimal, cached at the edge and changed rarely. Global dependencies — DNS, the identity provider, a shared config push — bypass cells entirely; an incident there has the old blast radius. Below a few dozen tenants or one region the overhead usually exceeds the benefit. Expressed by deployment topology, not by code.

  • practice
  • context-dependent
  • config

Conflicts with: Chaos engineering ↔ error budget

“We will survive losing a replica” is a hypothesis; until it has been tested by injecting the failure, it is an opinion. The method: steady-state metric → hypothesis (“killing a pod does not move p99”) → minimal blast radius (27) → injection → comparison. The detail people skip: the end goal is production, because staging reproduces neither the traffic, nor the data, nor the configuration.

The practice comes from Netflix: Chaos Monkey, which terminated production instances during business hours, appeared around its move to AWS, and the discipline was written up in Basiri et al., “Chaos Engineering” (IEEE Software, 2016) and in the Principles of Chaos Engineering. The principles are the method above: define steady state as a measurable output, hypothesize that it continues in both the control and the experimental group, vary real-world events, run in production, automate, minimize blast radius.

Code example (config)
Experiment as an artifactconfig
# an experiment is a versioned artifact, like a test
experiment: kafka-broker-loss
steady_state: { metric: consumer_lag_p99, threshold: "< 30s" }
method: [{ action: kill-pod, selector: "app=kafka, broker=2" }]
rollback: automatic          # abort on threshold breach
blast_radius: cell-eu-1      # one cell, not the fleet

An honest assessment. Chaos without observability is vandalism: if you cannot answer “what broke?”, you have no business breaking anything. The order is strict: SLOs and tracing first, injections second. Game days on staging are a legitimate first step, but not the final one.

What a good experiment finds. Not that the broker died — you knew that — but the assumption around it: a producer configured with acks=1 that loses acknowledged writes on leader failover, a client whose retry storm outlasts the outage (24), an alert that never fired. Each finding becomes a fix plus a permanent automated experiment, like a regression test.

Limits. Fault injection tests resilience, not correctness: a consistency bug under partition needs history checking against a model (98), not a dashboard. Where a failed experiment harms people or breaks a regulatory duty, production injection is replaced by simulation and restore drills (97). And for a small system with one region and an untested backup, the restore test pays off long before the monkey does.

  • practice
  • established
  • fragment

Scope. A drill proves one scenario on one store. The replay-timestamp RPO overestimates loss on an idle primary, a scratch-instance restore says nothing about cutover, DNS or dependent services, and recovering several stores to one consistent moment is a separate design problem. The target numbers themselves are a cost decision: AWS guidance allows no DR at all for workloads where recovery costs more than the loss.

Versions. PostgreSQL 18: pg_verifybackup checks tar-format (including compressed) backups, but WAL parsing works only for plain format, hence -n.

Primary sources: Google SRE Book — Chapter 26: Data Integrity: What You Read Is What You Wrote, AWS Well-Architected — REL13-BP03 Test disaster recovery implementation to validate the implementation

References: AWS Whitepaper — Disaster Recovery of Workloads on AWS: Business Continuity Plan (RTO and RPO definitions), AWS Well-Architected — REL11-BP04 Rely on the data plane and not the control plane during recovery, PostgreSQL 18 — Continuous Archiving and Point-in-Time Recovery (PITR), PostgreSQL 18 — pg_verifybackup

Other claims on this card, typed separately:

A backup is an artifact; recovery is a capability. RPO (how much data may be lost) and RTO (how long the service may be down) are business numbers set per workload, and neither is a property of a backup job that exits 0. The only evidence for them is a restore that was actually performed, timed end to end and checked for content. Until then “we have backups” is a hypothesis about a procedure nobody has run.

Where the numbers actually come from. Achieved RPO is not the backup schedule but the lag of the last durable, restorable point: for PITR that is the base backup plus an unbroken WAL sequence up to the failure, and a single missing segment ends replay there. RTO is the sum of everything on the path — finding the right backup, fetching it, restoring, replaying, validating, and cutting traffic over — and replay time grows with the distance to the last base backup. Replication does not replace any of this: it carries a DROP TABLE or a corrupting bug to every replica within seconds; the defence against logical damage is a point in time before it. Restoring one store is also not restoring a system: a database and an object store recovered to different moments leave dangling references, the same boundary problem as in 68.

Code example (fragment)
fragment
Bash · PostgreSQL 18
#!/usr/bin/env bash
set -euo pipefail
t0=$(date +%s)                         # the simulated disaster happens now
backup=/backups/latest                 # pg_basebackup -Ft -z --wal-method=stream
pgdata=/drill/pgdata

# 1. Integrity against the manifest (tar format cannot have its WAL parsed, hence -n).
#    Necessary, not sufficient: the PostgreSQL docs still require a real restore.
pg_verifybackup -n "$backup"

# 2. Restore into a scratch instance and replay the archive to its end.
rm -rf "$pgdata" && install -d -m 700 "$pgdata"
tar -xzf "$backup/base.tar.gz" -C "$pgdata"
tar -xzf "$backup/pg_wal.tar.gz" -C "$pgdata/pg_wal"
cat >> "$pgdata/postgresql.auto.conf" <<'CONF'
restore_command = 'cp /archive/%f "%p"'
archive_mode = off   # a promoted drill copy must never write into the production archive
CONF
touch "$pgdata/recovery.signal"      # no recovery target: replay everything, then promote
pg_ctl -D "$pgdata" -o '-p 5499' -l /drill/restore.log -W start
until [ "$(psql -p 5499 -d postgres -Atc 'select pg_is_in_recovery()' 2>/dev/null)" = f ]; do
  sleep 5
done

# 3. Measure the promise instead of asserting it. The RPO figure overestimates
#    the loss when the primary was idle before t0; RTO is wall-clock to a promoted server.
psql -p 5499 -d app -v ON_ERROR_STOP=1 -At <<SQL
select 'rpo_s', extract(epoch from to_timestamp($t0) - pg_last_xact_replay_timestamp());
select 'rto_s', extract(epoch from now()) - $t0;
select 'orders_last_hour', count(*) from orders
 where created_at > to_timestamp($t0) - interval '1 hour';
SQL

Failure modes a drill finds and a dashboard does not. An archive command that returns 0 without having stored the file: PostgreSQL then recycles the segment and the hole surfaces only at restore time. Encryption keys, credentials or the runbook itself living inside the system being recovered. And recovery that depends on the control plane: provisioning instances, changing DNS records, creating resources — APIs that can be impaired while the data plane keeps serving. AWS guidance is explicit here: recover with data-plane actions and pre-provision what recovery needs (101, 105).

Decision rule. A recovery claim is the measured result of the last drill, with its date, for example “on <date>: restored to <N> s before t0, promoted in <M> min, row counts matched” — not a retention setting. Drills run on a schedule, in automation, against the real backup location, and a failed drill pages like an outage, because it is one that has not happened yet. If the measured RTO exceeds the target, the fix is architectural (more frequent base backups, warm standby, smaller restore units via cells, 27) and not a more optimistic runbook.

  • practice
  • established
  • no code

Scope. The guidance is drawn mainly from AWS’s own prescriptive material, and its figures — a hot standby roughly doubling cost, the Route 53 control plane in us-east-1, DynamoDB’s conflict and transaction behaviour — are AWS-specific. The table is a simplification: real systems mix rows per dataset, and the data at risk also depends on queues, caches and third-party services that the table does not list.

Conflicts with: Active/active availability ↔ multi-row transactional invariants

Primary sources: AWS Prescriptive Guidance — AWS multi-Region fundamentals

References: AWS multi-Region fundamentals — Fundamental 2, Understanding the data, AWS multi-Region fundamentals — Fundamental 3, Understanding your workload dependencies, AWS multi-Region fundamentals — Fundamental 4, Operational readiness, AWS Fault Isolation Boundaries — Static stability, Amazon DynamoDB Developer Guide — How global tables work

A second region protects against a regional failure only if nothing on the recovery path still depends on the first one, and it is not cheap: AWS’s own guidance says a hot standby roughly doubles the cost and that most availability and RTO/RPO goals can be met inside one region. When the goal really needs two, the first design question is not “active/active or active/passive” but for each dataset, which region may accept writes right now, and how every other region knows it. Replication lag, conflicts and split brain all follow from that answer.

TopologyWho accepts writesData at risk on regional lossWhat you must build
Active/passive, async replicationOne primary regionWrites not yet replicated; they are stranded until the old region returnsPromotion with fencing of the old primary, reconciliation of stranded writes, rehearsed failback
Partitioned ownership (region as a cell)Each shard's home regionUnreplicated writes of the shards homed in the failed region onlyShard-aware routing, per-shard failover; a regional failure hits only the shards homed there
Active/active, async, multi-writerAny regionUnreplicated writes, plus concurrent writes silently resolved by the conflict ruleA conflict rule the business accepts (last writer wins drops one update), idempotent writes, invariants that hold per region
Synchronous quorum across three regionsAny region, via the quorumNone for acknowledged writes (RPO zero)Cross-region round trips on every write, a third region or witness, retries on write conflicts

Failover is a split-brain problem. Promoting the standby while the old primary may still be alive and reachable by some clients produces two writers. DNS does not fence: answers are cached, and a client that already resolved the old address keeps using it. The promoted side needs an epoch that the storage itself checks, or the old primary must be made unable to accept writes before promotion (66). In an active/active table the equivalent trap is quieter: DynamoDB global tables in the default eventual mode resolve concurrent updates of one item by the latest timestamp, and their transactions are atomic only inside the region that ran them — a cross-row invariant from 90 does not survive the trip.

The recovery path must not live in the failed region. AWS’s example: the Route 53 control plane is hosted in us-east-1, so a failover that needs a DNS record change is a poor mechanism when the primary is us-east-1. Prefer pre-provisioned capacity and switches that need no control-plane change on the recovery path (101); keep certificates, keys, secrets and parameters region-local, with staggered expiry so one renewal failure cannot hit both; forbid cross-region calls inside a user journey and fail the whole journey over together. Deploy one region at a time, so a bad release cannot take both (27, 72).

Decision rule. Start from per-workload RTO and RPO (97). If waiting out a regional outage meets them, stay in one region with backups in another. Otherwise pick the simplest row of the table that meets the RPO, write down who owns each dataset’s writes and how ownership moves, monitor replication lag as an SLI, and keep the failover automated but the decision human. A failover that has not been run — both ways, with the same runbook as in a real event — is a hope, not a capability.

V

Distributed data and consistency

  • heuristic
  • established
  • no code

Scope. This is a taxonomy of false assumptions, not an observed regularity: Deutsch’s list predicts nothing; it enumerates premises the design must explicitly refute. It is checked with a checklist at every network boundary, not by measurement, and so it lives in review, not in monitoring.

Eight fallacies (Deutsch / Gosling): the network is reliable, latency is zero, bandwidth is infinite, the network is secure, topology doesn’t change, there is one administrator, transport cost is zero, the network is homogeneous. Each is “obviously false” — and each is routinely baked into designs implicitly.

The list is folklore from Sun Microsystems in the 1990s: it is attributed to L. Peter Deutsch and colleagues there, with the eighth added later by James Gosling. It was never a paper with data; it is a summary of how distributed designs kept failing. The empirical record came later. Bailis and Kingsbury, “The Network is Reliable” (ACM Queue, 2014), collected public postmortems and studies of real partitions — switch failures, misconfigured firewalls, GC pauses that looked like partitions to peers — in systems whose designers had assumed the network away.

How they appear in backend code. Reliable network: an HTTP client with no timeout, so one slow dependency exhausts the caller’s threads. Zero latency: an ORM loop issuing one query per row, fine on a laptop and 400 round trips across zones in production. Infinite bandwidth: a service returning a full object graph when the caller needs two fields. Secure network: plaintext between services “because it is internal” (32). Topology doesn’t change: IPs cached past a pod reschedule. Zero transport cost: cross-region replication priced only after the bill (78).

A checkable form. Every service-to-service call in the code must answer three questions: timeout? retry policy (24), and is the operation safe to retry (22)? what happens when the other side is unavailable? No answer = fallacy #1 baked in. The opposite error is treating an in-process call as remote “to be safe” — that is accidental complexity (40). Not expressible in code; expressed as a review checklist for every network boundary.

  • heuristic
  • established
  • no code

Scope. PACELC is a design taxonomy, not a theorem: it proves nothing and classifies the trade-offs a system makes in and out of a partition. “A CP/AP database” is a marketing label, because real systems are tuned per operation.

Conflicts with: Linearizability ↔ latency/availability (PACELC)

Primary sources: Daniel Abadi — Consistency Tradeoffs in Modern Distributed Database System Design (PACELC), IEEE Computer, 2012

Other claims on this card, typed separately:

CAP in its popular form is degenerate: P is not a choice — partitions happen, and the only choice is between C and A during a partition. PACELC is more honest: even without a partition (E), a choice between latency (L) and consistency (C) always exists — synchronous replication costs an RTT on every write.

CAP began as Eric Brewer’s conjecture in his PODC 2000 keynote and was proved by Seth Gilbert and Nancy Lynch (SIGACT News, 2002) for a precise model: C there means linearizability, A means every request to a non-failed node gets a response, and the proof is about behaviour while messages are lost. Brewer himself revisited it in “CAP Twelve Years Later: How the ‘Rules’ Have Changed” (IEEE Computer, 2012), stressing that partitions are rare and the choice is made per operation. PACELC is Daniel Abadi’s, from “Consistency Tradeoffs in Modern Distributed Database System Design” (IEEE Computer, 2012): if there is a Partition, choose A or C; Else, choose Latency or Consistency. In his classification the default configurations of Dynamo-style stores are PA/EL, and fully ACID systems are PC/EC.

Honest assessment. “CP or AP” as a database label is a marketing taxonomy; real systems are tuned per operation (quorum vs one in Cassandra, synchronous_commit in Postgres). The principle: the consistency level is a property of the operation, not of the system, and it is chosen by the invariant the operation protects.

PACELC does not invalidate CAP, it extends it: CAP describes behavior during a partition, PACELC adds the choice in normal operation. The “insufficient” in the title refers to the popular use of CAP as a label for databases. And synchronous_commit in PostgreSQL is a commit-acknowledgment and durability/replication setting, not a per-operation consistency-model switch.

The EL/EC choice in numbers. A PostgreSQL primary with a synchronous standby in another availability zone waits for the standby on every commit: about a millisecond or two inside a region, tens of milliseconds to another region. Asynchronous replication removes that wait and opens a window in which an acknowledged write exists on one machine only. Neither is “the right setting”; the invariant decides (65, 90) — a payment ledger pays the RTT, a view counter does not.

  • pattern
  • established
  • fragment

Scope. Consistency is a vector of guarantees, not a single scale: causal consistency and session guarantees cover different axes and do not form a total order. The only thing strictly ordered is the cost of real time: linearizability requires it, sequential and causal do not.

Conflicts with: Linearizability ↔ latency/availability (PACELC)

Primary sources: Herlihy, Wing — Linearizability: A Correctness Condition for Concurrent Objects

“Read your writes” is not a property of the database but a guarantee the service either gives or doesn’t, and it must be written into the contract. Consistency is not a single “stronger/weaker” scale but a set of observable guarantees: operation order, visibility of other clients’ writes, freshness, behavior within a session, binding to real time. Linearizability is the most expensive because it demands the last one: an operation must appear to take effect at a single instant between invocation and response. Most features are well served by session guarantees (read-your-writes, monotonic reads) — paying for linearizability by default means paying for something you don’t use.

Code example (fragment)
fragment
Java
// read-your-writes via a version token, no sticky sessions
record WriteResult(OrderId id, long lsn) {}          // the write's LSN/HLC goes back to the client

Order read(OrderId id, long minLsn) {
    var replica = pool.any();
    replica.waitForLsn(minLsn, TIMEOUT);             // replica catches up, or we route to primary
    return replica.get(id);
}
// client carries lsn in a cookie/header → its own writes are visible,
// others' only eventually; that is a session guarantee, declared explicitly
Go
// same at the contract level: the request/response type carries the guarantee
type ReadConsistency int

const (
    Eventual ReadConsistency = iota
    ReadYourWrites
    Linearizable // documented as expensive: goes through the leader
)

func (s *Store) Get(ctx context.Context, id OrderID, c ReadConsistency) (Order, error)

A design-review test question: “which anomaly will the user pay for this optimization with — and do they know about it from the contract?”

Exposing long lsn leaks PostgreSQL into the contract. What goes out is an opaque consistency token with documented semantics, a wait timeout and defined behavior when it expires (fall back to reading from the leader or an explicit error).

  • pattern
  • established
  • fragment

No TTL-based lock (Redis, ZooKeeper lease, k8s lease) protects anything on its own: the holder can stall (a GC pause, a network delay), the TTL expires, someone else acquires the lock — and the “dead” holder wakes up and writes over them. The only correct scheme: the lock hands out a monotonic token, and the storage rejects writes carrying a stale token. The protection lives on the resource side, not the lock side — this is the E2E argument (21) applied to locking.

Code example (fragment)
fragment
Go
token, err := locker.Acquire(ctx, "settlement-2026-08-04") // token = 34
if err != nil { return err }
defer locker.Release(token)

// the store checks: accepted max token = 33 → 34 passes;
// a zombie arriving with token = 33 after 34 was seen is rejected
if err := store.Write(ctx, key, val, WithFenceToken(token)); err != nil { ... }
Rust
// the token is in the type: a write can't be built without it
pub struct Fenced<T> { token: FenceToken, payload: T }

impl Store {
    pub fn write(&self, w: Fenced<Write>) -> Result<(), Stale> { ... }
}

An honest assessment. The requirement on the resource is more precisely stated as a monotonic check: a fencing token is one special case, and a version or a conditional write / compare-and-swap expresses the same protection. If the resource supports none of these (S3 without a precondition, an external API), the lock buys you efficiency — not doing the work twice — but not correctness; confusing these two uses is the other half of “distributed lock” incidents, including the well-known Kleppmann vs antirez debate over Redlock. And separately: consensus (Raft/Paxos) is an algorithm, not a heuristic; you do not write your own implementation, you take an existing one.

  • pattern
  • established
  • fragment

Scope. Logical clocks solve one problem — the “happened before” relation. Durations and deadlines are measured with monotonic clocks, domain and human time stays with the wall clock, and ownership of a resource is proven by a generation number (66), not by time at all. The card’s claim is about ordering across nodes, not that the wall clock is unnecessary.

Primary sources: Leslie Lamport — Time, Clocks, and the Ordering of Events in a Distributed System

NTP drift, leap smearing, VM pauses: System.currentTimeMillis() on two machines is not comparable, and “last write wins by timestamp” silently loses writes. Ordering is done with Lamport clocks / HLC / version vectors; wall clock is only for humans (logs, metrics) and for TTLs with a margin.

Three clocks that get confused. Wall clock (currentTimeMillis, CLOCK_REALTIME) answers “when, by the calendar”: logs, domain dates, TTLs with a margin. It jumps — forward and backward — on NTP correction. Monotonic clock (nanoTime, CLOCK_MONOTONIC) doesn’t jump and is therefore the only one fit for durations, timeouts and deadlines — but it is not comparable across machines and resets on restart, so storing it is pointless. Logical clocks (Lamport, vector, HLC) are the only source of the “happened before” relation between nodes. The typical bugs are exactly these substitutions: a duration computed from the wall clock goes negative during a correction, and “ordering” by the monotonic clock is meaningless outside the process. A separate layer is the fencing token (66): there you need not the order of events but the owner’s generation number.

Code example (fragment)
fragment
Python · HLC
# physical time + logical counter, monotonicity guaranteed
@dataclass(frozen=True, order=True)
class HLC:
    wall_ms: int
    logical: int

    def tick(self, now_ms: int) -> "HLC":
        if now_ms > self.wall_ms:
            return HLC(now_ms, 0)
        return HLC(self.wall_ms, self.logical + 1)   # clock went backwards: bump the counter

    def recv(self, other: "HLC", now_ms: int) -> "HLC":
        m = max(self.wall_ms, other.wall_ms, now_ms)
        l = max(self.logical if m == self.wall_ms else -1,
                other.logical if m == other.wall_ms else -1) + 1
        return HLC(m, l)
Java
// review smell: comparing times recorded by different nodes
if (eventA.timestamp().isBefore(eventB.timestamp())) { ... } // ← flag it if A and B
                                                             //   were written by different nodes

Google TrueTime is the exception that proves the rule: Spanner buys clock comparability with atomic clocks and an explicit uncertainty interval (commit-wait). If you don’t have atomic clocks, you don’t have comparable timestamps.

Don’t mix the three clocks: wall clock for calendar time and display; monotonic clock for durations, timeouts and deadlines; HLC or version vectors for causal order. And last-write-wins is not always a bug: it is a legitimate convergence policy if losing a concurrent update is a deliberate decision rather than a surprise.

  • pattern
  • established
  • fragment

Conflicts with: Outbox ↔ write latency

“Wrote to the database and published to Kafka” is two systems with no shared transaction: you can always crash between them, and one side will lie. There are two correct schemes: outbox (the event is written in the same DB transaction, a relay / CDC delivers it) or CDC directly from the WAL. “Publish first, then commit” and the reverse are both broken — they just produce phantoms of opposite sign.

Code example (fragment)
fragment
SQL
-- one transaction: the business fact and the event are atomic
BEGIN;
INSERT INTO payments (id, amount_cents, status) VALUES ($1, $2, 'captured');
INSERT INTO outbox (event_id, aggregate_id, type, payload)
VALUES (gen_random_uuid(), $1, 'PaymentCaptured', $3);
COMMIT;

-- relay (Debezium / poller) reads outbox → Kafka; delivery is at-least-once,
-- so consumer-side dedup by event_id is mandatory (see 21)
Go · relay
// at-least-once loop: mark only after a confirmed publish
rows := tx.Query(`SELECT event_id, payload FROM outbox
                  WHERE published_at IS NULL ORDER BY seq LIMIT 100 FOR UPDATE SKIP LOCKED`)

for _, e := range rows {
    if err := producer.Send(ctx, e); err != nil { break } // not marked — we'll come back
    tx.Exec(`UPDATE outbox SET published_at = now() WHERE event_id = $1`, e.ID)
}

Outbox does not give exactly-once — it gives atomic writes and at-least-once delivery; deduplication still happens at the end (21). Listen-to-yourself and “just retry the publish” are not equivalents but well-known ways to get out of sync.

The relay loop is not idempotent by itself — it is at-least-once: the mark is set only after a confirmed publish, so a crash between send and update yields redelivery, not loss. Hence the mandatory details: claiming rows with FOR UPDATE SKIP LOCKED, a bounded batch, a publish timeout, and deduplication at the consumer (21).

  • pattern
  • context-dependent
  • fragment

Conflicts with: Saga ↔ isolation

Distributed ACID transactions exist (XA, 2PC) and give a stronger contract, atomicity across all participants at once, but they pay in coordination: participants hold locks until the protocol completes, and between prepare and the decision they are not free to decide on their own. A replicated coordinator (a decision log under Raft) removes the single point of failure, but it removes neither the locks nor the extra round trips. For long-running cross-service processes a saga is more practical: a chain of local transactions, each with a compensating action. The main cost people forget: isolation is gone. Intermediate states are visible to other participants, and a compensation does not “roll back”; it writes a new fact (a refund ≠ cancelling the charge).

Code example (fragment)
Javafragment
// steps and compensations are declared in pairs; state is an explicit persisted type
sealed interface SagaStep permits ReserveInventory, ChargeCard, BookShipment {}

record ChargeCard(Cents amount) implements SagaStep {
    Compensation compensation() { return new RefundCard(amount); } // a new fact, not a rollback
}

// isolation by hand: a semantic lock; an order in status PENDING_PAYMENT
// is invisible to resale until the saga completes or is compensated

An honest assessment. As the number of steps grows, an explicit orchestrator (a state machine) is usually easier to observe and recover than implicit choreography: the question “who owns the process right now?” stops having a clear answer exactly when you need one at 3 a.m. This is a heuristic, not a threshold: for two participants with a clear contract, choreography is cheaper. A long-running saga = a workflow engine (Temporal and the like), which is not a new idea but a persisted saga orchestrator.

Caching

  • heuristic
  • established
  • fragment

Conflicts with: Cache ↔ consistency

A cache is not a “speedup” but a deliberate weakening of consistency: the TTL is an SLA on staleness, and it must be derived from a business requirement (“the price may be wrong for 60 seconds”), not from “eh, let's say 5 minutes.” Event-driven invalidation with a TTL as a safety floor is a good default precisely as a pair: invalidations get lost not because of delivery semantics but because of operational failures (a crashed relay, an overflow, a routing error, a desync after a rebalance), and the TTL bounds the damage of such a loss. This is a default, not an obligation: durable write-through with explicitly stated guarantees is also correct.

The best-documented account of this at scale is Nishtala et al., “Scaling Memcache at Facebook” (NSDI 2013). Two of its mechanisms answer the failures above: invalidations are driven from the database’s commit log rather than from application code, so a crashed web server cannot skip one, and leases stop a slow reader from writing a stale value back after a newer write deleted the key — the race every cache-aside implementation has by default. HTTP made staleness explicit in the protocol: Cache-Control: max-age and stale-while-revalidate (RFC 5861, on top of the caching semantics of RFC 9111) state how old a response may be and for how long it may be served while refreshing.

Code example (fragment)
Gofragment
// TTL as a contract, not a constant buried deep in the code
type CachePolicy struct {
    TTL                  time.Duration // = the documented staleness budget
    StaleWhileRevalidate time.Duration // serve stale, refresh in the background
}

var pricePolicy = CachePolicy{TTL: 60 * time.Second, StaleWhileRevalidate: 10 * time.Second}
// review question: "why 60?" must have an answer citing the requirement

Cache-aside / read-through / write-through / write-behind is not a matter of taste: write-behind loses data when the cache goes down and is acceptable only for recoverable data (counters, metrics). A key with two writers (the service and the invalidator) is a race by construction; version the value or write through a single point.

Where no staleness budget exists. Some reads must not be stale at all: a balance checked before a withdrawal, a permission just revoked, an idempotency key (22). There the cache either is bypassed for that read or becomes part of the write path with the same guarantees as the database. If nobody can state the budget in business terms, that is the finding: the cache is hiding a consistency decision nobody made. Stampedes on expiry are a separate problem (71).

  • pattern
  • established
  • fragment

Expiry of a hot key = a synchronized volley from all its subscribers into the origin, a self-DDoS on a schedule. Three mandatory mechanisms: collapse concurrent misses into one request (singleflight), randomize TTLs (so keys don't expire in unison), and cache absence (otherwise every request for a nonexistent id hits the database).

Code example (fragment)
fragment
Go · singleflight
var g singleflight.Group

func (s *Svc) Product(ctx context.Context, who Principal, id string) (Product, error) {
    // one key for cache AND singleflight: tenant + access scope + id,
    // or a caller joins a flight that loaded someone else's view
    key := who.TenantID + "|" + who.Scope + "|" + id
    if p, ok := s.cache.Get(key); ok { return p, nil }
    v, err, _ := g.Do(key, func() (any, error) {      // N misses → 1 origin request
        p, err := s.db.Product(ctx, who, id)
        if errors.Is(err, ErrNotFound) {
            s.cache.SetNegative(key, 30*time.Second)  // negative cache, short TTL
            return Product{}, err
        }
        s.cache.Set(key, p, jitterTTL(5*time.Minute)) // ±20% — desynchronizes expiry
        return p, err
    })
    ...
}
Python · XFetch
# probabilistic early refresh: hot keys are refreshed BEFORE they expire,
# with probability rising as the TTL approaches
import math, random, time

def should_refresh(expires_at: float, delta: float, beta: float = 1.0) -> bool:
    return time.time() - delta * beta * math.log(random.random()) >= expires_at

Negative caching without a short TTL is a way to cache a transient error forever; cache absence, not errors (an origin 5xx is never written to the cache).

Limits of applicability. Negative caching is needed where misses are genuinely expensive; otherwise it is an extra layer. The cache key must include the tenant and access rights — otherwise singleflight will merge requests from users with different visibility and hand out someone else's data. And cancelling the first caller's context must not cancel the work for everyone who joined it.

  • heuristic
  • established
  • fragment

Scope. That snapshot isolation admits write skew is a result about histories (Berenson et al.); that PostgreSQL SERIALIZABLE aborts such interleavings is documented behaviour and was reproduced on PostgreSQL 18.6 for the example shown. The decision rule itself — pick the level from the shape of the invariant, prefer constraints, then row locks, then SERIALIZABLE — is engineering judgment, not a theorem. The SQL comments describe one observed interleaving: which transaction SSI aborts, and on which statement, depends on timing, and SSI can also abort transactions that were in fact serializable.

Versions. Behaviour verified on PostgreSQL 18.6 (postgres:18 image). PostgreSQL has shipped SSI as its SERIALIZABLE level since 9.1; REPEATABLE READ there is snapshot isolation, and READ UNCOMMITTED behaves as READ COMMITTED.

Conflicts with: Active/active availability ↔ multi-row transactional invariants

Primary sources: Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil — A Critique of ANSI SQL Isolation Levels (SIGMOD 1995), Ports, Grittner — Serializable Snapshot Isolation in PostgreSQL (VLDB 2012)

References: Adya, Liskov, O’Neil — Generalized Isolation Level Definitions (ICDE 2000), PostgreSQL 18 documentation — Transaction Isolation, PostgreSQL 18 documentation — Serialization Failure Handling

Other claims on this card, typed separately:

An isolation level is a list of anomalies you agree to handle yourself. Snapshot isolation (PostgreSQL’s REPEATABLE READ) stops lost updates on the same row, but not write skew: two transactions read an overlapping set, each checks the invariant against its own snapshot, then each writes a different row — no write-write conflict, both commit, and the invariant that spans the rows is gone. So the question at review is not “which level do we use” but “which invariant does this transaction protect, and is it a single-row fact, a multi-row fact, or a predicate over rows that may not exist yet”.

Code example (fragment)
fragment
PostgreSQL 18 · two sessions
-- Invariant: shift 1 always has at least one doctor on call.
CREATE TABLE on_call (doctor text PRIMARY KEY, shift_id int NOT NULL, on_call bool NOT NULL);
INSERT INTO on_call VALUES ('alice', 1, true), ('bob', 1, true);

-- Sessions A and B interleave; each step runs in time order.
BEGIN ISOLATION LEVEL REPEATABLE READ;                             -- A
BEGIN ISOLATION LEVEL REPEATABLE READ;                             -- B
SELECT count(*) FROM on_call WHERE shift_id = 1 AND on_call;       -- A: 2, "safe to leave"
SELECT count(*) FROM on_call WHERE shift_id = 1 AND on_call;       -- B: 2, "safe to leave"
UPDATE on_call SET on_call = false WHERE doctor = 'alice';         -- A
UPDATE on_call SET on_call = false WHERE doctor = 'bob';           -- B: different row, no wait
COMMIT;                                                            -- A: ok
COMMIT;                                                            -- B: ok
-- Result: nobody on call. Both transactions were correct in isolation.

-- Same interleaving with BEGIN ISOLATION LEVEL SERIALIZABLE:
-- A commits; B's COMMIT fails and bob stays on call:
--   ERROR: could not serialize access due to read/write dependencies among transactions
--   SQLSTATE 40001, "Canceled on identification as a pivot, during commit attempt."
-- The caller re-runs B from BEGIN, including the SELECT: the retry now sees count = 1.

-- Alternative at READ COMMITTED: lock the rows the decision reads.
SELECT doctor FROM on_call WHERE shift_id = 1 AND on_call FOR UPDATE;
-- B blocks until A commits, then gets only 'bob' back and must refuse.
-- At REPEATABLE READ the same statement fails B with 40001 instead.
-- Neither variant stops a concurrent INSERT of a new matching row (a phantom).

Decision rule. A single-row invariant is covered by READ COMMITTED plus an atomic statement (UPDATE … SET x = x - 1 WHERE x > 0) or a version check (66). An invariant expressible as a constraint belongs in the schema: UNIQUE, CHECK, EXCLUDE — the database enforces it at every level. A multi-row invariant over rows that already exist can be protected by SELECT … FOR UPDATE on exactly those rows; the lock must cover everything the decision read, and it blocks. A predicate over rows that may be inserted concurrently (“no overlapping booking”, “at most N active sessions”) needs SERIALIZABLE, a materialized conflict row that every writer updates, or a coarser lock. SERIALIZABLE is the only option that stays correct when a new transaction type appears that nobody reviewed against the old ones.

What SERIALIZABLE costs in PostgreSQL. It is SSI: snapshot isolation plus tracking of read/write dependencies through non-blocking SIReadLock predicate locks; when two consecutive dependencies between concurrent transactions appear, one of them is aborted (the pivot, if it has not committed yet). The detection is conservative, so some aborted transactions would have been serializable. The price is paid in retries, and the retry contract is strict: SQLSTATE 40001 can arrive on any statement including COMMIT; the whole transaction, including the reads and the logic that chose what to write, is re-run (any side effect outside the database inside that code needs 22, and the loop needs a bound, 24); nothing read inside it counts as true until it commits. Sequential scans take relation-level predicate locks and raise the failure rate, so the queries need indexes. Read-only reports can use SERIALIZABLE READ ONLY DEFERRABLE, which waits for a safe snapshot and then cannot be rolled back by a serialization failure.

Level names are not portable. ANSI defines levels by phenomena, and Berenson et al. showed those definitions are ambiguous and that snapshot isolation is incomparable with locking REPEATABLE READ: each allows an anomaly the other forbids. The same keyword means different guarantees in different engines, and read replicas or caches in front of the database change them again (65, 70). Specify the anomaly, not the keyword.

  • pattern
  • established
  • fragment

Scope. The safety of Raft and Paxos and the FLP impossibility are proved, each in its own model: crash-stop or crash-recover failures, no Byzantine behaviour, durable storage that does not lie. The card’s engineering advice — how to read, change membership and retry — is practice built on those results, not a consequence of them. A proof covers the algorithm, not a given implementation: the membership-change bug shows that even a published variant can be wrong.

Versions. The Go fragment targets the etcd client go.etcd.io/etcd/client/v3 v3.7.1 and was checked with go vet on Go 1.27; linearizable-by-default reads and the serializable option are described in the etcd v3.7 API guarantees.

Primary sources: Diego Ongaro, John Ousterhout — In Search of an Understandable Consensus Algorithm (extended version of the USENIX ATC 2014 paper), Leslie Lamport — Paxos Made Simple (2001)

References: Diego Ongaro — Consensus: Bridging Theory and Practice (PhD dissertation, Stanford, 2014), Diego Ongaro — bug in single-server membership changes (raft-dev, 2015), etcd v3.7 documentation — KV API guarantees

Other claims on this card, typed separately:

Replication copies bytes; consensus decides which command is entry N, once, for every replica. Deterministic state machines that apply the same log in the same order end in the same state — that is the whole product. In Raft an entry is committed when the leader that created it has it on stable storage of a majority; only then is it applied and acknowledged. Every message carries a term (an epoch): a server that sees a higher term steps down, and a request with a stale term is rejected. Safety — no two replicas apply different commands at the same index — must never depend on timing; only liveness does.

Code example (fragment)
fragment
Go · etcd client v3.7
// Default Get is linearizable: it goes through Raft and reflects every acknowledged write.
cur, err := cli.Get(ctx, "config/limits")
if err != nil {
    return err
}
// Serializable: answered by the local member, may lag the quorum.
if _, err := cli.Get(ctx, "config/limits", clientv3.WithSerializable()); err != nil {
    return err
}

rev := int64(0) // a missing key compares as ModRevision 0
if len(cur.Kvs) == 1 {
    rev = cur.Kvs[0].ModRevision
}
// Compare-and-swap on the revision we read; the replicated log orders the contenders.
txn, err := cli.Txn(ctx).
    If(clientv3.Compare(clientv3.ModRevision("config/limits"), "=", rev)).
    Then(clientv3.OpPut("config/limits", next)).
    Commit()
if err != nil {
    return err // outcome unknown: the write may have committed; re-read before retrying
}
if !txn.Succeeded {
    return errLostRace // someone committed in between: re-read, recompute, retry
}

Where the theorem ends. FLP proves that no deterministic protocol guarantees agreement and termination in a fully asynchronous system if even one process may crash. Paxos and Raft answer by never giving up safety and buying termination with timing assumptions: Raft keeps a stable leader while broadcastTime ≪ electionTimeout ≪ MTBF, and randomized election timeouts make split votes unlikely, not impossible. The engineering consequences: a majority of 2f+1 servers tolerates f failures, so a fourth server adds cost and no tolerance; during an election or a partition the minority side must refuse writes — the C in 64; and every persisted term, vote and log entry is fsynced before the reply, or a restart can vote twice.

The three places implementations get it wrong. Reads: a leader answering from local state may already be deposed. Raft’s linearizable read commits a no-op at the start of the term and confirms leadership with a majority heartbeat before answering; a leader lease skips the round trip but makes safety depend on bounded clock drift (67) — the one place the timing rule is knowingly bent. Membership: changing the voter set must keep old and new majorities overlapping (joint consensus, or one server at a time); the single-server scheme published in 2014 had a split-brain bug fixed by one rule — a leader appends a configuration entry only after committing an entry from its own term. Clients: a retry after a timeout can apply a command twice; linearizable semantics need client ids and sequence numbers deduplicated inside the state machine (22).

Consensus orders commands inside the group. A lock or leadership taken from it protects an external resource only with a fencing token checked by that resource (66); an etcd revision like the one above can serve as that token. Use an existing implementation (etcd, ZooKeeper, a database’s built-in quorum) and test it under faults rather than trusting the paper’s proof for your code (98, 99).

  • heuristic
  • established
  • fragment

Scope. The claim that no access method minimizes read, update and space overhead at once is the RUM Conjecture — its authors call it a conjecture, not a theorem, and it is stated for access methods in general, not as a bound with constants. The three amplification ratios are RocksDB’s working definitions; real numbers depend on key distribution, value size, compaction settings and hardware, so the card gives directions of the trade, not magnitudes.

Versions. The Java fragment compiles against rocksdbjni 10.2.1 (setFilterPolicy replaced the deprecated setFilter). level_compaction_dynamic_level_bytes is on by default since RocksDB 8.4, so the explicit call matters only for older versions and for readability.

Primary sources: Athanassoulis, Kester, Maas, Stoica, Idreos, Ailamaki, Callaghan — Designing Access Methods, The RUM Conjecture (EDBT 2016)

References: RocksDB wiki — RocksDB Tuning Guide (amplification factors), RocksDB wiki — Leveled Compaction, RocksDB wiki — Universal Compaction, RocksDB wiki — Write Stalls, RocksDB wiki — RocksDB Bloom Filter, RocksDB wiki — Implement Queue Service Using RocksDB (tombstones and iteration), PostgreSQL 18 documentation — Write Ahead Log settings (full_page_writes)

Other claims on this card, typed separately:

Three ratios describe an engine: write amplification (bytes written to storage per byte written to the database), read amplification (disk reads per query) and space amplification (size on disk per byte of live data). A B-tree updates pages in place: a point read is one root-to-leaf path, but every change rewrites a page and also goes to the WAL. An LSM tree appends to a WAL and a memtable, flushes immutable sorted files and merges them in the background: writes are sequential and cheap up front, while a read may consult several sorted runs, and overwritten or deleted data occupies disk until compaction reaches it. No engine minimizes all three; choosing one without measuring them on your access pattern is choosing blind.

Code example (fragment)
fragment
Java · RocksJava
// Each knob moves cost between the three ratios; none of them is free.
var table = new BlockBasedTableConfig()
    .setFilterPolicy(new BloomFilter(10));          // ~10 bits/key, ~1% false positives:
                                                    // fewer SST reads for point lookups (read amp),
                                                    // paid in memory; no help for plain range scans
var options = new Options()
    .setCreateIfMissing(true)
    .setCompactionStyle(CompactionStyle.LEVEL)      // leveled: low space and read amp,
                                                    // higher write amp than UNIVERSAL (tiered)
    .setLevelCompactionDynamicLevelBytes(true)      // level targets follow the real data size,
                                                    // which bounds space amp
    .setMaxBytesForLevelMultiplier(10)              // fanout: larger = fewer levels (reads),
                                                    // more rewriting per level (writes)
    .setTableFormatConfig(table);
// Switching to UNIVERSAL cuts write amp, but a full compaction keeps inputs and output
// on disk at once: provision free space for roughly twice the data.

The bills arrive late. An LSM engine that accepts writes faster than compaction can merge them accumulates debt, and RocksDB repays it with write stalls: too many immutable memtables, too many L0 files or too many pending compaction bytes slow writes down and then stop them for the whole database. Delete-heavy patterns (a queue, a TTL table) leave tombstones that range scans must step over until compaction removes them; bound the iterator and track the live range instead of scanning from the start. A B-tree pays differently: PostgreSQL writes a full page image to the WAL on the first change of each page after a checkpoint, so checkpoint frequency multiplies WAL volume, and MVCC leaves dead tuples that only vacuum turns back into usable space.

Decision rule. Write down the workload before the engine: point reads vs range scans, update and delete rate, value size, how much free disk and memory the node has, and which latency percentile matters (26). Then measure all three ratios on that workload with the engine’s own statistics rather than benchmark headlines (37). “LSM for writes, B-tree for reads” is a first guess, not an answer: compaction style, fanout, filters and fill factor move an engine far along the curve. Streaming state lives on the same engines, so the same bills show up as checkpoint size and recovery time (82).

VI

Streaming and data pipelines

  • pattern
  • established
  • fragment

Versions. Flink 1.20 / 2.x: in 2.x the overloads taking org.apache.flink.streaming.api.windowing.time.Time were removed in favor of java.time.Duration — check against your cluster version.

Conflicts with: Watermark: completeness ↔ latency

References: Apache Flink — Streaming Analytics

Event time and processing time always diverge: network, retries, offline clients, replay. A watermark is an estimate of event-time progress: the system’s statement “we no longer expect events older than T”, from which it follows when a window can be considered complete enough. How much of it is an estimate and how much a guarantee depends on the source: for a partition with monotonic time it is nearly a guarantee; for mobile clients that go offline it is a heuristic with a tail. Choosing T is a trade-off between completeness and latency, and the business makes it, not the framework. Late data is the normal mode, not an error: every window needs a deliberately chosen policy.

From there the watermark splits into four distinct decisions that look like a single setting in the API: how long to wait (the watermark itself), how long to keep accepting data after the window closes (allowed lateness), what to do with whatever arrives even later (route it to a side output or lose it), and what to do with a result already emitted (re-firing, retraction, nothing). A separate item is the idle source: a partition nobody writes to anymore freezes the overall watermark, because it is computed as the minimum across all partitions, and the pipeline stalls while the data is perfectly fine.

Code example (fragment)
Java · Flinkfragment
// every constant here is a business decision, not a “default” config
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(30))
    .withIdleness(Duration.ofMinutes(1));

stream.keyBy(Event::customerId)
    .window(TumblingEventTimeWindows.of(Duration.ofMinutes(5)))

    .allowedLateness(Duration.ofMinutes(10))
    // Window state lives 10 more minutes past the watermark. An event arriving
    // within that interval DOES enter the window and triggers a re-firing, i.e.
    // an updated version of the aggregate. This, not the side output, is what
    // downstream must be able to accept.

    .sideOutputLateData(lateTag)
    // This receives whatever arrives after window end + allowedLateness:
    // it can no longer enter the window and would otherwise just be dropped.

    .aggregate(new Revenue());

// A growing share of events in the side output is reality shifting, not “noise”:
// fix the source or revisit the 30s.

Honest assessment. “Exactly-once event-time aggregation” does not make the problem go away: a window closed by the watermark is closed according to the declared trade-off, and a late event is either lost or produces a correction. A downstream that cannot accept re-firing (an append-only report) silently records a lie — the late-data policy is end-to-end, all the way to the last consumer.

A bug in a transformation, a new column, a corrupted interval: history will be replayed; the only question is whether that is designed in or improvised at 3 a.m. Requirements by construction: transformations are deterministic (no now(), no random(), no lookups into mutable unversioned reference data), sinks are idempotent or transactional, the position (offset / checkpoint) is a first-class managed artifact, and source retention ≥ the replay horizon.

Code example (fragment)
fragment
Java
// replay's enemy: nondeterminism hidden inside a transformation

// BAD:    enrich(event, dict.current())        // a replay a month later gives
//                                              // a different result
// GOOD:   enrich(event, dict.asOf(event.ts())) // temporal join on event time:
//                                              // the result is reproducible
//                                              // within the data contract
Go
// a replay request as an explicit type: bounds, rate, destination
type ReplayJob struct {
    Source    TopicRange   // {topic, partitions, fromOffset/fromTs, toTs}
    Transform VersionedRef // transformation sha: which code is replayed
    Sink      SinkRef      // the same prod topic OR a shadow one; an explicit choice
    RateLimit TokenBucket  // replay must not crowd out live traffic (bulkhead, 24)
}

Replaying into the same downstream requires the whole chain below to be idempotent (22, dedup by event_id: 21); otherwise, replay into a shadow sink, compare, cut over. Kappa architecture is the replay principle elevated to an architecture: batch = a replay of the stream. The consequence for retention: a topic's retention period is not a disk cost but the insurance horizon for reprocessing; cutting it is a one-way-door decision (20).

Byte-for-byte equality is achievable only if the entire transitive dependency is versioned: transformation code, schemas and parsers, reference data, libraries, the floating-point environment. That is why “reproducibility” is verified by comparison rather than declared, and is stated within the bounds of the data contract. This also covers the comparison strategy, reconciliation, retention limits imposed by law and privacy, and separating resources between replay and live traffic.

  • pattern
  • context-dependent
  • fragment

Conflicts with: DLQ ↔ per-key ordering

With a naive consumer, one unparsable message in a partition stops the whole partition forever (a crash loop on a single offset). The policy, by construction: N attempts → dead letter with full context (original message, error, offset, stack trace, schema version) → the partition moves on. But a DLQ is a queue of unprocessed obligations: it has an owner, an alert on growth and a re-drive procedure; a DLQ without a consumer is /dev/null with a clear conscience.

Code example (fragment)
Gofragment
if err := process(msg); err != nil {
    if !errors.Is(err, ErrTransient) || attempts(msg) >= 3 {
        dlq.Send(ctx, DeadLetter{
            Original: msg.Raw, Topic: msg.Topic, Offset: msg.Offset,
            Error: err.Error(), SchemaVer: msg.Header("schema"), FirstSeen: now,
        })
        consumer.Commit(msg) // partition unblocked: that was the whole point
        return nil
    }
    return err // transient → retry with backoff (24), offset stays put
}

Honest assessment. A DLQ breaks per-key ordering: if message N went to the DLQ while N+1 with the same key was processed, re-drive will apply N after N+1. For ordering-sensitive keys (balances, statuses) the correct alternative is to park the whole key (pause the key, not the partition) or to re-drive via a full replay of the key (80). The choice follows the invariant, and it must be written down.

“Pausing a key” is not a built-in Kafka operation but a separate architecture: buffering or routing by key, pause() on partitions, or retry topics that restore ordering. Plus the details without which a DLQ does harm: an attempt counter kept outside process memory, PII redaction in the payload, schema and version metadata, idempotent re-drive, quarantine for poison messages, and stack-trace truncation.

  • practice
  • established
  • fragment

Conflicts with: Short checkpoint interval ↔ checkpoint overhead and state size

A stateless operator restarts for free; a stateful one drags along checkpoints, savepoint compatibility and migrations. State is a database smeared across the job: every operator has a stable uid (otherwise the savepoint won't restore after a refactor), every state type has evolvable serialization (POJO/Avro, not the default Kryo), every key has a TTL or an explicit “lives forever” decision (otherwise state grows monotonically until OOM — Lehman, 45, within a single job).

Code example (fragment)
Java · Flinkfragment
// three decisions that cannot be made "later"
stream.keyBy(...)
    .process(new Dedupe())
    .uid("dedupe-v1");                      // 1. stable uid: renaming the class
                                            //    must not lose state

StateTtlConfig ttl = StateTtlConfig         // 2. TTL from the business horizon
    .newBuilder(Duration.ofDays(7))         //    (dedup window), not "while it fits"
    .cleanupInRocksdbCompactFilter(1000).build();

// 3. state type — POJO/Avro with explicit evolution; Kryo generic = state
//    you can't change without a full rebuild from the source (= 80)

A check before every job release: “will this code restore from the previous savepoint?” — a contract test (74) between versions of itself. If the answer is “no”, this is a state migration with its own expand — migrate — contract, not a deploy.

The cost of state is not just heap: RocksDB runs into local disk, compactions, checkpoint amplification and recovery time. On top of the three decisions come serializer snapshots, maxParallelism and key groups (set once, and it limits future resharding), and TTL cleanup semantics — lazy cleanup does not free disk at the moment of expiry.

  • heuristic
  • established
  • fragment

Scope. The remapping bounds are theorems about the hash functions in their papers’ models (random hash functions, even division of objects); they say nothing about ordering, in-flight data or skewed key popularity, which is where the card’s engineering advice comes from. The Kafka and librdkafka behaviour described is that of their current default partitioners; a custom partitioner changes all of it.

Versions. Kafka 4.3 Java producer (BuiltInPartitioner: murmur2 of the serialized key; sticky partitioning for null keys); librdkafka master CONFIGURATION.md (default partitioner consistent_random); Flink 2.x (StreamExecutionEnvironment.setMaxParallelism, key groups up to 32768).

Conflicts with: Even load ↔ per-key order

Primary sources: Apache Kafka 4.3 — Design: the producer, semantic partitioning, Apache Kafka 4.3 — Introduction: events with the same key go to the same partition and are read in write order

References: Apache Kafka 4.3 — Basic operations: modifying topics (effects of adding partitions), Apache Kafka 4.3 — Producer configs (partitioner.class, partitioner.ignore.keys), Apache Kafka 4.3 — BuiltInPartitioner.partitionForKey (murmur2), librdkafka — CONFIGURATION.md (partitioner), Apache Flink 2.3 — Production readiness checklist (maximum parallelism, operator uids), Apache Flink 2.3 — Parallel execution (maximum parallelism and key groups)

Other claims on this card, typed separately:

Choosing a partition key decides three things at once: what is ordered (Kafka orders records only within a partition), what is co-located (every keyed join, aggregate and piece of state for one key lives on one partition — 82), and what cannot be split (a key whose traffic exceeds one partition’s capacity saturates it no matter how large the cluster is). The partition count is part of the same decision and a one-way door (20): with hash(key) % N, changing N reroutes most keys.

What changing the count does. The Java producer maps a keyed record to murmur2(serialized key) % partitions; keyless records go to a sticky partition that switches after roughly batch.size bytes. After partitions are added, a key may land on a different partition than its history, Kafka does not move existing data, consumers with auto.offset.reset=latest can skip records produced to the new partitions before they notice them, and the count can never be reduced. Per-key order across the change is therefore gone unless the consumer drains the old placement first. The hash is also a cross-client contract: librdkafka’s default partitioner is CRC32-based, so a librdkafka-based producer (confluent-kafka-python, confluent-kafka-go) on defaults and a Java producer send the same key to different partitions unless librdkafka is set to murmur2_random. Flink hashes a key into one of maxParallelism key groups and assigns key-group ranges to subtasks; rescaling moves whole groups, but maxParallelism itself cannot change without discarding keyed state.

Remapping is a solved problem; order and hot keys are not. Consistent hashing (Karger et al.) is monotone — a new bucket takes items only from old buckets, never shuffles them between old ones — and needs virtual points per bucket for balance. Rendezvous (HRW) hashing reaches the minimum possible disruption, 1/n of the objects when a server comes or goes; jump hash needs no state but only works with sequentially numbered buckets. All three bound how much moves, none of them preserves order for what does move, so a keyed topic is resized by expand — migrate — contract into a new topic (17) rather than by --partitions. A hot key is a data-model problem: split it by salting only when every downstream operation is commutative and is re-merged by the unsalted key, or route it to its own topic; otherwise it sets the tail for everyone sharing its partition (26, 93, 103).

Code example (fragment)
fragment
Java · Kafka clients 4.x · Flink 2.x
// The key is the contract: all events of one account share a partition, in send order.
producer.send(new ProducerRecord<>("payments", event.accountId(), event));

// Anti-pattern for this topic: a null key spreads load evenly (sticky partitioner)
// and destroys per-account order.
// producer.send(new ProducerRecord<>("payments", null, event));

// A key hotter than one partition is not fixed by adding partitions. Salting trades
// per-key order for spread; valid only because this topic feeds commutative sums
// that are re-aggregated by accountId downstream.
String key = hotAccounts.contains(event.accountId())
    ? event.accountId() + "#" + ThreadLocalRandom.current().nextInt(SALT_BUCKETS)
    : event.accountId();
producer.send(new ProducerRecord<>("payment-totals", key, event));

// Flink: key -> one of maxParallelism key groups -> a subtask's range of groups.
// Rescaling moves whole groups; changing this number later discards keyed state.
env.setMaxParallelism(720);
  • heuristic
  • established
  • fragment

Scope. The measured numbers come from one study of Spark on two SQL benchmarks and one production workload (2015); they bound what a faster network buys there, not everywhere — workloads with high shuffle-to-input ratios, or disaggregated storage where every read crosses the network, can be network-bound. The card’s rule — cut bytes and exchanges before speeding them up — is a heuristic built on both papers, not a measured law.

Versions. Spark 4.2 (plan verified with EXPLAIN FORMATTED on apache/spark:4.2.0; AQE defaults: skewedPartitionFactor 5.0, skewedPartitionThresholdInBytes 256MB, autoBroadcastJoinThreshold 10MB); Flink 2.3 SQL join semantics.

Conflicts with: Even load ↔ per-key order, “Network is the scarce resource” (MapReduce) ↔ “network is rarely the bottleneck” (Ousterhout et al.)

Primary sources: Jeffrey Dean, Sanjay Ghemawat — MapReduce: Simplified Data Processing on Large Clusters (OSDI 2004)

References: Apache Spark 4.2 — Performance tuning (join hints, adaptive query execution, storage partition join), Apache Flink 2.3 — SQL joins (state of regular joins)

Other claims on this card, typed separately:

In a distributed plan the operators are the easy part; the exchanges between them are where bytes are serialized, sent, spilled and deserialized, where one oversized partition becomes one straggler task, and where a wrong size estimate becomes an out-of-memory error. The order of moves is fixed: first move fewer bytes — prune partitions and columns, aggregate partially before the exchange (the MapReduce combiner), ship the small side instead of the large one — and only then think about moving them faster.

What the evidence says the cost is. MapReduce was designed around scarce bandwidth: tasks are scheduled next to their input, and the combiner merges map output before it crosses the network. A decade later, blocked-time analysis of Spark on two SQL benchmarks and a production workload found the opposite for the wire itself: a faster network improves job completion time by a median of at most 2%, CPU is often the bottleneck, for some queries up to half of CPU time goes to deserialization and decompression, and the median shuffled-to-input ratio was below 0.35. Both findings point the same way: the price of movement is mostly CPU to encode and decode, disk to spill, and skew — so fewer bytes and fewer exchanges help, a faster network mostly does not. Measure before choosing which (37, 89).

Join strategyWhat movesBreaks when
Broadcast hashthe small side, to every task; the large side stays putthe size estimate is wrong and the “small” side does not fit in memory (Spark auto-broadcasts below autoBroadcastJoinThreshold, 10 MB by default)
Shuffle hashboth sides, hash-partitioned on the keya partition’s build side does not fit its in-memory hash table — skew makes that likely, which is why Spark’s AQE switches to it only when every post-shuffle partition is below a threshold
Sort-mergeboth sides, partitioned and sortedit pays for sorting both sides, and a skewed partition still makes one straggler — the case Spark’s adaptive skew-join splitting targets
Storage-partitioned / bucketednothing — both sides are already laid out by the join key, so the plan has no exchange before the jointhe layouts were not decided together at write time (92)

Adaptive query execution (on by default in Spark since 3.2) re-plans at each shuffle boundary from the sizes it actually observed: it coalesces small partitions, turns a sort-merge join into a broadcast join, and in a sort-merge join splits a partition treated as skewed — larger than 5× the median and than 256 MB by default. It repairs estimates, not the data model: a hot key is still one key (92). In a streaming engine the exchange never ends — a regular Flink SQL join keeps both inputs in state for good unless a TTL bounds it, which is a state decision (82), not a tuning knob.

Code example (fragment)
fragment
Spark SQL 4.2
-- orders is PARTITIONED BY (order_date); dim_country is a few hundred rows
EXPLAIN FORMATTED
SELECT /*+ BROADCAST(c) */ c.region, sum(o.amount_cents) AS revenue_cents
FROM   orders o
JOIN   dim_country c ON o.country_code = c.code
WHERE  o.order_date >= DATE '2026-09-01'
GROUP  BY c.region;

-- Read the plan for movement, not for operators:
--   Scan orders       PartitionFilters: (order_date >= 2026-09-01)   older days never read
--   BroadcastExchange dim_country                                    the small side travels
--   BroadcastHashJoin                                                orders is not shuffled
--   HashAggregate     partial_sum(amount_cents)                      combine before the exchange
--   Exchange          hashpartitioning(region, 200)                  the only shuffle left
--   HashAggregate     sum(amount_cents)
-- Every Exchange node is where rows are serialized and moved between tasks;
-- a date filter that does not show up under PartitionFilters reads every partition.
  • heuristic
  • established
  • fragment

Scope. Only the snapshot’s consistency is a theorem, and only in the Chandy–Lamport model (reliable FIFO channels, a static set of processes and channels). “Exactly-once ends at the cut” is an engineering reading of Flink’s and Kafka’s documented contracts, not a proof: it assumes replayable sources and deterministic reprocessing, and a sink that is transactional or idempotent. The timeout and isolation defaults are those of the versions named on the card.

Versions. Flink 2.x configuration API (CheckpointingOptions, org.apache.flink.core.execution.CheckpointingMode); flink-connector-kafka 5.0.0 targets Flink 2.1/2.2 — the Flink 2.3 docs list no connector release yet. Kafka 4.3 client and broker defaults: isolation.level=read_uncommitted, transaction.timeout.ms 60 s, transaction.max.timeout.ms 15 min.

Conflicts with: Short checkpoint interval ↔ checkpoint overhead and state size

Primary sources: Paris Carbone, Gyula Fóra, Stephan Ewen, Seif Haridi, Kostas Tzoumas — Lightweight Asynchronous Snapshots for Distributed Dataflows (2015)

References: Apache Flink 2.3 — Checkpointing, Apache Flink 2.3 — Checkpointing under backpressure (unaligned checkpoints), Apache Flink 2.3 — Kafka connector (KafkaSink fault tolerance, KafkaSource offset committing), flink-connector-kafka v5.0.0 — KafkaSinkBuilder (default transaction timeout of 1 h), Apache Kafka 4.3 — Consumer configs (isolation.level, last stable offset), Apache Kafka 4.3 — Broker configs (transaction.max.timeout.ms)

Other claims on this card, typed separately:

A Flink checkpoint is one global state: a position in every source plus the state of every operator, as if everything before the barrier had been processed and nothing after it. “Exactly-once” is a property of that state. An external effect falls inside the guarantee only if it is committed atomically with the checkpoint (a transactional sink) or is idempotent (22). Everything else — an HTTP call, a non-transactional write, an alert — is replayed from the last completed checkpoint on recovery, which makes it at-least-once, whatever the job config says.

Mechanism. Chandy–Lamport assume FIFO, lossless channels: a marker travels along every channel; a process records its own state on the first marker, and a channel’s state is whatever arrived on it after that and before its own marker. The theorem is subtle: the recorded state is reachable from the start and the final state is reachable from it, yet it may never have existed in the actual run — a checkpoint is a possible state, not a photo. Flink’s asynchronous barrier snapshotting (Carbone et al.) aligns barriers on acyclic graphs so channel state is empty and only operator state is stored. Alignment blocks the inputs that already delivered a barrier, so under backpressure checkpoint duration tracks the queue (23). Unaligned checkpoints let the barrier overtake buffers and persist the in-flight data — Chandy–Lamport channel state is back, checkpoints grow, and on recovery watermarks are emitted only after the in-flight data is restored. aligned-checkpoint-timeout starts aligned and switches only when alignment is slow.

Where the cut meets Kafka. With EXACTLY_ONCE, KafkaSink writes into a Kafka transaction that is committed after the checkpoint completes. Four consequences are routinely missed. (1) The consumer default is isolation.level=read_uncommitted: such a consumer also reads records of aborted transactions, so the guarantee silently stops at the topic. (2) A read_committed consumer only reads up to the last stable offset, so output becomes visible in checkpoint-sized steps — worst case the interval plus the checkpoint duration — and a slow checkpoint stalls every such reader of the partition. (3) If the broker aborts the transaction before Flink commits it — transaction.timeout.ms shorter than the longest checkpoint plus the longest restart — the data is lost; the sink builder defaults that timeout to one hour, above the broker’s default cap transaction.max.timeout.ms of 15 minutes, which fails at producer initialization until one of the two is changed. (4) The offsets KafkaSource commits to the consumer group are for monitoring only; recovery uses the offsets inside the checkpoint. The same boundary drives replay (80) and state evolution (82); end-to-end deduplication is still the last word (21).

Code example (fragment)
fragment
Java · Flink 2.2 · flink-connector-kafka 5.0
Configuration conf = new Configuration();
conf.set(CheckpointingOptions.CHECKPOINTING_INTERVAL, Duration.ofSeconds(30));
conf.set(CheckpointingOptions.CHECKPOINTING_CONSISTENCY_MODE, CheckpointingMode.EXACTLY_ONCE);
conf.set(CheckpointingOptions.CHECKPOINTING_TIMEOUT, Duration.ofMinutes(5));
// start aligned; if alignment takes longer than 10 s (backpressure), store in-flight buffers
conf.set(CheckpointingOptions.ENABLE_UNALIGNED, true);
conf.set(CheckpointingOptions.ALIGNED_CHECKPOINT_TIMEOUT, Duration.ofSeconds(10));
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(conf);

KafkaSink<Order> sink = KafkaSink.<Order>builder()
    .setBootstrapServers(brokers)
    .setRecordSerializer(serializer)
    .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
    // stable across restarts, unique among all jobs writing to this cluster
    .setTransactionalIdPrefix("orders-enricher")
    // must exceed checkpoint timeout (5 min) + worst restart, and must not exceed the
    // broker's transaction.max.timeout.ms (default 15 min); the builder default is 1 h
    .setProperty("transaction.timeout.ms", String.valueOf(Duration.ofMinutes(15).toMillis()))
    .build();

// Outside this job: every consumer of the output topic needs
// isolation.level=read_committed (the default is read_uncommitted), and sees
// new records only after the checkpoint that contains them completes.
  • pattern
  • established
  • fragment

Scope. The snapshot/stream alignment is guaranteed by PostgreSQL only for a snapshot exported when the slot is created (or a watermark scheme like DBLog); the card’s claims about Debezium describe its documented PostgreSQL connector, and other databases and connectors expose different primitives. Exactly-once apply is not provided by any of these layers — it is the sink’s job.

Versions. PostgreSQL 18 (flow verified on 18.6: CREATE_REPLICATION_SLOT … SNAPSHOT 'export', SET TRANSACTION SNAPSHOT, pgoutput proto_version 4; import fails once the exporting connection runs another command); failover slots need PostgreSQL 17+. Debezium 3.6 PostgreSQL connector.

Conflicts with: Lock-free incremental snapshot ↔ schema changes during the snapshot

Primary sources: PostgreSQL 18 — Logical decoding concepts (replication slots, exported snapshots)

References: PostgreSQL 18 — Streaming replication protocol (CREATE_REPLICATION_SLOT, START_REPLICATION), PostgreSQL 18 — SET TRANSACTION, PostgreSQL 18 — Logical replication message flow (Relation messages, proto_version), PostgreSQL 18 — Logical replication restrictions (DDL is not replicated), Debezium 3.6 — PostgreSQL connector (snapshots, incremental snapshots, primary key updates, WAL disk space, failure behaviour)

Other claims on this card, typed separately:

A change stream is not a table: it starts at some log position, and everything before it has to come from a snapshot. Correctness is a single invariant — the snapshot contains exactly the commits up to position P, and the stream starts at P. PostgreSQL provides it directly: creating a logical slot can export a snapshot showing exactly the state after which every change is in the slot’s stream. “Copy the table, then start from the current LSN” leaves a gap (lost changes); the reverse order leaves an overlap (duplicates, harmless only for an idempotent sink — 22).

What the seam does not fix. Delivery is at-least-once at every layer: a slot persists its position only at checkpoint, so after a server crash recent changes are sent again, and Debezium can emit duplicates while it recovers — the sink applies by primary key and skips events not newer than the LSN it already holds (21). Per-row order survives only if the message key is the primary key (92); Debezium turns a primary-key update into a delete for the old key and a create for the new one. The slot pins WAL and the catalog rows VACUUM would remove, so a stalled consumer fills the primary’s disk, and a database whose captured tables are quiet needs heartbeats to let the slot advance. DDL is not replicated: pgoutput describes a table’s shape only in the relation message sent before its next change, so a new column is seen when a row changes, not when it is added — schema changes go through expand — migrate — contract (17, 87). A slot that did not follow a failover (logical slots sync to standbys only from PostgreSQL 17, with FAILOVER) means a new snapshot.

Resnapshot without stopping the stream. A full re-copy under a lock does not scale to a live table, and replay (80) needs it routinely. DBLog’s watermark algorithm does it without locks: write a low watermark into the log, select the next primary-key chunk, write a high watermark; while reading the log between the two, drop from the chunk every key that changed there; at the high watermark emit what is left. A chunk row therefore never overwrites a newer logged change. It needs a primary key with an efficient range scan and reads that see every commit made before them. Debezium’s incremental snapshots follow the same design (1024-row chunks by default), and its PostgreSQL connector does not support schema changes while one is running.

Code example (fragment)
fragment
PostgreSQL 18 · replication protocol
-- Session 1: a replication connection, e.g. psql "dbname=shop replication=database"
CREATE_REPLICATION_SLOT cdc_orders LOGICAL pgoutput (SNAPSHOT 'export');
--  slot_name  | consistent_point | snapshot_name       | output_plugin
--  cdc_orders | 0/1799278        | 0000007E-00000002-1 | pgoutput
-- The exported snapshot lives only until this connection runs its next command.

-- Session 2: copy exactly the state the slot's stream continues from
BEGIN ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION SNAPSHOT '0000007E-00000002-1';
COPY orders TO STDOUT (FORMAT binary);
COMMIT;

-- Session 1, once session 2 has imported the snapshot:
START_REPLICATION SLOT cdc_orders LOGICAL 0/1799278
  (proto_version '4', publication_names 'orders_pub');
-- Streams every change committed after the snapshot and none before it.
-- After a server crash the slot may resend from its last checkpointed position,
-- so the sink upserts by primary key and ignores events whose LSN it already applied.
VII

Observability

  • author’s position
  • opinionated
  • fragment

Scope. Wide events do not replace metrics. The working position is complementarity: metrics for cheap aggregate detection, traces for the causal chain, events for detail, profiles for CPU/memory. High cardinality demands control over cost and privacy.

Monitoring checks conditions formulated in advance; observability is the ability to make sense of a state nobody anticipated, without shipping new code to do it. Wide structured events with high cardinality are a strong mechanism for that ability, not its definition: metrics, traces and profiles answer different questions and are not replaced by events.

The word comes from control theory: Rudolf Kálmán (around 1960) called a system observable if its internal state can be determined from its outputs. Software borrowed it, loosely, in the 2010s; Majors, Fong-Jones and Miranda, Observability Engineering (O’Reilly, 2022), give the version most engineers now use. Monitoring is still necessary — Google’s Site Reliability Engineering (2016) names latency, traffic, errors and saturation as the four golden signals to alert on — but it only answers questions someone thought to ask. A dashboard of averages per endpoint cannot tell you that p99 rose only for one customer, on one gateway, since one deploy; you either kept that context on each event or you ship code and wait for it to happen again. A practical test: in the last incident, how many questions could only be answered after a code change or a new metric was deployed?

Code example (fragment)
fragment
Go
// One wide event per request instead of a dozen scattered log lines
logger.Info("request_completed",
    "trace_id", traceID, "customer_id", custID, "endpoint", "/charge",
    "duration_ms", ms, "retry_count", retries, "gateway", gw, "outcome", outcome,
    "amount_cents", amt, "deploy_sha", buildSHA)

// later: "show p99 by gateway for EU customers on version abc123" — answerable
Python · structlog
# context accumulates along the request path
log = structlog.get_logger().bind(trace_id=tid, customer_id=cid)
...
log.info("request_completed", duration_ms=ms, outcome="declined", reason=r)

Costs and limits. High cardinality is cheap in a columnar event store and ruinous as metric labels: customer_id on a Prometheus counter creates a time series per customer. Wide events cost storage, so they are sampled — keep every error and slow request, sample the boring ones, and record the sample rate so counts stay correct. Events also carry personal data; what goes into them falls under retention (36). Trace IDs across boundaries (29) are what let one event be joined to the rest of the request.

  • practice
  • established
  • fragment

Logs from ten services with no shared identifier are ten unrelated stories about one request. A trace ID is useful exactly as far as it survives every hop: a single service that loses the context breaks the whole chain — so propagation is an obligation at every boundary, not a library setting in one place.

The design comes from Google’s Dapper paper (Sigelman et al., “Dapper, a Large-Scale Distributed Systems Tracing Infrastructure”, 2010): a trace id shared by every span of one request, a span id per unit of work, a parent id that turns the spans into a tree, all carried in RPC metadata. The wire format is now a standard, the W3C Trace Context recommendation: the traceparent header carries version, trace id, parent span id and a sampled flag, and tracestate carries vendor-specific data. OpenTelemetry implements both, which is what makes propagation an interoperable contract rather than a convention of one vendor’s agent.

Code example (fragment)
fragment
Java · OpenTelemetry
// context flows through HTTP → Kafka → Flink automatically,
// if propagation is configured; by hand — inject/extract at every boundary
var headers = new RecordHeaders();
GlobalOpenTelemetry.getPropagators().getTextMapPropagator()
    .inject(Context.current(), headers, (h, k, v) -> h.add(k, v.getBytes()));
producer.send(new ProducerRecord<>(topic, null, key, value, headers));
Rust · tracing
// span hierarchy in code
#[tracing::instrument(skip(payload), fields(order_id = %payload.id))]
async fn process(payload: Order) -> Result<()> { ... }

The Rust example shows only a local span. Context does not cross a network boundary on its own: you need an explicit inject on the producer and an extract on the consumer via TextMapPropagator. For Kafka — headers with a fixed encoding, without duplicating trace headers that already exist.

Where the chain usually breaks. Not at HTTP — middleware handles that — but at the asynchronous seams: a message published without headers, a batch job that consumes 500 events and emits one result, a retry scheduled on a fresh goroutine or executor without the captured context. For fan-in, a single parent is the wrong model; OpenTelemetry’s span links let one batch span point to every trace that contributed to it.

Limits. A trace id is a correlation key, not an identity or a business key: never authorize on it and never deduplicate by it. A traceparent arriving from the public internet is untrusted input (32) — a client can force sampling or collide ids — so the edge usually starts a new trace and keeps the incoming one as a link. And it does not belong in metric labels: unbounded cardinality breaks the metrics store; exemplars attach a few trace ids to a histogram instead.

  • practice
  • established
  • config

Scope. An exhausted budget does not have to freeze every release automatically — that is a policy decision. Sometimes a reliability fix has to ship precisely when the budget is exhausted.

Conflicts with: Chaos engineering ↔ error budget

They turn reliability from religion into a budget: “99.9% over 30 days = 43 minutes of downtime; budget exhausted → features stop, releases frozen” — a formalized trade-off between shipping and stability that both sides agreed to in advance.

The vocabulary comes from Google’s Site Reliability Engineering (2016), chapters “Service Level Objectives” and “Embracing Risk”. An SLI is a measured ratio of good events to valid events — successful /charge requests, or reads under 300 ms. An SLO is a target for that ratio over a window. The error budget is 1 − SLO: the unreliability you are allowed to spend on releases, experiments and maintenance. 100% is the wrong target, because users cannot tell 99.99% from 100% through their own networks and devices, and every extra nine costs more than the last. The SLA is a different object: a contract with penalties, set looser than the SLO so the internal alarm rings first.

Alerting follows the budget, not the raw error rate. The Site Reliability Workbook (2018, chapter “Alerting on SLOs”) defines burn rate as how fast the budget is consumed relative to the window: a burn rate of 1 spends exactly the 30-day budget in 30 days. Its recommended starting point pages when 2% of the budget burns in one hour (burn rate 14.4) or 5% in six hours (6), and opens a ticket for 10% in three days (1), each checked over a long and a short window so the alert also clears quickly. The Sloth config below generates those rules from one SLO definition.

Code example (config)
Sloth / Prometheusconfig
slos:
  - name: charge-availability
    objective: 99.9
    sli:
      events:
        error_query: sum(rate(http_requests_total{path="/charge",code=~"5.."}[5m]))
        total_query: sum(rate(http_requests_total{path="/charge"}[5m]))
    alerting:
      page_alert: { labels: { severity: page } }   # multiwindow multi-burn-rate

Where it fails. An SLO on the wrong SLI is green while users suffer: server-side success misses requests that never arrived, and a mean latency hides the tail (26). Low-traffic services make ratios noisy — ten requests an hour cannot measure 99.9%. And a budget with no agreed consequence is only a dashboard; the freeze policy is what makes it a budget.

  • heuristic
  • context-dependent
  • fragment

Deterministic replay, dry-run modes, inspectable intermediate state. Without them the time to find a production bug is bounded only by luck: the failure cannot be reproduced, the change cannot be previewed, and the state that led to it is gone.

The strongest form of the idea is deterministic simulation testing, popularized by FoundationDB: Will Wilson’s talk “Testing Distributed Systems w/ Deterministic Simulation” (Strange Loop 2014) describes running the whole cluster in one thread with simulated network, disk and clock, so that a seed reproduces a failure exactly. TigerBeetle’s simulator follows the same design. The price is architectural, which is why the principle is a requirement rather than a tool: every source of nondeterminism — time, randomness, threads, I/O — has to sit behind an interface from the start; retrofitting it into a codebase that calls the clock and the network directly is close to a rewrite.

Code example (fragment)
fragment
Rust · deterministic simulation testing
// FoundationDB/TigerBeetle style: all I/O behind a trait,
// in tests, a deterministic simulator with a seed
trait Clock { fn now(&self) -> Instant; }
trait Net { fn send(&self, to: NodeId, msg: Msg); }

// prod: the real ones; test: SimClock + SimNet with controlled faults
// a test fails with seed=42 → reproduces byte for byte, every time
Python · dry-run
# Dry-run as a mode, not as a flag checked in three ifs
class Executor(Protocol):
    def apply(self, change: Change) -> None: ...

class RealExecutor: ...

class DryRunExecutor:            # prints the plan; the logic ABOVE can't tell the difference
    def apply(self, change: Change) -> None: print(f"WOULD apply: {change}")

A dry run must build the same immutable plan that is then applied. Separate dryRun() and apply() branches drift apart, and they drift silently, exactly when someone is relying on the dry run.

The ordinary version is a plan file. terraform plan -out=plan writes the exact change set, and terraform apply plan applies that file and nothing else (86). The same shape works for a data backfill: compute the list of rows to change, persist it with a hash, let someone read it, then apply by hash. Replay needs the inputs, and that collides with privacy (36): recorded production traffic is personal data with a retention period, so record what reproduces the decision, not everything that passed by.

VIII

Security and trust

  • pattern
  • established
  • fragment

A trust boundary is where data arrives from someone you do not control: an HTTP request, a queue, a file, a third-party API response, model output (48). There is one rule: at the boundary, input is parsed into a type rather than checked along the way — a check throws away the knowledge that it passed (2), and whoever comes next in the code has to check again. A type keeps that knowledge, and the compiler will not let you forget it.

“Trust boundary” is a threat-modelling term: in a data-flow diagram it is the line where the level of privilege changes, and Adam Shostack’s Threat Modeling: Designing for Security (2014) makes it the place where threats are enumerated (STRIDE). The second half of the rule — parse at the boundary into a type — is Alexis King’s “Parse, don’t validate” (2019), and an older relative is Perl’s taint mode, which marks external data and refuses to use it in system calls until it has been explicitly extracted.

Code example (fragment)
Rustfragment
// The type records where the data came from
pub struct Untrusted<T>(T);                      // raw, came from outside
pub struct Sanitized<T>(T);                      // went through the parser

impl Untrusted<String> {
    pub fn parse_html_fragment(self) -> Result<Sanitized<String>, Rejected> { ... }
}

// render(s: Sanitized<String>) physically cannot accept raw input

Sanitization is contextual. A single Sanitized<String> type creates a false sense of universal safety: what is safe as HTML text is dangerous in an attribute, in a URL, in an SQL identifier and in a shell argument. Types must name the sink — SafeHtmlText, SqlIdentifier, ShellArg, SafeUrl — and escaping must happen at the boundary of the specific sink.

Where the boundary actually is. Usually further in than people draw it. An internal service that trusts an X-User-Id header because “only the gateway calls us” has a boundary at its own port, enforced by nothing; a Kafka topic another team writes to is a boundary; so is a file in a shared bucket. Deserialization is the classic failure: Java native serialization or Python pickle lets the input choose which types to construct, so parsing must go through a format where the code, not the data, picks the type. The opposite mistake — re-validating everywhere inside one process — is not extra safety but the symptom the rule removes: parse once, then trust the type, as long as the network between components is itself authenticated (102).

  • practice
  • established
  • fragment

A component gets exactly the permissions its task requires, because the cost of a bug and the cost of a compromise are set not by the component’s intentions but by its capabilities. This is not only about IAM and securityContext: in code, the same principle is a narrow interface. A reporting service handed an OrderReader instead of the whole repository has no means to corrupt an order — neither by malice nor by typo.

The classic statement is in Jerome Saltzer and Michael Schroeder, “The Protection of Information in Computer Systems” (Proceedings of the IEEE, 1975), one of eight design principles: every program and every user of the system should operate using the least set of privileges necessary to complete the job. Their reason is the one above — it limits the damage from an accident or an error — plus a second one that is easy to forget: with fewer privileges, fewer programs need to be audited when a privilege is misused. The same paper gives fail-safe defaults (deny unless granted), which is how least privilege is enforced in practice.

Code example (fragment)
fragment
Go
// Minimum rights per component; in code, these are narrow capability interfaces
type OrderReader interface{ GetOrder(ctx context.Context, id OrderID) (Order, error) }
type OrderWriter interface{ PutOrder(ctx context.Context, o Order) error }

// the report service gets OrderReader; it physically cannot write
Kubernetes
securityContext:
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities: { drop: ["ALL"] }

The layers stack. A nightly export job gets a database role with SELECT on three tables, a storage credential that can write to one bucket prefix, a service account with no Kubernetes API access, and a container that cannot write its own filesystem. If the job is compromised through a dependency (35), the attacker gets read access to three tables and one prefix — not the cluster. Each grant is a line in code under review, not a console click.

Where it costs. Narrow grants break when tasks change, and the usual response — widen to * to unblock a release — is how most over-privileged roles are born. The remedies are generating policies from observed access and reviewing unused permissions, not giving up. Static credentials undermine all of it; short-lived, workload-bound identity (102) is what makes narrow grants practical. For agents the same rule has its own card (49).

  • practice
  • established
  • fragment

Injection is the erasure of the boundary between program and data: a concatenated string goes to the interpreter (SQL, shell, LDAP, HTML) whole, and it cannot know which part of it was user input. Parameterization does not “escape dangerous characters” — it sends the query and the values over separate channels, so a value never becomes code in the first place. Manual escaping is not equivalent: it again relies on you having guessed the receiver’s grammar.

The first widely read public description of SQL injection is usually traced to Jeff Forristal’s article in Phrack issue 54 (1998), and injection has been in every edition of the OWASP Top 10 since. The fix has been known for as long, which is why it is stated as a rule rather than a technique: the OWASP SQL Injection Prevention Cheat Sheet puts prepared statements with bound parameters first and treats escaping user input as a last resort. The same split exists outside SQL: in Python, subprocess.run(["convert", path, out]) passes an argument vector to the program with no shell in between, while shell=True with an f-string hands the whole line to /bin/sh to parse.

Code example (fragment)
fragment
Python
# The only correct way, in every language
cur.execute("SELECT * FROM users WHERE email = %s", (email,))  # not an f-string. ever.
Java
var ps = conn.prepareStatement("SELECT * FROM users WHERE email = ?");
ps.setString(1, email);

Parameterization covers values, not identifiers: table names, column names and the ORDER BY direction cannot be bound through a placeholder. There only an allowlist or a type-safe query builder works. Managing the secrets themselves is a separate topic (35); this entry is only about injection.

Where parameterization quietly stops. Not every driver sends values on a separate channel: psycopg2 interpolates on the client side (correctly, from type information), psycopg 3 binds on the server. The guarantee you rely on is that the driver encodes the value, never your code. It ends inside the database too: a PL/pgSQL function that builds a statement for EXECUTE by concatenation is injectable however the caller bound its arguments — use format('%I', …) for identifiers and USING for values. A bound value in a LIKE pattern is still a pattern, so % and _ from the user need escaping. And prompts have no such channel at all: instructions and data share one token stream, which is why prompt injection is handled by limiting what the model can do (48, 49), not by escaping.

The main practical attack vector of recent years is not your code.

The two acronyms in the title answer different questions. An SBOM is an inventory of what an artifact is built from: packages, versions, hashes. It exists not for compliance paperwork but so that “does this CVE affect us?” is answered in minutes by a machine query rather than in a week of polling teams. SLSA is a tiered scale of requirements on how the artifact came to be, not on what the code does. Its Build track asks that the build run from a versioned source, on an isolated runner, with signed provenance stating “this binary was built from this commit by this pipeline”; since v1.2 a Source track adds requirements on how that source is authored, reviewed and managed. In short: the SBOM says what is inside; SLSA says whether you can trust how it was written and built. Pinning is a precondition for both: without it, the inventory does not describe what ships to prod tomorrow (83).

Code example (config)
config
Rust · cargo-deny
# Cargo.lock is committed + cargo-deny in CI
[bans]
deny = [{ name = "openssl" }]        # policy: rustls only

[advisories]
vulnerability = "deny"
Go
// go.sum: cryptographic hashes of all dependencies; public modules
// are additionally checked against the checksum database (sum.golang.org).
//
// GOPRIVATE=git.example.com/*  — private prefixes bypass the proxy and checksum DB
// GONOSUMDB / GONOSUMCHECK     — narrower settings, and NOT a hardening:
//                                they DISABLE verification, not enable it
//
// govulncheck in CI: call-graph analysis, alerts only on actually reachable vulnerabilities
Python · uv
# lock is committed; floating versions are banned; an SBOM for every release
uv lock --check      # CI fails if the lock has drifted from pyproject.toml
uv sync --frozen     # install exactly what the lock says, no re-resolution
syft dir:. -o spdx-json > sbom.json
Java · Gradle
# verification-metadata.xml pins the hashes of all build artifacts
./gradlew --write-verification-metadata sha256 help   # generate it
./gradlew build      # fails if a dependency hash does not match
# Maven: same idea via dependency locking and the repository's checksum policy
  • practice
  • established
  • fragment

Conflicts with: Event sourcing ↔ privacy by design

Retention and deletion are a property of the schema, not a script. The direct conflict with event sourcing is resolved by crypto-shredding: PII is encrypted with a per-subject key; deleting the subject = deleting the key, and the log stays immutable.

The term comes from Ann Cavoukian, then Information and Privacy Commissioner of Ontario, whose “Privacy by Design: The 7 Foundational Principles” (2009) include privacy as the default setting and privacy embedded into design rather than bolted on. The EU’s GDPR made a version of it law: Article 25, “data protection by design and by default”, alongside data minimisation and storage limitation in Article 5 and the right to erasure in Article 17. For an engineer the translation is concrete: each personal field has a declared purpose and retention period, collection is the minimum the purpose needs, and deletion is a tested code path that runs on a schedule, not a ticket someone handles by hand.

Code example (fragment)
Javafragment
record CustomerEvent(UUID customerId, EncryptedPayload pii, byte[] nonPii) {}

// keystore.delete(customerId) => all the subject's events unreadable forever,
// the Kafka log is not rewritten

Why crypto-shredding: an event log or a compacted topic is designed never to be rewritten, and backups are deliberately hard to edit. Encrypting each subject’s personal fields with their own key moves deletion from the many places the data lives to the one place the key lives.

What crypto-shredding does not do. Deleting the key makes exactly the encrypted payload inaccessible — and only if every copy of the data and keys falls under the same deletion protocol. What usually slips past it: PII in logs, traces and DLQs; derived tables and search indexes; caches; exports; key backups; decrypted copies in consumers; aggregates that allow re-identification. Crypto-shredding is part of a deletion strategy, not a substitute for a data inventory and retention enforcement. Whether encrypted data with a destroyed key counts as erased is a legal judgement, not an engineering one — ask before relying on it.

  • practice
  • established
  • config

Scope. Workload identity moves the root of trust, it does not remove it: the issuer, its signing keys and the attestation that decides which workload gets which identity become the most sensitive part of the platform. Audience binding helps only where verifiers actually check it, and a short-lived bearer token can still be replayed within its lifetime.

Versions. Kubernetes v1.22+ mounts short-lived TokenRequest tokens by default; the projected-volume fields and the 600-second minimum are as documented for the current release (v1.37).

Conflicts with: Short credential TTL ↔ static stability of the data plane

Primary sources: SPIFFE — Concepts (identity, SVIDs, Workload API), SPIFFE — JWT-SVID specification (aud and exp requirements, replay), Kubernetes Documentation — Service Accounts (short-lived tokens; long-lived Secret tokens not recommended)

References: Kubernetes Documentation — Projected Volumes: serviceAccountToken, Kubernetes Documentation — Configure Service Accounts for Pods (token projection, kubelet refresh, api-audiences), Kubernetes Documentation — Managing Service Accounts (bound tokens, pod deletion invalidates), SPIFFE — X509-SVID specification

A service should prove what it is with a credential the platform issues to an attested workload — short-lived, rotated automatically, and bound to the audience that will verify it — rather than with a long-lived key someone copied into a secret store. Identity answers “who is calling”; whether the caller may do something is a separate decision made by policy. Secrets that cannot be avoided are then fetched with that identity, not shipped alongside it.

Authentication is not authorization. SPIFFE deliberately stops at identity, and a Kubernetes service account that authenticates successfully can do nothing until RBAC grants it something. “Holds a valid token, therefore allowed” is the bug: every verifier still maps identity to the narrowest permission (33) at its own trust boundary (32). The audience is what stops lateral replay. A bearer token accepted by any verifier lets every service that receives it impersonate the caller to every other service. Binding the token to one audience, and having verifiers reject tokens without theirs, confines a leaked token to one recipient; the kube-apiserver itself only accepts tokens bound to its own configured audiences.

Code example (config)
config
Kubernetes
apiVersion: v1
kind: ServiceAccount
metadata:
  name: billing-exporter
  namespace: billing
automountServiceAccountToken: false   # no API-server token unless a pod asks for one
---
apiVersion: v1
kind: Pod
metadata:
  name: billing-exporter
  namespace: billing
spec:
  serviceAccountName: billing-exporter
  containers:
  - name: exporter
    image: registry.example.com/billing-exporter:1.4.2
    volumeMounts:
    - name: warehouse-token
      mountPath: /var/run/secrets/warehouse   # not a subPath: those never see rotation
      readOnly: true
  volumes:
  - name: warehouse-token
    projected:
      sources:
      - serviceAccountToken:
          audience: warehouse.example.com   # rejected by the API server and by verifiers expecting another audience
          expirationSeconds: 600            # the minimum the API accepts
          path: token                       # the kubelet rewrites it; the app must re-read it

Short-lived means rotation is the normal path. The kubelet refreshes a projected token once it is older than 80% of its TTL (or 24 hours), and the application is responsible for re-reading the file; a client that reads the token once at startup works in every test and fails in production exactly one TTL later. Pod-bound tokens also stop authenticating when the pod is deleted, which is revocation for free. What remains is replay within the lifetime: a JWT is still a bearer credential, so for service-to-service traffic an X.509 identity over mTLS, which requires possession of the private key, is the stronger form.

The TTL is also a dependency budget. The issuer is part of the control plane (101): if it is down for longer than the gap between the refresh point and expiry, the data plane loses its credentials. At 80% refresh that gap is a fifth of the TTL — two minutes for a 600-second token — so the TTL is chosen from issuer availability as much as from exposure. Static third-party API keys remain secrets; keep them in a manager that the workload reaches with its identity, never baked into the image (34).

IX

Evidence over taste

Knuth’s famous line is a claim about measurement, not a ban on optimization: guesses about where a program spends its time are usually wrong, so the critical part is found with a profiler first and optimized second.

The source is Donald Knuth, “Structured Programming with go to Statements” (ACM Computing Surveys, 1974). The full passage: “We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” And right after it he argues that a priori judgments about which parts are critical are often a mistake, because programmers who use measurement tools find that their intuitive guesses fail. The quote is usually cut before the second sentence, which turns a rule about where to look into an excuse not to look.

What it looks like in a backend. A nightly job takes 40 minutes and the team suspects the JSON parser. A profile shows 70 % of wall time waiting on the database: one query per row, an N+1 that EXPLAIN ANALYZE and a batched read fix in an afternoon. The parser was 4 %; by Amdahl (89) making it infinitely fast would have saved under two minutes.

Code example (fragment)
fragment
Java · JMH
// the only correct way to microbenchmark on the JVM
@Benchmark
@BenchmarkMode(Mode.SampleTime)
public Portfolio settle(BenchState s) { return Settlement.settle(s.trades, s.rates, DAY); }

// not System.nanoTime around a loop: JIT, warmup, DCE turn that into a lottery
Rust · criterion
// statistics + DCE protection via black_box
c.bench_function("settle", |b| b.iter(|| settle(black_box(&trades), &rates, day)));
Go
// built-in harness + profiles from production (net/http/pprof — always on)
func BenchmarkSettle(b *testing.B) {
    for b.Loop() { Settle(trades, rates, day) }
}

// go tool pprof -http=: http://svc:6060/debug/pprof/profile
Python
# pyperf for micro; py-spy/scalene for prod: sampling, no instrumentation
$ py-spy record --pid 4711 --format speedscope

Caveats to the examples. JMH is the standard for JVM microbenchmarks, but not the only correct way to measure: production profiles and load-test rigs answer different questions. testing.B.Loop requires Go 1.24+. net/http/pprof is not “always on”: the package has to be imported and an HTTP handler exposed, and in production it must be locked down by network and authentication — it is a debugging surface.

Where “measure first” is not enough. Some decisions cannot be measured after the fact because they are expensive to undo: data layout (10, 39), the partition key (92), a synchronous call in the request path. Those belong to Knuth’s critical 3 % and are measured with a prototype before they are built. And a measurement can lie: a microbenchmark that the JIT optimized away, a load test that cannot exceed capacity (100), a profile from a dataset ten times smaller than production.

  • theorem
  • established
  • no code

Scope. The formula is proven for a fixed problem size and perfect parallelization of the remainder: it accounts neither for the cost of coordination itself (USL adds that, 38) nor for the workload growing along with resources (Gustafson’s counterpoint). The practical difficulty is not the math but measuring s: the serial fraction is rarely visible in code and usually turns up as a shared resource — a leader, a lock, a single partition.

Primary sources: Gene Amdahl — Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities (AFIPS 1967)

Speedup with N workers is S(N) = 1 / (s + (1−s)/N), where s is the fraction of work that cannot be done in parallel. As N → ∞ the limit is 1/s: 5% serial code means a ×20 ceiling, no matter how many cores, nodes or workers you add.

Serial fraction s8 workers64 workers1,024 workersCeiling 1/s
1 %×7.5×39.3×91.2×100
5 %×5.9×15.4×19.6×20
10 %×4.7×8.8×9.9×10
25 %×2.9×3.8×4.0×4

The argument is Gene Amdahl’s, “Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities” (AFIPS Spring Joint Computer Conference, 1967). The paper itself contains no equation; it argues in prose that the sequential overhead of data management bounds what parallel machines can deliver, and the formula above is the later, standard reading of that argument. Read the table by columns: going from 64 to 1,024 workers buys almost nothing once s is 5 % or more.

The practical value is not the formula but that it forces you to name s. In services the serial fraction almost never looks like a “single-threaded section of code”: it is the single writer, a global lock, an auto-increment key, transaction coordination, the leader in a quorum, a shared rate limiter, the one partition a hot key converges on. Until that fraction is found and measured, horizontal scaling is buying cores under a ceiling someone else calculated.

The law is derived for a fixed problem size, and that is its limit. Gustafson’s counterpoint: if the amount of work grows along with resources (more data, higher resolution, a wider window), the serial fraction shrinks, and the practical gain behaves better than the formula promises. Both statements are true and answer different questions: “how many times faster is the same problem” and “how much bigger a problem can be handled in the same time.” What Amdahl’s law does not describe at all is the performance drop caused by coordination itself; USL (38) adds that.

A data-pipeline example. A Spark job runs 20 minutes on 16 executors; 2 minutes of it is the driver collecting results and writing one output file. Even with unlimited executors the job cannot finish in under 2 minutes, and doubling the cluster saves at most 9. The money is in the serial part: write in parallel, partitioned output, or drop the collect.

  • model
  • context-dependent
  • no code

Scope. USL is a model with fitted parameters, not a theorem: σ and κ are obtained by regression on measurements of a specific system and do not transfer between systems. Its predictive power is limited to the range that was measured; beyond it is extrapolation, and the honest name for that is a hypothesis for the next experiment.

Primary sources: Neil Gunther — A General Theory of Computational Scalability Based on Rational Functions

USL: throughput C(N) = λN / (1 + σ(N−1) + κN(N−1)). The second term (coherency κ) explains why adding nodes yields negative returns: crosstalk grows quadratically.

The model is Neil Gunther’s: he set it out in Guerrilla Capacity Planning (2007) and in “A General Theory of Computational Scalability Based on Rational Functions” (arXiv, 2008). σ is contention — the share of work queued on a shared resource; κ is coherency — the cost of keeping copies of state consistent, which grows with the number of pairs of workers and therefore roughly as N². Setting the derivative to zero gives the peak at N* = √((1 − σ) / κ): with σ = 0.05 and κ = 0.001 throughput is highest at about 31 workers and falls after that.

The relation to Amdahl’s law (89) is direct: with κ = 0 USL reduces to it, and σ is that same serial fraction. The difference is that Amdahl’s law follows from arithmetic and gives a ceiling — the curve levels off into a plateau — while USL adds a term that makes the curve go down past its peak: coordination between nodes becomes work in its own right. The status differs too: Amdahl leaves nothing to fit, whereas USL’s σ and κ exist only as the output of a regression on your measurements.

Where κ comes from in data systems. Anything every worker must agree on: a consumer group rebalance that stops all members, checkpoint barriers that wait for the slowest subtask, a distributed lock, cache invalidation broadcast to every node, a shuffle in which each of N tasks talks to each of the others (93). Kafka adds a hard step on top: consumers beyond the number of partitions sit idle, which no smooth curve predicts. Hence the practical rule: measure σ and κ on a load-test rig before scaling out, or you pay for nodes that lower throughput.

Limits of the fit. USL is a tool for extrapolation and a vocabulary, not a formula to fill with numbers from documentation. Fit on measurements at many load levels across the range you care about; through three points almost any curve fits. Extrapolate a little past the last measurement, not by a factor of ten: a new bottleneck (network, disk, a downstream service) changes the parameters. A fitted κ that comes out negative or a σ above 1 means the model does not describe this system, not that the system is superlinear by magic — although genuine superlinear effects exist, for example when added nodes bring enough cache that the working set suddenly fits in memory.

  • heuristic
  • context-dependent
  • fragment

Scope. All examples assume a 64 B cache line — that is platform-specific. Check the size on the target platform, and the gain with hardware counters rather than reasoning. @Contended is an internal JDK API and needs a separate launch flag.

Conflicts with: Boring technology ↔ mechanical sympathy

Primary sources: Soares, Stumm — FlexSC: Flexible System Call Scheduling with Exception-Less System Calls (OSDI 2010)

References: Linux kernel documentation — Page Table Isolation (PTI)

Cache line 64B, false sharing, NUMA, syscall cost. Adjacent to DOD, but broader — it includes knowing the runtime.

One key unlocks the whole list: the processor works not with bytes but with 64-byte cache lines. False sharing: two threads write to different variables that happen to land on the same line — logically there is no contention, physically the line ping-pongs between cores, and the code slows down without a single lock (the fix is padding to the line size). NUMA: on a multi-socket machine memory has a “local” and a “remote” socket, and reaching the remote one costs more, so where a thread runs relative to its data matters. A syscall is not a switch to another task and does not flush the CPU caches, but it is not free either. The direct cost is the privilege transition and the kernel's own work, plus mitigations: under KPTI every entry and exit also reloads the page tables (CR3), about a hundred cycles by the kernel's own documentation, and on CPUs without PCID each such write flushes the whole TLB. The indirect cost is that kernel code fills the caches, the TLB and the branch predictor with its own state, and user code runs slower until it warms them back up: Soares and Stumm (FlexSC, OSDI 2010) measured up to 14,000 cycles of lowered throughput after a single pwrite and a 20–60% drop in user-mode instructions per cycle at server-like syscall rates. A blocking call may also hand the core to another thread. Hence batching instead of one call per item. What all four share: the cost is invisible in the source and shows up only when measured (37).

Code example (fragment)
fragment
Java
// false sharing: two counters on one cache line kill a multithreaded increment
@jdk.internal.vm.annotation.Contended  // or pad manually
volatile long producerIdx;
Rust
#[repr(align(64))]                       // a counter on its own cache line
struct PaddedCounter(AtomicU64);
Go
// Idiom: per-P sharded counters + padding
type shard struct { n atomic.Uint64; _ [56]byte } // pad to 64B
Python
# In CPython the cost is dominated not by cache lines but by the interpreter loop,
# so mechanical sympathy here means moving the loop out of Python into C
total = sum(row.price for row in rows)        # ~10**6 interpreter steps

prices = np.frombuffer(buf, dtype=np.int64)   # the same computation in one call:
total = int(prices.sum())                     # the loop and vectorization run in C

# without dependencies, memoryview does the same:
# slicing bytes copies; slicing a memoryview does not
window = memoryview(buf)[offset:offset + size]
  • practice
  • context-dependent
  • fragment

Conflicts with: Shadow-run ↔ cost, Cost as an SLI (78) ↔ latency headroom (100)

Cost-per-request / cost-per-tenant is an engineering metric with a budget and an alert, not a quarterly surprise from billing. For multi-cloud, egress is a first-order architectural constraint: cross-region / cross-cloud traffic can cost more than compute, and the data topology is designed around it.

There is no single origin for this one; it is a practice. The FinOps Foundation’s framework is the main public reference and centres on unit economics — cost divided by a business unit such as a request, an order or an active tenant. The engineering point is that a cloud bill is the sum of decisions made in code and configuration: a retry policy, a cache TTL, a log level, a cross-zone replica, a query that scans a whole partition. Those decisions are made by engineers, so the feedback has to reach engineers at the same speed as latency does — per deploy, per endpoint, per tenant — not monthly to a finance team that cannot change them.

Code example (fragment)
Gofragment
// cost is attributed in the same wide event (see 28)
logger.Info("request_completed",
    "tenant", tid, "compute_ms", ms, "bytes_egress", n,
    "downstream_calls", calls, "est_cost_usd", est) // → cost per tenant/endpoint
                                                    //   is answered by a query, not by finance

An example of a cost that latency dashboards miss: a service and its database replica in different availability zones. Every query crosses a zone boundary, which most clouds bill per gigabyte, and a chatty ORM can make that transfer a large share of the service’s cost while latency stays fine. The estimate in the event above needs only rough per-unit prices from the provider’s current price list, reconciled monthly against the real bill; trends matter more than cents.

Link to 55: an LLM fallback cascade is cost engineering in its purest form. Link to 38: USL tells you when an added node yields negative gain, and a cost SLI tells you how much you pay for that negative gain. Unit economics break silently: an alert on cost-per-request matters more than an alert on the absolute bill. The misuse is optimizing cost below the reliability target (30): removing the replica that made the SLO achievable is not a saving.

  • practice
  • established
  • fragment

Scope. A history checker is sound for the specification it encodes and nothing else: it can prove that a recorded history violates the model, never that the system is correct. What a run excludes depends on the faults, workload and duration it covered. Deterministic simulation covers only code that runs inside the simulator.

Versions. The Go fragment targets github.com/anishathalye/porcupine v1.3.0 and was run with go vet and go test on Go 1.27.1 against histories that must and must not linearize.

Primary sources: Kyle Kingsbury, Peter Alvaro — Elle: Inferring Isolation Anomalies from Experimental Observations (PVLDB 14(3), 2021), Jingyu Zhou et al. — FoundationDB: A Distributed Unbundled Transactional Key Value Store (SIGMOD 2021), §4 Simulation Testing

References: Jepsen — analyses and method, Jepsen — framework README (generators, nemesis, history, checker), FoundationDB documentation — Simulation and Testing, etcd — robustness tests, Porcupine — a fast linearizability checker in Go (package documentation)

Other claims on this card, typed separately:

“Linearizable”, “serializable”, “no acknowledged write is lost” are properties of every history a system can produce, not of its happy path. The test that can refute such a claim has four parts: concurrent clients run a generated workload; a nemesis injects partitions, crashes, pauses and clock skew; every invocation and completion is recorded with its outcome; and a checker searches the recorded history for an ordering the specification allows. Example-based integration tests exercise the interleavings someone thought of; the bugs that matter here live in the others.

Code example (fragment)
fragment
Go · Porcupine 1.3
type regIn struct {
    write bool
    value int
}

type regOut struct {
    value   int
    unknown bool // the client timed out: the write may or may not have happened
}

// The model is the specification: a single register, no replication, no faults.
var register = porcupine.Model{
    Init: func() any { return 0 },
    Step: func(state, in, out any) (bool, any) {
        i, o := in.(regIn), out.(regOut)
        if i.write {
            return true, i.value
        }
        return o.value == state.(int), state
    },
}

// A timed-out write is not a failed write. It stays open to the end of the
// history, so the checker may place its effect anywhere after the call — or
// after every other operation, which is the same as never.
func indeterminateWrite(client, value int, call int64) porcupine.Operation {
    return porcupine.Operation{
        ClientId: client, Input: regIn{write: true, value: value},
        Call: call, Output: regOut{unknown: true}, Return: math.MaxInt64,
    }
}

func checkHistory(t *testing.T, history []porcupine.Operation) {
    res, info := porcupine.CheckOperationsVerbose(register, history, time.Minute)
    switch res {
    case porcupine.Illegal:
        _ = porcupine.VisualizePath(register, info, "history.html")
        t.Fatal("history is not linearizable; see history.html")
    case porcupine.Unknown:
        t.Fatal("checker timed out: partition the history by key or shorten it")
    }
}

What makes the verdict mean something. The oracle is a model of the contract, not the implementation’s own assertions: a sequential register or map for linearizability, a dependency graph for isolation. Linearizability is a local property — a history is linearizable iff each object’s sub-history is — which is why checkers split the history per key; checking it in general is NP-complete, so the workload is shaped for the checker (few keys, short per-key histories). Elle infers Adya’s dependency graph from transactions whose reads reveal version order (append to lists, read the whole list), which keeps isolation checking roughly linear in history length (90). Indeterminate operations: a timeout is neither success nor failure (24). Recording it as a failure makes a correct system look broken when the write later appears; dropping it entirely does the same; the only sound encoding is “may take effect at any point after the call” — the Return: math.MaxInt64 above. Safety versus liveness: every property is the intersection of a safety and a liveness property, and only the safety part has a finite witness. A finite history can prove a lost write; it can never prove that progress will not come. “The cluster recovers” is checked by healing every fault and demanding progress within a bound — a test of the bound, not a proof.

Two ways to drive the schedule. Black-box (Jepsen, etcd’s robustness suite): real binaries on real nodes, faults through the network and the process table. It tests what ships, and a failure found this way may never replay. Deterministic simulation (FoundationDB): network, disk, time and the random generator sit behind interfaces, the whole cluster runs as a discrete-event simulation in one thread, and a seed replays a run exactly; “buggification” makes rare code paths common, swarm testing randomises which faults and knobs each run enables, and simulated time skips idle stretches. The price is that it has to be designed in (54), and it cannot exercise code outside the simulator or a dependency whose real contract is weaker than believed — FoundationDB’s own paper names both limits. Neither is chaos engineering (77): a chaos experiment asks whether an aggregate steady-state metric survives a fault; this asks whether a single history breaks the contract. A green chaos run says nothing about a lost write. And neither is model checking (99), which covers every schedule of a small model of the design; these tests sample schedules of the real implementation.

Decision rule. A consistency level written into an API contract (65) gets a history checker for that level in CI, run under a nemesis; a vendor’s level is a hypothesis of the same kind — the Elle paper opens by noting that many databases do not provide the isolation they claim. Every anomaly found is kept as a minimal history and replayed as a regression test. A run that finds nothing bounds nothing: its strength is exactly the faults, workload and duration it covered, so report those next to the green result.

  • practice
  • context-dependent
  • fragment

Scope. A model check proves the invariant only for the finite instance and the atomicity the spec assumes; it says nothing about whether the code implements the spec, and nothing about performance or emergent overload. The industrial evidence is an experience report from one company, not a controlled comparison, and the payoff depends on the protocol having a small, subtle concurrent core.

Versions. The PlusCal module was translated with pcal.trans and checked with TLC 2.19 from tla2tools.jar v1.7.4 (the latest non-prerelease on GitHub) on OpenJDK 21, run with -deadlock; the counts on the card are from those runs.

Primary sources: Chris Newcombe, Tim Rath, Fan Zhang, Bogdan Munteanu, Marc Brooker, Michael Deardeuff — How Amazon Web Services Uses Formal Methods (CACM 58(4), 2015)

References: Newcombe et al. — Use of Formal Methods at Amazon Web Services (2014 version of the same report), Leslie Lamport — The PlusCal Algorithm Language (ICTAC 2009), Leslie Lamport — The TLA+ Home Page, TLA+ tools — releases (tla2tools.jar)

A concurrent protocol is a set of steps plus a claim about every order they can run in. Prose and diagrams show the orders someone drew; a model checker runs through all of them. Write the design as a specification — initial state, the atomic steps, and the invariant that must hold in every reachable state — and let TLC search a small instance exhaustively. What comes back is either “no error in N states” for that instance, or a step-by-step trace that breaks the invariant, found before any code exists to defend.

Code example (fragment)
PlusCal · TLA+ tools 1.7.4fragment
---------------------------- MODULE LeaseFencing ----------------------------
EXTENDS Naturals
CONSTANTS Clients, FenceReads, FenceWrites

(* --algorithm LeaseFencing
variables
    holder = "none",   \* whom the lock service believes holds the lease
    epoch = 0,         \* last token issued, strictly increasing
    value = 0,         \* the protected resource: a counter
    maxToken = 0,      \* highest token the resource has seen
    accepted = 0;      \* increments the resource acknowledged

define
    NoLostUpdate == value = accepted
end define;

process LockService = "lock"
begin
Expire:
    while TRUE do
        await holder /= "none";
        holder := "none";    \* the TTL ran out; the holder is not told
    end while;
end process;

process Client \in Clients
variables token = 0, seen = 0;
begin
Acquire:
    await holder = "none";
    epoch := epoch + 1;
    token := epoch;
    holder := self;
Read:
    if FenceReads /\ token < maxToken then
        goto Done;           \* a newer holder already reached the resource
    else
        if FenceReads then maxToken := token; end if;
        seen := value;
    end if;
Write:    \* may run long after the lease expired: a GC pause, a slow network
    if FenceWrites /\ token < maxToken then
        goto Done;           \* the resource rejects a stale token
    else
        if FenceWrites then maxToken := token; end if;
        value := seen + 1;
        accepted := accepted + 1;
    end if;
end process;
end algorithm; *)
\* pcal.trans inserts the TLA+ translation between the algorithm and this line.
\* Model: Clients = {c1, c2}, INVARIANT NoLostUpdate, checked with tlc2.TLC -deadlock
\* (the lock service waits forever once both clients finish; that is not a bug).
=============================================================================

What the checker found in this model. With no fencing, TLC reports NoLostUpdate violated with an 8-state trace: c1 takes token 1; the lease expires; c1 reads 0; c2 takes token 2 and reads 0; c1’s delayed write lands; c2 writes; the counter is 1 after two acknowledged increments. Checking the token only on writes — the blind-write scheme of 66 — fails with the same 8-state trace, because c1’s stale write reaches the resource before it has ever seen token 2. For read-modify-write the resource must learn the newer token at read time (FenceReads), or the write must be conditional on the value that was read: with both token checks TLC finishes with no error (57 distinct states for two clients, 667 for three), and a compare-and-set variant of the write step, checked separately, also held. A design review that reads “we have fencing tokens” approves both versions; TLC separates them in a run it reports as under a second.

What “no error” means. Explicit-state model checking is exhaustive only for the constants configured — two or three clients, one counter. Bugs that need more actors or longer histories need a larger instance; the bet that most design bugs show up at small scope is a heuristic, not a guarantee. Each label is one atomic step, so the grain of atomicity is the central modelling decision: if the real resource cannot check the token and write in one step, the model above is describing a system that does not exist. And the spec is not the code — asked how they know the implementation matches the verified design, the AWS engineers answer that they don’t. The bridge is to carry the invariant into runtime assertions and into the history checker of 98, which tests the real implementation under faults.

Decision rule. Specify before coding when three things coincide: concurrency or partial failure, a small core of steps, and an invariant whose violation is irreversible — lost or duplicated data, two owners of one resource. Typical targets: lease and leader handoff (66, 91), online migrations and shard moves (17, 87), the snapshot-to-log seam of CDC (104), workflow state machines with compensations (69). Keep the spec next to the design document and re-check it on every change to the protocol: in the AWS report, specs written for DynamoDB’s replication were reused to check a later migration feature, and the checker found a bug in its first design. Model checking does not address emergent slowdowns and retry storms (24); the same report says its authors know no feasible way to model that class. Start with safety invariants; liveness needs fairness assumptions and a separate check.

  • heuristic
  • established
  • fragment

Scope. The title’s “nonlinear” is proved only inside queueing models: the table and the 1/(1 − ρ) factor are exact for M/M/1 in steady state (Poisson arrivals, exponential service, one server, unbounded FIFO queue, ρ < 1) and nowhere else. Multi-server pools, bounded queues, heavy-tailed service times, retries and timeouts move the knee, so no specific utilization threshold follows from the card — only the procedure of locating the knee on measurements. Little’s law, by contrast, needs none of those assumptions but relates means only. The open-versus-closed findings come from one study’s implementations and simulations; their direction is the robust part, their magnitudes are workload specific.

Conflicts with: Cost as an SLI (78) ↔ latency headroom (100)

Primary sources: Schroeder, Wierman, Harchol-Balter — Open Versus Closed, A Cautionary Tale (NSDI 2006), Robert G. Gallager — Discrete Stochastic Processes, ch. 6, Markov Processes with Countable State Spaces (MIT OCW 6.262, 2011), John D. C. Little — Little’s Law as Viewed on Its 50th Anniversary (Operations Research, 2011), Gil Tene — wrk2, a constant-throughput HTTP benchmark (coordinated omission)

References: HdrHistogram — recordValueWithExpectedInterval and coordinated omission

Other claims on this card, typed separately:

Latency does not grow in proportion to load. In the simplest queueing model the mean time in system is the service time divided by 1 − ρ: doubling utilization from 45% to 90% multiplies it by 5.5, and at ρ ≥ 1 there is no steady state at all. Real services are not that model, but they inherit its shape — a knee, then a wall — so headroom is decided where the curve bends on measured data, and the load test that measures it must be open-loop, or it hides the wall it was run to find.

Utilization ρMean jobs in system, ρ/(1−ρ)Mean time in system, in service times, 1/(1−ρ)
0.512
0.845
0.9910
0.951920
0.9999100

What is a theorem, and where. The table is M/M/1 in steady state: Poisson arrivals at rate λ, exponential service at rate μ, one server, an unbounded queue, and ρ = λ/μ < 1 — without that last condition the chain is not positive recurrent and the queue has no stationary length. Inside those assumptions the numbers are exact; outside them they are an analogy. Little’s law L = λW is different: it holds on any single observed sample path over a finite window, with no assumption about arrival or service distributions, stationarity or queue discipline. That makes it an engineering tool, not a model: requests in flight = arrival rate × mean latency, so 2,000 req/s at 50 ms mean needs about 100 concurrent slots (a pool, a connection limit, a semaphore), and a dashboard whose three numbers disagree is measuring something wrong. It relates means only; it says nothing about p99.

What is a heuristic. Any fixed “keep CPU under N%” rule. Where the knee sits depends on the number of servers, service-time variability, queue bounds and the averaging window — a 60-second mean of 60% can contain seconds at ρ > 1, and those seconds are where the tail lives (26). So the rule is procedural: measure latency against offered load (37), find the knee, set the operating point below it by the size of the bursts and failover load you must absorb, and decide in advance what happens past the wall — bounded queues and shedding (23), per-tenant admission (103). Retries raise λ exactly when ρ is highest, so a retry budget (24) is part of capacity, not of error handling. Throughput versus node count is a separate curve (38, 89).

The load generator decides what you see. A closed generator sends the next request only after the previous one returns; an open one sends on a schedule. Schroeder, Wierman and Harchol-Balter found that at equal load mean response times are markedly lower in the closed model, that a closed system approaches the open one only slowly as its population grows, and that most of the web workload generators they surveyed were closed. A closed test cannot push a server past its capacity: it slows down with the server and reports a comfortable latency. The same mechanism inside the measurement is Tene’s coordinated omission: a stall delays the requests that should have been sent during it, and timing from the actual send drops them. Pick the model from how your traffic arrives — independent users are open, a fixed worker pool is closed — and for open traffic measure from the intended send time (wrk2, or the correction in HdrHistogram).

Code example (fragment)
fragment
Python · asyncio
import asyncio
import random
import time


async def closed_loop(send, workers, duration_s):
    # The next request waits for the previous response: when the server
    # stalls, each worker stalls with it, records one slow sample and sends
    # nothing meanwhile, so the stall is under-counted (coordinated omission).
    latencies = []
    deadline = time.perf_counter() + duration_s

    async def worker():
        while time.perf_counter() < deadline:
            started = time.perf_counter()
            await send()
            latencies.append(time.perf_counter() - started)

    await asyncio.gather(*(worker() for _ in range(workers)))
    return latencies


async def open_loop(send, rate_per_s, duration_s, rng=random):
    # Arrivals follow the clock, not completions. Latency is measured from
    # the intended send time, so a stall anywhere is charged to every request
    # it delayed instead of being omitted. In-flight requests are not capped,
    # as in an open system: at rho >= 1 latency grows for as long as the run.
    latencies = []
    start = time.perf_counter()

    async def one(intended):
        await send()
        latencies.append(time.perf_counter() - intended)

    tasks = []
    intended = start
    while intended < start + duration_s:
        await asyncio.sleep(max(0.0, intended - time.perf_counter()))
        tasks.append(asyncio.create_task(one(intended)))
        intended += rng.expovariate(rate_per_s)  # Poisson arrivals, as in M/M/1
    await asyncio.gather(*tasks)
    return latencies
X

Simplicity and economics

  • heuristic
  • established
  • no code

The frame for any argument about “cleanliness”: the complexity of the problem domain cannot be removed; the complexity introduced by tools and abstractions can be, and should be attacked.

The source is Fred Brooks, “No Silver Bullet — Essence and Accident in Software Engineering” (1986, reprinted in IEEE Computer, April 1987). Brooks borrows Aristotle’s distinction: the essence of software is the conceptual construct — data, relationships, algorithms, interfaces — and its accidents are the difficulties of representing that construct in a language on a machine. His argument: high-level languages, time-sharing and unified environments had already removed most of the accidental difficulty, so no further single development could yield an order-of-magnitude gain within a decade. That was a forecast backed by argument, not by measurement. Moseley and Marks, “Out of the Tar Pit” (2006), sharpen the line for engineers: essential is what the users’ problem requires; everything else, including most mutable state and explicit control flow, is accidental.

An example. Tax rules per jurisdiction, late-arriving events that must be attributed to the right day (79), refunds that reverse a partially shipped order — that is essential; no framework makes it smaller, and code that pretends otherwise just hides it. A message bus between two functions in one process, a repository interface with one implementation over an ORM over SQL, a custom DSL for five config values — that is accidental, and every layer has to be read, deployed and debugged. The review question: “what essential complexity does this layer express?” No answer — the layer is accidental.

Misuse. The line is not fixed: a retry layer is accidental in a monolith and essential once the call crosses a network (63). Labelling a colleague’s abstraction “accidental” is not an argument until you say which requirement it fails to serve. And removing accidental complexity is not free either — a rewrite to remove it can cost more than living with it (73, 75).

  • heuristic
  • context-dependent
  • no code

Scope. Gall’s original wording — “a complex system designed from scratch never works and cannot be patched up to make it work” — is an aphorism, not a law: complex systems designed up front do exist where the specification is fixed in advance and the cost of error rules out iteration — avionics, protocols, silicon. The heuristic holds where requirements are refined along the way, which is most product development, and it is worth exactly as much as the intermediate state is.

A complex system is safer to grow through working intermediate states than to introduce in one big bang: on the evolutionary path every step yields a working system you can measure and roll back, whereas with a big bang the first contact with reality coincides with the launch date.

The source is John Gall, General Systemantics (1975; later editions as Systemantics and The Systems Bible): “A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works and cannot be patched up to make it work.” Gall was a pediatrician writing a deliberately humorous book about how systems fail; the “law” is an aphorism drawn from cases, not a measured regularity, and “never” is rhetoric. What makes it useful is the mechanism behind it: a design contains assumptions about load, data and users that can only be tested by running it, and a big-bang launch tests all of them at once, with no working state to fall back to.

In practice: a walking skeleton (Alistair Cockburn’s term) first — the thinnest end-to-end path that runs in production — then increments. For a new event pipeline that means one event type flowing from producer through the broker to one table, with deploy, alerting and backfill working, before the second event type or the clever partitioning. For a replacement system it means routing a slice of traffic to it while the old one still runs (73) and releasing behind a flag (72).

Limits. Some decisions cannot be grown into: a storage format, a partition key, a public API shape (20). Those need thought before the first step, because the simple system that works will fix them in place. Incrementalism without direction also fails — a sequence of working states can wander into a shape nobody chose. Gall says how to get there, not where to go.

  • heuristic
  • context-dependent
  • no code

Scope. The criterion is a proven shared axis of change (the same one as in 7), not a copy count; the “three” survives in the name as a mnemonic. The opposite extreme is just as real: one piece of knowledge copied into ten places drifts apart silently (43).

Conflicts with: OCP ↔ YAGNI

An abstraction appears when repetitions have exposed a shared axis of change — not by counting copies. The first duplication is cheaper than the wrong abstraction: the wrong abstraction forces every future case to lie about its nature. “On the third repetition” is a mnemonic: by the third time the axis is usually visible.

YAGNI — You Aren’t Gonna Need It — reads literally: don’t write code nobody needs today. Not a “might as well” function, not a “just in case” parameter, not a factory for a single implementation. The cost isn’t the lines: from day one this code demands reading, tests, and edits in every refactoring — and it tells the reader about an axis of change that doesn’t exist. A practical test: if you can’t name a second case already present in the code, there is no second case. “We’ll probably need it” in practice means “we won’t, and if we do, it will take a different shape.”

What this looks like with OCP. An interface plus a factory instead of an if on type is the canonical “extend with new code, don’t modify old code” (1): a new variant is added as an implementation, old code stays untouched. That exact construction is the textbook YAGNI violation while there is only one implementation: three entities instead of one branch, for an axis of change nobody has proven. The order is the reverse of the habitual one — first the second and third repetition, then the abstraction; OCP is applied to an axis that has already shown itself, not to a predicted one.

  • heuristic
  • context-dependent
  • pseudocode

Conflicts with: DRY ↔ bounded contexts

The same five lines in two bounded contexts are not duplication: they change for different reasons. “Eliminating” them couples things that must change independently.

Both extremes cost money. Copied knowledge, one rule physically living in three places, diverges silently: the fix lands in two of the three copies, the third keeps computing the old way, and nobody finds out until the incident. An eliminated coincidence, code from two contexts merged into a shared helper because it happens to be identical today, couples what must diverge: the first change that differs arrives as a flag in the signature. The question is always the same: is this one piece of knowledge, or two that currently look alike? The telltale sign of the latter is a package common / utils that grows without bound: a dumping ground has no owner and no reason of its own to change, because it has as many reasons as it has consumers.

Bounded context (14): a boundary within which words mean the same thing. Billing's “Customer” and marketing's “Customer” are different types even when their fields coincide. DRY applies within such a boundary. Across it, identical text is a coincidence, not duplication, and extracting it into a shared module glues two contexts together through code that belongs to neither of them.

Code example (pseudocode)
Gopseudocode
// DON'T: pkg/common/validation.go, where billing and crm both go for validateEmail

// DO: each context keeps its own copy; billing will soon need
// RFC strictness for invoices, CRM tolerance of junk marketing addresses.
// A shared function would force one of the contexts to lie.
  • heuristic
  • established
  • no code

Don't delete code whose purpose you haven't understood. A condition that looks pointless was written by someone for a reason; find the reason first, then decide. git log -S"strange_condition" before deleting is the minimum procedure — a countermeasure to refactoring enthusiasm, not a ban on removal.

The source is G. K. Chesterton, The Thing (1929), the essay “The Drift from Domesticity”. A reformer finds a fence across a road and says he sees no use for it; the wiser reformer answers: if you don't see the use, I certainly won't let you clear it away — go and find out, and when you can tell me why it was put up, I may let you take it down. The argument is about reform in general, not software, and it is asymmetric on purpose: the burden of proof sits with the person removing, because the person who built the fence knew something the passer-by does not.

A backend example. An ingestion job skips rows where updated_at < created_at; the check looks like noise. git log -S"updated_at < created_at" finds the commit, the commit links an incident: an upstream system replayed a month of rows with a broken clock, and the check stopped them from overwriting newer data. The fence now has an owner and a reason. The decision is still open: if that upstream system was retired, the check can go — together with a commit message that says so, so the next reader does not repeat the search. The cheap tools are pickaxe search (git log -S / -G), git blame with -w -M -C to see through moves, the linked issue, and a query of production logs or metrics for how often the branch actually fires.

Where it stops. The fence is not a veto. When the history is gone and nobody can say why, the honest move is to make the removal observable and reversible: log or count the branch first, remove it behind a flag or in a small commit, and watch. Treating every unexplained line as sacred produces the opposite failure — the code nobody dares to touch, which grows by accretion (45). The principle trades against YAGNI (42): YAGNI says don't add what is not needed; the fence says don't assume something is unneeded just because you can't see the need. Its counterpart for generated code is 61.

  • empirical law
  • context-dependent
  • no code

Scope. Lehman’s laws are empirical observations of E-type systems (embedded in a changing environment), drawn from OS/360 releases in the 1970s and a financial transaction system in the 1990s. The authors themselves apply them with confidence only to the domains their data came from.

Primary sources: Lehman, Ramil, Wernick, Perry, Turski — Metrics and Laws of Software Evolution: The Nineties View (1997)

Other claims on this card, typed separately:

    • author’s position
    • opinionated

    “Thermodynamics” is a metaphor that inherits none of the properties of physical entropy: growing complexity is the sum of locally rational decisions, so the work of countering it is a budget line that can be managed, not a force that cannot be resisted.

A system in use must keep changing; its complexity grows unless work is invested specifically to counter it. The entropy-reduction budget is a recurring line item, not “someday later.”

Meir Lehman began with a study of IBM OS/360 releases with László Belady in the 1970s and stated the laws in “Programs, Life Cycles, and Laws of Software Evolution” (Proceedings of the IEEE, 1980); the 1997 FEAST paper revisited them with more data. The first two carry most of the weight. Continuing change: a program used in the real world must be adapted or it becomes progressively less satisfactory. Increasing complexity: as it evolves, its complexity increases unless work is done to maintain or reduce it. Both apply to what Lehman called E-type systems — those embedded in a changing environment, which is almost every backend. The evidence is case studies of a few large systems, and later studies found exceptions (Godfrey and Tu, 2000, saw the Linux kernel growing faster than the laws predicted), so treat them as strong observations, not physics.

Two different claims in one card, and they carry different weight. Lehman's observations are empirical: a system embedded in a changing environment (E-type) either changes or loses its usefulness, and its structure degrades unless the work of preserving it is funded separately. “Thermodynamics,” however, is a metaphor, and it lies about the main thing: growing complexity is not a spontaneous process but the sum of locally rational decisions, each of which, taken alone, is cheaper than the right one. The physical analogy would imply “resistance is futile”; the empirical evidence implies the exact opposite: this is a budget line item, and it can be managed.

“Debt” is also a metaphor, with an author. Ward Cunningham introduced it in his OOPSLA 1992 experience report on the WyCash system: shipping code that reflects an incomplete understanding is like borrowing — fine if repaid promptly by reworking the code as understanding grows, ruinous if the interest compounds. It did not mean sloppy code. In practice the repayment is concrete: a dependency upgrade lane, a fixed share of each cycle for removals and migrations, deletion of dead paths (44). The misuse is calling every disliked design “debt” to justify a rewrite (76).

  • heuristic
  • established
  • fragment

The measure of astonishment is not the author’s taste but the established idiom of the language and the surrounding code: what astonishes is whatever behaves differently from ninety-nine similar places. Rule of thumb: if a behaviour needs a comment to explain it, removing the behaviour is cheaper than writing the comment — a reader who skips the comment will get it wrong anyway (47).

Code example (fragment)
fragment
Python
# The classic violation — a mutable default
def add_item(item, bucket=[]): ...      # bucket is shared across calls

def add_item(item, bucket=None):        # the expected behaviour
    bucket = bucket if bucket is not None else []
Java
// equals without hashCode: the object goes into a HashSet and “vanishes”
class Money {
    @Override public boolean equals(Object o) { ... } // hashCode is Object's
}
// set.add(new Money(5)); set.contains(new Money(5)) → false

// record generates equals and hashCode together and keeps them in sync
record Money(long cents, Currency currency) {}
Go
// a nil pointer in an interface is not a nil interface: it has two fields,
// type and value, and a non-nil type makes the whole thing non-nil
func find() *MyErr { return nil }

var err error = find() // type *MyErr, value nil
if err != nil {        // true — and that surprises every other reader
    log.Print("an error that isn't there")
}

// CORRECT: return error, not a concrete error type
func find() error { return nil } // here err == nil, as expected
Rust
// Deref is meant for pointers, not for is-a: through it the methods of
// Inner silently appear on Wrapper, and a reader can't find where they're declared
impl Deref for Wrapper {
    type Target = Inner;
    fn deref(&self) -> &Inner { &self.0 }
}

// CORRECT: delegate explicitly exactly what is promised outward (88)
impl Wrapper {
    pub fn id(&self) -> Id { self.0.id() }
}
  • author’s position
  • opinionated
  • no code

Scope. The “an order of magnitude more often” in the popular wording is a rhetorical estimate, not a measurement; the testable part of the claim is not the multiplier but the set of passes the text goes through after it is written.

The case for explicitness over magic: in Java — less reflection and annotation magic; in Rust — fewer macro DSLs; in Go — it is the language’s whole philosophy; in Python — “explicit is better than implicit” from import this.

The best-known statement is the preface to Abelson and Sussman’s Structure and Interpretation of Computer Programs (1985): programs must be written for people to read, and only incidentally for machines to execute. Knuth’s literate programming (1984) made the same bet in tooling. The Python version is Tim Peters’s Zen of Python (PEP 20); the Go version is Rob Pike’s proverb “Clear is better than clever”.

The popular phrasing is “code is read an order of magnitude more often than it is written.” “An order of magnitude” is rhetoric here, not a measurement, and it is not the part worth holding on to. The testable part: after it is first typed, the text goes through review, incident diagnosis, edits by a neighbouring team, and reading by someone hunting for exactly where it breaks — and the cost of each of those passes depends on how much of the behaviour is expressed explicitly. Magic saves time exactly once — at the moment of writing, for the person who already holds the whole picture in their head.

What magic costs, concretely. Spring’s @Transactional works through a proxy, so a call from one method of a bean to another method of the same bean bypasses the proxy and runs without a transaction — nothing in the code at the call site says so, and the bug shows up as partial writes under failure. An ORM that loads relations lazily turns a loop over 1,000 orders into 1,001 queries that are invisible in the source. In both cases the cost is paid by the reader during an incident, not by the author.

Where explicit is not clearer. Readability is relative to the reader. A #[derive(Serialize)] or a SQL query is “magic” in the sense that it hides work, yet every reader on the team knows what it does; spelling it out by hand would add noise and a place for bugs. Explicitness repeated in fifty places is duplication of knowledge (43). The test is not “no abstraction” but whether the next reader can predict behaviour from what is on the screen (46, 7).

  • heuristic
  • opinionated
  • no code

Scope. “Three tokens” is Dan McKinley’s metaphor, not a measurable limit.

Conflicts with: Boring technology ↔ mechanical sympathy

A team has roughly three innovation tokens; each “interesting” technology spends one — on operations, hiring, unknown failure modes. Boring ≠ bad: boring = the failure modes are known and already googled. Postgres, until proven otherwise, is not conservatism but economics.

The source is Dan McKinley’s essay and talk “Choose Boring Technology” (2015), written from his years at Etsy. Two arguments carry it. First, the capacity for novelty is limited: “about three” tokens is his illustrative number, not a measurement, and the point is that it is small and fixed. Second, a technology’s cost is mostly paid after adoption, in operations, and the total cost grows with the number of distinct technologies, not with how good each one is: every additional database needs backups, upgrades, monitoring, on-call knowledge and a person who understands its failure modes at 3 a.m. A tool that is better for one problem can be worse for the company when it is the only thing of its kind in the stack.

An example. A team needs a job queue, full-text search and a time series of metrics. Postgres covers all three acceptably for a long while — SELECT … FOR UPDATE SKIP LOCKED for the queue, built-in text search, a partitioned table for time series — with one backup story and one set of operational knowledge. Adding a broker, a search cluster and a TSDB is three tokens spent before the product has found its load profile. When one of them becomes a measured bottleneck, that is the moment to spend a token on it.

The checkable form. A new technology in the stack requires an ADR (20) answering “what essential complexity (40) does the boring option fail to handle?” plus a measurement (37), not a benchmark from a vendor’s blog. A token is spent according to the load profile, not a conference talk. Misuse: “boring” as a veto on anything new — the essay argues for spending tokens deliberately, not for never spending them — and boring options stretched past their limits at great cost.

  • empirical law
  • context-dependent
  • no code

Scope. The card holds two different observations. Brooks’s law is about the cost of coordination: it depends on how decomposable the task is, the cost of onboarding and the project stage, and it barely shows up in loosely coupled work with onboarding already in place. The second-system effect is about the author’s motivation, not the arithmetic of communication, and it strikes even a team of constant size. Both are observations from “The Mythical Man-Month”, not universal constraints.

Primary sources: Frederick P. Brooks — The Mythical Man-Month (the primary source for both observations)

Adding people to a late project slows it down: communication grows quadratically, and onboarding takes away the most knowledgeable people. Second-system effect: the second version is the most dangerous — everything that “didn't fit” into the first gets dumped into it — a direct violation of Gall's Law (41) from within the team that wrote the first.

Both come from Fred Brooks, The Mythical Man-Month (1975), drawn from his management of IBM OS/360. The law, in his words: “Adding manpower to a late software project makes it later.” The mechanism has three parts. Work that cannot be partitioned does not speed up with more people at all. New people need training, paid for by the people who already know the system. And if each pair must coordinate, n people have n(n−1)/2 channels: 5 people have 10, 10 have 45. Brooks introduced the law with the words “oversimplifying outrageously”; it is an observation from experience with a plausible mechanism, not a measured constant.

The second-system effect is the chapter of the same name: an architect’s first system is spare because they are unsure; the second carries every idea deferred from the first, and generalizes the parts that worked. In practice it is the rewrite pitched as “v2 with everything we learned”: a new data platform that will also unify batch and streaming, add multi-tenancy and replace the scheduler — and that has to reach parity with the old one before it delivers anything.

Limits. Adding people early, to work that partitions well, with low ramp-up, does help — the law is about late projects and tightly coupled work. Team boundaries that cut communication paths (15) change the quadratic term. Not expressible in code; expressed instead as a decision to “cut scope, don't hire” and as a requirement that rewrite proposals go through a strangler fig (73) rather than “this time we'll do it right”.

XI

Platform as a product

  • pattern
  • established
  • fragment

Conflicts with: Build cache ↔ correctness

A build is a pure function of its declared inputs; anything undeclared (the system toolchain, the network, time, latest tags) makes the output irreproducible and the cache a lottery. The corollary that is the point of the whole exercise: aggressive caching (local, remote, build avoidance) is correct only under hermeticity — a cache hit on an incomplete key is a silent delivery of a stale artifact to prod.

Code example (fragment)
fragment
Gradle · Kotlin DSL
// cacheability conditions are hermeticity conditions
tasks.register<Test>("integrationTest") {
    inputs.files(configurations.runtimeClasspath).withNormalizer(ClasspathNormalizer::class)
    inputs.property("jdk", java.toolchain.languageVersion)  // toolchain is an input, not environment
    outputs.cacheIf { true }
    // system time, env vars, network are NOT inputs;
    // a test reading them either declares them or is uncacheable
}
CI
# the reproducibility check is itself a CI test:
# two clean-cache builds on different agents → artifacts must not differ
diff <(sha256sum build-a/*.jar) <(sha256sum build-b/*.jar)

Honest assessment. Full Bazel/Nix-style hermeticity is expensive; the pragmatic boundary is hermeticity of the cache key: everything that affects the output goes into the key. A “works locally, fails in CI” incident is almost always an undeclared input found empirically; each one is a patch to the declaration, not to the README.

outputs.cacheIf { true } does not make a task hermetic — it only permits caching. Correctness comes from a complete declaration: all inputs, the toolchain, environment variables, locale and network dependencies are either declared or eliminated. Reproducible archives additionally need normalized timestamps and file order.

  • practice
  • opinionated
  • no code

Scope. The merge queue and 10–15 minutes of CI are reference points from large-monorepo practice, not constants. Flags are not the only mechanism: branch by abstraction and backward-compatible slices achieve the same without flag debt.

Conflicts with: Trunk-based ↔ unfinished features

A merge conflict is not created by merging — it is created by divergence and merely discovered by merging; hundreds of long-lived branches = an organization accumulating undiscovered conflicts for months. Trunk-based flips this: integration is continuous, and unfinished work hides behind flags (72), not behind branches. A branch lives for days, not sprints.

The practice is older than the name: continuous integration originally meant everyone integrating into mainline at least daily, and Humble and Farley’s Continuous Delivery (2010) builds on that. Paul Hammant’s trunkbaseddevelopment.com collected the variants. The evidence is DORA’s: in Accelerate (2018) Forsgren, Humble and Kim report that having fewer than three active branches, merging them within a day and having no code freeze correlated with higher delivery performance in their surveys. That is correlation in survey data, not a controlled experiment; teams that can merge daily may also be better in other ways.

The mechanics without which trunk-based falls apart at 80+ services: a merge queue (PR batches are tested in their future merge order, not against a stale main — otherwise “green PR, red main” happens daily) plus required checks faster than 10–15 minutes: slow CI recreates long-lived branches economically, because frequent integration becomes expensive for people. Flags instead of branches move the risk from “conflict at merge” to flag debt (72) — a deliberate trade; the latter is cheaper because it is visible.

Not expressible in code; expressed through repository policy and, above all, CI speed — trunk-based is first and foremost an SLO on the build.

Where branches are right. Open source with untrusted contributors needs forks and review before anything reaches main. Supported release lines — a 2.x that still gets security fixes — are long-lived branches by design; fixes land on trunk first and are cherry-picked back. The merge queue’s idea is Graydon Hoare’s “not rocket science rule”: automatically maintain a repository that always passes all its tests, by testing each change as it would be merged.

  • practice
  • context-dependent
  • no code

Scope. Opt-in applies to developer-facing workflow, not to security/compliance guardrails — those can be mandatory by construction.

Conflicts with: Paved road ↔ team autonomy, Paved road ↔ blast radius

An internal platform imposed by mandate gets malicious compliance; a platform run as a product wins because the paved road is cheaper than the detour: onboarding in an hour, migrations are the platform’s problem, not the team’s. Deviating is legitimate, but it has an explicit price: leave the paved road and you own your stack yourself, on-call and upgrades included. The platform’s metric is adoption and time-to-first-deploy, not feature count.

“Paved road” is Netflix’s term for its supported path of tools and libraries; Spotify calls the same thing a “golden path”. The organizational model behind it is set out in Skelton and Pais’s Team Topologies (2019): a platform team provides internal services in an X-as-a-Service mode to stream-aligned teams, and aims for the “thinnest viable platform” that reduces their cognitive load. Backstage, which Spotify open-sourced, is one concrete form: software templates that scaffold a service with CI, telemetry and deployment already wired.

Honest assessment. The dark side of a paved road is that the platform becomes a monoculture: its bug is everyone’s bug (blast radius, 27; version the platform and roll the platform itself out as a canary, 72, across consumer teams). And Conway (15) in full force: a platform team nobody can reach with a question breeds workarounds regardless of code quality. A platform without support is a library, not a product.

It is expressed through the ownership structure and the platform team’s SLA; in code it shows up as “a new service template = one command.”

How to tell it is working. Count, don’t survey: the share of services on the current template version, the time from create to first production deploy, the number of teams that left the road and why. A team that leaves is data — either the road lacks something real, or the detour is cheaper than it should be because its costs (on-call, upgrades, audits) are not charged to it.

When not to build one. With a handful of teams a platform team is overhead; a shared template and a written convention do the job. A platform that tries to hide every decision becomes a leaky abstraction its users must still understand, and a platform with nothing left to adopt — because every new service already uses it — measures itself by migrations of the old ones (87).

  • pattern
  • established
  • fragment

Scope. Level-triggered reconciliation is not free: reading full state every pass costs API load and needs caches, and some actions are genuinely one-shot (sending an email, charging a card) and still need an idempotency key rather than a level. Static stability is a design goal with a price — pre-provisioned capacity and data planes that may run on stale configuration for the length of the outage.

Versions. controller-runtime v0.25 (Go): Result.Requeue is deprecated in favour of RequeueAfter; the fragment uses neither and relies on the error return for backoff.

Conflicts with: Short credential TTL ↔ static stability of the data plane

Primary sources: Kubernetes API Conventions — level-based behaviour, spec and status, observedGeneration, Kubernetes Documentation — Controllers

References: controller-runtime — package reconcile (v0.25.1)

Other claims on this card, typed separately:

The control plane changes the system; the data plane is the system doing its job. The control plane should be a set of controllers, each comparing desired state with observed state and acting on the difference — level-triggered, so an event is only a hint to look, never the input itself. The data plane should keep serving on its last known-good configuration when the control plane is gone: it gets no updates during the outage, but what worked keeps working.

Level, not edge. A handler that acts on “what changed” is only as correct as the event stream: a missed, duplicated or reordered event, a restart, or a resync silently desynchronises it. A reconciler reads the whole current state on every pass, so any number of events collapse into one correct action, and a value that went 2 → 5 → 3 is simply driven to 3. The price is that reconcile must be idempotent (22) and cheap enough to run often; the dividend is that crash recovery, resync and the normal path are the same code (25). “Done” is defined against a particular spec: status.observedGeneration equal to metadata.generation is the only honest “rolled out” signal, and deploy tooling should wait on it rather than on the API call having returned.

Code example (fragment)
fragment
Go · controller-runtime v0.25
// The request carries only a name, never "what changed": every call reads the
// current desired and observed state and converges, so a missed or duplicated
// event costs nothing.
func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var app webv1.WebApp
    if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
        // Gone: the Deployment is garbage-collected through its owner reference.
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
    // Mutate only the fields this controller owns; overwriting whole structs would
    // drop server-side defaults and turn every pass into a spurious update.
    _, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
        labels := map[string]string{"app": app.Name}
        dep.Spec.Replicas = ptr.To(app.Spec.Replicas)
        dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
        dep.Spec.Template.Labels = labels
        if len(dep.Spec.Template.Spec.Containers) == 0 {
            dep.Spec.Template.Spec.Containers = []corev1.Container{{Name: "web"}}
        }
        dep.Spec.Template.Spec.Containers[0].Image = app.Spec.Image
        return controllerutil.SetControllerReference(&app, dep, r.Scheme)
    })
    if err != nil {
        return ctrl.Result{}, err // requeued with backoff; the retry re-reads everything
    }

    // Observed state, stamped with the generation it was computed against.
    want := webv1.WebAppStatus{ObservedGeneration: app.Generation, ReadyReplicas: dep.Status.ReadyReplicas}
    if app.Status == want {
        return ctrl.Result{}, nil
    }
    app.Status = want
    return ctrl.Result{}, r.Status().Update(ctx, &app)
}

func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
    // Watches are triggers only: a Deployment event enqueues its owner's name.
    return ctrl.NewControllerManagedBy(mgr).
        For(&webv1.WebApp{}).
        Owns(&appsv1.Deployment{}).
        Complete(r)
}

Failure modes of reconcilers. Two controllers owning the same field fight forever, each “correcting” the other — field ownership must be explicit. Writing status unconditionally can re-trigger the controller that wrote it: a hot loop. A permanent error retried with backoff is noise forever; mark it terminal and surface it in a condition. An external resource cannot be observed once its API object is gone, so its cleanup needs a finalizer. And GitOps (86) is the same loop one level up: a manual hotfix is drift and gets reverted, so the incident procedure must suspend sync explicitly rather than race it.

Static stability. The data plane must not call the control plane on the request path: no per-request lookups against the orchestrator API, no sidecar that fails closed when it cannot reach its controller, no startup that requires a control-plane write. Recovery actions should be data-plane actions against capacity provisioned in advance (97, 105). The flip side: a controller propagates a bad desired state everywhere at controller speed, so the control plane is where progressive rollout (72) and cell boundaries (27) matter most.

  • pattern
  • established
  • config

Scope. The fair-queueing result is about packets at a gateway with known sizes; carrying it to requests, jobs or queries needs a cost estimate that is only approximate, and fairness is defined per flow key, not per real-world tenant. Where tenants pay for different service levels, equal shares are the wrong target and weights or priority levels replace them.

Versions. flowcontrol.apiserver.k8s.io/v1 is the stable API since Kubernetes 1.29; before v1beta3 the shares field was named assuredConcurrencyShares.

Primary sources: Alan Demers, Srinivasan Keshav, Scott Shenker — Analysis and Simulation of a Fair Queueing Algorithm (Internetworking: Research and Experience, 1990; SIGCOMM 1989), AWS Well-Architected SaaS Lens — Noisy Neighbor

References: AWS Whitepaper — SaaS Tenant Isolation Strategies: Isolation: Security or noisy neighbor?, Kubernetes Documentation — API Priority and Fairness, Prometheus Documentation — Configuration: scrape_config sample_limit

Other claims on this card, typed separately:

A shared platform owes its tenants two different things. Isolation: one tenant cannot see or change another’s data. Fairness: one tenant cannot degrade another’s service. They need different mechanisms, and the second is the one usually missing: under first-come-first-served, a single source that sends fast enough takes an arbitrarily large share of the capacity. Fair queueing, with a separate queue per source served in turn, removes exactly that property.

Layers, not one knob. A quota caps a tenant’s total consumption, which bounds the worst case but says nothing about who goes first under contention. Per-tenant admission control rejects excess early with a 429 instead of letting it occupy a shared queue (23). Fair queueing inside the service divides what is actually available among the tenants that are actually busy, so idle capacity is not wasted. Shuffle sharding bounds how many others a heavy tenant can collide with, and cells turn that bound into a wall (27). Priority is a separate axis: fairness among equals does not decide whether the platform’s own leader election goes before a tenant’s batch job.

Code example (config)
config
Kubernetes · flowcontrol v1
# The API server is itself a multi-tenant service: a priority level caps a class
# of work, and fair queuing inside it keeps one namespace from starving the rest.
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: PriorityLevelConfiguration
metadata:
  name: tenant-workloads
spec:
  type: Limited
  limited:
    nominalConcurrencyShares: 20    # a share of the server's seats, not an absolute count
    lendablePercent: 50             # idle seats may serve other levels
    limitResponse:
      type: Queue                   # Reject would answer 429 immediately
      queuing:
        queues: 128
        handSize: 8                 # shuffle sharding: each flow is dealt 8 of the 128 queues
        queueLengthLimit: 50        # one flow can queue at most handSize * queueLengthLimit requests
---
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: FlowSchema
metadata:
  name: tenant-workloads
spec:
  priorityLevelConfiguration:
    name: tenant-workloads
  matchingPrecedence: 5000          # lowest value is tested first; the first match wins
  distinguisherMethod:
    type: ByNamespace               # flow = target namespace = tenant
  rules:
  - subjects:
    - kind: Group
      group:
        name: system:serviceaccounts
    resourceRules:                  # namespaced requests only; cluster-scoped ones fall through
    - verbs: ["*"]
      apiGroups: ["*"]
      resources: ["*"]
      namespaces: ["*"]

Fairness is only as good as attribution. A flow is whatever key the scheduler sees; a tenant that splits into many keys (users, namespaces, API keys) gets many shares. The tenant key must therefore come from authenticated identity (102) and travel with the request from the edge inward — a component that cannot attribute work cannot be fair about it. The second hard part is cost: fair queueing was designed for packets whose size is known on arrival, while a request’s cost is known only afterwards, which is why the Kubernetes API server estimates how many concurrency seats a list call will occupy from the number of objects it may return.

Tenants also load the platform’s own planes. Tenant-controlled dimensions — metric labels, topics, partitions (92), per-tenant objects in the control plane — grow cardinality and cost, not request rate, and can take down the observability or control plane while the data plane looks healthy. Limits there are often cliffs: in Prometheus, a scrape over its sample_limit fails as a whole. Decision rule: for every shared resource — pool, queue, partition, cache, metrics backend, control-plane API — name the tenant key, the limit, what happens at the limit, and whose SLO absorbs the burst; track cost per tenant as an SLI (78).

XII

Principles for the AI era

AI is one more intermediary, and the rule is the one this page holds everywhere: a conclusion inherits the trust of its least-verified basis. Three sets of practice apply it: A systems with an LLM inside, B engineering with AI assistants, C a person thinking with AI. Keep the practices apart; the principle is one.

A · Systems with an LLM inside

  • practice
  • established
  • fragment

The most important principle in this section. Anything the model has read (web pages, documents, emails) may contain instructions; therefore the model’s output carries the trust level of its least trusted input. The classic risk triad: access to private data + untrusted content + an outbound channel — never all three at once.

Simon Willison named the attack “prompt injection” in September 2022, by analogy with SQL injection, after Riley Goodside’s demonstrations; Greshake et al., “Not what you’ve signed up for” (2023), showed the indirect form, where the instructions arrive inside content the application retrieves rather than from the user. Willison later called the combination of private data, untrusted content and an exfiltration channel the “lethal trifecta”. OWASP’s Top 10 for LLM Applications lists prompt injection first.

Code example (fragment)
fragment
Python
# LLM output is parsed as untrusted input — the same parse-don't-validate.
# Tagged union: tool and args are tied by a discriminator, otherwise
# {"tool": "search_orders", "args": <InvoiceArgs fields>} passes validation
class SearchOrders(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    tool: Literal["search_orders"]
    args: SearchArgs

class GetInvoice(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    tool: Literal["get_invoice"]
    args: InvoiceArgs

ToolCall = Annotated[SearchOrders | GetInvoice, Field(discriminator="tool")]

raw = llm.complete(prompt)
call = TypeAdapter(ToolCall).validate_json(raw)  # not eval, not getattr(obj, raw_name)
Rust
// Same principle as a type: LLM text enters the system only via a parser
pub struct LlmRaw(String);                          // untrusted

pub enum Action { SearchOrders(Query), GetInvoice(InvoiceId) }

impl LlmRaw { pub fn parse(self) -> Result<Action, Rejected> { ... } }

A schema is necessary, not sufficient. Valid JSON can still carry a perfectly legal and perfectly unwanted command. Parsing is followed by deterministic checks the model does not control: authorization on behalf of the user, not the agent; the object belonging to the tenant; semantic limits (amounts, volumes, date ranges); rate limits; egress policy.

Why there is no filter that fixes it. Unlike SQL, a prompt has no separate channel for data (34): instructions and the email being summarized are the same kind of tokens. Classifiers and “ignore instructions in the document” prompts lower the success rate of known attacks; none has a guarantee, and an attacker iterates faster than a blocklist. A support agent that reads tickets and can issue refunds is exploitable by a ticket that asks for one. The structural fixes remove a leg of the triad: the agent that reads untrusted text has no refund tool, or every refund waits for a human (49, 50).

  • practice
  • established
  • fragment

An agent gets a narrow set of tools for its task, not “the whole API.” Tools with side effects are separated by risk level, and generated code runs in a sandbox — because whatever the agent reads can steer what it does.

Least privilege is Saltzer and Schroeder’s, from “The Protection of Information in Computer Systems” (1975): every program should operate using the least set of privileges necessary to complete the job (33). Agents make it urgent because their instructions and their data arrive in the same channel. A support agent that reads a customer email can be told by that email to “issue a full refund and forward the account history”. That is prompt injection, and it turns the agent into what Norm Hardy called a confused deputy (1988): a program with authority, tricked into using it on someone else’s behalf. No prompt reliably prevents it, so the defense is to limit what a deceived agent can do.

Code example (fragment)
fragment
Go
// Tools as capability objects: a read-only agent physically has no writer
type ReadTools struct{ Orders OrderReader; Docs DocSearcher }
type WriteTools struct{ ReadTools; Refunds RefundIssuer } // granted separately, with approval

func runSupportAgent(t ReadTools, query string) Answer { ... } // cannot issue a refund
Sandbox
# Generated code is executed only in a sandbox
sandbox: { network: none, fs: readonly-except:/tmp, timeout: 30s, memory: 512Mi }

A useful test is Simon Willison’s “lethal trifecta” (2025): an agent that has access to private data, is exposed to untrusted content, and can communicate externally can be made to exfiltrate the data. Remove any one leg — no outbound network in the sandbox, no untrusted input in that session, or no private data in reach — and that attack closes. Credentials follow the same rule: the tool holds a scoped, short-lived token for this task and tenant, never the agent’s whole identity (102).

Trade-offs. Too narrow a tool set makes agents fail or route around the limits through the tools they do have — a shell tool is every tool. Irreversible actions go through approval (50); everything the agent returns is untrusted input (48).

  • heuristic
  • context-dependent
  • fragment

Conflicts with: HITL ↔ agent autonomy

One-way doors (a payment, a deletion, sending an email to a customer) require confirmation; two-way doors (a draft, a search) do not. This is the reversible/irreversible decisions principle (20) applied to agents.

Code example (fragment)
Javafragment
sealed interface AgentAction permits Draft, Search, Irreversible {}
record Irreversible(Action a, Justification j) implements AgentAction {}

// pipeline: Irreversible goes to the approval queue; Draft/Search run immediately

The reason for the gate is asymmetric cost. An agent that drafts a wrong reply costs a minute of editing; one that sends a wrong refund, drops a table or emails every customer costs what cannot be taken back, and agents can be steered by what they read (49). Making the action a type, as above, means the executor cannot reach the irreversible path without an approval object — the gate lives in the code, not in a prompt asking the model to be careful.

The weak point is the human. Parasuraman and Riley, “Humans and Automation: Use, Misuse, Disuse, Abuse” (Human Factors, 1997), describe how people over-trust automation that is usually right and stop checking it; Bainbridge’s “Ironies of Automation” (1983) adds that monitoring rare failures is a task humans do badly. An approval queue where 99% of items are fine trains its reviewers to click “approve”. So the request must show what matters for the decision — the diff of the state, the amount, the recipients, the agent’s stated reason — not a log of its reasoning.

A single reversibility criterion leads to rubber-stamping: when there are too many approvals, people stop reading them. The threshold is made up of reversibility, scale of impact, the initiator's authority, and uncertainty. And an approval must be bound to the hash of a specific immutable action — otherwise a TOCTOU gap (time-of-check to time-of-use) remains between “shown for approval” and “executed”.

Make doors two-way where you can. A delayed send with an undo window, a soft delete with a retention period, a refund capped per day: each moves an action out of the approval queue and keeps human attention for the few that need it.

  • practice
  • established
  • fragment

Evals are tests for a non-deterministic component. Without evals, swapping a prompt/model is flying blind. The hierarchy by cost: assertion-based → golden set + metrics → LLM-as-judge (which itself needs calibration against human labels).

There is no single inventor; the practice grew out of NLP benchmarking. HELM (Liang et al., Stanford, 2022) argued that a model must be evaluated on many scenarios and metrics at once, not one score, and OpenAI’s open-source Evals framework (2023) made per-application eval suites a normal engineering artifact. The case for treating judges with suspicion is measured: Zheng et al., “Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena” (NeurIPS 2023), found that strong models agree with human preferences at roughly the rate humans agree with each other, and also documented position bias, verbosity bias and a preference for their own answers.

Code example (fragment)
Pythonfragment
# Level 1: cheap programmatic checks, run on every prompt PR
def eval_extraction(case: Case) -> Score:
    out = pipeline(case.document)
    return Score(
        json_valid   = is_valid_json(out),
        schema_ok    = validate_schema(out, InvoiceSchema),
        amounts_ok   = out["total"] == case.expected_total,   # exact match, not similarity
        no_leak      = case.ssn not in out_raw,
    )

# Level 2: golden set is versioned next to the prompt; regression = red CI
# Level 3: LLM-judge — only for style/tone,
#            periodically checked against humans

Level 1 is not the whole eval. A non-deterministic component requires: repeated runs and confidence intervals instead of a single number; slices (by language, length, document type), because the average hides a failure on a slice; adversarial cases; calibration of the LLM judge against human labels; a check for golden-set leakage into the prompt; and monitoring for drift in production. Cost and latency are measured alongside quality, not separately.

How big a difference is real. On 100 golden cases a 90 % pass rate has a 95 % binomial interval of roughly ±6 points, so a prompt change that moves it from 90 to 93 is indistinguishable from noise — and a non-deterministic model adds run-to-run variance on top. Before comparing two versions, decide the minimum difference that matters and size the set (or the number of repeats) to detect it; compare paired, on the same cases.

Limits. An eval measures only what is in the set, and a set tuned against for months becomes a target (Goodhart): the prompt fits the golden cases and production drifts away from them. Keep a held-out slice nobody optimizes on, and refresh the set from real failures. Evals do not replace deterministic guards (48, 53): a 99 % score on “no PII leaked” is a 1 % leak rate.

  • practice
  • established
  • config

Prompt = code: review, version, changelog, rollback. The model is pinned to an exact version — gpt-x-latest in production is an unpinned dependency (Hyrum’s law (16) for models: the behavior of any version is an observable contract, and a version change is a breaking change until proven otherwise).

The evidence that a model name is not a fixed thing: Chen, Zaharia and Zou, “How Is ChatGPT’s Behavior Changing over Time?” (2023), ran the same tasks against the March and June 2023 versions behind the same product names and found large changes in both directions — on some tasks accuracy dropped sharply, and the models’ willingness to follow formatting instructions shifted. Any pipeline that parsed that output broke without a single line of its own changing. A dated snapshot does not stop the provider from retiring it, but it turns a silent change into a scheduled migration you can test.

Code example (config)
prompts/invoice_extract/v3.yamlconfig
# golden set and eval results live alongside
model: claude-haiku-4-5-20251001    # dated snapshot; claude-haiku-4-5 is
                                    # a family alias, i.e. an unpinned dependency
temperature: 0
prompt_sha: 8f3ab2
eval_baseline: { schema_ok: 0.998, amounts_ok: 0.974 }   # below this, deploy is blocked

The file above is the unit of change. A prompt edit is a pull request that reruns the eval set (51) and shows the new scores next to eval_baseline; a model upgrade is the same pull request with one line changed. Production logs record the prompt hash and the model snapshot for every call (57), so a bad answer can be traced to the exact configuration that produced it, and rollback is a revert, not an archaeology session.

temperature: 0 does not give you determinism — don’t build on it. The whole setup is versioned: model snapshot and provider, system prompt, tool definitions, schemas, retrieval index version (58), runtime parameters and safety configuration. Pinning has a cost — snapshots are retired, and staying pinned too long means a forced migration under a deadline — so upgrades are scheduled, not avoided.

  • pattern
  • established
  • fragment

Free text between machine components is a stringly-typed interface. The schema is imposed on generation, and the output is validated anyway (double control): the schema guarantees shape, only your own checks guarantee meaning.

There are three levels of strength. Asking in the prompt for JSON gets JSON most of the time and a trailing comment or a truncated object the rest. Function calling and JSON modes make the provider aim for valid JSON. Constrained decoding makes invalid output impossible: at each step the decoder masks every token that would leave the grammar. Willard and Louf, “Efficient Guided Generation for Large Language Models” (2023, the Outlines library), showed how to compile a regular expression or JSON Schema into a finite-state machine so the mask costs little per token; llama.cpp grammars and the providers’ “strict” structured-output modes apply the same idea. Support for JSON Schema features differs between implementations — check which keywords yours enforces.

Code example (fragment)
fragment
Java
// schema from the type, output into the type
record Invoice(String number, long totalCents, LocalDate due) {}

var invoice = mapper.readValue(llm.completeWithSchema(prompt, schemaOf(Invoice.class)),
                               Invoice.class); // this can throw too — handle it
Go
type Extract struct {
    Number     string `json:"number"`
    TotalCents int64  `json:"total_cents"`
}

// json.Unmarshal + domain validation AFTER unmarshaling — a model can
// return syntactically valid, semantically false JSON

An invoice-extraction example. The schema guarantees that total_cents is an integer; it does not guarantee that it equals the sum of the line items, that due is after the issue date, or that the number was in the document at all rather than invented. Those are domain checks, done after parsing (2), with a rejection path: retry with the error in the prompt, route to a human, or mark the record unextracted. A refusal or an empty object must be a representable outcome — a schema with every field required forces the model to fill fields it could not find.

Trade-offs. Constraining format can cost answer quality; one 2024 study (Tam et al., “Let Me Speak Freely?”) reported lower reasoning scores under strict formats on some tasks. A common remedy is a free-text reasoning field before the structured fields, or a second call that only formats. Measure it on your own evals (51) rather than assuming either way. The schema is also an interface: version it with the prompt (52) and evolve it compatibly (18).

  • pattern
  • context-dependent
  • fragment

The same functional core, imperative shell (5): an LLM call is an I/O effect of the shell. The core (parsing, routing, business rules) stays deterministic and is tested without the model; the model’s variability is measured separately, by evals, where it belongs.

The pattern predates LLMs: Gary Bernhardt’s talk “Boundaries” and his screencast “Functional Core, Imperative Shell” (2012) argued for pushing every effect — network, disk, clock, randomness — to a thin outer layer. A model call is that kind of effect, with one more property: the same request can return different output. Even temperature: 0 does not guarantee identical output from a hosted model (52). If model output is consumed in ten places, every one of them becomes flaky; if it is consumed once, parsed into a typed value (53) and handed to pure code, the flakiness has one address.

Code example (fragment)
Rustfragment
trait Completion { async fn complete(&self, req: Req) -> Result<Raw>; }

// prod: an HTTP client; test: recorded fixtures (golden traces), so the pipeline
// is tested deterministically; evals run the live model separately and less often
fn route(ticket: &Ticket, label: Label) -> Queue { /* pure: no model, no clock */ }

A support-ticket pipeline: the shell fetches the ticket, calls the model for a label, and writes the result; the core maps (ticket, label) to a queue, applies SLAs and escalation rules. Core tests feed labels directly — thousands of cases a second, no network. Pipeline tests replay recorded model responses (golden traces) so a change to parsing or routing is caught deterministically. Evals (51) run the live model on a labelled set, on a schedule and on every prompt or model change, and report a score with its variance, not a pass/fail.

Limits. Recorded fixtures go stale: they prove the code handles last month’s outputs, not today’s model, so they are re-recorded on model changes. And some systems put the model inside the control flow — an agent choosing its next tool — where the fence is around each step, not around the whole.

  • pattern
  • context-dependent
  • fragment

A model is a dependency with a non-trivial SLA and cost. The cascade: cache → cheap model → expensive model → rule/template → honest refusal. Circuit breakers and budgets (tokens/latency/money) are the same principles from section IV, applied to a new kind of dependency.

Graceful degradation is an old reliability idea: when a dependency fails, serve a reduced answer rather than an error. Michael Nygard’s Release It! (2007) gave backend engineers the circuit breaker and the bulkhead for it (24). An LLM provider adds two failure modes that most dependencies lack: rate limits and overload are routine, not exceptional, and every call has a price that varies by two orders of magnitude between models. The cascade as a cost tool was studied by Chen, Zaharia and Zou in “FrugalGPT” (2023): send a query to a cheap model first, score its answer, and escalate only when the score is low. They reported large cost savings at matched accuracy on their benchmarks — a result about those tasks, worth reproducing on your own evals (51) before adopting the routing.

Code example (fragment)
Pythonfragment
async def classify(ticket: Ticket) -> Category:
    if (hit := semantic_cache.get(ticket.embedding, threshold=0.97)): return hit
    try:
        return await small_model.classify(ticket, timeout=1.5)
    except (Timeout, Overloaded):
        return rules_fallback(ticket)          # degradation shows in metrics, not silent

Each step of the cascade is a quality level the product has agreed to show. For ticket classification, a keyword rule that routes to a general queue is acceptable; for a legal answer, a template saying “we can’t answer this now” is the honest floor, and a cheaper model guessing is not. The degradation level is a metric and a label on the response, so a silent slide into fallback shows up on a dashboard (57) instead of in complaints.

The semantic-cache risk. Embedding similarity is not query equivalence, and a shared cache is a leak channel between tenants. The key must include the tenant, the user’s permissions, the model, the prompt version, the index version and the tool state; the false-hit rate is measured, not assumed. Retries across the cascade also multiply load on an already struggling provider — budget them (23).

  • practice
  • established
  • fragment

A RAG answer without a reference to the retrieved chunk is an unverifiable claim. But a non-empty list of references guarantees nothing either: what must be checked is support for each claim individually, not whether the answer as a whole has a bibliography.

Retrieval-augmented generation was named by Lewis et al., “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks” (NeurIPS 2020): condition generation on passages fetched from an index, so knowledge can be updated without retraining and answers can point at their evidence. The gap between pointing and supporting was then measured. Rashkin et al. defined attribution to identified sources (AIS) as a human-judged standard, and Liu, Zhang and Liang, “Evaluating Verifiability in Generative Search Engines” (2023), found that in the systems they audited only about half of the generated sentences were fully supported by their citations.

Code example (fragment)
Pythonfragment
class Claim(BaseModel):
    text: str
    support: list[ChunkRef]
    support_status: Literal["supported", "partial", "unsupported"]

class GroundedAnswer(BaseModel):
    claims: list[Claim]                      # claims, not a wall of text

    @model_validator(mode="after")
    def must_be_supported(self):
        for c in self.claims:
            if c.support_status != "supported":
                raise ValueError(f"unsupported claim: {c.text[:60]}")
            for ref in c.support:
                if not index.exists(ref):    # the chunk actually exists
                    raise ValueError(f"phantom citation: {ref}")
                if not acl.readable(ref, self.actor):  # and this user can see it
                    raise ValueError(f"citation leaks source: {ref}")
        return self

The validator checks form. The substantive part is a separate step: an entailment check (does the chunk support the claim) and a coverage metric — what share of checkable claims has support. Both go into evals (51) because they are expensive and non-deterministic. No support → “I don’t know” is cheaper than a hallucination.

What grounding does not give. A supported claim is traceable, not true: the chunk may be stale, superseded by a later policy, or wrong. So the index needs the same freshness and provenance discipline as any data (58), and a citation should carry the document version, not just its title. Answers that combine several chunks — “A is cheaper than B” — are often only partially supported by each; treat partial as its own outcome rather than rounding it up. And the strict rule has a cost: a system that refuses everything not literally in the corpus will refuse reasonable inferences; decide per product whether “I don’t know” or a labelled inference is the better failure.

Tokens, cost, latency, prompt and model version — in every trace span, following the OpenTelemetry gen_ai.* semantic conventions rather than home-grown names: otherwise dashboards and backends don’t understand your attributes. Those conventions now live in their own GenAI repository and are still at Development status, so pin the version you emit. Quality drift is caught by continuous evals in production, not by user complaints.

The conventions define span attributes (gen_ai.request.model, gen_ai.usage.input_tokens, …) and two client metrics, gen_ai.client.token.usage and gen_ai.client.operation.duration, so token spend and latency can be aggregated without reading spans. The reason to follow them rather than invent names is the same as for any telemetry contract (29): a backend can only build a cost dashboard over attributes it recognizes.

Code example (fragment)
Go · OpenTelemetry GenAIfragment
span.SetAttributes(
    // standard gen_ai.* attributes — off-the-shelf dashboards understand them
    attribute.String("gen_ai.provider.name", "anthropic"),
    attribute.String("gen_ai.request.model", "claude-haiku-4-5-20251001"),
    attribute.Int("gen_ai.usage.input_tokens", usage.In),
    attribute.Int("gen_ai.usage.output_tokens", usage.Out),
    // custom ones — only where no standard attribute exists
    attribute.String("app.prompt_sha", promptSHA),
    attribute.Float64("app.cost_usd", cost),
    attribute.Bool("app.cache_hit", hit),
)
// Content (prompts, responses, tool arguments, retrieval queries)
// is NOT written to the span: the gen_ai conventions explicitly warn it holds PII.

Content is a separate stream with different rules. Capture is off by default and enabled selectively; PII redaction happens before the exporter, not in the backend; length is truncated; sampling is enabled; metadata and content have different retention and ACLs; tenant separation is mandatory; secrets in span attributes are not allowed. A regular trace carries the prompt version (prompt_sha), not the prompt itself.

What it buys in an incident. Daily spend doubles overnight. With prompt_sha and token counts on every span, the question “what changed” is one group-by: a new prompt version that pulls three times as many retrieved chunks into context, or a cache hit rate that fell after a key format changed. Without them it is a week of guessing from the provider’s invoice. Cost is an SLI like latency (78), and it moves with the same deploys.

Limits. Telemetry says how much and how slow, not how good: a span cannot tell a correct answer from a fluent wrong one, which is why quality comes from evals on sampled production traffic (51). Sample traces, but keep every error and every refusal — rare failures are what sampling drops first.

  • practice
  • established
  • no code

The data-centric view: system quality is more often limited by the data than by the model. Datasets and embedding indexes are versioned (DVC / lakeFS / Iceberg snapshots), and lineage is traceable: which index, from which documents, with which chunker, with which embedding model.

The argument is older than LLMs. Sculley et al., “Hidden Technical Debt in Machine Learning Systems” (NeurIPS 2015), observed that data dependencies cost more than code dependencies because nothing like a compiler or linker tracks them: an input signal changes upstream and the model degrades silently. Gebru et al., “Datasheets for Datasets” (2018), proposed documenting how a dataset was collected and processed for the same reason. A retrieval system has the same shape: an answer was wrong — was it the model, the prompt, a document that was never indexed, a chunk split mid-table, or an index built from last month’s export? Without lineage the question has no answer, and a fix cannot be shown to work.

What goes into git is not the datasets but an immutable manifest: object identifiers and their hashes, the schema, the parser and chunker versions, the embedding model, the access-policy version. The data itself lives in versioned object storage. Every eval result (51) and every production answer log (57) records the manifest id it ran against, so a regression can be bisected across data versions the way code is bisected across commits.

Changing the embedding model = a full reindex = an expand–contract migration (17), not an in-place one: vectors from two models are not comparable, so the new index is built beside the old, evaluated, switched by alias, and the old one dropped after a rollback window.

Limits. Versioning everything is expensive for large, fast-changing corpora; snapshot at the granularity you need to reproduce a result, not every write. And versioned data still carries its deletion obligations: a snapshot that keeps erased personal data forever breaks retention (36).

B · Engineering with AI assistants

  • author’s position
  • opinionated
  • no code

Scope. A hard PR size limit produces artificially fragmented changes. What works is risk-based reviewability: logical cohesion, separating generated code, ownership, a semantic diff, staged rollout.

The cost has shifted: writing code is almost free; checking it is not. Throughput is now set by how much a person can honestly verify, so changes are shaped for the reviewer, not for the generator.

What we know about review predates code generation and is mostly observational. Bacchelli and Bird (“Expectations, Outcomes, and Challenges of Modern Code Review”, ICSE 2013) found at Microsoft that finding defects was the top stated motive for review, but that comments were more often about understandability and design, and that understanding the change was the reviewers’ main difficulty. Sadowski et al. (“Modern Code Review: A Case Study at Google”, ICSE-SEIP 2018) describe a culture of small changes with quick turnaround. A Cisco study published by SmartBear reported that defect discovery fell off when a review covered more than a few hundred lines or went faster than roughly 500 lines an hour; it is a single vendor-run study, so read the numbers as an order of magnitude. Together they say the scarce resource is a reader’s understanding, and generation increases the supply of text against it.

Consequences: small diffs (reviewability matters more than generation speed), a ban on PRs larger than a reviewer can honestly read, and the author’s responsibility cannot be delegated — “the model wrote it” is no excuse in a postmortem. In practice: mechanical changes (a rename across 200 files) go in their own PR, produced by a script that is itself reviewed; behavioral changes go in stacked, separately reviewable steps; the description says what was verified and how. A schema migration, its backfill and the code that reads the new column are three reviews, not one.

Trade-offs. Not every line deserves equal reading: shift checking to machines where they are reliable — types and tests (60), linters, property tests (9) — and keep human attention for intent and design. Splitting too finely has its own cost: ten trivial PRs can hide one decision that only shows when they are read together.

  • heuristic
  • context-dependent
  • no code

The stricter the types and contracts, the fewer degrees of freedom generation has to get it wrong: Rust or strict mypy give the assistant the compiler as an immediate feedback loop, and the tests a human wrote say what “right” means.

A generator produces plausible text; a checker decides which plausible text is acceptable. Every rule the checker enforces is a mistake the generator cannot hand to a reviewer. If amount_cents is a Cents type and not an int, a generated function that adds cents to a percentage fails to compile instead of failing in an invoice. If an order state is a closed enum (3), a generated handler that forgets Refunded fails exhaustiveness. If input is parsed into a validated type at the boundary (2), the generator cannot reach the core with an unchecked string. The loop also gets faster: a compile error in two seconds beats a failing integration test in four minutes, and an agent iterates against it without a person in the loop.

Tests are written and reviewed by a human with particular care — a generated test fitted to a generated bug is green and useless. The test is the oracle; if the same process writes both the code and the oracle, they share the same misunderstanding. A cheap check of a test suite’s strength is mutation testing, an idea from DeMillo, Lipton and Sayward (“Hints on Test Data Selection”, IEEE Computer, 1978): flip a comparison or drop a line, and see whether some test fails. Generated tests that survive every mutant assert nothing. Property-based tests (9) state invariants rather than examples, which makes them harder to fit to a bug by accident.

Limits. Types check what is expressible in them: they say an amount is in cents, not that the tax rate is the right one for the jurisdiction. A strict type system can also push a generator into casts and unwrap() to make errors go away — so a review rule that flags new escapes (as, # type: ignore, unsafe) belongs with the strictness.

  • heuristic
  • opinionated
  • no code

The assistant does not delete code it does not understand — and the human does not accept generated code they do not understand. “It works, but I don’t know why” is the accumulation of code that never had an author who understood it; a new kind of debt.

The first direction is Chesterton’s original (44): an agent asked to “clean up” a module removes a guard it cannot explain, because nothing in its context says why the guard is there. The second direction is new. Peter Naur argued in “Programming as Theory Building” (1985) that a program is not its text but the theory its programmers hold — why each part is the way it is and how it would change — and that a program whose team has dispersed is effectively dead even if it still runs. Generated code accepted without being understood starts in that state: the text exists, the theory never did. Nobody can say which lines are load-bearing, so nobody can change them safely, and the fence problem reappears one level up: the next person cannot ask the author why.

A concrete case. A generated migration for a Postgres table adds CREATE INDEX CONCURRENTLY, a lock_timeout and a backfill in batches of 10,000. It passes CI. The reviewer who approves it should be able to answer: why concurrently, what happens if the index build fails halfway (it leaves an invalid index that must be dropped), and where the batch size came from. If those answers are unavailable, the change is not ready, however green it is — the next migration on that table will be written by someone who copies the pattern without the reasons.

Limits. “Understand” means being able to predict the code’s behavior and explain its non-obvious choices, not having typed it — the same bar applies to a library you depend on, and nobody reads all of those. The defense scales with stakes: a throwaway script needs less theory than a payment path. The cost lands on review (59) and on the reviewer’s own skill (112).

  • author’s position
  • opinionated
  • no code

Scope. “Generation quality = context quality” is reductive: the result also depends on the model, the task, the tools, the policy and the eval loop. Small files are not a goal in themselves; cohesion matters more than size.

References: AGENTS.md — a vendor-neutral instruction file for coding agents

Generation quality = context quality. The context window is a finite budget with a falling marginal value: what goes into it is chosen, kept short and kept true, like any other scarce resource.

A model answers from what it was given plus what it learned in training; for your codebase, only the first part knows your conventions. More context is not monotonically better. Liu et al., “Lost in the Middle: How Language Models Use Long Contexts” (TACL, 2024), found that on multi-document question answering, accuracy was highest when the relevant passage sat at the start or end of the input and dropped when it sat in the middle — measured on the models of that time, but the lesson that position and noise matter has held up. Every irrelevant file in the window costs tokens, latency and attention.

Practices: one vendor-neutral canonical instruction file in the repository (AGENTS.md: project conventions as a machine-readable artifact), with tool-specific files such as CLAUDE.md reduced to thin adapters that point at it instead of copying it; task context delivered by retrieval from a repo structure that is friendly to it (small files, descriptive names, up-to-date module READMEs), not by multiplying instruction files. Every always-loaded instruction spends context in every session, and every copy is a future contradiction. For the first time, documentation has a consumer that actually reads it.

What to put in, and what not. In: what the code cannot tell — invariants, why a constraint exists (44), traps, the commands that verify a change. Out: what a command or the code already answers — file trees, API inventories — because it rots and then misleads with authority. A stale instruction is worse than none; review instruction files like code. The trade-off is against discoverability: too little and the agent rediscovers conventions by trial; too much and the rules that matter drown. Measure by outcome — fewer corrections per task — not by length.

C · Thinking with AI

  • heuristic
  • opinionated
  • no code

Most failures with an AI happen before the first prompt: you ask for what seems needed, not for what is. The model answers the question asked, and a question that is not about the goal leads away from it.

Every question asserts more than it asks. “Which of the three frameworks?” asserts that a framework is needed, that there are three and that one fits; if any of that is false, every direct answer is wrong however good it sounds. Before asking, answer three things: what I actually want, how I will know I have it, and what I already take for granted. Write the presuppositions down and check first the one whose failure would change the decision most.

Same rule in code: an XY problem in a ticket is a presupposition nobody checked. Initiative — what counts as the problem — and the signature under the result stay with the human; this is a norm we choose, not a fact about the model.

Where it comes from. The engineering name is the XY problem: someone wants X, believes Y is the way to get it, and asks about Y. Eric S. Raymond’s “How To Ask Questions The Smart Way” gives the rule as “Describe the goal, not the step”. The idea of a hidden presupposition is older and comes from the philosophy of language — Frege and later Strawson on statements that presuppose something they do not assert — and the everyday form is the loaded question. Gause and Weinberg’s Are Your Lights On? (1982) is a short book-length treatment of “what is the problem, really”.

An engineering example. “How do I make the consumer skip a bad message faster?” presupposes that skipping is acceptable, that the message is bad rather than the parser, and that speed is the issue. The goal is usually “the partition keeps flowing and no record is silently lost”, which leads to a dead-letter queue with an owner (81) — an answer the original question could never have produced. Stated up front, the goal also gives the done criterion: lag back under the SLO, and every skipped record accounted for.

Where it is overkill. When the goal is known and narrow — a syntax question, a flag name — interrogating presuppositions is waste (111). And in open exploration the done criterion cannot be stated in advance; then state instead what result would change your direction, and revisit the goal when one arrives.

  • heuristic
  • established
  • no code

Almost any problem you bring to an AI has been worked on before. The most expensive way to solve it is from a blank page; the cheapest is to find how it was solved and check whether that fits you.

A model will happily design a system from scratch in a minute and will not say “this exists, it is called so-and-so” unless asked. So “has this been done?” is the second question, before any “how”: name the problem in its field’s words, find two or three existing solutions with real sources, then decide — adopt, adapt, reject or build. An existing solution is also a map of the pitfalls its authors already hit.

Repeating a solved problem on purpose — to learn, to reproduce, to check — is legitimate; only repetition out of ignorance is waste. In code this is Choose boring technology and the Rule of Three seen from the other side.

Where it comes from. George Pólya’s How to Solve It (1945) puts it among the first moves of problem solving: “Do you know a related problem?” — and, when one is found, “Here is a problem related to yours and solved before. Could you use it?” In engineering the same instinct is Dan McKinley’s “Choose Boring Technology” (75): known solutions come with known failure modes.

An engineering example. Asked for “a rate limiter for our API”, a model will often write a fresh counter-in-Redis design with its own edge cases. The field already has names for this: token bucket, leaky bucket, the generic cell rate algorithm, sliding-window counters — each with documented behaviour at bursts and at window edges, and implementations in the gateway you probably run. Naming the problem in its field’s words turns a design task into a selection task, and selection is easier to verify.

Limits. A found solution was built for its authors’ constraints — scale, consistency, team size — and fitting it to yours is real work (44). A dependency carries its own cost in supply chain and upgrades (35). And a model asked for prior art can invent plausible papers and libraries; the existence of a cited source is itself a claim to check (56).

  • heuristic
  • context-dependent
  • no code

Scope. Sycophancy and weak self-correction are measured behaviour of current models; why they arise is a hypothesis, and stronger models may shrink both without removing the need for an outside check.

Primary sources: Sharma et al. — Towards Understanding Sycophancy in Language Models (2023), Kamoi et al. — When Can LLMs Actually Correct Their Own Mistakes? (TACL 2024)

The trouble is not that the model is wrong — every source is sometimes. The trouble is that we agree with it and do not notice. Do not ask it what is right; make it attack your position and its own, and check what the conclusion stands on outside the chat.

Your premise is input too. Models measurably drift toward the user’s stated view: “help me justify X” returns X, polished. State the question without your answer in it, or ask for the strongest case against.

Agreement filters, it does not verify. Re-asking, self-review and a second model give stability, not truth: without an external signal self-correction rarely fixes reasoning errors. Truth comes from outside — a run, a test, a primary source. Find the load-bearing claim, have the model attack it, check it outside the chat, and say how much your own confidence moved and why.

The same rule as LLM output is untrusted input and Grounding, applied to the person instead of the system.

The evidence. Sharma et al., “Towards Understanding Sycophancy in Language Models” (2023), found sycophancy across several production assistants: they shift answers toward a user’s stated belief, give way when challenged even when they were right, and human preference data rewards some of this. Kamoi et al.’s survey of self-correction (TACL, 2024) concludes that LLMs rarely fix their own mistakes from prompting alone; reliable gains come with external feedback — a tool, a test, a verifier. Both are measured behaviour of current models; why it happens is still a hypothesis.

An engineering example. “Confirm that reading from our replica is fine right after a write” returns a confident yes with a tidy explanation. Asked instead “under what conditions does a read on the replica miss a write just acknowledged by the primary?”, the same model names asynchronous replication lag — and that is checkable outside the chat: write, read from the replica, compare, under load (65).

Limits. A model told to attack will always produce objections, and some are weak or invented; weigh them like any other output. Adversarial review costs time, so spend it on the claims the decision rests on and where being wrong is expensive (111).

A true claim can still fail to explain your case. Before acting on an explanation, hold its rivals and find the observation that tells them apart.

Bills fell after the lamps were replaced. “Because of the lamps” is one explanation; a closed wing, a new tariff, a warm winter and shorter hours explain the same fact, and several may act at once. Asked “why did costs fall after the lamps?”, a model gives the one explanation the question already contains, and gives it confidently. Checking that LED lamps use less power does not tell you what share of the drop they caused. Ask for alternatives explicitly, write down what each predicts differently, and look for the observation that separates them.

The method is old. The geologist T. C. Chamberlin, “The Method of Multiple Working Hypotheses” (Science, 1890), warned that a single favoured hypothesis becomes a “ruling theory” that bends the evidence toward itself, and recommended holding several at once. John Platt, “Strong Inference” (Science, 1964), turned it into a procedure: list the alternatives, design the experiment whose outcome excludes some of them, run it, repeat. Both are about scientific practice; neither measured how much better it works, so treat them as well-argued method, not proven effect.

An incident version. p99 latency doubled after Tuesday’s deploy. Rivals: the new code; a dependency that degraded the same hour; a traffic mix shift toward a heavy tenant; a noisy neighbour on the node pool. They predict different things: the code predicts the regression follows the new version across hosts and tenants; the dependency predicts it shows in that dependency’s span on old and new versions alike; the tenant predicts it concentrates in one customer. One query over wide events (28) grouped by version, tenant and node separates them — rolling back first and seeing the problem stay would separate them too, at higher cost.

In systems this is incident analysis: a postmortem with one root cause usually stopped too early. Observability exists to answer questions that separate hypotheses. The cost is time: under a live outage, mitigate first on the most likely cause, then discriminate.

  • heuristic
  • opinionated
  • no code

When two or three requests in a row have not changed your understanding of the problem, stop refining and step up: from the request to the task, from the task to the problem.

A stalled dialogue looks productive: the model answers at length, agrees with every refinement and offers a next step. The cause is nearly always the same — the task is being solved at the level it was posed, and it was posed above or below where the problem lies. The paragraph is not bad; the thought behind it is. Delivery ads are not the reason orders fall. The model will rewrite the paragraph forever, because that is what it was asked. Rising agreement without a shift in understanding is the signal.

The costliest move here: admitting the framing you came with was wrong after time has gone into it. The owner of the higher-level question is often a person, not the model — ask them.

Where it comes from. Pólya’s How to Solve It (1945) recommends the move when a problem resists: drop part of the condition, generalize, solve an auxiliary problem. Gause and Weinberg’s Are Your Lights On? (1982) is built on the observation that a problem is a difference between things as desired and things as perceived, and that people solve the problem they were handed rather than the one they have.

An engineering example. Three rounds of asking a model to tune executor memory for a Spark job that keeps failing: each answer is reasonable, each changes a number, the job still dies on one task. One level up the question is “why does one task need so much memory?”, and the answer is a skewed key concentrating a quarter of the rows in one partition (92, 93) — a fix in the data, which no memory setting reaches.

Limits. “Two or three requests” is a rule of thumb, not a measured threshold. Going up has no natural stop — every problem is part of a strategy — so climb one level, restate, and act there. And some loops are legitimately iterative: polishing wording or tuning a parameter on measured results is convergence, not stagnation; the signal is understanding that stopped moving, not the number of turns.

A check is worth doing only if its result could change the decision. Checking where an error is cheap and reversible, or checking what changes nothing, looks like diligence and produces no result.

Stakes are two independent questions: what happens if the answer is wrong and I rely on it, and can I fix it when I notice. A wrong date in a sent letter is cheap and irreversible; a month spent in the wrong direction is expensive and reversible. Both errors are symmetric: accepting an answer where error is costly, and verifying where it is not. Artificial urgency — a deadline, a “today only” — makes a two-way door look like a one-way one; ask whether a smaller reversible step (a pilot) answers the same question.

The model never stops by itself — there is always one more reasonable check to propose. Stopping is the human’s decision, and it is the moment responsibility becomes a fact: someone says “enough, act” and signs. The same logic as Reversible vs irreversible decisions and Human-in-the-loop for irreversible actions.

Where it comes from. Decision analysis made this precise: Ronald A. Howard, “Information Value Theory” (IEEE Transactions on Systems Science and Cybernetics, 1966), defines the value of information as the difference between the expected value of the best decision with it and without it. Information that cannot change the decision is worth exactly zero, however interesting. The door metaphor is Jeff Bezos’s: his 2015 letter to Amazon shareholders separates Type 1 decisions, one-way doors that deserve slow care, from Type 2, two-way doors that should be made quickly by small groups.

An engineering example. A model’s claim about a config default behind a feature flag is cheap and reversible: try it on a canary (72). Its claim that a migration may drop a column, or that last night’s backup restores, is neither — that one is checked by a restore and a read of the code path, not by asking again (20, 97).

Limits. Howard’s formula needs probabilities you rarely have, so in practice it orders checks rather than prices them. And irreversibility hides: a sent email, a published key or a deleted row looks like a two-way door until you try to walk back through it.

  • heuristic
  • context-dependent
  • no code

Scope. One field experiment at one school; the direction of the effect is the claim, not its size.

Primary sources: Bastani et al. — Generative AI without guardrails can harm learning (PNAS, 2025)

What you hand to the model you stop practising. Delegate what you already understand; learn yourself what you will need to judge.

In a field experiment with high-school maths students (Bastani et al., PNAS 2025), unrestricted access to a chat assistant raised scores during practice and lowered them once access was removed, below students who never had it; a tutor that gave hints instead of answers largely removed the harm. The checks in the other cards here — finding the load-bearing claim, spotting a missing explanation — need the very skill that unchecked delegation erodes. One experiment in one setting is not a law of nature, but it agrees with an older result.

Lisanne Bainbridge, “Ironies of Automation” (Automatica, 1983), studied process-control operators: automation takes over the routine work and leaves the human to monitor and to step in when it fails. But monitoring a system that rarely fails is something people do badly, and the skill needed to take over decays exactly because the automation does the work. The more reliable the automation, the less practised the person who must handle its failures. A code assistant inverts nothing here: the engineer who no longer writes SQL by hand is the one asked to judge whether a generated query will lock a table.

How to delegate without the atrophy. Separate the work you understand and want done from the work you need to keep judging. Hand over the first freely — boilerplate, a migration you could write in your sleep. For the second, ask for hints, critiques or a plan instead of the finished answer, write the first version yourself and compare, and periodically do the task unaided to check that the skill is still there. The cost is speed now; the trade is against review capacity later (59). Chesterton’s Fence, both ways (61) covers code without an author who understood it; this card covers the author who stops understanding.

XIII

Conflict summary table

Principles are heuristics with a scope, not axioms. Known conflicting pairs:

Pairs of principles that pull in opposite directions, and how to resolve them
ConflictResolution
DOD ↔ information hidingHot path gets DOD; everything else gets hiding. The profile draws the line
Event sourcing ↔ privacy by designCrypto-shredding, together with an inventory of every copy of the data and keys
DRY ↔ bounded contextsDRY only within a context
Local reasoning ↔ dataflowDataflow where the scheduler gives a measurable win
OCP ↔ YAGNIRule of three — or, more precisely, a proven axis of change
Postel ↔ schema evolutionStrict at the external boundary; tolerant of new fields on internal contracts
Backpressure ↔ availabilityLoad shedding is availability too, just the honest kind
HITL ↔ agent autonomyThreshold on reversibility, impact and permissions, not on the model’s “confidence”
Linearizability ↔ latency/availability (PACELC)Linearizability only for invariants (money, uniqueness); session guarantees for the rest
Cache ↔ consistencyTTL = a documented staleness budget derived from a business requirement
Saga ↔ isolationSemantic locks and pending states; compensation is a new fact, not a rollback
Feature flags ↔ local reasoningRemoval date in the flag declaration; removal is part of the DoD
Canary ↔ schema migrationsExpand — migrate — contract is mandatory: two code versions always run side by side
Outbox ↔ write latencyOutbox adds a row to the transaction — that is the price of correctness; CDC off the WAL if even that is too much
Chaos engineering ↔ error budgetChaos spends the budget deliberately and stops when it runs out
Boring technology ↔ mechanical sympathyAn innovation token is spent on a measured profile, not on a conference talk
Contract testing ↔ provider velocityThe provider gains the right to break anything no contract uses — that is acceleration, not a brake
Watermark: completeness ↔ latencyBounded out-of-orderness comes from a business requirement; late data is measured and has a policy all the way to the last consumer
Replay ↔ side effectsEffects only behind an idempotent/transactional sink; otherwise replay into a shadow sink and compare
DLQ ↔ per-key orderingFor ordering-sensitive keys, park the key or re-drive by replaying the key, not the single message
Retention as cost ↔ replay horizonRetention = the insurance horizon for reprocessing; shortening it is a one-way door with an ADR
Build cache ↔ correctnessA cache hit is legitimate only with a hermetic key; non-reproducibility is a bug, not a quirk
Trunk-based ↔ unfinished featuresFlags or branch by abstraction instead of branches: hidden debt is traded for visible debt
Paved road ↔ team autonomyDeviating is legitimate at the full cost of ownership; security guardrails stay mandatory
Paved road ↔ blast radiusPlatform monoculture: version the platform itself and canary it team by team
Immutable infra ↔ stateful systemsMachines are cattle, data is a pet: recreate compute, migrate data, never mix the two up
Shadow-run ↔ costThe double run is temporary, with a cutover date and a diff-rate threshold written down before it starts
Even load ↔ per-key orderKey by the entity whose order matters; spread (null keys, salting) only topics whose consumers are order-insensitive and re-aggregate by the real key.
“Network is the scarce resource” (MapReduce) ↔ “network is rarely the bottleneck” (Ousterhout et al.)Different decades, hardware and workloads; both still favour moving fewer bytes, because much of the measured cost of moving data is CPU spent on (de)serialization rather than wire time. Profile blocked time before buying network.
Short checkpoint interval ↔ checkpoint overhead and state sizeWith a transactional sink, the interval sets how long read_committed readers wait for output and how much every non-transactional effect replays after a failure; choose it from those two numbers, then check that checkpoints of the resulting size complete well inside the Kafka transaction timeout.
Cost as an SLI (78) ↔ latency headroom (100)Raising utilization lowers cost per request only below the knee; past it every saved core is paid for in queueing delay, so the cost budget is set at the measured operating point, not as a utilization target.
Short credential TTL ↔ static stability of the data planeA shorter TTL shrinks the replay window but also the time the data plane survives an issuer outage; choose the TTL from both, and treat the issuer as control plane with its own availability target.
Lock-free incremental snapshot ↔ schema changes during the snapshotFreeze DDL on the captured tables for the snapshot’s duration, or finish the expand step of the migration before triggering it.
Active/active availability ↔ multi-row transactional invariantsAsync multi-writer replication keeps transactions atomic only within a region and resolves concurrent writes by a rule, so an invariant that needs serializable isolation gets a single write owner per dataset (active/passive or partitioned ownership) or a synchronous quorum, and active/active is kept for data whose invariants hold per item.

An engineering decision is a choice of which principle to break at a given point, with the rationale written down (ADR).

Reference · 112 principlesJava · Rust · Go · Python 3.12+Last technical review: 2026-08-04By · CV · hi@nikolay-eremeev.com