Production Failure Modes & Governance · module 9 of 9 · Time, Scale, Multiplicity, Trust · lesson 82 of 84 · ~45 min
$
uv run python curriculum/advanced/invariants-and-drift-detection/lab/lab.py
Invariants and Drift Detection¶
In one line. The first version of the clock invariant let a future-dated write redefine the clock and pass its own check.
Where this sits¶
Stage: govern · Level: advanced · ~45 min
You need first: Memory Observability
Concepts assumed: Leak Assertion · Bi-Temporal Modeling · Derivation Graph
This unlocks: Migrating Live Memory
The problem¶
This course states invariants in eight modules and checks each in its own lesson's tests. That is right for teaching and useless in production, where the question is "is the store healthy now?" and the answer must be one call.
invariant kind count holds
no cross-tenant memory is visible structural 0 True
no belief is retired before it was recorded structural 0 True
every derived_from reference resolves structural 0 True
every superseded_by reference resolves structural 0 True
ids are unique structural 0 True
no memory is dated past the store's clock policy 0 True
no slot holds more than four live beliefs policy 0 True
structural: 5 policy: 2
The two kinds must not be reported together unlabelled. A structural violation can only come from a bug in this system. A policy violation can come from a legitimately unusual user — and a dashboard that shows them identically makes a real bug look like a heavy-user alert.
Why this isn't RAG¶
An index has few invariants worth asserting because it is derived: if it disagrees with the corpus, rebuild it. Consistency is restorable by recomputation, so drift is an operational nuisance rather than a correctness problem.
A memory store is the truth. There is nothing to rebuild from, so a violated invariant is not a stale derivation — it is data that is now wrong and will stay wrong. That difference is why these have to run continuously rather than at deploy time.
Mechanism¶
They catch a real historical bug. Run the same seven against earlier profiles:
@I8: 1 failing [('no belief is retired before it was recorded', 1)]
@A1: 0 failing
@A3: 0 failing
That is the Berlin claim validity-intervals found — retired nine months before it was recorded — detected by an invariant rather than by reading code, and shown fixed at the module that fixed it.
The invariant that could not fail¶
The clock check was written the obvious way: compare each memory against the newest event in the store. Then a future-dated write arrives and:
unguarded future-dated write: 0 failing
It passes, because the rogue is the newest event and therefore is not past it. An invariant computed from the data it is checking cannot detect an outlier that moves the reference.
Leave-one-out fixes it — each memory is compared against the newest of the others:
unguarded future-dated write: 1 failing [("no memory is dated past the store's clock", 1)]
The same record the A3 write policy refuses, now caught at rest by the check that is supposed to catch it. A boundary control and an invariant are not redundant: one refuses, the other notices what got in another way.
flowchart LR
W["<b>a future-dated write</b>"] --> POL{"<b>write policy</b><br/>refuse it at the boundary?"}
POL -->|"refused"| N["never lands"]
POL -->|"unguarded"| S[("store")]
subgraph SLF["invariant over the data it checks"]
direction LR
ALL["newest event<br/><i>anywhere in the store</i>"] --> PSS["<b>passes</b><br/><i>the rogue is the newest</i>"]
end
subgraph LOO["invariant, leave-one-out"]
direction LR
OTH["newest event<br/><i>among the others</i>"] --> FLS["<b>fails</b><br/><i>caught at rest</i>"]
end
S --> SLF
S --> LOO
style POL fill:#f9e79f,stroke:#b7950b
style PSS fill:#f5b7b1,stroke:#c0392b
style OTH fill:#aed6f1,stroke:#2874a6
style FLS fill:#aed6f1,stroke:#2874a6,stroke-width:2px
Design decisions¶
Why label kind rather than severity? Because severity is a deployment decision and kind is a fact. Structural means "only a bug produces this"; policy means "unusual data produces this". How loudly each pages someone depends on who is on call, and encoding that here would bake one team's rota into the library.
Why is "no slot holds more than four live beliefs" a policy invariant? Because four is what this corpus happens to have — the diet slot, with all four beliefs simultaneously true. There is nothing wrong with five. It is a drift detector, and calling it structural would make a legitimate user look like a defect.
Why run these against old profiles at all? Because an invariant nobody has seen fail is an invariant nobody knows works. Running the set against @I8 produces a real failure with a known cause, which is the cheapest possible test of the checker itself — and it is exactly the technique memory-attacks used to prove leak_check fires.
Lab¶
You'll implement: check, failing, and by_kind.
Run:
uv run python curriculum/advanced/invariants-and-drift-detection/lab/lab.py
Expected output: seven invariants all holding at @A3, 5 structural and 2 policy, the @I8 run failing exactly one, and the future-dated write caught — with the whole-store version of the same check reporting 0.
Stretch: compute the clock reference over the whole store instead of leave-one-out, and add two future-dated records instead of one. Both pass, and they now agree with each other. A pair of outliers that validate each other is the failure mode of every self-referential check.
What this adds to the capstone¶
memlab.production.invariants — Kind, Violation, check, failing, by_kind. Collects assertions from eight modules into one pass; adds no new rules.
Failure modes¶
| Symptom | Cause | How to detect | Mitigation |
|---|---|---|---|
| Outlier passes its own check | Reference computed over all data | Add one extreme record | Leave-one-out |
| Real bug looks like a busy user | Kinds reported together | Ask which are structural | Label the kind |
| Invariants never fire | Never tested against a broken store | Run against an old profile | Prove they fail |
| Drift detector reads as a defect | Threshold from one corpus | Ask what makes the number right | Call it policy |
| Assertions scattered across modules | Each checked in its own tests | Ask for one health call | Collect them |
Check yourself¶
Why did the clock invariant pass on exactly the record it was written for?
Because it defined the clock as the newest event in the store, and the rogue was the newest event. Nothing about the check was wrong except that its reference came from the data under test — so the outlier moved the standard it was measured against. Leave-one-out is the smallest fix, and the general lesson is that any invariant using an aggregate of the data cannot catch a record that shifts that aggregate.
Structural and policy invariants both report a count. Why separate them?
Because they mean different things about what to do next. A structural violation says code is broken and the data is now wrong permanently; a policy violation says a user did something unusual and may need a look. Reporting them in one undifferentiated list means every alert requires an investigation to determine which kind it was.
Six of the seven already had tests. What does collecting them buy?
A question you can ask of a running store. The lesson tests answer "did this module work when it was written?"; the collected check answers "is this store healthy right now?", which is the only form the question takes in production — and it is what makes running them against @I8 possible, which is how you find out the checker works.
Connections¶
Stage: govern · Level: advanced · ~45 min
You need first: Memory Observability
Concepts assumed: Leak Assertion · Bi-Temporal Modeling · Derivation Graph
This unlocks: Migrating Live Memory