The Engineer + Agent Playbook
Part II — Working Together
§6 Memory hygiene
Part I was how to start the first conversation. Part II is how the partnership survives more than one. Memory is the first chapter because it's the persistence layer — what makes §5's loop stick from Tuesday to Thursday. Get it right and next week's agent shows up already knowing the scars. Get it wrong and every Monday is a cold start with confident wrong answers.
Rule: Memory has four types. Use the right one or it rots.
Why: User facts, feedback, project state, and reference pointers have different staleness profiles. Mixing them produces a junk drawer where nothing is trusted because some of it is always wrong. How to apply: User preferences → user memory. Corrections from past conversations → feedback memory. Sprint cycle, current version, live project state → project memory. Pointers to Linear, a retro, an external doc → reference memory. Write the type at the top of every memory file so the next reader knows what they're looking at.
The failure mode is one big memory file holding "the user prefers parallel agents" two lines above "current sprint cycle ID is 3827cdb9" two lines above "always run the notification sound after tasks." Three half-lives, one file, no cleanup schedule. The preference is good for years. The cycle ID is stale in six weeks. The sound-notification rule belongs in the harness config, not memory at all. When a file mixes all three, nobody prunes it — pruning means re-reading everything to decide what's still true, and nobody has the afternoon. Separate the types and each gets its own small, obvious pass.
Field note — case study 1: CLAUDE.md Gets a Haircut, the four-way sort. The 60% pruning pass turned out to be a sort operation in disguise: stable facts to topic files, live state to memory, procedures to skills, permissions to harness config. Every line in the old file belonged in exactly one of those four places. The reason the file had grown so large was that nobody had ever been forced to decide which one.
Rule: Stale memory is worse than no memory.
Why: An agent with no memory asks. An agent with stale memory acts — confidently, on last quarter's facts, with no tell that anything's wrong. The lookup the first agent would do is the lookup the second one skips. How to apply: When you read a memory line and it's wrong, fix or delete it in the same turn. Not "I'll clean that up later." Later is the bug. Budget a memory pass at the end of every release, with a bias toward deletion.
The Four Scoping Gaps story is the canonical version and it cost us rounds (see Part V). A memory line from the previous release said the release-automation tool handled downstream workflow triggering. By the next release we knew better — that tool plus the default workflow token doesn't trigger downstream workflows at all — but nobody had gone back to fix the note. A subagent picked up a release task, read the memory, acted on it, and we burned a round on "why didn't deploy fire?" before someone remembered the note was already wrong. No memory would have forced a fresh doc check. Stale memory hid the check behind a sentence that looked authoritative. Cheap to fix when you notice; expensive every round after.
Rule: A belief you've never probed is a hypothesis, not a memory.
Why: Stale memory (above) is a fact that was true and stopped. This failure is worse: a fact that was never true and got written down anyway, then repeated by every layer that reads it. Memory, the instructions file, and PR descriptions will all confidently restate a belief forever, because nothing in the loop ever tests beliefs — verification tests changes. Beliefs about infrastructure — branch protection, backup tiers, monitoring, permissions — are the most dangerous kind, because their whole job is to matter only on the day something else goes wrong. How to apply: For any memory line that asserts a protection or property of the world rather than a decision you made, attach the probe: the command whose output proves it. Run the probe when the line is written and again at audit time (§15). If a belief has no cheap probe, mark it explicitly as unverified — an honest "we think" outperforms a false "it is."
The Lock That Wasn't (Part V) is the scar. The fifth case study's instructions file, its auto-memory, and weeks of PR descriptions all stated that the main branch was protected. One API call — the first anyone had ever made — returned 404: Branch not protected. "Both were lying. Not maliciously. Just confidently. For weeks." The gate had appeared to be doing its job because no one had tested whether the gate existed; every un-reviewed commit that "couldn't happen" simply hadn't happened yet. The fix took two minutes. The lesson took a rule: auto-memory and instructions files are amplifiers — they repeat what they're given with perfect confidence, and only a command output ever breaks the loop. (The strange small joy from the retro: after the protection was actually enabled, "the auto-memory was not corrected. It is now correct without modification.") Pin: memory asserts; probes verify. A protection you've never tested is a sentence, not a protection.
Rule: Save the why, not just the rule.
Why: A year from now, "always use X" is unfollowable when X conflicts with the new architecture. "Always use X because Y bit us in v4.0" lets future-you decide whether the rule still applies or whether the world moved on. How to apply: Every feedback memory needs a Why line and a How to apply line. Always. If you can't write the why, you don't understand the rule well enough to save it — find the scar before you file the note.
Context-free rules become dogma. The Cache That Lied In CI (Part V) is the example. The rule "use .model_dump(mode='json') before caching Pydantic models" was written down; the why was not. Months later a new agent reached for json.dumps(model, default=str) because it looked equivalent — and the image-generation pipeline broke in CI while passing locally. With the why attached ("because default=str calls str() and produces a repr, not a dict"), the next person knows which footgun the rule is aimed at. Without it, you're carrying a superstition with no target.
Rule: Don't memorize what the code already says.
Why: File paths, function names, module boundaries, route registrations — all one grep away. Memory is for what's outside the repo: preferences, corrections, live state, cross-system gotchas. Duplicating what the code says is how memory silently disagrees with reality the moment someone renames a file.
How to apply: If you can find it with one grep, don't put it in memory. If it lives in git log, don't put it in memory. If a test asserts it, double-don't — the test is already the source of truth, and the memory line will drift out from under it.
The CLAUDE.md haircut is this rule enforced at scale. The file had grown to hundreds of lines restating what the code already made obvious: the directory tree, every endpoint path, which file held the admin router, how the audit middleware was wired. All of it was ls and grep away. We deleted it in one pass and the agent got better at finding things — because now when it needed a fact it read the code instead of trusting a note that had been quietly wrong for a month. Work got faster after the cut, not slower: a stale 900-line preamble is both less reliable and more expensive than an empty CLAUDE.md that forces the agent to read the code. Memory carries what the code can't tell you. Everything else is load the code can carry itself.
Rule: File your neighbor's scars, keyed to the trigger that will make them yours.
Why: When you run more than one project, the most valuable memory entries are often about bugs that haven't happened to you yet — they happened one repo over, and the only reason they'll happen to you later is a trigger you can name right now: a dependency bump, a platform migration, a copied config. A scar filed with its trigger converts a future debugging session into instant recognition. A scar filed without one is trivia.
How to apply: When a sibling project hits a bug whose cause you share (same dependency, same platform, same pattern), write the memory entry in your own project's terms: the trigger ("when we next bump X past version Y"), the symptom ("deploy 502s at import time"), and the cure (the polyfill, the pin, the flag). Cross-project memory is also how a fleet learns — the instructions files of new projects should inherit the scars of old ones, the same way their pipelines inherit the Dockerfiles.
The sixth case study is a fleet of small apps sharing a platform, and its retros show the mechanism firing in both directions inside one week. A sibling app's deploy 502'd because a newer client library demanded a WebSocket global that the platform's Node runtime didn't provide; the fleet's own lockfile happened to pin an older version, so the bug was theoretical — and it was filed anyway, keyed to the future dependency bump. Two days later a third app, pinning the newer version, hit it live, and the fix was retrieved rather than rediscovered: "the bug that was theoretical for us was load-bearing for them… we imported the cure with the disease." The same corpus shows the payoff of inherited scars: a platform's build-time env-var baking was "a trap we'd already been burned by once on the other project and got to recognize on sight this time." Recognition-on-sight is what a memory system is for — and it works across repo boundaries exactly as well as you file across them. Pin: a neighbor's bug plus a named trigger is your cheapest future fix. File it before it's yours.
§7 Skills as institutional knowledge
§6 was about pruning what you remember. This chapter is about what you shouldn't trust yourself to remember at all. A skill is a procedure with discipline: invoked on purpose, executed step by step. A CLAUDE.md note is a fact you hope gets followed. Only one of those is load-bearing when you're tired. Skills are followed; notes are remembered — and the second one is a lie.
Rule: Skills are procedures with discipline. CLAUDE.md notes are facts you hope get followed.
Why: Invoking a skill is a deliberate act — the agent opens it, reads the steps, runs them. Reading CLAUDE.md is passive ambient context, absorbed on cold start and then competing with everything else in the window. The difference is whether a step happens because the harness made it happen or because the agent felt like it this turn. How to apply: If a procedure has more than three steps and matters, write it as a skill. If it has three or fewer and matters, write it as a skill anyway. The threshold isn't length — it's whether you want it executed or merely recalled.
The tell is the CLAUDE.md paragraph that starts "remember to also…" The agent doesn't remember; it re-reads on cold start and decides, turn by turn, which lines are relevant. Anything that must happen reliably needs a host other than the agent's attention. Release flow is the canonical case: a CLAUDE.md line notes where version lives, and a release skill runs the sync script, drafts the retro, walks the deploy. Note is atmosphere; skill is action. When we confused the two in the Four Scoping Gaps release — treating "the release-automation tool handles downstream triggering" as a remembered fact rather than a procedure step — we burned a round on a Release PR that merged cleanly and triggered nothing. Pin: if you want it done, skill it. A note is optional.
Rule: Rigid skills exist for a reason. Don't adapt the discipline away.
Why: TDD, systematic debugging, brainstorming-before-planning — these skills are rigid because the failure mode they prevent is the failure to follow them. They feel like overkill in exactly the moments they're needed most, because the same thing that makes them feel unnecessary — "the problem looks simple" — is why you're about to skip them. Simple problems are where rigid skills earn their keep. How to apply: Follow rigid skills exactly, especially when you're sure you don't need to. Six steps means run six. If you genuinely skip one, skip it out loud — same rule as §5's loop.
The Four Scoping Gaps release is the version of this lesson I'd like to forget (see Part V). The brainstorm skill would have forced us to ask what exactly does this tool read, and from where? before we wrote a line of config. We skipped it because the fix was "obvious": add one config line, flip a checkbox, done. It turned out to be four fixes in a trench coat. A fifteen-minute brainstorm catches all four. We skipped it because we were sure we didn't need to. Pin: when the rigid skill feels like overkill, run it twice.
Rule: Write a skill after the third correction.
Why: The first correction is a one-off. The second is a pattern. The third is the moment "I keep saying this" becomes "the system should enforce this." Promoting earlier wastes a skill slot on a fluke; promoting later means you've spent a week re-typing the same sentence. Three is the elbow of the curve. How to apply: Track your corrections. When you catch yourself writing the same nudge a third time, promote it — not to a CLAUDE.md paragraph, which is back to passive notes, but to an actual skill or a harness hook. The goal is to stop needing the correction at all.
Trust Your Local Tests (§12, Part V) is this rule made visible. Three releases of typing the same correction into fresh subagent conversations — "Postgres only, no SQLite branches, trust your local tests" — until the fix stopped being a prompt and became a piece of infrastructure: the SQLite branch ripped out of the bootstrap, fixtures wired to a shared Postgres engine with per-test truncation, the agreement baked into the code. The correction became structurally unnecessary. Third time you correct the agent the same way, the system should change, not the prompt. Pin: count your corrections. Three is the promotion line.
Counter-case — when not to promote. Promotion has a cost: a shared abstraction is code that every consumer becomes coupled to, and abstractions over code that isn't actually duplicated yet are worse than the duplication they claim to fix. The second case study hit this explicitly. When a later release added a second consumer of a binary-encoding helper, the plan called for ~200 lines of helpers copy-pasted from the existing caller — because the only other caller was one directory over, the helpers were small and stable, and a shared package would have forced both consumers into the same abstraction at exactly the moment the second one was still finding its shape. The retro named it directly: "decided the duplication was less disruptive than the abstraction. Ask us again in a year."
The fourth case study has both halves of this story, paired across two releases. Extract worked when the pattern was stable: one release had 825 lines of duplicated edge function boilerplate across 15 functions — CORS handling, auth checks, rate limiting, error shaping, response formatting, all repeated 15 times with subtle differences. The pattern was stable across all 15 callers; nobody was inventing new variants. Extracting it into a 139-line shared handler pipeline was a clean win and immediately caught two functions that had silently omitted a step the pattern guaranteed. Failure-to-extract bit them when the duplicates drifted: the same project let a small naivePlural() helper get duplicated across three different edge functions instead of extracting it. By the next release, one of the three copies had incomplete irregular-noun handling — missing tooth, goose, child, person — so the same input produced different outputs depending on which function rendered it. The user-visible bug was "1 mouse" rendering as "1 mice" on one path and "1 mouses" on another; the root cause was three copies that had drifted apart while nobody was looking.
The rule for promotion isn't "count to three and extract" — it's "count to three and then check whether the abstraction is cheaper than the duplication for the shape the code is in right now, and whether the duplicates will drift if you don't." Sometimes the third correction is a skill. Sometimes the third copy is still cheaper than the shared thing. Sometimes the third copy is already drifting and you should have extracted at the second. The test has two parts: (1) is the duplicated logic still finding its shape (duplicate is fine) or has it stabilized across callers (extract); and (2) are the copies drifting silently from each other (extract regardless of stability — drift is the more dangerous failure). Pin: promotion is a bet that the pattern has stabilized. Don't make the bet until it has — but don't refuse the bet so long that the duplicates start lying to each other.
Rule: Ship the cheap defense now; schedule the clean refactor for a quiet phase.
Why: When you find a drift-prone seam — two representations of the same thing that must be updated in lockstep, a convention only vigilance enforces — there are two honest fixes and one dishonest non-fix. The clean structural fix is right but expensive, and doing it while another task is urgent means doing it badly. Ignoring the seam is free today and a production bug later. The move the retros validate is the third: an eight-line warning comment or guard now, and the structural fix scheduled — actually scheduled — for the next quiet phase. How to apply: When you spot the seam, ship the cheapest thing that will stop the next person (human or agent) from stepping in it: a loud comment at both sites naming the coupling, a lint rule, an assertion. File the structural fix with enough context to execute cold. Then, in the next maintenance-themed cycle, actually do it. The cheap defense is a bridge, not a destination — the failure mode is letting the comment become the permanent fix.
The fifth case study carried a snapshot whose columns were dual-maintained in a TypeScript interface and a ten-parameter SQL function — add a column, update both, or ship silent data loss. Mid-release, with other work urgent, the fix was a warning comment at both sites. Two releases later, in a deliberately boring maintenance cycle, the ten parameters became two jsonb payloads and the drift class was eliminated outright. The retro's summary is the rule: "The right time to do the cleaner refactor was when we had a quiet afternoon, not when adding a snapshot column was already the urgent task. The cheap defense bought us the right time." Corollary — rigor budgets are per-audience. The same project's debug tooling deliberately took shortcuts its user-facing features couldn't (approximating historical state instead of replaying it), with the shortcut documented at the call site: "Debug-mode features get to be lighter than user-facing features for exactly this reason. Not because they're sloppy. Because their audience can read the source." Spend the rigor where the audience can't. Pin: the cheap defense buys time; the quiet phase spends it. Skipping either half is how seams become incidents.
§8 Verification before completion
If you take one chapter of this playbook seriously, take this one. Verification is the load-bearing chapter — every other discipline in Part II points back here, because every other discipline fails the same way when verification is sloppy: confidently, in production. The rules are dry; the consequences are not.
Rule: "Tests pass" is not "feature works." Verify the feature.
Why: Tests verify the code under test, not the user-visible behavior of the system. A suite can be 100% green while the feature is broken because no test exercised the wire between the parts. How to apply: For UI work, open a browser. For deploy work, hit the live endpoint. For data work, query the data. For API work, read the response body, not just the status code. If "done" lives behind a screen, look at the screen.
The Doorbell That Never Rang (Part V) is the cleanest version of this rule. Audit logging shipped — backend green, frontend green, beautiful admin table rendering zero rows. No service call anywhere in the admin routes actually emitted an audit event; both halves tested in isolation, nobody wired the doorbell to the button. A thirty-second click in a real browser would have caught it. Pin: if you didn't open the thing and use it, you didn't verify it.
Two companion scars, each the same rule from a different angle. The Comparison That Quantum-Superposed Into Nonexistence (Part V) is the Doorbell inverted: the event fired perfectly, into a UI that a stale-cache race condition in a sibling component kept wrapped in {!isLoading && …} forever — bell rang, room nobody could see into. The bufconn Gap (Part V, second case study) is the same failure at the network layer: an adapter with full in-memory test coverage passed every test and had no discovery wire to find from a real client. A green test suite tells you the parts work. Only a real session in the real environment tells you the user can use the thing. Two mechanisms, one rule, two more reasons to verify where it matters.
Second-edition clause: check what the assertion would tolerate. A test can be green, correctly written, and still guarding a weaker property than the one you need. The first case study's v6.0 work needed reference identity on unchanged objects (so a memoized UI wouldn't re-render); the existing tests asserted with value equality on spread copies — toEqual where the invariant was toBe. Every test passed; the invariant was unguarded; three characters fixed it. Before trusting a green test, ask what change it would fail to catch. A test that tolerates the bug you're worried about is a smaller version of the wrong-claim theater above.
Rule: Ask: would these tests pass if the change were reverted?
Why: This one question is the sharpest instrument yet found for separating verification from verification theater. A verification that would pass anyway — because it exercises a path the change doesn't touch, or tests through a layer that bypasses the one being fixed — proves nothing about the change, no matter how green it is. The question converts "we have tests" into "these tests witness this fix," and it takes ten seconds to ask. How to apply: At review time, for every test attached to a change, ask the question out loud. If the answer is "yes, they'd still pass," the test is pinning something adjacent — keep it if it's honest about what it pins (rename it so it stops impersonating end-to-end coverage), and add a verification that actually witnesses the change, in the layer the change lives in. Reviewers of delegated work (§13) should ask it as a standing check.
The first case study coined the question during the crawler-routing fix. The E2E tests for the restored social-preview routing all passed — by hitting the backend endpoint directly and the dev server directly, bypassing nginx entirely. Nginx was the layer being fixed. Reverted, every test would still have been green. The reviewer's one question exposed it; the retro called it "the sharpest tool we have for distinguishing verification from verification theater." The remedy was two-part honesty: the test file was renamed to say what it actually pins (the endpoint contract), and the real verification — does the routing route? — moved to the post-deploy smoke test, the only place that exercises the layer in question. Pin: a test that would pass with the change reverted is not a test of the change.
Rule: Evidence before assertions. Always.
Why: An agent that confidently says "the build passes" without showing the output is the agent you cannot trust. Verification is a habit, not a claim. Accept "I ran the tests and they passed" as evidence and you've trained the agent that prose is sufficient — and prose is what hallucinations look like.
How to apply: Never claim success without producing the verification command and its actual output, pasted in. pytest plus the green dots is evidence. "Tests pass" is not. If the agent says "deploy succeeded," the next sentence had better be a curl against the live URL with the response body attached. Sharpening: evidence of the wrong claim is still theater. A green check is only evidence for the specific claim it tests. Before accepting any successful output, name the claim the evidence actually supports. "180 unit tests passed" is evidence that 180 unit tests passed — it is not evidence that the feature works, the component renders, the cache-hit path fires, the touch event reaches the handler, or any of the other claims the green check might be mistaken for. The third case study's v7.0.1 hotfix is the canonical example: a data-layer migration shipped with a full green suite, and the failure was in the cache-hit path no test exercised. The tests weren't lying; the humans were reading them as answering a question they had never been asked. Evidence that answers the wrong question is not weaker evidence — it's theater, and it's dangerous precisely because it looks authoritative.
Verify What You Shipped, Not What You Built (Part V) is the canonical scar: deploy script reported success, new container healthy, every status check green — and the live site still serving the old version because nginx had never flipped. "I started the container" had become a stand-in for "reachable from the internet." The fix was one curl against the public URL with the response body compared to the SHA we'd just built. Not SSH exit codes. Not unit status. The user-facing URL. Pin: the verification command and its output, pasted, or it didn't happen.
Rule: Verify in the environment that matters.
Why: Local-passing + CI-failing is the single most expensive failure mode in this project's history. Cache backends differ. Databases differ. Node versions differ. Serialization paths differ. "Local green" only proves the code works against the specific lies your laptop tells. How to apply: If the change touches anything that runs in CI or production, run it there before claiming done. If you can't reproduce CI's environment locally, that's the bug — fix the parity gap, then verify. Cross-reference: this is the same lesson the DevOps playbook pins as "dev == prod." Two playbooks, one rule, and the overlap isn't an accident — parity between environments is the substrate that makes verification mean anything. If you have the DevOps discipline, this rule is cheap. If you don't, this rule is where you find out.
The Cache That Lied In CI (Part V) is the worst version we shipped. Cache layer refactored, Pydantic models flowing through Redis, someone wrote json.dumps(model, default=str) — green locally, image pipeline detonated in CI. Local was green because local used an in-memory cache with no serialization path at all; CI used real Redis. The serialization step that didn't exist locally was the entire bug. The fix is model_dump(mode="json"); the rule is bigger — cache changes pass locally and fail in CI until the parity gap closes. Pin: if your local doesn't run what CI runs, your local green is a lie.
Three companion parity-gap scars tell the same rule in different substrates. The Docker Port Mappings That Weren't (Part V, second case study): 180+ passing tests, zero data races, four clean releases, and a container that had been unreachable from outside every one of them because the Makefile mapped host ports to privileged container ports the non-root process couldn't bind. CI validated the image built; nothing validated it answered. Two Hotfixes In One Release (Part V, third case study): a TanStack Query migration shipped with 161 unit tests green and broke cache-hit paint within 24 hours (a side effect inside queryFn never ran on cached data), and its stablemate — a touch-vs-mouse guard tested only on desktop — silently blocked single taps on iPad Safari. Both slipped a full green suite because no test exercised the interaction pipeline on the real substrate. Four projects, four substrates, one rule: when you're swapping a foundation layer, the test gap is always on the side you weren't looking at.
Second-edition clauses. Test with the client that doesn't send the convenient headers. The first case study's perimeter middleware had four authorization checks that were secretly sequential — three dead, and the survivor keyed on a header that browsers send on every request and native mobile clients never send. The bug slept for three major versions and 311 test runs because every test client was the polite one. When an endpoint serves more than one kind of client, verify with the rudest client you support. Verify what was fetched, not what rendered. When the fifth case study removed a third-party font dependency for privacy, the page rendering correctly proved nothing — a page can render beautifully while still leaking requests. The verification that counted was the browser's resource-timing log showing zero requests to the third-party host. For any "we no longer talk to X" claim, the evidence is the network log, not the screenshot.
Rule: When a value crosses a layer boundary, verify the wire, not the fixture.
Why: Some delivery properties are structurally invisible to unit tests — not undertested, unverifiable at that layer. A test fixture that reads log records before the formatter runs cannot see what the formatter discards. Code review of application code cannot see that the production compose file never forwards the env var. For any value that crosses a boundary — middleware to formatter, env file to orchestrator to container, service to sidecar — a green unit suite is necessary and cannot be sufficient, because the failure lives between the layers, where no unit stands. How to apply: When a feature's value crosses a layer boundary, the plan must include a wire-level integration task as a first-class step — run the real stack, capture the actual emitted artifact (the log line as written, the response as served, the env as seen inside the container), and compare it to the claim. This is not extra credit and not "more discipline"; it is the only layer that can answer the question the feature was built to answer.
The first case study's consumer-tier release is the crown jewel. Forty of forty tests green, and the mandatory manual-integration task found two production-killers in an afternoon. First: the production compose file explicitly allowlists which env vars reach each container, and the new CONSUMER_API_KEYS wasn't on the list — the feature was "entirely correct in code review and entirely broken in production," because the break lived in a file no unit test loads. Second: the logging pipeline's formatter had been silently discarding every structured extra={...} payload since the day it was configured — and the test suite's log-capture fixture passed because it reads records before any handler runs. The retro's rule, verbatim: "When testing whether a feature delivers, not whether it runs, look at the wire output, not the test fixture." Pin: unit tests verify the contract at the unit's edge. Delivery happens past the edge — go look at the wire.
Rule: Health checks must check health — and observability must be observed.
Why: A 200 from /health doesn't mean the database is reachable — it means nginx is awake. A health check that can't detect a downed dependency is a status check cosplaying as a health check, and the worst thing it can do is succeed during an outage.
How to apply: Health checks must verify the actual dependencies — DB ping, Redis ping, downstream reachability — and return them in the response body, with the build SHA, so verifiers can confirm they hit the right endpoint and the right version. If it can't tell "healthy" from "half the stack is on fire but uvicorn is still up," it isn't one. (See DevOps Playbook Phase 7.1 and Gotcha #8 for the /health vs /api/v1/health split and the verification pattern that catches this.)
The Health Check That Wasn't — told in full in Part V — is the canonical scar for this rule and probably for the whole chapter: three deploys chasing a missing git_sha through every Docker mechanism the team had, because the verification curl was hitting /health (a two-line status check with no git_sha field) instead of /api/v1/health (the full health endpoint). The producer was never broken. The verifier was looking through the wrong window. The lesson lives in two rules at once: health checks must actually report dependencies, and verification curls must hit the endpoint that reports them. Pin: if your health check can't say what's broken, it can't say anything's healthy.
Second-edition clause: the monitoring is part of the system — verify it observes. The instruments you'd use to notice an outage can themselves be silently down, and nothing downstream of a dead instrument ever complains. The first case study discovered its frontend error tracker had been a no-op for five releases: a security release had tightened the Content-Security-Policy to connect-src 'self', which blocked the tracker's own reporting endpoint — the release that hardened the app disarmed its smoke alarm. ("We have not yet decided whether this is hilarious or appalling.") The check is cheap and almost never run: trigger a deliberate test error and confirm it arrives; confirm the metrics dashboard shows the deploy you just did. Put it in the post-deploy checklist next to the health curl. Pin: an unobserved observer is a prop. Fire a test flare through it now and then.
§9 Trust boundaries
Verification (§8) tells you the work is correct. Trust boundaries tell you whether the agent had the authority to do the work at all. This chapter is the political layer on top of the technical one. Get the technical layer right and the political one wrong and you ship a verified force-push to main.
Rule: Match the action to its blast radius. Confirm before crossing the line.
Why: A local file edit is reversible. A force-push is not. Treat both the same and you'll eventually force-push something that should have been a file edit. The agent doesn't intuit blast radius — it sees both as "a tool call I'm allowed to make." That intuition is the engineer's job. How to apply: Local and reversible — let the agent run. Shared state, hard to reverse, visible to other people — confirm first. The test is recovery time: if this is wrong, how long to undo it? Seconds, fine. Hours of someone else's work, ask first.
The cost of an action is not visible in its syntax. git commit and git push --force-with-lease origin main are two characters apart and four orders of magnitude apart. The Four-Fix WCAG Contrast Cycle is the calibrated version of this (full story in Part V) — four consecutive contrast violations across two releases, each fix a Tailwind class swap of one shade. Reversible, local, tiny blast radius. The right move was to let the agent change the colors, run the axe-core suite, and confirm by evidence (§8) rather than by approval — the verification step did the blessing. A deploy in the same session gets a manual confirmation, because the recovery time is "how long until rollback finishes," not "Cmd-Z." Different blast radius, different boundary. Pin: blast radius is set by recovery time, not by command length.
Field note — case study 1: The fourth contrast fix in two releases. Four WCAG contrast violations across two releases, each a one-class utility color swap caught by axe-core on the next run. Reversible, local, tiny blast radius. The right move was to stop adding approval ceremony to the fixes and let the automated rail do the blessing — the rail was doing its job; humans were the slow learners.
Second-edition clause: reversibility has a time axis — bake in the hard-to-undo levers. Recovery time isn't binary; some actions are undoable in principle and effectively permanent in practice, because the undo propagates through caches, browsers, or third-party lists on a schedule you don't control. For those, deploy the conservative setting first and calendar the escalation. The fifth case study's HSTS rollout is the pattern: preload is close to irreversible (browsers ship the list), so the release set max-age to one day, opened a ticket, and put the bump to a year plus preload on the calendar after a bake-in week. The retro's line: "the most dangerous lever is the one that's hard to put back." Anything with that shape — DNS TTLs, permanent redirects, published package names, retention policies — gets the same treatment: smallest committing step first, escalation scheduled, never both at once.
Rule: Authorization is scoped, not blanket.
Why: "Yes, push" once does not mean "yes, push" forever. When a user approves a destructive action, they are approving that specific action, not handing out a permission slip for the next one. An agent that generalizes a single yes into a standing yes will eventually cross a line nobody told it not to cross.
How to apply: Every risky action is its own decision unless durable instructions — CLAUDE.md, settings.json permissions, an explicit skill — say otherwise. If in doubt, ask. The cost of asking is one round-trip. The cost of not asking is a retro chapter.
The interesting code, as the Admin Who Couldn't Fire Herself release put it (full story in Part V), is in the denial path. RBAC went in for admins, and the rules that needed care weren't "an admin can edit a record" — those were one-liners. The careful rules were "no self-role-change, no self-deactivation, no self-deletion, no escalation via update." Half a dozen explicit refusals, because trust granted in one direction (you are an admin) does not generalize in every direction (therefore you can fire yourself). Scoped authorization for agents works the same way. You approved a git commit; that does not mean the next git push --force is pre-approved. Where the harness can express "this tool, this scope," use it — harness permissions are the durable form of "yes, but only this." Everything else is a per-action decision. Pin: every yes is for one action. The next risky action is its own conversation.
Field note — case study 1: The Admin Who Couldn't Fire Herself. RBAC landed for the admin panel and the one-liners were all in the "yes" paths. The careful rules were in the refusals — no self-role-change, no self-deactivation, no self-deletion, no escalation via update. Half a dozen explicit denials, each written on purpose, because trust in one direction doesn't generalize in every direction. A later release added one more the first pass missed: the last superadmin can't fire themselves either.
The corollary is The Default That Was Admin (Part V, case study 4): a shared edge-function handler signature that defaulted to admin when the client wasn't specified silently elevated every new endpoint written after the refactor. The fix was one line — change the default to anon. The broader lesson: the default direction matters as much as the authorization rule. The unsafe default is the one everyone who didn't read the docs will end up using — including agents, whose default behavior is "use the API the way the type signature suggests." Defaults must be the least-privilege option, every time. Pin: make the safe choice the default; require explicit opt-in for elevation.
Rule: When the agent hits an obstacle, it must investigate, not delete.
Why: Unexpected files, branches, lock files, and orphaned containers usually represent in-progress work — from a teammate, from an earlier you, from a process that hasn't finished. Deleting them to make the error go away is how you lose a day of uncommitted work nobody can reconstruct.
How to apply: Investigate root causes. git reset --hard, rm -rf, --no-verify, git clean -fd, "let me just drop the database" — last resorts, not first resorts. If the agent cannot tell you why deleting the thing is safe, it isn't.
The Deleting the Ghost near-miss was a deletion that almost shipped (full story implied across Part V). The release was cleaning up a legacy deploy script — replaced by the new blue-green version, no callers in the codebase, no references in any workflow, every grep clean. By every test the agent could run, the file was dead code. The code-quality reviewer caught it: the operations runbook still pointed at the old script as the manual recovery path during a rollback. The reference wasn't in any source file or workflow — it was in a Markdown runbook nobody had thought to grep. One careless deletion would have left the on-call playbook pointing at a ghost. The rule generalized: never delete a file without grepping for its name across docs and runbooks first, and when the agent says "this is safe to delete," the right response is "show me where you looked." Pin: if the agent can't show you where it looked, it didn't look.
Field note — case study 1: Deleting the Ghost. A legacy deploy script was scheduled for deletion. Every grep of the source tree and workflows came back clean. A code-quality reviewer noticed the operations runbook still pointed at the old script as the manual recovery path during a rollback — a reference that lived in a Markdown file nobody had thought to search. Deleting it would have left the on-call playbook pointing at a file that no longer existed. Grep your docs before you delete anything.
Second-edition clause: investigation depth follows blast radius too. "Investigate, not delete" is about destructive shortcuts, and it stands. But its mirror image also needs saying: not every mystery deserves a full excavation. When the fifth case study's test runner wedged inexplicably, the team adopted a config that worked, banked the ninety minutes, and left "a TODO-shaped observation" rather than burning the session on a full diagnosis — because the blast radius of the unexplained behavior was zero once the workaround held. The discriminator is the same one as always: what breaks, and for how long, if the mystery stays unsolved? A phantom in shared state or a security surface gets the full §10 treatment. A tooling quirk with a working configuration gets a note and a ticket. Spending craft attention on zero-blast-radius mysteries is the human doing the laborer's job in disguise.
Rule: Secrets never transit the transcript.
Why: An agent session is a document. It gets logged, persisted, summarized, sometimes shared, sometimes fed to other tools — and every credential that was ever printed into it is now in it, wherever it goes. A secret that passes through the transcript on its way to the secrets store has been copied to a place with none of the store's protections and all of the store's value.
How to apply: When an agent session must move credentials — deploy keys, service-role tokens, API keys — pipe them: from the secret manager's CLI straight into the target (vault read … | target-cli set …), through shell variables, never echoed, never pasted into the conversation. If a secret does transit the transcript, treat it as exposed: rotate it, don't rationalize it. Verify properties of secrets (length, prefix, checksum) rather than values. And build the habit before the incident: the goal state is a deploy session whose retro can say "zero secrets printed."
The sixth case study learned this in two consecutive deploys. The first moved a service-role key into the host's env config by way of the conversation — it worked, and the retro's last line item was the rotation that followed, because worked and safe are different claims. The second deploy was run under the corrected discipline — every secret piped from the platform CLI into shell variables, never printed — and its retro logged the number that matters: "0 secrets printed to the transcript this time." The same sessions produced the companion lesson (§2): when a sourced secret is rejected downstream, check its length before doubting your code — one platform CLI returned a 20-character slice of a 64-character secret, and the rejection was the system working. Pin: the transcript is a copy of everything that touches it. Don't let secrets touch it.
Rule: Before touching a non-negotiable, write down what would change your mind.
Why: Every project has load-bearing commitments — "no AI in the response path," "anonymous by design," "zero runtime dependencies." Sooner or later a plausible proposal will arrive to revisit one, and the two cheap responses are both wrong: reflexive refusal (the commitment becomes dogma nobody can re-examine) and casual revisiting (the commitment erodes through a series of reasonable-sounding exceptions). The disciplined move is a pre-registered experiment: define, before gathering any evidence, exactly what result would justify the change — plus the full cost accounting the proposal's framing conveniently omits. How to apply: File the challenge as a research ticket, not a change ticket. In it: the success bar stated as measurable thresholds, the costs that don't show up in the benchmark (privacy posture, determinism, the marketing copy that made the commitment a promise), and an explicit "we don't know the answer yet." Then run the experiment on its own schedule, not the release's. If the bar is met, the change earns a real conversation. If nobody can state a bar, that's the answer: the proposal is a mood, not a case.
The fifth case study's classifier is deterministic on purpose — "nothing you write is ever sent to a model" is in the product's public copy. When "would a small LLM classify better than the regex?" inevitably surfaced, the team neither refused nor complied. They filed it with a pre-declared bar (≥95% register agreement and a lower unclassified rate and free-tier resource fit), itemized the costs the benchmark can't see, and closed with the sentence that makes the whole move: "We don't know the answer. We have written down what would change our mind." The commitment stayed intact, the question stayed askable, and no one had to win an argument to keep it that way. Pin: a non-negotiable survives scrutiny by pre-registering the scrutiny — name the bar before you look at the data.