Invariant Hunting
The highest-severity bugs are rarely a missing check. They are a check whose
protection does not survive the journey — established in one layer, silently
voided in another. "There is auth", "there is cleanup", "there is an
allowlist" are observations about a point in the code. The finding lives in
what happens after that point.
The core question of this skill is never "is there a control?" It is:
A property was established at T0. What proves it still holds when the
effect occurs at Tn?
Composes with the library:
- red-team-auditing — earns or refutes the verdict on each invariant
- concurrency-reasoning — when the transition between T0 and Tn is an
interleaving
- atomic-state-mutation — when T0-to-Tn spans multiple writes
- agent-trust-boundaries — when the transported property is authority over
content an agent reads
- versioned-schema-evolution — when the transition is a reload of
persisted state
Part 1 — Build the invariant table before reading for bugs
Do not start from sinks. Start from the properties the system believes it
maintains, and tabulate their lifecycle:
| Object |
Established at |
Transported by |
Reconstituted at |
Sink |
| approval authority |
human decision |
message / persisted state |
resume |
execute |
| credential scoping |
auth policy |
request headers |
each redirect hop |
origin server |
| object identity |
registry / creation |
identifier |
lookup |
privileged use |
| session/thread namespace |
request routing |
state store key |
resume |
cross-tenant data |
| integrity of state |
writer |
storage + format |
restore |
decisions on that state |
| event ordering |
creation |
log / checkpoint |
replay |
state machine |
Sources for rows: ADRs, design docs, docstrings, changelog entries, fix
history. A document that says "these IDs are candidate keys until ownership is
verified" is not documentation — it is an invitation with coordinates.
For each row, the hunt question is identical: where in the transport or
reconstitution can the property change while the code assumes it did not?
Part 2 — The violation families
Name the family before testing; each has a known shape and known hunting
grounds.
- Validated once, never re-validated. The check ran on the object as it
was; the object changed before the sink. Redirects, JS navigation,
retries, mutation between check and use.
- Decision downstream, re-materialization upstream. The security decision
(strip the credential, deny the action) is made in one layer; a later layer
re-injects what was removed, because nothing binds the layers. Pipelines
where policy order is load-bearing are the habitat.
- Authorization decay across transitions. Principal → session →
checkpoint → pending request → execution: each hop is individually
authenticated, but some hop resolves by ID alone, so
owner(action_state) == authenticated_principal silently breaks.
- Semantic boundary failure. Two layers assign different meaning to the
same bytes: one knows this header is a secret, the other treats it as
generic metadata. Each layer is locally correct; the composition leaks.
- Namespace collapse. Scoped keys in one code path, bare keys in another
(
scope␟thread_id vs thread_id) — two contexts share one slot.
- Deferred resurrection. A decision is retained while the context it was
made in is unavailable; when the context returns, the old decision executes
without fresh authority.
- Batch laundering. One visible item carries the check; hidden items ride
its approval. Verify the keying binds content, not just proximity in a
batch.
- Cross-context substitution. State serialized under context A is
restored under context B (different topology, version, owner) and
interpreted as if A — no malicious modification required.
Part 3 — Each invariant is a binary hypothesis
For each candidate violation, before writing any proof:
- State the invariant as one checkable equation —
namespace(execution_state) == namespace(approval_state),
ApprovedCapability(T1) == ExecutedCapability(T2) — identity of the whole
capability, not just the name.
- State the falsifier: what observation kills it.
- Verify the constructs exist in the live code first. Function names,
flags, and line references from second opinions, docs, or your own memory
are claims, not facts (
audit-before-patch). Grep them before building on
them.
- Read the whole enforcing function, never infer from names. A function
named
deserialize_type may be hardened; a resolver named _resolve_* may
fail open. The name is a hypothesis; the body is the verdict.
- Trace every path from T0 to Tn — sync, streaming, parallel, resume,
error paths. An invariant enforced on four of five paths is violated.
- Respect the author's declared boundary. A docstring that says "not a
security boundary; here are all the bypasses" preempts the naive bug and
tells you where the real boundary lives — hunt the layer it names instead.
Part 4 — When the invariant holds
Record it in the discard registry (forensic-persistence) with the evidence:
which paths were traced, which functions were read in full, what the enforcing
mechanism is. A verified invariant is assurance data — and a map of where not
to dig again. The repo that withstands a serious invariant hunt is telling you
to change family or change layer, not to stop.
Anti-patterns
- Grepping for
eval, subprocess, open and calling the result an audit
(that is beyond-the-sink's core warning — sink-grep finds scanner-shaped
bugs).
- Testing the check exists, then assuming the protection reaches the sink.
- Treating one safe path as proof the invariant holds on all paths.
- Building on a second opinion's line references before confirming the
constructs exist in the live code.
- Reading the docstring's "not a boundary" as a bug report instead of a map.
- Collapsing "each endpoint has auth" into "the flow is authorized" — decay
lives between the endpoints.
1---2name: invariant-hunting3description: Hunt for violations of declared or implied security invariants across transitions — a property established at T0 (validation, authority, identity, integrity, namespace) must still hold when the effect happens at Tn. Use whenever auditing or debugging anything with state transitions, redirects, resume/checkpoint flows, approval gates, retries, batching, serialization, or multi-layer pipelines. Trigger on "the check exists but", "validated once", "re-materializes", "re-injects", "authorization decay", "does the check survive", "who approved this", "TOCTOU", "resume", "redirect", "checkpoint", "stale state", "the property is established here but used there", or whenever a security decision is made in one layer and consumed in another. Sibling of red-team-auditing — that skill earns verdicts on candidates; this one generates the highest-yield candidates by naming the invariant the system believes it keeps.4---56# Invariant Hunting78The highest-severity bugs are rarely a missing check. They are a **check whose9protection does not survive the journey** — established in one layer, silently10voided in another. "There is auth", "there is cleanup", "there is an11allowlist" are observations about a point in the code. The finding lives in12what happens *after* that point.1314The core question of this skill is never "is there a control?" It is:1516> **A property was established at T0. What proves it still holds when the17> effect occurs at Tn?**1819Composes with the library:2021- **red-team-auditing** — earns or refutes the verdict on each invariant22- **concurrency-reasoning** — when the transition between T0 and Tn is an23 interleaving24- **atomic-state-mutation** — when T0-to-Tn spans multiple writes25- **agent-trust-boundaries** — when the transported property is authority over26 content an agent reads27- **versioned-schema-evolution** — when the transition is a reload of28 persisted state2930---3132## Part 1 — Build the invariant table before reading for bugs3334Do not start from sinks. Start from the properties the system *believes* it35maintains, and tabulate their lifecycle:3637| Object | Established at | Transported by | Reconstituted at | Sink |38|---|---|---|---|---|39| approval authority | human decision | message / persisted state | resume | execute |40| credential scoping | auth policy | request headers | each redirect hop | origin server |41| object identity | registry / creation | identifier | lookup | privileged use |42| session/thread namespace | request routing | state store key | resume | cross-tenant data |43| integrity of state | writer | storage + format | restore | decisions on that state |44| event ordering | creation | log / checkpoint | replay | state machine |4546Sources for rows: ADRs, design docs, docstrings, changelog entries, fix47history. A document that says "these IDs are candidate keys until ownership is48verified" is not documentation — it is an invitation with coordinates.4950For each row, the hunt question is identical: **where in the transport or51reconstitution can the property change while the code assumes it did not?**5253## Part 2 — The violation families5455Name the family before testing; each has a known shape and known hunting56grounds.57581. **Validated once, never re-validated.** The check ran on the object as it59 was; the object changed before the sink. Redirects, JS navigation,60 retries, mutation between check and use.612. **Decision downstream, re-materialization upstream.** The security decision62 (strip the credential, deny the action) is made in one layer; a later layer63 re-injects what was removed, because nothing binds the layers. Pipelines64 where policy order is load-bearing are the habitat.653. **Authorization decay across transitions.** Principal → session →66 checkpoint → pending request → execution: each hop is individually67 authenticated, but some hop resolves *by ID alone*, so68 `owner(action_state) == authenticated_principal` silently breaks.694. **Semantic boundary failure.** Two layers assign different meaning to the70 same bytes: one knows this header is a secret, the other treats it as71 generic metadata. Each layer is locally correct; the composition leaks.725. **Namespace collapse.** Scoped keys in one code path, bare keys in another73 (`scope␟thread_id` vs `thread_id`) — two contexts share one slot.746. **Deferred resurrection.** A decision is retained while the context it was75 made in is unavailable; when the context returns, the old decision executes76 without fresh authority.777. **Batch laundering.** One visible item carries the check; hidden items ride78 its approval. Verify the keying binds content, not just proximity in a79 batch.808. **Cross-context substitution.** State serialized under context A is81 restored under context B (different topology, version, owner) and82 interpreted as if A — no malicious modification required.8384## Part 3 — Each invariant is a binary hypothesis8586For each candidate violation, before writing any proof:8788- **State the invariant as one checkable equation** —89 `namespace(execution_state) == namespace(approval_state)`,90 `ApprovedCapability(T1) == ExecutedCapability(T2)` — identity of the whole91 capability, not just the name.92- **State the falsifier**: what observation kills it.93- **Verify the constructs exist in the live code first.** Function names,94 flags, and line references from second opinions, docs, or your own memory95 are claims, not facts (`audit-before-patch`). Grep them before building on96 them.97- **Read the whole enforcing function, never infer from names.** A function98 named `deserialize_type` may be hardened; a resolver named `_resolve_*` may99 fail open. The name is a hypothesis; the body is the verdict.100- **Trace every path from T0 to Tn** — sync, streaming, parallel, resume,101 error paths. An invariant enforced on four of five paths is violated.102- **Respect the author's declared boundary.** A docstring that says "not a103 security boundary; here are all the bypasses" preempts the naive bug *and104 tells you where the real boundary lives* — hunt the layer it names instead.105106## Part 4 — When the invariant holds107108Record it in the discard registry (`forensic-persistence`) with the evidence:109which paths were traced, which functions were read in full, what the enforcing110mechanism is. A verified invariant is assurance data — and a map of where not111to dig again. The repo that withstands a serious invariant hunt is telling you112to change family or change layer, not to stop.113114---115116## Anti-patterns117118- Grepping for `eval`, `subprocess`, `open` and calling the result an audit119 (that is `beyond-the-sink`'s core warning — sink-grep finds scanner-shaped120 bugs).121- Testing the check exists, then assuming the protection reaches the sink.122- Treating one safe path as proof the invariant holds on all paths.123- Building on a second opinion's line references before confirming the124 constructs exist in the live code.125- Reading the docstring's "not a boundary" as a bug report instead of a map.126- Collapsing "each endpoint has auth" into "the flow is authorized" — decay127 lives *between* the endpoints.