Trust, then Verify · Nº 08

Trust, then Verify

A field guide to AI-written code

AI-written code has one distinguishing property: it is optimized to look right, so fluency tells you nothing about correctness. This guide is the verification stack for a builder whose code is written by models — review protocols ordered by blast radius, tests that attack invariants instead of examples, mutation testing to test the tests, CI gates that say no so you don't have to, and the terms under which an agent earns the deploy button.

Module 01 How AI code fails

You already review code. The problem is that the heuristics you review with were calibrated on human authors, and they are now pointed at a writer with no human tells. A tired junior's code looks tired: inconsistent naming, a copy-pasted block with the variable renamed in three of four places, a comment that contradicts the line below it. Those surface signals were never the point — they were proxies for the author's attention, and attention correlated with correctness. A model produces uniform, confident, well-commented, idiomatic code whether it understood the problem or not. The proxy is severed at the root.

So this module does the unglamorous work first: it names the five ways AI-written code goes wrong, precisely enough that you can recognize each one in a diff without a tool. Then it places those five signatures onto the verification stack — spec, static analysis, tests, CI gates, runtime monitors — so you know, for each failure class, which layer is supposed to catch it. Everything after this module is the construction of those layers. This one gives you the map and the vocabulary.

Fluency is not evidence

Treat every diff as a conjecture: a claim that this code satisfies some intent, offered without proof. That framing is not a metaphor borrowed for color — it changes what you do. If a diff is a conjecture, your job is not to admire it, agree with it, or feel good about it. Your job is to try to break it, and to build machinery that keeps trying after you have stopped looking. Confidence in the code should be proportional to the strength of the refutation attempts it survived, and nothing else.

Human code came with a free signal you have been reading for years without noticing. Sloppiness was evidence of inattention, and inattention was evidence of defects, so "this reads carefully" was weak but real evidence of correctness. Generated code destroys that inference. The model's objective is to produce text that looks like correct code for this context; producing text that is correct is a frequent side effect, not the target. So plausibility saturates — every diff reads like the work of a careful author — while correctness stays a coin flip weighted by how well-represented your exact problem was in training data. Plausibility and correctness come apart, and every heuristic you had was built on their correlation.

Quadrant chart plotting how right code reads against how much evidence exists that it is correct, showing careless human code low on both axes, expert human code high on both, and AI-written code occupying a vertical band of high plausibility and unknown correctnessthe danger bandcareless human codelooks wrong, is wrongexpert human codereads well because it was understoodAI-written codeplausibility: pinned highcorrectness: unknownit spans the whole axisHow right it reads →Evidence of correctness →Heuristics that fail inside the band: reads cleanly · has comments · has tests · author seemed careful
Figure 1.1 — Plausibility no longer predicts correctness. For human authors, the two axes correlated, so reading quality was cheap evidence about correctness. Generated code is pinned to the right-hand edge regardless of whether it works, so the entire vertical band is live and every reviewer heuristic that reads the horizontal axis returns the same answer for correct and broken code alike.

The practical consequence is uncomfortable: your sense that a diff is fine carries almost no information, and it feels exactly as strong as it did when it was informative. That feeling is the thing to distrust. Everything in this guide is machinery for replacing it with evidence you can name — a criterion checked, a property that held, a gate that passed, a mutant that died.

Anti-pattern: "it runs" = "it's right"

Symptom: the endpoint was demonstrated live, the refund appeared in the console, everyone relaxed, and it shipped that afternoon. Diagnosis: a successful happy-path execution exercises precisely the paths least likely to be wrong — the ones the model has seen ten thousand times. Corrective: treat a working demo as evidence that the code is syntactically valid and the obvious path is wired up, and nothing more. Every defect in this guide's running example survives a flawless demo.

The load-bearing idea

Fluency is a property of the generator, not of the code's relationship to your intent. Because the model optimizes for plausible text, plausibility is guaranteed and correctness is not — which means your reviewing attention must be spent on things that cannot be faked by fluent prose: authorization semantics, invariants, failure interleavings, and what the infrastructure actually grants.

Hallucinated interfaces

A hallucinated API is an invented method, parameter, endpoint, or configuration key: something that fits the shape of the library's design so well that the model produced it by interpolation rather than recall. These are the most-discussed AI failure and the least dangerous variety — when they are loud. A call to a method that does not exist raises AttributeError the first time the line runs, which is to say the first time anyone touches the feature. It dies at import or in the first smoke test, and nobody's money moves.

The dangerous variety is silent. Consider what happens when the model decides that the payment-processor client takes a timeout:

processor = ProcessorClient(
    api_key=os.environ["PROCESSOR_KEY"],
    timeout_seconds=5,          # plausible. also: not a real parameter.
)

# the client's actual signature:
class ProcessorClient:
    def __init__(self, api_key, **options):
        self.api_key = api_key
        self.timeout = options.get("timeout", 30)   # note the key it looks for

Nothing raises. **options absorbs timeout_seconds, the client uses its 30-second default, and the code reads as if a five-second timeout is enforced. Six weeks later the processor gets slow, your requests hang for thirty seconds each, the Gunicorn workers saturate, and the support console goes down during a billing incident — the exact moment agents need it. The reviewer who read that line saw a timeout being set. It was, in a real sense, there: it just was not doing anything.

Where silence comes from

Any interface that accepts unknown input without complaint is a hallucination absorber: **kwargs and **options, dictionary-based configuration, JSON payloads validated loosely, environment variables read with a fallback default, and cloud SDK calls whose extra fields are ignored server-side. When you review a diff that configures something through one of these, the question is not "is this parameter plausible" but "can I point at the line in the dependency that reads it."

The generalization worth carrying: absence of an error is not presence of a behavior. An exception is a gift, because it is the dependency refusing to pretend. The failures that hurt are the ones where the dependency shrugs, and those are found by checking the interface — reading the signature, the type stub, or the vendor's documentation — not by checking that the program still starts.

A subtler cousin is the interface that exists but does not mean what the model assumed. processor.refund(charge_id, amount) is a real method in our running system; whether amount is dollars or cents, and whether omitting it refunds the full charge, are facts the model guessed. That guess is unfalsifiable by reading the calling code, and it is off by a factor of one hundred if it is wrong.

Plausible-but-wrong logic

The second signature is the one the whole guide is really about: logic that is correct-shaped and incorrect. Inverted conditions, boundaries off by one comparison operator, defaults that are reasonable in general and wrong here, an early return that skips a step the author would not have skipped. These are not sloppy — they are the outputs of a process that pattern-matched a common shape onto your specific problem and got the shape right.

Here is the version that runs through the rest of this course. Our system is Ledgerline, a subscription-billing support console: a React front end for support agents, a Flask support-api service in a container on AWS, Postgres behind it, deployed with CDK. Support agents issue refunds. Tier-1 agents have a refund cap of $50.00; anything above that needs a supervisor. The first generated handler contains no cap check at all — that omission is the subject of the next section — so this is the follow-up patch, produced when the model was asked to add one:

REFUND_CAP_CENTS = {"tier1": 5000, "tier2": 25000}

cap = REFUND_CAP_CENTS.get(session["role"], 5000)
if amount > cap:
    return jsonify({"error": "refund_exceeds_cap"}), 403

Read it the way you would read a contract clause rather than a line of code, and three separate questions appear, none of which the code answers. First: is the cap inclusive? amount > cap permits exactly $50.00; amount >= cap forbids it. Both are defensible policies; only one is yours, and the code does not tell you which was intended — only which was generated. Second: the .get(..., 5000) fallback silently assigns tier-1 privileges to any role the dictionary does not know, including a future "contractor" role that should have a cap of zero. Failing open on an unknown role is a permission decision made by a default argument. Third: amount is the requested refund, but the policy a lawyer would write says an agent may not refund more than $50.00 on a charge — a cap on the running total, not on any single request. Three requests of $30.00 against charge ch_7c2m pass this check individually and refund $90.00 in aggregate.

What each failure needs

The boundary question ($50.00 allowed or not) is settled by an acceptance criterion with an exact number — module 3 — and pinned by an assertion at exactly 5000 cents, which module 4 shows is missing and finds by mutation testing. The fail-open default and the per-request-versus-per-charge confusion are intent divergences: no tool can flag them, because nothing is locally wrong. They belong to human review, and module 2 gives them a slot in the first pass so they are found while you still have attention left.

Notice the common structure. In each case the model produced the modal implementation — the one that appears most often in code that looks like this — and the modal implementation encodes assumptions that were never yours to begin with. The defect is not in any line. It is in the gap between the specification you did not write and the distribution the model fell back to.

Security-naive defaults

The third signature has a mechanical explanation that is worth stating plainly, because it predicts what you will see: a model reproduces the median public example, and the median public example is insecure. Tutorials optimize for the shortest path to a working demo. Stack Overflow answers optimize for answering the question asked, not the question unasked. Neither audience is a production system handling other people's money. So generated code arrives carrying the security posture of a blog post: string-built SQL, CORS(app) with no origin list, secrets read from a literal because the example did, verify=False because the tutorial's certificate was self-signed, and — most consequentially — authentication mistaken for authorization.

Two of these are planted in the Ledgerline refund endpoint and will be caught by two different layers later in this course. Preview them now:

cur.execute(f"SELECT amount, customer_id FROM charges WHERE id = '{charge_id}'")

That is a request-controlled value interpolated into SQL. It is the most mechanically detectable defect in the entire codebase — a fixed syntactic pattern, matched by static analysis in milliseconds — which is exactly why module 5 assigns it to a CI gate and forbids you from spending review attention on it.

session = verify_session(request.headers.get("Authorization"))
if not session:
    return jsonify({"error": "unauthenticated"}), 401
# ... and then straight to the refund. no role check. no tenant check.

That one no tool will ever catch. The code is locally impeccable: a session is verified, an unauthenticated request is rejected with the correct status code. It is missing the question "is this agent permitted to refund this charge for this amount" — and a missing question has no syntax. This is the single most common serious defect in generated backend code, and it is the reason human review keeps its seat at the table.

From your security work

None of this is new vulnerability research. It is the OWASP catalogue — injection, broken access control, security misconfiguration — regenerated fresh in every diff, by a process with no memory of your last remediation. That changes the control design. In an application you review once, you fix a class and it stays fixed. Here the class is re-emitted continuously, so the control has to be continuous too: a required gate for the mechanical classes, a protocol step for the semantic ones. Point-in-time remediation against a continuous generator is a treadmill.

Phantom handling and test-shaped tests

The last two signatures are one failure in two costumes: the form of rigor without its content. The model has learned what careful engineering looks like — error handling exists, tests exist — and produces the visual signature of both without the substance of either.

Phantom edge-case handling is the block that appears to handle a failure and actually deletes it:

try:
    result = processor.refund(charge_id, amount)
except Exception as e:
    logger.warning("refund failed: %s", e)
    result = {"status": "pending"}

Read what this does rather than what it looks like. The refund did not happen. The caller is told pending, which is a lie with a plausible shape. The support agent tells the customer it is processing. The warning lands in a log stream nobody alerts on. In three days a chargeback arrives and the audit trail says the system believed a refund was in flight. The except block did not handle the edge case; it converted a loud failure into a quiet wrong answer, which is a strict downgrade. A reviewer scanning for robustness sees error handling and moves on — the block is defensive-looking, and that appearance is the whole problem.

Anti-pattern: exception laundering

Symptom: a broad except Exception whose body is a log call and a fabricated success-shaped return value. Diagnosis: the handler was written to make the code look robust, not because anyone decided what should happen on that failure. Corrective: for each caught exception, name the recovery. If you cannot name one, do not catch it — let it propagate to something that alerts. "Handled" means the system reaches a correct state, not that the traceback stopped.

A test-shaped test is the same trick applied to verification:

def test_refund_succeeds(monkeypatch):
    monkeypatch.setattr(processor, "refund",
                        lambda cid, amt: {"status": "succeeded"})
    resp = client.post("/refunds", json={"charge_id": "ch_7c2m", "amount": 8400})
    assert resp.json["status"] == "succeeded"

This test cannot fail for any reason related to your refund logic. It stubs the processor to return success, then asserts that success came back — it verifies that the stub works and that the handler can copy a string. Delete the cap check, delete the authorization check, break the retry loop: still green. Yet it adds a line to the coverage report, it appears in the PR as diligence, and it is indistinguishable at a glance from a test that means something. The tell is structural and quick to check: does the assertion follow from anything other than the setup? If the value asserted was placed there by the test itself, the test asserts nothing.

Both signatures matter beyond their local damage because they defeat the layers above them. Phantom handling makes runtime monitors blind — errors never reach them. Test-shaped tests make the test layer report health it does not have. A failure that disables your detection is worse than one that merely breaks something.

Layered pyramid of the verification stack from specification at the base through static analysis, tests, CI gates, and runtime monitors at the apex, with the failure signature each layer catches on the left and what each layer structurally cannot catch on the rightThe verification stackcatchesstructurally cannot catchRuntime monitorsalerts, error rates, audit trailswhat escaped —once, in productionanything not yettriggered, or launderedCI gatesrequired checks at the merge boundaryregressions, policyviolations, driftsemantics — it onlyruns what you wroteTestsexamples, properties, mutation scoreplausible-but-wronglogic · phantom handlinganything beyond itsown assertionsStatic analysis and typesknown-bad patterns, signature checkssecurity-naive defaultshallucinated interfacesintent — every patternhere is locally legalSpecificationacceptance criteria · invariants · non-goalsthe gaps that becomeevery defect aboveits own violation —a spec cannot runTest-shaped tests attack the stack itself: they make the Tests layer report health it does not have.
Figure 1.2 — The verification stack, and the hole in each layer. Each layer is defined as much by what it cannot see as by what it catches: specification cannot detect its own violation, static analysis cannot read intent, tests cannot exceed their assertions, gates cannot judge semantics, and monitors only observe what already happened. Confidence comes from the layers being independent, so a defect must slip past several unrelated mechanisms. This figure is the course map: modules 3 through 8 build these layers from the base upward.

Module 02 Reading by blast radius

Module 1 removed your instincts. This one gives you a procedure to use instead. The procedure exists because of an arithmetic problem: the volume of code arriving for review has gone up by a large factor, the number of hours you have to review it has not moved, and the quality of your attention degrades within about twenty minutes of continuous reading. You cannot review everything carefully. You never could, but you used to be able to pretend, because the volume was bounded by how fast humans type.

So the question is not how to review well — it is how to allocate a fixed budget of careful reading across an unbounded queue. The answer this module argues for is blast radius: what a change can reach, and how irreversibly. Line count is the allocator your eye reaches for, and it is close to uncorrelated with risk. We will state the protocol, then run it in full on the Ledgerline refund endpoint, which has three defects in thirty-four lines and passes every demo.

Attention is the budget

Start with the resource, because every review methodology that ignores it fails the same way. Careful reading — the kind where you hold the system's invariants in your head and ask whether this change violates one — is expensive, degrades fast, and is not fungible with skimming. Call it ninety minutes a day of real capacity, on a good day, with interruptions.

Against that budget you have a queue that no longer scales with human typing speed. Diff size has decoupled from authorship effort: a model produces four hundred lines in the time it takes to write a sentence describing them. The traditional implicit allocator — read the whole diff, spend time roughly proportional to its length — now hands your best attention to whatever happens to be longest. Longest usually means most mechanical: a generated test file, a component extraction, a migration touching forty call sites.

Meanwhile the nine-line diff that changes which role may issue a refund gets the tail end of your attention, at 5:40 p.m., after four hundred lines of React. That is the allocation you must invert, and inverting it requires an explicit rule, because the default is not a decision anyone made — it is what happens when you read diffs in the order they arrive.

Anti-pattern: skim-for-style review

Symptom: a review that leaves eleven comments — naming, a nested ternary, an import order, a docstring that should be imperative mood — and an approval. Diagnosis: style is the cheapest signal in a diff to detect and the most satisfying to comment on, because every comment is defensibly correct and requires no model of the system. It produces the felt experience of rigor at a fraction of the cognitive cost. Corrective: style is a formatter's job and a linter's job; if it reached your eyes, that is a tooling gap to file, not a review to perform. Measure a review by whether the blast-radius zones were visited, not by comment count.

From your litigation years

You have solved this problem before. Nobody reviews a two-million-page production by starting at page one. You build a triage: privilege first, because a privilege error is unrecoverable once produced; then the custodians and date ranges where the smoking gun would live; then everything else, sampled. The ordering is driven by irreversibility and expected value, not by document order. Blast-radius review is the same discipline pointed at a diff — and the same failure mode lurks, which is the associate who reads carefully in page order and runs out of hours before reaching the custodian who mattered.

The protocol

The protocol is an ordered hunt, and the word hunt is doing work. You are not reading the diff from top to bottom looking for problems. You open the diff and search it five times, in this order, for five specific things:

1. Authorization and authentication semantics. Who is allowed to do this, and does the code ask? Not "is there a decorator" — does the check compare the acting principal against the specific resource and the specific action? This step is first because the failure is invisible (nothing is locally wrong), unbounded (an authorization gap is exploitable by anyone who finds it), and undetectable by every other layer in the stack.

2. Money movement and irreversible effects. Charges, refunds, payouts, emails to customers, external notifications, anything with a real-world counterpart that cannot be rolled back by reverting the deploy. Second because irreversibility means no later layer gets a second attempt.

3. Data mutation and deletion. Writes, migrations, cascades, bulk updates, anything with a DELETE or a schema change. Third because damage is bounded by backups — real, but recoverable at a cost.

4. External calls and their failure behavior. Every network boundary, and specifically what happens when it times out, returns an error, or succeeds slowly. This is where retries, idempotency, and partial failure live, and it is where generated code is weakest, because correct failure handling is underrepresented in the tutorials the model learned from.

5. Everything else. Timeboxed. A skim, honestly labeled as a skim.

The order tracks irreversibility × reach, and it is a priority queue rather than a checklist: if you run out of attention at step 3, you have spent it on the three zones that most deserved it, and you can say so in the review. A checklist that you abandon halfway is a failure; a priority queue that you abandon halfway did its job.

Decision tree for reviewing a diff: successive questions about whether the diff touches authorization, money or irreversible effects, data mutation, or external calls, each branching right into what to read closely, with the final branch exiting to a timeboxed skimA diff arrives1 · authZ / authN?roles, scopes, sessionsyesRead closely — ask:is the acting principal compared to this resource,this action, this tenant — or only authenticated?no2 · money orirreversible effect?yesRead closely — ask:what limits the amount, how many times can thisfire, and what undoes it if it fires wrongly?no3 · mutates ordeletes data?yesRead closely — ask:what bounds the row count, is it transactional,and is the prior state recoverable?no4 · calls out?network, queue, SDKyesRead closely — ask:on timeout, does the retry repeat a side effect,and is partial failure distinguishable from failure?no5 · everything elsetimeboxed skimLabel it a skim in the review. An honest skim is aposition; an unlabelled one is a claim you did not earn.Steps are a priority queue, not a checklist — running out of attention at step 3 is a valid, reportable outcome.
Figure 2.1 — The blast-radius review protocol. Each pass searches the diff for one zone and asks the zone's specific question; the ordering follows irreversibility multiplied by reach, so attention is spent before it degrades. Because it is a priority queue rather than a checklist, an incomplete run still allocated correctly — which is the property a checklist does not have.

The refund endpoint, read cold

Here is the artifact, as generated. Thirty-four lines, idiomatic Flask, consistent naming, a retry loop with exponential backoff, correct HTTP status codes throughout. It passed review at a company you have heard of.

@app.route("/refunds", methods=["POST"])
def create_refund():
    session = verify_session(request.headers.get("Authorization"))
    if not session:
        return jsonify({"error": "unauthenticated"}), 401

    body = request.get_json()
    charge_id = body["charge_id"]
    amount = body["amount"]                     # cents

    cur = db.cursor()
    cur.execute(f"SELECT amount, customer_id FROM charges WHERE id = '{charge_id}'")
    row = cur.fetchone()
    if row is None:
        return jsonify({"error": "charge_not_found"}), 404

    for attempt in range(3):
        try:
            result = processor.refund(charge_id, amount)
            break
        except ProcessorTimeout:
            if attempt == 2:
                return jsonify({"error": "processor_unavailable"}), 502
            time.sleep(2 ** attempt)

    refund_id = new_id("rf")
    cur.execute(
        "INSERT INTO refunds (id, charge_id, amount, agent_id) "
        "VALUES (%s, %s, %s, %s)",
        (refund_id, charge_id, amount, session["agent_id"]),
    )
    db.commit()
    return jsonify({"refund_id": refund_id, "status": result["status"]}), 201

Step 1 — authorization. Search the diff for the authorization decision. There is a session check on line 3: verify_session returns a session or nothing, and nothing produces a 401. That is authentication — it establishes who is calling. Now ask the authorization question: where is the comparison between this principal and this action on this resource? Read the handler again looking only for that. It is not there.

The consequences, stated concretely with our cast: agent agt_d4k1 holds role tier1, whose policy cap is $50.00. She can refund the full $84.00 on charge ch_7c2m — the role is read from the session for the audit insert on line 30 and never consulted for a decision. Worse, no line ties charge_id to the agent's tenant: the handler will happily refund a charge belonging to a completely different customer of a completely different Ledgerline client, if the agent guesses or enumerates an ID. Call this D1.

The load-bearing idea

Nothing in this handler is locally wrong. Every line does something reasonable; the session check is correct, the status codes are right, the parameters are bound in the insert. D1 is a missing question, and a missing question has no syntax — no linter, type checker, or SAST rule can flag the absence of a decision nobody wrote down. This is why step 1 is first and why human review still exists. Everything else in this diff, a machine can eventually own.

Step 2 — money and irreversibility. processor.refund(charge_id, amount) moves real money out and cannot be reverted by rolling back the deploy. Read the loop around it. Three attempts, backoff, correct-looking. Now ask the step-4 question early, because it is the same call: what does ProcessorTimeout mean? It means the request did not come back. It does not mean the processor did not act. If the refund succeeded and the response was lost, attempt two issues a second real refund — the customer receives $168.00 against an $84.00 charge, and the refunds table records one row, so the discrepancy surfaces at reconciliation, days later, as a number nobody can explain. There is no idempotency key anywhere in the call. Call this D2.

Step 3 — data mutation. One bounded insert with bound parameters. Note in passing that the insert happens after the money moves and outside a transaction with it, so a crash between them loses the audit record for a completed refund — worth a comment, and a smaller radius than D1 or D2.

Step 4 — external calls. Already covered by D2. Also note the read on line 12 while you are here: f"...WHERE id = '{charge_id}'" interpolates a request-controlled string into SQL. Call this D3.

Now route the findings

Three defects, and the review's most consequential act is deciding which of them you own. D1 stays with you — you are the only layer that can catch it, so it goes in the review as a blocking comment naming the exact missing checks. D2 goes to the test layer: the review comment is not a design argument about idempotency keys, it is "this needs a property test over retry-and-timeout interleavings before merge," because a review comment saying "add an idempotency key" fixes today's diff and nothing else. D3 goes to the pipeline: you file the SAST rule, and the rule catches it and every future instance forever. If you spend your review paragraph explaining SQL injection to the author, you have done a linter's job by hand, badly, once.

Annotated anatomy of the refund handler showing five code zones from session check through response, each labelled with its blast-radius protocol step, with the three planted defects pinned to the zones where they livePOST /refunds — 34 lines, three defectslines 3–5 · session checkverify_session(...) → 401zone 1 · authZ — and the zone ends hereD1 — authorization gapno role-cap check, no tenant scope: agt_d4k1(tier1, $50.00 cap) can refund $84.00, any chargecaught by: human review onlyline 12 · charge lookupcur.execute(f"... id = '{...}'")D3 — string-built SQLrequest-controlled value interpolated into a query;a fixed syntactic pattern, zero judgment requiredcaught by: static-analysis gate (m5)lines 17–24 · retry loopprocessor.refund(charge_id, amount)zones 2 + 4 · money · external callD2 — non-idempotent retrytimeout after processor success → second realrefund; $168.00 out against an $84.00 chargecaught by: property test (m4)lines 26–31 · audit insertINSERT INTO refunds (...) VALUES (%s...)parameters bound — clean. But the write is nottransactional with the money movement above it.line 33 · responsezone 5 · timeboxed skimNothing here needs your best twenty minutes.
Figure 2.2 — Where the defects live, and who catches them. The handler's zones map onto protocol steps, and each defect is pinned to the layer structurally capable of finding it: D1 to human review because it is a missing question with no syntax, D3 to static analysis because it is a fixed pattern, D2 to a property test because it only manifests in a fault interleaving. One defect per layer is the course's whole argument, drawn on one endpoint.

What human review is for

Run that review again and sort the findings by who could have made them. D3 was found by pattern recognition: a fixed syntactic shape, no context required, catchable by a regular expression a decade old. D2 required knowing that timeouts are ambiguous — general engineering knowledge, and mechanizable as a property once someone writes the invariant down. D1 required knowing that Ledgerline has tiered refund caps, that tier1 means $50.00, that the console is multi-tenant, and that the business considers an agent refunding another client's charge a serious incident. None of that is in the repository.

That difference is your entire comparative advantage, and it is worth stating as a rule: you are the only layer that carries intent. Humans catch divergence between what the system does and what the business meant, authorization semantics, business invariants, and the question of whether this feature should exist in this shape at all. Machines catch patterns, types, known-bad constructs, regressions, and — once you write the invariant down — violations of it across an input space you could never enumerate by hand.

The operational consequence is sharper than it first sounds. Every review finding that a machine could have made is a process bug, not an achievement. When you catch string-built SQL by eye, the correct emotional response is mild alarm: the pipeline should have caught that, your attention was the backstop, and backstops made of human attention fail silently on the Friday you are tired. Module 5 is the discipline of converting these findings into gates, and the review is where the gate backlog is generated.

A useful two-question test at review time

For each finding, ask: could a tool have found this with no knowledge of our business? If yes, file the tool change — that is the durable fix, and mentioning it in the review is optional. Did finding this require knowing something true only of our system? If yes, this is your job, it is the highest-value thing you will do today, and it should be the paragraph you write most carefully.

This also settles what a good review looks like when you are pressed for time. A review that says "I ran steps 1 and 2; D1 blocks this; steps 3–5 unread" is more useful to the author and more honest to the record than a full-length review that visited every line and found the nested ternary. The first is a position. The second is coverage without detection — Figure 1.2's failure mode, performed by a person.

Module 03 Specify, then generate

Modules 1 and 2 were about detection. This one is about the only move that reduces the amount of detecting you have to do. Every defect in the Ledgerline handler traces back to a gap in what was asked for: nobody said what a tier-1 agent may refund, nobody said a refund must be safe to retry, nobody said queries are parameterized. The model filled each gap with the distribution's default, which is what it is for.

The claim of this module is stronger than "write better prompts." It is that specification is a layer of the verification stack — the base layer in Figure 1.2 — with a distinctive property: it is the only one that exists before the code, which means it is the only one that can prevent rather than detect. Everything downstream gets cheaper when it is good. Review gets cheaper because the reviewer can distinguish decisions from accidents. Tests get cheaper because the invariants are already written. Gates get cheaper because the criteria say what to check. And when the code is wrong, you get to fix the generator's input rather than argue with its output.

Vague prompts produce unreviewable diffs

The mechanism is worth stating precisely, because the popular version — "garbage in, garbage out" — is both true and useless. Here is what actually happens.

A prompt is a constraint set. "Add an endpoint so support agents can refund a charge" constrains perhaps a dozen decisions and leaves fifty open: the boundary of the refund cap, what happens on an unknown role, whether refunds are per-request or per-charge, what a timeout means, how the query is built, whether an audit record is required, whether it is transactional with the money movement, what the retention period is. The model must resolve all fifty to emit code. It resolves them by sampling the training distribution — that is, by choosing what code like this usually does.

Now the diff arrives, and consider the reviewer's epistemic position. She sees amount > cap. Was $50.00-inclusive a decision the author made and she should defer to, or an accident of sampling she should challenge? She cannot tell from the code, because a decision and an accident produce byte-identical output. There is no comment saying "chose exclusive after asking legal," and there would not be even if someone had. So she has three options: approve on faith, ask the author (who does not know either, having not made the decision), or re-derive the intended behavior from first principles — for all fifty open questions, in every diff, forever.

The load-bearing idea

An unreviewable diff is not one that is hard to read. It is one where the reviewer cannot distinguish decisions from accidents, because the record of what was decided does not exist. Every unspecified question becomes reviewer burden — permanently, on every regeneration — and reviewer burden is the resource module 2 established you do not have.

This is why the specification layer is not a productivity nicety. It is the artifact that makes review possible: a diff plus a spec can be evaluated against something, and a diff alone can only be evaluated against the reviewer's memory of a conversation. It also explains a phenomenon you have probably felt without naming — that reviewing generated code is more tiring than reviewing human code of the same size. You are not just checking the work; you are reconstructing the requirements it was supposedly built against.

Acceptance criteria as contract

An acceptance criterion is a falsifiable statement of done: concrete inputs, an observable outcome, and no adjectives doing load-bearing work. The operational test is a person, not a rule — could a competent stranger with no access to me turn this into a test? If she would have to ask a question, the criterion is not finished.

Compare. "The endpoint should handle refund limits appropriately" — appropriately is a placeholder for a decision nobody made, and it will be resolved by sampling. "Refunds respect agent role caps" is better and still fails the stranger test: which roles, what caps, inclusive or exclusive, capped on what quantity. Here is the same requirement as criteria, drafted for Ledgerline:

AC-1  A request from an agent with role tier1 to refund an amount such that
      (already refunded on the charge + requested amount) <= 5000 cents
      succeeds and returns 201.

AC-2  The same request where the sum would be 5001 cents or more is rejected
      with 403 and error code refund_exceeds_cap. No processor call is made.

AC-3  Exactly 5000 cents in total on a charge is PERMITTED for tier1.
      (The cap is inclusive. Decided with Billing Ops on 2026-03-04.)

AC-4  A request whose role is not present in the role-cap table is rejected
      with 403. Unknown roles fail closed.

AC-5  A request for a charge whose tenant_id differs from the session's
      tenant_id is rejected with 404 (not 403 — existence is not disclosed
      across tenants). No processor call is made.

Five things happened there that are worth naming. AC-1 states the quantity the cap applies to — the running total on the charge — which was the ambiguity that let three $30.00 refunds through in module 1. AC-3 pins the boundary as its own criterion and records who decided, so it reads as a decision rather than an accident. AC-4 converts the fail-open default into an explicit fail-closed requirement. AC-5 specifies the status code and gives the reason, because "404 not 403" looks like a bug to anyone who does not know it is a cross-tenant disclosure choice. And every one of them says what happens to the side effect — "no processor call is made" — which is the part a test can actually assert.

From your litigation years

This is elements-of-a-claim drafting. A claim survives a motion to dismiss when each element is independently pleaded and independently provable; a criterion is useful when it is independently testable. The failure modes transfer exactly: compound clauses ("succeeds and logs and notifies") cannot be proved or disproved as a unit, so split them; conclusory language ("appropriately," "gracefully," "reasonably") states the conclusion rather than the facts supporting it; and an element you leave out is not implied by the others, no matter how obvious it seems at drafting time. The last one is the model's favorite gap.

The criterion that over-specifies

The opposite failure is real: "the handler must use a dictionary lookup against REFUND_CAP_CENTS" prescribes an implementation, which means any correct refactor breaks the criterion and the criterion stops tracking correctness. Criteria constrain observable behavior at the system's boundary. If you find yourself naming a data structure, you have written a design note wearing a criterion's clothes.

Invariants: the negative space

Acceptance criteria describe what the system does. Invariants describe what it must never do, across all inputs, all sequences, and all implementations. That difference is not stylistic — it changes what the statement can catch.

A criterion is a point: this input produces this output. It is refuted by one failing example, and it says nothing about the inputs you did not enumerate. An invariant is a universally quantified claim, so it is refuted by any counterexample anywhere in the input space, including sequences no one thought to write down. D2 lives precisely there: no reasonable person writes an example test for "two refund requests where the first times out after the processor already succeeded," because that is not a feature, it is an interleaving. But it is trivially inside the region an invariant covers.

Ledgerline's invariants, stated in the spec, above the criteria:

INV-1  The total amount refunded on a charge, counted at the processor,
       never exceeds the original charge amount.
       (ch_7c2m: $84.00 in, never more than $84.00 out — under any
        sequence of requests, retries, timeouts, or concurrent agents.)

INV-2  No refund is issued without a recorded authorization decision naming
       the acting agent, the charge, and the amount. The audit row and the
       money movement are committed together or not at all.

INV-3  No SQL statement is constructed by string interpolation of any value
       derived from a request.

INV-4  No agent observes or affects a charge outside their tenant.

Note the phrase counted at the processor in INV-1. That is the difference between an invariant that catches D2 and one that does not: counted in our own refunds table, the double refund is invisible, because the handler only wrote one row. The invariant has to be stated against the place where the real-world effect lands. This is the kind of precision that survives being handed to a property test in module 4 — and INV-1 becomes that test almost verbatim.

Why invariants outlive features

Features churn; the refund endpoint will be rewritten, moved behind a queue, and regenerated by a better model. INV-1 is true of all of those implementations, which is what makes it worth the effort of stating well. A useful heuristic for finding them: ask what a post-incident review would say the system "should never have done," and write that sentence before the incident happens. Most invariants are incident report titles in advance.

From your GRC work

Invariants are control objectives, and criteria are control activities. You already know the failure mode of confusing them: an organization that documents activities without objectives cannot tell whether a changed activity still satisfies anything, so every process change becomes an unbounded re-assessment. Same here — a spec of criteria with no invariants cannot answer "is this refactor safe," because there is no statement of what the criteria were protecting.

The spec-tightening loop

Two loops are available when generated code is wrong, and they differ in whether information accumulates.

In the first, you read the diff, dislike something, and re-prompt: "that's not right, the cap should apply per charge." The model regenerates. Something else is now wrong. You re-prompt again. On the fourth attempt the tests pass and you ship. This is prompt-and-pray, and its defining feature is that the three failures taught you three real things — about the cap semantics, about the retry, about the query — and all three evaporated into a conversation that will be closed by Thursday. The next feature starts from the same zero, and so does the next engineer, and so does the model.

In the second, each failure is diagnosed for its cause: the code is wrong because the spec was silent. You write the missing clause into the spec, then regenerate against the amended spec. The spec is now permanently stronger. The next generation cannot make that mistake, because the constraint is in the input. The failure was converted into a durable asset rather than a conversational one.

Two-panel comparison of the prompt-and-pray loop, where each iteration discards what the previous failure taught, against the spec-tightening loop, where every failure is written back into the specification as a permanent clausePrompt-and-praypromptgenerateeyeball it · dislike itre-prompt · re-rollEach failure taught something real.It is stored nowhere. Iteration n+1 starts at zero.Spec-tighteningspeccriteria + invariantsgenerateverify against criteriaa failure names a missing clauseamend the specEvery failure becomes a permanent clause.The upstream control strengthens monotonically.
Figure 3.1 — Where the information goes. Both loops regenerate until the output is acceptable; they differ in what survives an iteration. In prompt-and-pray the correction lives in a chat transcript and is discarded, so the same gap is re-sampled next time. In spec-tightening the correction is written into the constraint set, so the class of failure cannot recur — including for the next engineer and the next model.
Anti-pattern: prompt-and-pray

Symptom: "it finally passed on the fourth regenerate." Diagnosis: the loop was run for output rather than for information, so three diagnoses were produced and none were recorded; worse, "passed eventually" means the test suite is now the de facto specification, and nobody audited it for being a specification. Corrective: after each failure, write the clause before you write the next prompt. If you cannot state the clause, you have not yet diagnosed the failure — you have only noticed it.

When to amend versus when to patch. Not every failure is a spec gap, and treating them all as one produces a spec bloated with implementation trivia. The discriminator: would a competent engineer, given this spec, have made this same choice? If yes, the spec is silent on something load-bearing — amend it and regenerate, because the gap will otherwise be re-sampled every time. If no — the spec was clear and the output contradicted it — that is a local slip, so patch it by hand and add a test. A dozen local slips in a row is itself a signal, but about the model or the task decomposition, not the spec.

Traceability chain showing how a specification clause becomes an acceptance criterion, then an executable check, then a merge gate, with one invariant and one criterion traced end to endspec clausestated asexecuted byenforced atINV-1total refunded at theprocessor ≤ chargea universallyquantified propertyproperty-based testover fault schedules · m4required checktest suite · m5INV-3no SQL built bystring interpolationa forbiddensyntactic patternstatic-analysis ruleB608-class · m5required checkSAST · m5A clause with no row in this table is an aspiration. INV-2 and INV-4 are the reader's exercise.
Figure 3.2 — From clause to gate. Specification pays off only when each clause has a downstream mechanism: an invariant becomes a property, a forbidden pattern becomes a static rule, and both terminate in a required check that blocks merge. The chain also runs backwards as a diagnostic — a defect that reached production is traced to the first missing link, which is usually a clause that was written and never mechanized.

Module 04 Tests as refutation

This is the longest module in the course and the one that changes the most about how you work. Everything so far has been about attention: where to spend it, how to specify so less is needed. Tests are different in kind — they are the only part of the stack that keeps refuting after you have stopped paying attention, on every commit, forever, at no marginal cost.

The organizing question is not "what should I test" but what can this test refute? A test that cannot fail for any reason connected to your logic has zero refutation power regardless of how much code it executes, which is the whole content of module 1's test-shaped tests. So we work through three layers: what unit and integration tests can each refute and where AI code preferentially breaks (the seams), how a property turns module 3's invariants into a machine that searches for counterexamples you would never have written, and how mutation testing audits the suite itself — the only answer to a model that wrote both the code and its tests.

What a test can refute

The unit-versus-integration argument is usually conducted as a matter of taste, speed, or file layout. Reframe it as refutation power and it becomes decidable. A unit test isolates a component and refutes claims about that component's logic, cheaply and fast. An integration test runs a component against real collaborators — a real database, a real HTTP boundary — and refutes claims about the seams: what happens across a transaction, whether the ORM emits the query you think, whether the retry and the idempotency mechanism compose.

Here is the fact that should drive your allocation: AI-written code fails disproportionately at seams. The reason is structural rather than mystical. Each component in isolation is a well-represented pattern the model has seen thousands of times, and it produces those correctly. The joint behavior — this ORM's transaction semantics combined with this retry policy combined with this processor's timeout contract — is specific to your assembly, appears nowhere in training data as a unit, and must be inferred. Inference is where the plausible-but-wrong lives. D2 is the canonical case: the retry loop is textbook, the processor call is textbook, and the composition double-refunds.

A mock is an assumption stated as fact

Every mock encodes a belief about a collaborator: that processor.refund raises ProcessorTimeout only when the refund did not happen, that the ORM's commit is atomic across these two writes, that the SDK retries nothing internally. Mocks convert those beliefs into ground truth for the test, which means a test suite built on mocks cannot refute any belief encoded in its mocks — and the beliefs are exactly what the model guessed. When a model writes both the code and the mocks, the mocks are guaranteed to agree with the code's misunderstandings. That is not a bug in mocking; it is the reason integration tests exist.

A working allocation rule for a system like Ledgerline: unit-test the pure decision logic where the branch space is large and the collaborators are irrelevant — cap arithmetic, role resolution, boundary conditions. Integration-test every place where a belief about a collaborator is load-bearing — the transaction spanning the audit row and the processor result, the tenant-scoped query actually filtering. And reach for a property whenever the claim is universally quantified, which is where we go next.

Property-based testing: invariants enforced

An example test asks: for this input, is the output right? A property-based test asks: for inputs drawn from a description of the whole space, does this claim hold? You supply generators that describe the space and an assertion that must hold everywhere in it; the framework searches for a counterexample, and when it finds one it performs shrinking — reducing the failure to its minimal form so what you read is the smallest input that still breaks.

The crucial move for verifying AI code is that the generated space includes fault schedules, not just values. Real systems fail in interleavings, and interleavings are precisely what nobody writes example tests for — not out of laziness, but because "the second request arrives after the first timed out following a successful processor call" is not a scenario anyone lists under a feature. Take INV-1 from module 3 and make it executable.

from hypothesis import given, strategies as st

CHARGE_ID = "ch_7c2m"
CHARGE_TOTAL_CENTS = 8400          # $84.00, customer cus_9x4t


class RecordingProcessor:
    """Processor-side ground truth. Performs the side effect FIRST, then
    raises if this attempt is scheduled to time out — modelling the case
    that matters: the refund happened and the response was lost."""

    def __init__(self, fault_schedule):
        self.faults = list(fault_schedule)
        self.performed = []            # what money actually moved

    def refund(self, charge_id, amount, idempotency_key=None):
        if idempotency_key is not None:
            for key, _cid, _amt in self.performed:
                if key == idempotency_key:
                    return {"status": "succeeded", "id": "rf_3n8p"}
        self.performed.append((idempotency_key, charge_id, amount))
        if self.faults and self.faults.pop(0):
            raise ProcessorTimeout("response lost after the refund succeeded")
        return {"status": "succeeded", "id": "rf_3n8p"}


@given(
    requests=st.lists(st.integers(min_value=1, max_value=CHARGE_TOTAL_CENTS),
                      min_size=1, max_size=4),
    faults=st.lists(st.booleans(), max_size=12),
)
def test_total_refunded_never_exceeds_the_charge(requests, faults):
    processor = RecordingProcessor(faults)
    client = make_client(processor=processor,
                         charge=(CHARGE_ID, CHARGE_TOTAL_CENTS, "cus_9x4t"))

    for amount in requests:
        client.post("/refunds",
                    json={"charge_id": CHARGE_ID, "amount": amount})

    moved = sum(amt for _key, cid, amt in processor.performed
                if cid == CHARGE_ID)
    assert moved <= CHARGE_TOTAL_CENTS, (
        "refunded %d cents against an %d cent charge"
        % (moved, CHARGE_TOTAL_CENTS)
    )

Four design decisions carry the test. The stub records processor-side truth, because module 3 established that counting in our own table shows one refund while two occurred. The side effect is appended before the exception is raised, which encodes the only failure mode that matters — success followed by a lost response. The generators cover both request amounts and a fault schedule, so the search space is sequences × failure patterns rather than values alone. And the assertion is the invariant, in cents, unchanged from the spec.

Run it against the handler from module 2 and it fails on roughly the first attempt. Shrunk, the counterexample is two lines long:

Falsifying example: test_total_refunded_never_exceeds_the_charge(
    requests=[4201],
    faults=[True],
)
AssertionError: refunded 8402 cents against an 8400 cent charge

Read what shrinking produced. One request. One timeout. Not a stress test, not a concurrency scenario — the minimum possible failure. The amount shrank to 4201 because that is the smallest value whose doubling exceeds the charge, which tells you the framework understood the shape of the violation. And the mechanism is legible in the output: the handler asked for 4201 cents, the processor performed it, the response was lost, the retry asked again with no key to recognize the resend, and 8402 cents left the building. This is a bug report you could hand to anyone.

The fix is to derive the key once per logical operation, outside the retry loop — one refund request, one key, however many attempts:

idem_key = f"rf:{session['agent_id']}:{body['request_id']}"   # once, before the loop
for attempt in range(3):
    try:
        result = processor.refund(charge_id, amount,
                                  idempotency_key=idem_key)
        break
    except ProcessorTimeout:
        ...
Cross-reference — Guide Nº 07

The mechanism design — where the key lives, its scope, retention window, and behavior on reuse with a different body — is treated in depth in the API design guide. This module's contribution is the other half: the machinery that proves the mechanism works under fault interleavings, and that keeps proving it after someone regenerates this handler next quarter. Knowing to add a key is one skill; having a test that fails when a future diff mints the key inside the loop is a different one.

Two panels showing the same input space of refund amount against request and fault sequence shape: on the left, example-based tests cover a handful of isolated points along the no-fault row; on the right, a property test sweeps a shaded region that contains the timeout-after-success failure zoneExample-based testsProperty-based testD2 failure zonetimeout after success,amount > 4200 centsno example lands herefour points: happy path, a boundary, an error caseD2 failure zoneinside the swept region[4201], [True]the generators describethe region, not the pointsamounts × request counts × fault schedulesrefund amount →refund amount →sequence complexity →sequence complexity →Vertical axis: bottom = one clean request · middle = several requests · top = requests interleaved with timeoutsExamples fail to find D2 not from carelessness but from geometry: nobody writes a point in a region they never thought of.
Figure 4.1 — Points versus regions. Both panels show the same space: refund amount horizontally, sequence complexity vertically. Example tests are isolated points, and they cluster along the bottom row because features are described as single clean requests. The property's generators describe the region, so the search reaches the upper band where timeouts interleave with successes — and shrinking returns the minimal member of the failure zone, requests=[4201], faults=[True], rather than whatever random point it first landed on.

Mutation testing: testing the tests

You now have a suite. What refutes the claim that the suite refutes anything? Mutation testing answers it directly: the tool seeds small defects into your source — flip a comparison, delete a call, negate a branch, replace a return with a constant — and runs the suite against each mutant. A mutant the suite fails on is killed. A mutant the suite passes is a survivor, and a survivor is a proof: there exists a defect in your code that your tests do not detect. The mutation score is the kill rate, and unlike coverage it measures detection rather than execution.

Run it on the Ledgerline cap module, whose fixed logic is one function:

def within_cap(already_refunded, amount, cap):
    return already_refunded + amount <= cap
Flowchart of a mutation testing run on the refund cap function: the mutation engine produces three mutants, the test suite runs against each, two are killed and one — the inclusive-to-exclusive boundary flip — survives, identifying a missing assertion at exactly 5000 centssource · within_cap()already + amount <= capmutation engine seeds defectsM1 · operator swapalready + amount < caprejects exactly 5000M2 · arithmetic swapalready - amount <= capignores the running totalM3 · condition forcedreturn Truecap never enforcedsuite runssuite runssuite runsSURVIVEDevery test stayed greenkilledmulti-refund test failedkilledover-cap test failedWhat M1 proves:no test asserts behavior at exactly 5000 cents, so the suite cannot tell an inclusive cap from anexclusive one — AC-3, the criterion that recorded the decision, was written and never made executable. Mutation score 2/3 = 67%.
Figure 4.2 — A survivor names a missing assertion. Two seeded defects are detected by existing tests and die. The third — flipping the inclusive comparison to exclusive — passes the entire suite, which proves that no test distinguishes a cap of "at most $50.00" from "under $50.00." The output is not a suggestion to write more tests; it is the precise identification of one missing assertion at one value.

That is the property worth internalizing: a survivor is a named missing assertion, not a vague coverage gap. The corrective is mechanical — add a test asserting within_cap(0, 5000, 5000) is True and its neighbours at 4999 and 5001 — and then the mutant dies and the suite has demonstrably gained the power to detect that defect class. Compare the alternative feedback you would otherwise have received: a coverage report showing 100%, because every one of those lines executed on the happy path.

Anti-pattern: 100% coverage as a proxy for correctness

Symptom: a green coverage gate at a high threshold, and production defects in code the report shows as fully covered. Diagnosis: coverage counts lines executed while a test ran; it makes no claim that anything was checked. A test with no assertions at all achieves full coverage of whatever it touches. Corrective: keep coverage as a cheap floor for finding wholly untested code, and use mutation score on the modules that matter as the measure of detection. When the two disagree — 100% coverage, 40% kill rate — believe the kill rate.

Anti-pattern: trusting the model's own tests

Symptom: the same session produced the handler and test_refunds.py; the suite is green; confidence is high. Diagnosis: the code and the tests share an author, a context window, and therefore a set of misunderstandings. If the model believed timeouts imply the refund did not happen, it wrote a handler on that belief and a test asserting that belief. The suite confirms the misunderstanding rather than probing it — the model grading its own homework, and failing in exactly the places the homework fails. Corrective: mutation-test model-written suites before granting them any trust, and require that at least the invariants come from a human. A kill rate on generated tests is the cheapest possible audit of whether they mean anything.

Mutation testing is expensive — it runs your suite once per mutant — so treat it as a scheduled audit on your highest-radius modules rather than a per-commit gate. Nightly on the money and authorization paths is a defensible policy; on the whole repository per pull request, it will be turned off within two weeks.

A testing policy for AI-written code

Put the pieces into a division of labor you can state to a team, because the point is not that you personally test more carefully — it is that verification stops depending on anyone's care.

The human writes the invariants and the acceptance-level tests. These are module 3's spec made executable, and they must come from outside the generator, because they are the standard the generator is being measured against. The model may write example tests — it is fast and genuinely good at enumerating cases you would skip — but generated tests are treated as an artifact under verification, not as verification. Mutation testing audits the boundary between those two categories, and it is what makes the arrangement enforceable rather than aspirational.

The load-bearing idea

Verification independence: a test's authority comes from having a different basis than the code it checks, not from being a different file, a different session, or a different model. Two samples from the same distribution share the distribution's blind spots. Independence comes from a human-stated invariant, a real collaborator instead of a mock, or a mechanical audit like mutation testing — sources whose errors are uncorrelated with the generator's.

From your GRC work

This is separation of duties, and the analogy is exact rather than decorative. A control owner does not test their own control; the tester's independence is what gives the test evidentiary value, and an audit performed by the operator produces a document rather than assurance. "The model wrote its own tests and they pass" is the engineering equivalent of a self-attested control with no independent testing — you would not accept it in a SOC 2 review, and the reason you would not accept it there is the same reason it fails here.

Layered architecture of the Ledgerline refund endpoint from HTTP handler down through cap logic, repository, and processor client, with three test boundaries drawn: unit tests around the pure logic, an integration test spanning handler to a real Postgres, and a property test spanning the whole handler with faults injected at the processorWhere each test cutsFlask handler · POST /refundsrequest parsing, orchestration, status codesauthorization + cap logicwithin_cap() · tenant_allows()refund repositoryqueries, transaction boundaryprocessor clientrefund call, retry, idempotency keyreal PostgresRecordingProcessorfaults injected hereunitintegrationpropertyunit: boundary arithmetic at 4999 / 5000 / 5001 · integration: audit row and processor result commit togetherproperty: INV-1 over request sequences × fault schedules, counted at the processorMutation testing audits all three — nightly, on this module, because it is the money path.
Figure 4.3 — Test boundaries chosen by what each can refute. The unit boundary encloses the pure decision logic where the branch space is large and collaborators are irrelevant. The integration boundary reaches a real Postgres, because the claim under test — that the audit row and the processor result commit together — is a belief about a collaborator and cannot be refuted by a mock of it. The property boundary spans the whole handler and injects faults at the processor, because INV-1 is a claim about the system's joint behavior under failure.

Module 05 Let the pipeline say no

Modules 2 through 4 produced a lot of knowledge: a review protocol, a specification discipline, a testing strategy. All of it currently depends on people remembering to do it, which means all of it degrades under exactly the conditions that produce incidents — a Friday deploy, a new team member, a quarter-end push, an engineer who has reviewed eleven diffs today.

This module is about converting that knowledge into mechanism. The distinction it turns on is small and consequential: a check that reports and a check that blocks are different objects, even when they run the same code and produce the same output. One is advice. The other is policy. Getting the second requires deciding, in advance and in writing, what the pipeline refuses — and then living with the refusal on the day it is inconvenient, which is the only day the decision matters.

Gates versus advice

Two repositories run identical static analysis on every pull request. In the first, the tool posts its findings as a comment. In the second, a failure sets the check to red and branch protection prevents merge. The analysis is the same; the control is not remotely the same, and the difference shows up in production defect rates rather than in any dashboard.

The reason is that an advisory finding creates a decision, and decisions get made by tired people under deadline. "Is this f-string SQL actually exploitable here? The charge id comes from our own front end. It's Thursday and this unblocks support." Each individual override is defensible; the aggregate is a control that fires exactly when nobody is under pressure, which is to say when it is not needed. A required check removes the decision. There is nothing to weigh, nobody to persuade, and — this is the underrated part — nobody to be the person who said no.

From your GRC work

You already have the vocabulary: this is preventive versus detective control design. A required check is preventive and operates at the merge boundary — the defect cannot enter the protected branch. Review comments, dashboards, and post-merge scans are detective: they tell you something is already true. Both have a place, and the classic assessment finding applies here without translation — a preventive control that can be bypassed at the operator's discretion is a detective control with better marketing. The corollary matters too: if you cannot articulate the control's exception process, you do not have a control, you have a default.

There is a human argument alongside the technical one, and it is worth stating because it changes how teams behave. Depersonalizing enforcement is a gift to your reviewers. When the standard lives in a person, holding the line costs social capital, and people spend social capital carefully — which means the standard is enforced most weakly against the most senior author and on the most urgent change. When the standard lives in the pipeline, the conversation shifts from "James is blocking my PR" to "the SAST gate is red," and the fix is to fix it. The gate never gets tired, never gets lobbied, and never wants to ship on Friday.

The gate inventory

A gate set is not a list of tools; it is a claim that each failure class has an owner. Build it by starting from module 1's taxonomy and asking, for each signature, what mechanism catches it. What follows is a defensible default for a Python service like support-api, stated technique-first — the technique is the gate, and the named tools are instances that will be replaced within a few years.

GateCatches (module 1 class)InstanceRequired?
Format and lintNothing important — it removes style from human reviewruff, blackYes, and auto-fixed
Static analysis / SASTSecurity-naive defaults: injection sinks, string-built SQL, disabled TLS verification, unsafe deserializationbandit (B608), semgrepYes
Type checkingHallucinated interfaces — invented attributes and wrong signatures on typed objects die here; keywords absorbed by **kwargs still slip pastmypy, pyrightYes
Test suite, including propertiesPlausible-but-wrong logic; invariant violations under generated fault schedulespytest, HypothesisYes
Coverage deltaNew code arriving with no tests at all — a floor, not a correctness measurecoverage.py diff-coverYes, on changed lines
Mutation spot-checkTest-shaped tests, on the modules that mattermutmut, cosmic-rayNightly, not per-PR
Secrets scanningCredentials in diffs, before git history becomes the system of recordgitleaks, trufflehogYes
Dependency audit and lock verificationKnown-vulnerable packages; unlocked or unhashed installs (module 7)pip-audit, OSVYes
IaC policy scanOver-permissive IAM, public exposure, destructive removal policies (module 6)cfn-nag, checkovYes

Two design notes about that table. Type checking earns its slot for a reason specific to generated code: in a typed codebase, a hallucinated method or an invented attribute on a typed object is a type error, so the most-discussed AI failure mode is largely mechanized — the exception being a parameter a **kwargs-accepting client silently absorbs, which stays a review concern. That is a strong argument for type annotations in a codebase written by models even if you found them unnecessary when it was written by you.

Coverage delta rather than absolute coverage is a deliberate choice. An absolute threshold punishes whoever touches a legacy module and can be satisfied by tests that assert nothing; a delta gate asks only that new lines arrive with tests, which is a floor worth having. It is still gameable — module 1's test-shaped tests satisfy it perfectly — which is why the mutation spot-check exists alongside it rather than instead of it.

The injection dies in CI

Return to D3 and watch it end. The line, unchanged from module 2:

cur.execute(f"SELECT amount, customer_id FROM charges WHERE id = '{charge_id}'")

The gate run produces:

>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector
   through string-based query construction.
   Severity: Medium   Confidence: Low
   Location: support_api/refunds.py:12
11    cur = db.cursor()
12    cur.execute(f"SELECT amount, customer_id FROM charges WHERE id = '{charge_id}'")
13    row = cur.fetchone()

Code scanned: 1 file, 34 lines
Total issues by severity: Medium: 1

Exit code: 1  —  required check "sast" failed

The fix is thirty seconds of work and the same in every language:

cur.execute("SELECT amount, customer_id FROM charges WHERE id = %s", (charge_id,))

Now account for what just happened at the level of process rather than code. Zero seconds of reviewer attention were spent. Nobody had to know about SQL injection, remember to look for it, or be alert at 5:40 p.m. The finding arrived before a human read the diff, phrased as an exit code. And the rule persists: the next generated handler, and the one after a model upgrade, and the one written by a contractor in eight months, all hit the same wall.

The load-bearing idea

After any incident or review finding, the question is not "who missed this" but which gate should have caught it, and why doesn't it exist? Review findings are gate backlog. A finding that produces only a fixed line has been half-used; a finding that produces a fixed line and a rule has been converted from a one-time detection into a permanent one. Track that conversion rate — it is the single best measure of whether a team's verification is compounding or being re-performed by hand every week.

Note the Confidence: Low in that output, because it is where these programs usually die. Static analysis produces false positives; a rule that fires on safe code trains people to add suppression comments, and a codebase full of suppressions is a codebase with no rule. The discipline is to tune the rule until its findings are trustworthy, require a written justification on each suppression, and review the suppression list quarterly the way you would review a control exception register. A noisy required gate does not survive contact with a deadline.

Pipeline diagram showing a pull request passing through format, static analysis, type check, test, and secrets gates before merge, with each gate having a downward failure exit, and the string-built SQL defect entering at the pull request and terminating at the static analysis gatepullrequestformat+ lintstaticanalysistypechecktests +propertiessecrets +depsmergeD3 · f-string SQL enters hereB608 · exit 1merge blockedfail exitfail exitfail exitfail exitD3 consumed zero seconds of reviewer attention — and the rule that killed it will kill every future instance.
Figure 5.1 — Where D3 dies. The string-built SQL enters with the pull request and terminates at the static-analysis gate with a non-zero exit before any human opens the diff. Each gate's fail exit is a merge block rather than a comment, which is what makes the arrangement a preventive control; and because the rule outlives the diff, the same defect regenerated next quarter meets the same wall.
Matrix of the five AI failure signatures against four automated gates plus a human review column, showing which gates catch each signature fully, partially, or not at all, with the final row of intent divergence and authorization semantics catchable only by humansWhat each layer can actually catchstatic analysistype checktest suitedeps + secretshumanhallucinated interfacepartlypartlypartlycannotpartlyplausible-but-wrong logiccannotcannotcatchescannotpartlysecurity-naive defaultcatchescannotpartlypartlypartlyphantom edge-case handlingpartlycannotpartlycannotcatchestest-shaped testcannotcannotcannotcannotpartlycaught instead by the mutation spot-check — a scheduled audit, not a per-PR gateintent divergence · authZsemantics — D1's classcannotcannotcannotcannotonly hereThe bottom row is the residue. Everything above it is work you should stop doing by hand.
Figure 5.2 — The residue that stays human. Reading down the columns tells you which gates to build; reading across the bottom row tells you what to protect your attention for. Intent divergence and authorization semantics — D1's entire class — are marked "cannot" under every automated gate, because both require knowledge that exists outside the repository. Everything in the rows above has a mechanical owner, and any of it still being caught by eye is a conversion you have not yet made.

Gate hygiene

Gates fail in one characteristic way, and it is not that they are too strict. A required check that fails intermittently for reasons unrelated to the change teaches the team a lesson very efficiently: red does not mean broken, red means re-run. Once that lesson is learned it generalizes, and it generalizes to the gates that are working. This is gate erosion, and its cost is not the flaky suite — it is that a genuine failure in a different gate no longer stops anybody.

Anti-pattern: flaky-gate erosion

Symptom: "just re-run it, the integration suite is like that" — said kindly, to a new engineer, as onboarding. Diagnosis: the team has adapted to an unreliable signal by learning to discount all signals of that shape, which is rational locally and catastrophic globally. Corrective: quarantine, do not tolerate. Move the flaky test out of the required set immediately so the gate is trustworthy again, file its instability as a defect with an owner and a date, and fix or delete it. A quarantined test is an honest gap; a flaky required test is a gap that also destroys the credibility of the tests around it.

Second element of hygiene: overrides are logged exceptions, not conversations. There will be a genuine emergency, and a control with no exception path invites a worse workaround — someone will push to the branch directly or disable the check globally. So define the path in advance: an override requires a named approver who is not the author, a written reason, an expiry, and an entry in a register that gets reviewed. You have run this exact process for control exceptions in a compliance program; import it wholesale, including the part where a persistently-renewed exception is escalated rather than rubber-stamped, because a permanent exception is a policy nobody agreed to.

Third: branch protection is what makes any of this real. Required checks that can be bypassed by pushing directly to the protected branch are advisory checks with extra steps. Configure the protection, include administrators, and accept the small friction — the alternative is that the control is strongest against exactly the people least likely to need it.

Speed is a hygiene property

A gate set that takes forty minutes gets worked around structurally: people batch changes into larger pull requests, which defeats module 2's review economics, or they stop opening pull requests until the work is done, which defeats plan review in module 8. Keep the required path fast — parallelize the gates, run the expensive ones (mutation, full integration) on a schedule rather than per-PR, and treat pipeline latency as a control property rather than a developer-comfort issue.

Module 06 Reading the CDK diff

An application defect corrupts requests. An infrastructure defect corrupts the platform the requests run on, and it does so in a medium where a great deal of consequence is compressed into very few lines. Three lines of YAML can make a bucket world-readable. One enum change can turn a database into something that disappears when a stack is torn down. This is the highest ratio of blast radius to line count you will encounter, which by module 2's rule makes it the highest-priority reading you do.

Model assistance makes it urgent for a specific and predictable reason: models resolve permission errors by widening permissions. Asked to fix an AccessDenied, the statistically normal answer — the one in the median forum post that ends with "this worked for me" — is a wildcard. So the failure class arrives constantly, wearing the clothes of a fix, in a diff whose stated purpose is something else entirely. This module gives you a reading order for that diff and a worked example carrying the two findings you will see most often in real life.

The largest radius per line

Put the two artifacts side by side. The refund handler's D1 lets an authenticated support agent exceed their refund cap and reach other tenants' charges — serious, bounded by what the endpoint can do. An IAM statement granting s3:* on * lets anything holding that role read, overwrite, and delete every object in every bucket in the account, including the export bucket, the backups, and the CloudTrail logs that would tell you it happened. It is D1 at cloud scale, and it fits on two lines.

That compression is the first reason infrastructure review is different. The second is that the blast radius is often not visible in the change's stated purpose. Nobody opens a pull request titled "grant delete access to all buckets." They open one titled "add refund-history export," and the wildcard is inside it, added at 11 p.m. to unblock a test run.

The third reason is the generation dynamic. When a model hits an AccessDenied, it has two paths: work out which specific action on which specific resource was denied and grant exactly that, or widen until the error stops. The second path is shorter, far more common in the training data, and — this is what makes it dangerous — indistinguishable from the first in a green build. Both make the error go away. Only one of them is a permission decision anyone would have approved.

The load-bearing idea

Infrastructure review is the module 2 protocol with its lowest rungs deleted. Almost everything in an infra diff is zone 1 (authorization) or zone 2 (irreversible effects); there is no "everything else" to skim. So the correct posture is inverted from application review: assume every line deserves attention until you have classified it, rather than hunting for the few lines that do.

Ground truth is the synthesized template

CDK's value is that it hides CloudFormation. That is also its review hazard: the source states intent, and the synthesized template states what was actually promised to AWS. The gap between them is where findings hide, because L2 and L3 constructs and grant* helpers expand into statements the source never displays.

Here is the export feature's source, in full, as it appeared in the pull request:

const exportBucket = new s3.Bucket(this, "RefundExportBucket", {
  encryption: s3.BucketEncryption.S3_MANAGED,
});

const exportRole = new iam.Role(this, "ExportRole", {
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
});

exportBucket.grantReadWrite(exportRole);

// unblock the AccessDenied we hit during testing
exportRole.addToPolicy(new iam.PolicyStatement({
  actions: ["s3:*"],
  resources: ["*"],
}));

Twelve lines, encrypted bucket, service principal correctly scoped, a named grant helper. It reads like careful work, and a reviewer moving quickly registers "grantReadWrite — sensible, least privilege, someone thought about this." The comment on the last statement even sounds diligent. Now run cdk diff and read what is actually being promised:

Resources
[+] AWS::S3::Bucket RefundExportBucket
[+] AWS::IAM::Role ExportRole
[+] AWS::IAM::Policy ExportRole/DefaultPolicy
     Statement 1:
       Action:   s3:GetObject, s3:GetObjectVersion, s3:GetBucket*,
                 s3:List*, s3:PutObject, s3:PutObjectLegalHold,
                 s3:PutObjectRetention, s3:PutObjectTagging,
                 s3:PutObjectVersionTagging, s3:Abort*, s3:DeleteObject*
       Resource: arn:aws:s3:::RefundExportBucket, .../*
     Statement 2:
       Action:   s3:*
       Resource: *

Two things the source did not show. Statement 1: grantReadWrite expanded to eleven actions including s3:DeleteObject* — defensible for this feature, and worth knowing, because "read write" in English does not obviously include delete. Statement 2 is the finding: every S3 action on every resource in the account. That includes s3:DeleteBucket on the backups bucket, s3:PutBucketPolicy on any bucket (which is how a private bucket becomes public), and read access to whatever else lives in this account.

Review the diff, not the source

Symptom: a review that quotes lines from the TypeScript. Diagnosis: the source is a program that generates the artifact under review; approving it is approving a description of a promise rather than the promise. Corrective: require cdk diff output against the deployed stack as a required artifact on every infrastructure pull request, posted by the pipeline so it cannot be omitted or edited. If the diff is not in the PR, the review has not started.

Annotated CDK diff for the refund-history export feature, showing four read-first zones in order — IAM statement changes, network exposure, deletion policy, and cost-bearing resources — each paired with the finding it containscdk diff — "add refund-history export"what it actually promises1 · IAM statements[+] AWS::IAM::Policy ExportRole/Default + Action: "s3:*" + Resource: "*"delete-bucket on every bucket in the accountincluding backups and CloudTrail logs; also grantss3:PutBucketPolicy, which can make any bucket publicsource said only: bucket.grantReadWrite(role)2 · network exposure[~] SecurityGroup ExportWorkerSg + Ingress 0.0.0.0/0 : 22SSH open to the entire internetadded "for debugging"; exposure begins the momentit deploys and cannot be retroactively un-exposed3 · deletion / removal policy[~] DynamoDB::Table RefundAuditLog DeletionPolicy: Retain → Deletethe refund authorization evidence traila stack teardown now destroys the records that provewho approved which refund — a compliance artifact4 · cost surface[~] Lambda::Function ExportHandler + ProvisionedConcurrency: 50about $550 a month, paid continuouslyfor a job that runs four minutes a night — 50 warmGB-instances billed for all 730 hoursThe CDK source for all four findings is twelve readable lines. None of these findings is visible in it.
Figure 6.1 — Four zones, read in this order. The synthesized diff is the artifact under review because helpers and constructs expand: grantReadWrite becomes eleven actions, and a hand-added statement becomes account-wide S3 control. Reading in blast-radius order — IAM, then exposure, then deletion policy, then cost — means the two findings that cannot be undone are reached before attention degrades.

IAM: the authorization of the cloud

Read a policy statement the way you would read an indemnity clause: every position is a term, and a wildcard in any position is an unbounded term. Three positions matter.

Action — what may be done. s3:* is not "S3 access"; it is roughly one hundred and thirty operations, including DeleteBucket, PutBucketPolicy (grant public access to anything), PutBucketVersioning (disable the versioning that makes deletion recoverable), and PutBucketLogging (turn off the record of what happened). The last two are why a wildcard on action is worse than the sum of its obvious parts: it includes the operations that disable the controls detecting its abuse.

Resource — what it may be done to. * means every bucket now and every bucket created in future, by anyone, forever. This position ages worst: the grant was written when the account had three buckets and silently expands as the account grows.

Condition — under what circumstances. Usually absent, which is fine for a service role, and load-bearing whenever a third party can assume the role (the confused-deputy conditions) or the grant should be limited by source, tag, or MFA state.

Now the corrective for statement 2, derived properly. Ask what the export feature actually calls: it reads refund rows, writes one CSV per night, and lists the prefix to find prior exports. That is three actions on one resource:

exportRole.addToPolicy(new iam.PolicyStatement({
  actions: ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
  resources: [
    exportBucket.bucketArn,
    exportBucket.arnForObjects("refund-history/*"),
  ],
}));

Better still, delete the hand-written statement entirely and use the grant helper scoped to the prefix — exportBucket.grantPut(exportRole, "refund-history/*") — because helpers are reviewed, versioned, and consistent, whereas hand-written statements are where wildcards go to hide. The general rule: prefer grants over hand-written policy statements, and treat a hand-written statement in a diff as a finding until justified.

Anti-pattern: wildcard rationalization

Symptom: "it fixed the AccessDenied, we'll narrow it later" — often with a TODO comment, which is what makes it feel handled. Diagnosis: narrowing later requires someone to reconstruct which permissions were actually needed, at a future date, with no error message to guide them and no forcing function. The information required to scope correctly is maximally available right now, from the denial itself, and decays from this moment. Corrective: scope it in this pull request. If the needed actions are genuinely unknown, deploy to a non-production account, collect the denials from CloudTrail, and write the policy from the observed calls — which takes an afternoon and produces a defensible policy instead of a permanent one.

From your litigation years

You would never accept "defendant may take such actions as it deems necessary with respect to any property" in a settlement agreement and plan to negotiate the scope after signing. The asymmetry is identical: broad terms are cheap to grant and expensive to claw back, the counterparty's incentive to renegotiate drops to zero the moment the ink dries, and the drafting leverage you have today is the most you will ever have. An IAM statement is a clause, and it is enforced by a machine that will never entertain an argument about what you meant.

Irreversibility: deletion policies and exposure

Some infrastructure changes are mistakes you fix, and some are events you have. The line between them is removal policy and exposure, and both classes are quiet in a diff.

In CDK, RemovalPolicy.DESTROY on a stateful resource means the data goes when the resource goes — on a stack teardown, on a resource replacement triggered by an unrelated property change, on a bad rollback. It is the correct setting for a scratch bucket in a development stack and a data-loss incident waiting for a trigger on anything that stores facts.

The export diff flips RefundAuditLog from Retain to Delete, and it does so inside a pull request about exports — almost certainly because the model saw a teardown fail on a retained table during testing and removed the obstacle. Look at what that table is. It records which agent authorized which refund for which customer at what time. It is the evidence trail for every money movement in the system: the artifact you produce when a customer disputes a refund, when an auditor samples authorization controls, and when the question is whether agt_d4k1 was permitted to refund ch_7c2m.

From your compliance work

Retention on this table is not an engineering preference. It is a records-retention obligation, and the deletion policy is a technical control implementing it — which means this one-word diff line is a change to a control, made by a model, inside a PR about a CSV export, with no control owner in the approval chain. That framing is the one that gets it taken seriously by people who would otherwise read it as configuration. It also tells you the corrective: stateful resources holding records subject to retention obligations get RemovalPolicy.RETAIN plus a policy-scan rule that fails the build on any change to it, so the control cannot be edited silently again.

Exposure is the second irreversible class, and it is irreversible in a different sense: the resource can be closed in a minute, but you cannot un-disclose what was read. A security group opening port 22 to 0.0.0.0/0 "for debugging," a bucket made public to make a front-end work, a database subnet made routable — for each of these, the question is not "can we revert it" but "between deploy and revert, what could have been taken, and would we know?" With versioning disabled and logging off, the honest answer is often no.

Practical discipline for both classes: they get a different gate, not just a more careful reader. A policy scan that fails the build on any removal-policy change to a stateful resource, on any ingress rule with a 0.0.0.0/0 source, and on any public-access-block removal, converts "we should have noticed" into "it could not merge." This is module 5 applied to infrastructure, and it is where the majority of the value is — the reader's job is to catch what the scan cannot, which is whether a correctly-formed grant is the right grant.

Cost surfaces

Cost belongs in this module because it is a blast-radius dimension with the same two variables as everything else: magnitude and reversibility. Treating it as a finance concern rather than a review concern is how a diff about a nightly export acquires a permanent monthly bill.

The export diff adds provisioned concurrency of 50 to ExportHandler. Provisioned concurrency keeps execution environments warm and bills for every second they are warm, whether or not anything invokes them. Do the arithmetic in the review, because a number changes the conversation:

50 environments × 1 GB × 730 h/month × 3600 s/h  =  131,400,000 GB-seconds
131,400,000 × $0.0000041667                      ≈  $548 / month

That per-GB-second figure is a published price, and published prices move — look up the current one before you quote a number in a review. The arithmetic is the durable part. The function runs once a night for about four minutes. The team is proposing roughly $6,500 a year to eliminate a cold start on a batch job that nobody waits for. Stated that way the finding needs no security framing to be obvious — but nobody states it that way unless someone in review does the multiplication, and the diff line itself reads as an innocuous performance setting that a model added because performance settings are what you add when asked to make something faster.

The cost surfaces worth memorizing, because they recur: provisioned concurrency and reserved capacity of any kind; NAT gateways (per-hour plus per-gigabyte, and the classic surprise in a private-subnet design); log retention set to "never expire" on a high-volume group; cross-availability-zone and egress data transfer; per-request-priced services multiplied by a retry loop; and anything with the word "provisioned" or "reserved" in its name, which is the general form of "you are now paying for idle."

How to file a cost finding

Review it exactly like a security finding: magnitude × reversibility, with a number. Cost is usually highly reversible — you can delete provisioned concurrency this afternoon — which correctly places it lower than IAM and removal policies in the reading order. What makes it worth catching in review anyway is that unreviewed cost is not detected by anything else until a monthly bill arrives, and by then it has often been normalized into the baseline. "About $550 a month for a four-minute nightly job — what problem is this solving?" is a complete and sufficient review comment.

Quadrant chart plotting infrastructure changes by reversibility against impact, placing configuration toggles and compute sizing in the low-impact reversible region, IAM widening at high impact with poor auditability, and removal policies and public exposure at high impact and irreversibleread-first orderconfig toggleslog level, timeouts, env varscost surfacesprovisioned concurrency 50 ≈ $548/moIAM widenings3:* on * — revocable in a minute,but you cannot audit what it didpublic exposure0.0.0.0/0 · public bucketcannot be un-leakedRemovalPolicy.DESTROY on the audit logreversible in minutesirreversible in practicereversibility →impact →
Figure 6.2 — The reading order falls out of the geometry. Plotting infrastructure changes by impact against reversibility produces the priority order directly: read the upper-right first. IAM widening sits high on impact and only nominally on the reversible side, because revoking the grant does not tell you what it was used for while it existed — which is why it is read alongside the genuinely irreversible changes rather than with the config toggles.

Module 07 Dependencies and slopsquatting

Everything so far has been about verifying code someone wrote for you. This module is about code you never looked at, running with your credentials, updated on a stranger's schedule. The dependency graph of a modest Flask service is several hundred packages, of which you chose perhaps fifteen.

That has always been true. What is new is that the decision to add a package has quietly moved from you to the model, and there is an attack built specifically around that transfer. It is worth understanding precisely, because it is the rare security threat whose entire premise is the existence of AI coding assistants — the attacker's input is the model's error distribution.

Every import is a counterparty

A dependency is not a library. It is a standing agreement with a party you have not met, under which their code executes inside your process, with your environment variables, your database connection, and your cloud role, whenever they publish a new version and you install it.

Stated that way the risk profile is obvious, and it is worth restating because familiarity has worn it smooth. Your direct dependencies are counterparties you chose. Their dependencies are counterparties chosen for you, by people optimizing for their own convenience. In a graph of four hundred packages, the number you have evaluated is roughly the number you typed, and the number that can execute code at install time or import time is all of them.

From your GRC work

This is third-party risk management, and the mapping is exact: a dependency is a vendor with production data access, onboarded without a questionnaire, with an unlimited right to push changes into your environment, and no contract. In a vendor-risk program you would never approve that arrangement — yet the engineering version happens dozens of times per project, and the approval step is a person typing a name into a file. The useful import from your compliance work is not paperwork; it is the instinct that the decision point is onboarding. Once a counterparty is inside, you are managing exposure rather than deciding on it.

What AI assistance changes is who makes the onboarding decision. When a model writes import redis, the choice to trust that project was made by a token sampler. Most of the time it picks the obvious, widely-used package and nothing is wrong. The failure mode is not that models pick bad packages on average — it is that the decision has moved somewhere with no notion of counterparty risk, and unless you take it back deliberately, it will be exercised hundreds of times without ever being made.

Slopsquatting: the attack aimed at you

Models hallucinate package names, and they do it with statistical regularity rather than randomly. Asked repeatedly for a library that solves a common problem, a model will propose the same plausible-but-nonexistent names — flask-idempotency-shim, boto3-retry-helpers, requests-retry-adapter — because those names sit where the naming conventions of the ecosystem say such a package would sit. Regularity is the vulnerability: a hallucination that repeats is a hallucination an attacker can predict.

The chain, with our running example. A model is asked to fix D2 and suggests pip install flask-idempotency-shim — a name that does not exist, invented by convention-matching. An attacker, having mined thousands of model outputs for exactly such names, has already registered it on the public index, with a plausible README, a version history, and a setup.py whose install hook posts the contents of the environment to a collection endpoint. A developer or an agent runs the install. It succeeds — of course it succeeds, that is the attack — and the payload runs with whatever the shell had: PROCESSOR_KEY, the database URL, and any AWS credentials in scope.

The load-bearing idea

A successful install of an AI-suggested package is not evidence of legitimacy. For a hallucinated name, the install succeeding is the attack working — the only reason that name resolves is that someone put something there. The correct reaction to "it installed fine" for a package you have not verified is suspicion, not relief.

Two control points break the chain, and they are different in kind — one is a judgment, one is a mechanism, and neither substitutes for the other.

CP1 — vet before add. A human verifies a package before its name enters the dependency set. Not after the install; before, because install-time code has already run by then. The check takes under five minutes: does the project exist on its own terms (a repository, an issue tracker, a history), who publishes it and what else have they published, when was it last updated and by how many people, and does the download pattern look like adoption or like a spike from nowhere. A package registered three weeks ago, by an account with no other work, with a version history that starts at 1.0.0, is the signature — and it is visible in thirty seconds on the index page.

CP2 — deterministic installs. CI and agent sandboxes install only from the lockfile, with hash verification, and never resolve a new name. This is the mechanical control, and it is the one that holds when nobody is paying attention: an agent that decides mid-task to install something helpful simply cannot, because the install command is constrained to the recorded set. CP1 depends on a person doing a thing; CP2 does not, which is why the pair is stronger than either.

Swimlane attack flow across five lanes — model, attacker, developer, registry, and your infrastructure — tracing a hallucinated package name through registry squatting and installation to credential exfiltration, with two control points drawn as gate bars intercepting the chainmodelattackerdeveloperregistryyourinfrastructurehallucinates a nameflask-idempotency-shimmines suggestions,registers it with a payloadinstalls on themodel's say-soresolves · serves packageinstall hook runs →env vars exfiltratedCP1 · vet before addCP2 · lockfile-only installCP1 stops the name entering the dependency set — a human check on existence, provenance, maintenance, reputation.CP2 stops anything not already recorded from resolving at all — the control that holds when nobody is watching.
Figure 7.1 — The chain, and where it breaks. The attacker's input is the model's error distribution: hallucinated names repeat, so they can be predicted and pre-registered. The install succeeding is the attack completing, not evidence of legitimacy. CP1 is a judgment applied once per new name; CP2 is a mechanism applied to every install, which is why an agent working unattended is contained by CP2 and not by CP1.

Lockfiles and pinning

The distinction between pinning and a lockfile is the most commonly misunderstood thing in this module, and CP2 rests on it entirely.

Pinning fixes versions in the manifest — the file you write. flask==3.0.2 is a pin. It is a policy about your direct dependencies and says nothing whatsoever about theirs. Flask depends on Werkzeug, Jinja2, itsdangerous, click, and blinker, each with a version range; those resolve at install time, to whatever satisfies the range today. Install the same pinned manifest in March and in July and you get different code.

A lockfile records the resolution: every package in the transitive closure, its exact version, and a cryptographic hash of the artifact. It is not a policy about the future — it is a record of the present, an answer rather than a question. Installed with hash checking enabled, it guarantees that what runs in CI, on your laptop, and in production is byte-identical, and that any artifact whose hash does not match the record fails the install rather than proceeding.

That last clause is the security property, and it is what makes CP2 mechanical rather than aspirational: a package that is not in the lockfile cannot arrive as a side effect of anything. Not a transitive bump, not a compromised upstream release, not an agent deciding a helper library would be useful. The install command has no discretion left.

Two-panel comparison of installing from a pinned manifest with floating transitive resolution against installing from a hash-checked lockfile, showing a squatted package resolving in the first case and failing the hash check in the secondpins only — floating resolutionrequirements.txt · flask==3.0.2resolver asks the index"what satisfies these ranges today?"whatever exists at install timetransitives drift between March and Julya newly registered name resolves cleanlyno record to contradict itlockfile — hash-checked installrequirements.lock + --require-hashesinstaller verifies the recordevery name, version, and hashexactly the recorded closure, or nothingan unlisted name has no hash → install failsa re-published artifact fails the hash checkthe installer has no discretion leftRanges are a policy about the future. A lockfile is a record of the present — which is what makes it enforceable.
Figure 7.2 — Why pinning is not locking. A pinned manifest constrains direct dependencies and lets the transitive closure resolve fresh at every install, so a newly registered package can enter cleanly with nothing to contradict it. A hash-checked lockfile records the entire closure, so an unlisted name has no hash to satisfy and the install fails — which is the mechanism CP2 is made of.

Audit tooling and update discipline

Dependency audit tools match your resolved graph against databases of known-vulnerable versions. They are cheap, they belong in your required checks, and they have a coverage boundary that is routinely misunderstood.

An audit answers: does my graph contain a package-version known to be bad? A slopsquat is not known to be bad. It was registered three weeks ago, nobody has reported it, and there is no advisory to match against — the database describes the past, and this attack is designed to be new. So a green audit is compatible with having installed a malicious package this morning, which is a fact worth saying out loud on any team that treats the audit gate as "supply chain: handled."

Anti-pattern: the audit-clean fallacy

Symptom: "pip-audit is green, so we're fine on dependencies." Diagnosis: conflating two controls that refute different conjectures — audit refutes "we are running a known-vulnerable version," while CP1 and CP2 refute "something arrived that should never have been here." Neither implies the other. Corrective: state the three controls separately in your posture and check each: vetting for new names, deterministic hash-checked installs, and audit for known vulnerabilities. If you can only have two, keep CP2 and CP1 — audit catches what will eventually be caught anyway, while the other two catch what nothing else will.

Update pull requests are code changes with a blast radius, and the automation that opens them makes them feel like a chore rather than a decision. When a bot proposes bumping a package, what is actually being proposed is that a third party's new code gets your credentials on the next deploy. Apply module 2's protocol: the interesting artifact is the lockfile diff, not the version number in the manifest, because that is where new transitive packages appear — a patch bump on a direct dependency can introduce three packages you have never seen, and each is a new counterparty.

Anti-pattern: auto-merging dependency updates

Symptom: a merge rule that lands all dependency-update pull requests when CI is green, because "they're just bumps." Diagnosis: CI proves your tests still pass; it says nothing about what the new code does when your tests are not looking, and a green build is precisely what a well-built malicious release is designed to produce. Corrective: auto-merge is defensible for patch bumps of dependencies you have already vetted where the lockfile diff introduces no new packages — that is the condition doing the work. Anything that adds a name, changes a maintainer, or bumps a major version is a review, and the review is: read the lockfile diff, read the changelog, and check the release against the project's usual cadence.

A practical cadence that works: update on a schedule rather than continuously, so the changes arrive as a reviewable batch rather than as constant background noise you learn to click through; keep the audit gate required so known-vulnerable versions cannot linger; and treat any package whose maintainership changed since your last update as a new counterparty requiring the CP1 check, because a transfer of publishing rights is the most common way a trusted package becomes an untrusted one.

Module 08 Trusting the agent

The question everyone asks first — how much should I let the agent do? — has no answer in the abstract, and this module's argument is that it is the wrong question. Autonomy is not a property of the agent. It is a property of the verification machinery surrounding a particular class of task: how much you can safely delegate is a function of how much you can mechanically refute, and that is something you built in modules 3 through 7 rather than something you assess about a model.

Which turns an unanswerable question into an answerable one. Instead of "is this agent good enough," you ask: for this task class, what refutes a wrong answer, how expensive is a wrong answer that escapes, and how contained is the worst case? The rungs of the ladder are then defined by their prerequisites, the credential design assumes a bad day rather than a good one, and the deploy button becomes a policy question rather than a judgment call. We close with the consolidated anti-pattern catalog — the one artifact from this course worth printing.

The autonomy ladder

Five rungs, each a strictly larger grant of blast radius, each with a prerequisite that is not optional.

Rung 1 — autocomplete. The agent proposes; you accept keystroke by keystroke. Radius: nothing reaches disk without your hands. Prerequisite: none.

Rung 2 — diff for review. The agent produces a change set; you read it before it exists anywhere durable. Radius: your attention is the only control. Prerequisite: the module 2 protocol — without an ordering rule, more diffs simply means less attention per diff, and the rung silently degrades into rubber-stamping.

Rung 3 — branch with a pull request. The agent works independently and opens a PR. Radius: still bounded by review, but now the code exists, CI runs, and volume goes up. Prerequisite: the module 5 gate set actually required, because at this volume gates do the work review used to.

Rung 4 — merge on green. The agent merges when checks pass, without a human read. Radius: your main branch. Prerequisite: a suite with demonstrated refutation power — module 4's properties on the invariants and a mutation score you have actually measured. "CI is green" means nothing until you know what green can detect; merge-on-green with a 40% kill rate is merge-on-nothing.

Rung 5 — deploy on green. The agent ships to production. Radius: customers. Prerequisite: everything above, plus canary deployment with automatic rollback, plus the eval gates in section 4.

The load-bearing idea

Trust is granted per task class, never per agent. The same agent can sit at merge-on-green for a React component extraction and at diff-for-review for anything touching IAM, and that is not inconsistency — it is the ladder working correctly, because the two task classes have different blast radii and different refutation machinery. "The agent has been reliable lately" is a claim about one distribution of tasks; it transfers to another only if the verification does.

Autonomy ladder showing five ascending rungs from autocomplete to deploy-on-green, each annotated with the blast radius it grants and the verification machinery required before it may be granted1 · autocompleteradius: nothingprerequisite:none2 · diff for reviewradius: your attentionprerequisite:m2 review protocol3 · branch + PRradius: review volumeprerequisite:m5 required gates4 · merge on greenradius: main branchprerequisite:m4 measured kill rate5 · deploy on greenradius: customersprerequisite:eval gates + canary+ auto rollbackyou are here — for this task classEach rung is granted per task class, not per agent: the agent that earned refactors has earned nothing about IAM.
Figure 8.1 — Rungs and their prerequisites. Each step up grants a strictly larger blast radius and requires a specific piece of machinery from the preceding modules — the review protocol, then required gates, then a suite with a measured mutation score, then eval gates and automatic rollback. The marker is placed per task class rather than per agent, so one agent occupies several positions at once.

Plan-review-execute

Reviewing a plan is the cheapest review you will ever perform, and the leverage comes from a simple asymmetry: a plan is two hundred words and a wrong plan becomes four hundred lines. Intent divergence — the class module 2 established is human-only — is fully visible in the plan and expensively buried in the diff.

A reviewable plan is short and states four things: the files it intends to touch, the approach in a sentence or two, the invariants it will honor, and what it is explicitly not doing. That last item does most of the work. A plan that says "not modifying the authorization middleware, not changing the refunds schema" is falsifiable at execution time and tells you immediately when scope has drifted.

PLAN — fix the non-idempotent retry in POST /refunds

Touching:  support_api/refunds.py, support_api/processor.py,
           tests/test_refunds_property.py
Approach:  derive an idempotency key once per request from
           (agent_id, request_id), pass it on every retry attempt;
           processor client forwards it as Idempotency-Key.
Honors:    INV-1 (total refunded at the processor never exceeds
           the charge), INV-2 (audit row transactional with result).
Not doing: no change to the cap logic, the authorization checks,
           the refunds schema, or the retry count.

Sixty seconds to read. Compare the alternative — inferring that same intent from a diff, where an unrequested change to the cap logic looks exactly like a requested one, and where you would have to reconstruct "was this in scope" from a chat transcript.

Two gates make the loop work, and both are load-bearing. The approval gate sits after the plan and before execution; rejection loops back with an amended spec rather than a conversational correction, which is module 3's tightening loop embedded in the agent workflow. The CI rejection path catches what the plan review cannot: the agent submits, a gate fails, and the agent repairs against the failure output and resubmits — a loop that can run without you, because the gate's output is machine-readable and unambiguous. That second loop is the mechanism that makes higher rungs economical: the agent handles its own mechanical failures, and you see only what needs judgment.

Swimlane sequence diagram of a plan-review-execute agent loop with three lifelines — human, agent, and CI — showing a human approval gate after the plan with a rejection path returning an amended spec, sandboxed execution, and a CI rejection path where a failed static analysis gate is repaired by the agent and resubmitted before human mergehumanagentCItask + spec (criteria, invariants — m3)plan: files · approach · invariants · not doingAPPROVAL GATEreject → amended spec, re-planapproved — executeexecute in sandboxephemeral credentialsopens pull requestSAST gate fails — B608, exit 1repairs against the failure output, resubmitsall required checks greenMERGE — rung 3At rung 4 this second gate is removed; the CI loop above it is what makes that survivable.
Figure 8.2 — Two gates and one self-service loop. The approval gate sits between plan and execution, where correcting intent costs a sentence; rejection returns an amended spec rather than a chat correction, embedding module 3's tightening loop. The CI rejection path runs without a human because gate output is machine-readable, so the agent repairs its own mechanical failures. Removing the human merge gate to reach rung 4 is only safe because the loop above it does real refutation.

Sandboxes and credentials

An agent's blast radius is not a property of its instructions. It is the set of things reachable from inside its execution environment — and prompts do not constrain that set, because a prompt is a request and the environment is a fact.

This matters more for agents than for other software because of the input surface. An agent reads issue text, code comments, web pages, error messages, and dependency README files, and any of those can contain instructions. Prompt injection is not exotic; it is the normal consequence of a system that treats retrieved text as input. So the design question is not "how do we stop the agent from being tricked" — assume it will be — but "when it is, what can it reach?"

Anti-pattern: standing credentials

Symptom: the agent holds a developer's long-lived AWS access key, or a personal access token with repository-wide scope, "so it can fix anything." Diagnosis: the credential defines the worst case, and a long-lived broad credential means a single successful injection is an incident with an unbounded upper limit and no natural expiry. Corrective: short-lived, task-scoped credentials issued per run — a role assumed for this task, expiring in an hour, scoped to the repositories and resources this task class needs, with no production data plane access at all. The worst day then has a boundary you can describe in advance.

Sandbox design, concretely, for the Ledgerline agent: a container built from a pinned image with the locked dependency set already installed and no egress to the package index (module 7's CP2 made structural); network egress limited to an allow-list — the source host, the model API, nothing else; credentials issued per run with a one-hour expiry, scoped to the support-api repository and a non-production AWS account; no access to the production database URL, the processor API key, or any secret that is not required by the task class; and the filesystem confined to the checkout.

Note what the third item rules out, because it is the mistake people make after doing everything else right: putting the production database URL in the sandbox's environment defeats the sandbox entirely. The container is isolated; the credential in it is not. A sandbox is defined by what is reachable from inside, and an environment variable is very much inside.

Cross-reference — Guide Nº 05

Agent identity, delegation, and credential exchange get the full treatment in the agent authorization guide. The view worth carrying from this course is narrower and complementary: an agent's credential scope is a reviewable artifact with a blast radius, so you read it exactly as you read the IAM statement in module 6 — actions, resources, conditions, and what a wildcard in each position concedes. The fact that the principal is an agent rather than a Lambda changes nothing about how the statement is read.

Eval gates and the deploy button

Required checks verify the work. At higher rungs you also need to verify the worker, because the thing you granted autonomy to is not a fixed artifact: the model changes under you, your prompts and tool definitions change, and a system that was safe at rung 4 in March can be a different system in June with nothing in your repository having changed.

An eval-style gate is a golden-task suite the agent must pass to hold its rung. Concretely for Ledgerline: fifteen tasks drawn from real history, each with a known-good outcome and a scored check — three where the correct behavior is to refuse or escalate (a task requiring an IAM change while the agent sits at diff-for-review for that class), three seeded with the module 1 signatures to confirm the agent's own review catches them, and one containing an injection attempt in an issue comment. It runs on a schedule, on every model version change, and on every change to the agent's prompts or tools. A drop below threshold demotes the agent a rung automatically — which is the point, since the alternative is discovering the regression through its consequences.

Cross-reference — Guide Nº 03

Building evals well — task selection, scoring functions that resist gaming, avoiding contamination between eval and development sets — is the subject of the evals guide. What matters here is the placement: an eval gate is CI for the worker rather than for the work, it is the prerequisite for rungs 4 and 5, and it is the only control that notices when the thing you granted autonomy to has quietly changed.

Now the deploy button. The question "when may an agent deploy unattended" feels like a judgment about trust, and the answer is that it is a question about policy encoding. An agent may deploy when the pipeline encodes the entire deployment decision: required checks that refute the failure classes you care about, eval gates confirming the agent still holds its rung, a canary that exposes a fraction of traffic first, and automatic rollback on defined error and latency thresholds.

At that point notice what has happened to the button. If all of those conditions are encoded and enforced, a human pressing deploy adds nothing — they are not re-deriving the checks, they are observing that the checks passed. And if they are not all encoded, a human pressing deploy adds only their mood: their sense that today feels fine, which is exactly the miscalibrated instinct module 1 spent thirty minutes dismantling. The button was never load-bearing. The human mood was, and that is the thing to replace.

The honest exception

Some deployments should stay attended, and the criterion is not risk in general but irreversibility: schema migrations that drop columns, changes to money-movement paths, anything touching the audit trail, and infrastructure with the properties module 6 flagged as irreversible in practice. Those get a human not because a human verifies better, but because someone should be present and undistracted when a decision cannot be taken back — the same reason you would not automate a filing you cannot withdraw.

The anti-pattern catalog

Every anti-pattern named in this course, in one table. The organizing insight is in the last column: most of these are misallocations — work being done at a layer structurally incapable of doing it, usually by a person doing a machine's job or a machine being asked for judgment.

Anti-patternSymptomDiagnosisCorrectiveOwning layer
"It runs" = "it's right"The demo worked, so it shippedThe happy path exercises the code least likely to be wrongTreat a demo as evidence of syntax and wiring only; require refutation of a different kindTests
Skim-for-style reviewEleven naming comments and an approvalStyle is cheapest to detect and most satisfying to comment on; it consumes the attention the authZ zone neededAutomate style entirely; measure a review by zones visitedHuman review + format gate
Prompt-and-pray"It finally passed on the fourth regenerate"Each failure diagnosed a missing clause; all were discarded, and the suite silently became the specWrite the clause before the next prompt; regenerate against the amended specSpecification
Exception launderingBroad except that logs and returns a success-shaped valueConverts a loud failure into a quiet wrong answer and blinds runtime monitorsName the recovery for each caught exception; if you cannot, do not catch itHuman review
Test-shaped testsGreen suite, assertions restate their own stubsThe form of verification with none of the content; coverage rises, detection does notCheck whether the asserted value came from the test's own setup; mutation-test the suiteTests (mutation)
Trusting the model's own testsOne session wrote the code and its tests; all greenCode and tests share an author and therefore a set of misunderstandingsHuman-written invariants; mutation-test generated suites before trusting themTests (mutation)
Coverage as correctness100% coverage, recurring production defects in covered codeCoverage counts executions, not detectionsCoverage-delta as a floor; mutation score as the measureCI gates
Advisory decorationA linter has flagged the same pattern for months and nothing changedAn ignorable check is advice; overrides accumulate under deadlineMake it a required check; tune until findings are trustworthyCI gates
Flaky-gate erosion"Just re-run it, the suite is like that"The team learned red means re-run, and the lesson generalizes to gates that workQuarantine immediately with an owner and a date; never tolerate re-running to greenCI gates
Wildcard rationalizations3:* on * with "we'll narrow it later"Narrowing later has no forcing function, and the information needed to scope is maximal todayScope in this PR from the denial itself; prefer grants over hand-written statementsIaC review + policy scan
Audit-clean fallacy"pip-audit is green, supply chain is handled"Audit matches known-bad; a fresh slopsquat is unknown by constructionKeep all three controls: vetting, lockfile-only installs, and auditSupply chain
Standing credentialsThe agent holds long-lived broad keys "so it can fix anything"The credential defines the worst case; one injection becomes an unbounded incidentPer-run, task-scoped, short-lived credentials; no production data planeAgent design
The closing argument

Read the owning-layer column and the course's thesis is visible in one glance. Almost none of these are fixed by trying harder, and every one of them is fixed by moving work to the layer that can actually do it. That is the whole discipline: when the model writes the conjecture, your contribution is the refutation machinery — specified upstream, layered so each mechanism catches what the others structurally cannot, and allocated by blast radius, because attention is the one input that does not scale with the code.

Concept index

Conjecture and refutation
The Popperian frame: every diff is a claim of correctness, and verification is the machinery built to break it.
Hallucinated API
An invented method, parameter, or endpoint — loud when it crashes, dangerous when something swallows it silently.
Plausible-but-wrong
Logic that reads correctly and is not — the signature failure of code optimized to look right.
Security-naive default
The median public example's security posture, faithfully reproduced: string-built SQL, missing authorization, permissive CORS.
Phantom edge-case handling
Error handling in form only — the except block that converts failure into silence.
Test-shaped test
A test that asserts its own stubs — the form of verification with none of the content.
Verification stack
The layered refutation machinery — spec, static analysis, tests, CI gates, runtime monitors — each catching what others cannot.
Blast radius
What a change can reach and how irreversibly — the allocation variable for review attention.
Review protocol
The ordered first pass: authorization, money, data mutation, external calls, then everything else.
Acceptance criterion
A falsifiable, testable statement of done — one a stranger could implement against without asking.
Invariant
What must never happen, stated as a system property — the spec's negative space and the property test's source.
Spec-tightening loop
The generation loop where every failure becomes a permanent spec clause instead of a fresh dice roll.
Unreviewable diff
The product of a vague prompt: a diff where the reviewer cannot distinguish decisions from accidents.
Property-based testing
Asserting an invariant over generated inputs — coverage as a region of the input space, not points in it.
Generator
The machinery that produces a property test's inputs — including the fault schedules real systems experience.
Shrinking
The property framework's reduction of a failing case to its minimal form — the counterexample you can read.
Mutation testing
Seeding defects into the code to measure whether the suite notices — the test of the tests.
Mutation score
The kill rate over generated mutants — the honest measure of a suite's refutation power.
Coverage proxy
Treating line coverage as correctness evidence; it measures execution, not detection.
Verification independence
Tests must not share their basis with the code they check — a model grading its own homework fails where the homework does.
Required check
A CI gate that blocks merge — policy, where an ignorable check is only advice.
Static analysis (SAST)
Pattern-matching code for known-bad constructs — the layer that owns mechanical findings like string-built SQL.
Secrets scanning
The gate that catches credentials in diffs before the registry of record is git history.
Gate erosion
What a flaky required check trains: overrides as habit, until a real red stops no one.
Synthesized template
The CloudFormation a CDK app expands to — the promise you review, as opposed to the source's intent.
Least privilege
The IAM drafting rule: actions and resources scoped to what the feature demonstrably calls.
Removal policy
The setting that decides whether deleting a resource deletes its data — the irreversibility clause of an infra diff.
Cost surface
The resources in a diff with a monthly number attached — blast radius denominated in dollars.
Lockfile
The recorded resolution of the full dependency graph, hashes included — the contract of what actually runs.
Pinning
Fixing versions in the manifest — a policy about direct dependencies that says nothing about transitives until locked.
Slopsquatting
Registering packages under names models hallucinate, so the install itself is the compromise.
Dependency audit
Matching your graph against known-vulnerable versions — necessary, and blind to attacks not yet in the database.
Autonomy ladder
The rungs from autocomplete to deploy-on-green — each a blast-radius grant with a named verification prerequisite.
Plan-review-execute
The agent loop with approval after the plan — intent divergence caught at its cheapest point.
Ephemeral credentials
Short-lived, task-scoped access issued per run — the credential design that assumes a bad day.
Standing credentials
Long-lived, broad access held by an agent — the anti-pattern that converts one bad prompt into an incident.
Eval-style gate
A golden-task suite the agent must pass to hold its autonomy rung — CI for the worker, not just the work.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.