WritingAI and agents
AI Agents in the SDLC: What Changes Beyond Code Generation
AI agents do not remove the software development lifecycle. They move its bottlenecks into intent, context, verification, and controlled release.
18 min readSeries 4 of 8
- 01AI Agents Need Architectural Boundaries, Not Just Prompts
- 02LLM Wiki for Software Teams: Two Problems AI Agents Can't Fix Themselves
- 03Keeping Spring Boot Delivery Aligned with Product Context and Engineering Standards
- 04AI Agents in the SDLC: What Changes Beyond Code Generation
- 05What a Passing Test or Eval Tells You About an Agent-Written Change
- 06Agents Can Open the Pull Request, but They Cannot Sign the Release
- 07How to Tell Whether the Agent Workflow Improved Delivery
- 08Agent-Written Prose Has No Failing Test

This series follows one made-up product: a research desk app. For each question, a research agent gathers notes from an approved source pool, an advocate agent and a skeptic agent argue from those notes, and a moderator agent writes a memo. A researcher reads the memo, makes the final call, and approves it for clients. A small team builds the app: a product manager, a tech lead, engineers, a security reviewer, and a coding agent that writes much of the code.
The product manager writes a ticket: "Let researchers publish the debate memo to clients faster: one click from the memo screen." The coding agent adds a POST /memos/{id}/publish endpoint, writes tests, and opens a small, tidy pull request. The tests pass. The reviewer approves, because nothing in the diff is wrong. It merges, and the next morning memo M-104 goes out to clients.
M-104 was still a draft. Two of its claims, c3 and c4, pointed at no research note at all. Nobody had signed it off. The desk had a rule for this, R1: every claim in a published memo traces to a sourced note, and a named researcher approves the exact memo before it reaches clients. The rule existed elsewhere in the repo. The old export flow called a PublishingPolicy module that enforced it. But the rule was missing from the ticket and from the new endpoint's checks. The new endpoint called the delivery adapter directly.
The incident is illustrative, and the series follows it from ticket to dashboard. Each part looks at the failure as it happened, before any of the controls described here, and then adds one more safeguard. I keep coming back to this shape because I think it is the failure teams underestimate when they bring coding agents in: correct work for a constraint that never arrived.
A better model would not have caught it
A stronger model, a longer prompt or more tests written from the same ticket do not fix this on their own, because the missing fact was not in the context the agent was given. The agent did not misread the ticket. It read an incomplete ticket very well, and the tests it wrote checked the code against that reading: the endpoint returns 200, the memo renders.
What the agent changed is speed. Before agents, the same ticket went to an engineer who might remember the old export, or might not. There was a pause where a person could notice. An agent can compress that pause unless the workflow keeps it on purpose. It produces coherent, passing, reviewable work faster than the process can supply the intent behind it.
If you count agent adoption in merged pull requests, this change looks like a win.
Four questions at every handoff
The model I use, and the one I have been writing down as a standard for coding agents in my own work, asks four questions every time work moves from one stage to the next:
- What may the agent do here?
- What artifact stays behind when this step is done?
- Who decides whether the work moves on?
- What evidence has to exist before it may?
An engineer who can answer those for the change in front of them knows what to write, what the agent may touch, which check must run, and who signs. A manager who can answer them knows who is accountable and where to escalate. Your organization's security policy and release process still win where they disagree with any of this.
Read it top to bottom. The agent's work sits between things people own. A rejected release goes back to the earliest step that failed, and a production signal becomes the next intent.
Most changes need almost none of this
A ten-row process for every change would be ceremony, and teams quietly stop following a standard that feels like ceremony. So the first thing I write down is a tier matrix. Ceremony rises with two things: how open-ended the agent's reasoning is, and how expensive a plausible wrong result would be.
Tier | Typical change | Agent may | Must exist before merge | Approvals |
|---|---|---|---|---|
0 | Typo, log text, docs | Edit, open a PR | Green CI | 1 engineer |
1 | Patch bump inside the dependency policy, owned config | Edit, open a PR | Green CI, one-line plan in the PR | 1 code owner |
2 | Behaviour change that a flag or redeploy can undo | Edit only the surfaces in the plan | Intent link, criteria, plan, tests mapped to criteria | Code owner and product manager |
3 | Publishing to clients, personal data, permissions, the delivery controls themselves (tier map, pipelines, eval cases, code owners, agent instructions), anything that cannot be undone once it happens | Draft a plan and code on its own branch; it cannot merge | All of tier 2, every constraint owned, security review, reversibility class, rollback reference | Code owner, product manager, security reviewer, named release authority |
Four rules make the matrix work. A change takes the highest tier of any path it touches, or of any behaviour it causes, whichever is higher. Anyone may raise a tier, only the path owner may lower it. The agent can never lower its own tier. And CI reads the tier map from the target branch, not from the pull request, so a change cannot reclassify itself.
The tier map lives in the repository, so CI and the agent read the same file:
# tiers.yaml: read by CI (from the target branch) and by the agent instructions
default: 2
paths:
"src/publishing/**": 3
"src/memos/**": 3
"src/**/policy/**": 3
# the controls that guard every other change
"tiers.yaml": 3
"azure-pipelines*.yml": 3
"evals/**": 3
"fixtures/**": 3
"agent-instructions.md": 3
"skeptic-instructions.md": 3
"docs/**": 0
"docs/policy/**": 3
tier_3_events: [memo_published, client_notified, role_granted]Here is where the original change slipped. The new endpoint lived in src/api/memos.py, a tier 2 path, and it never touched src/publishing/. Paths cannot see behaviour. So the change request carries one required line, "Causes a tier-3 event? yes/no". The author answers it, the product manager confirms it, and a "yes" makes the change tier 3 whatever paths it touches. Publishing a memo to clients is memo_published. That one line would have forced the questions nobody asked. After the incident, the team also moved src/api/** to tier 3, so the place where the bypass was added is covered by path as well as by behaviour.
Where the constraint has to enter
The cheapest place to catch the missing rule is the first handoff, the intent. The agent may draft an intent record from the ticket, ask the questions the ticket does not answer, and list what it could not resolve. It may not confirm any of it. The product manager corrects the record, and every constraint gets an owner beside it.
# Intent: faster memo publishing
Owner: product manager, research desk
Why: approved memos wait hours for a manual export, and researchers chase it
Wanted: a researcher publishes an already-approved memo to clients in one click
Constraints:
- R1a (security reviewer enforces): every claim in a published memo traces to
a sourced note.
- R1b (researcher approves): a named researcher signs off the exact memo
snapshot before it reaches clients. No agent can create that sign-off.
- R2 (tech lead): the old export and the new button use the same publishing path.
Open questions:
- Q1 (security reviewer): how long does a sign-off stay valid?
- Q2 (product manager): may a researcher retract a memo after notification?
Out of scope: memo editing, client listsThe lines to read are R1a and R1b, and the split ownership. The security reviewer owns the control and its exceptions. The researcher owns whether the evidence supports the memo and whether it goes out. The product manager owns what the button is for. If your team cannot adopt a full intent record, the lighter version is a ticket template with two required fields, constraints and who owns them. That alone would have been enough here.
The agent needs to be told to use it. This goes in the instruction file the agent reads at the start of every task:
## Before you plan a change
- Read tiers.yaml. State the tier of every path you will touch, and whether the
change causes a tier-3 event.
- For tier 2 and above, read the linked intent record. If there is none, stop and ask.
- If a constraint has no owner, or the ticket is silent on a rule that an existing
policy module enforces, list it under "Open questions". Do not decide it yourself.
- Never edit tiers.yaml, pipeline files, eval cases or policy modules unless the
accepted plan names them.The third bullet matters more than it looks. PublishingPolicy already existed for the old export. The rule was not in the ticket or in the new endpoint's checks, but the repo knew it. An agent told to look for policy modules near the behaviour it adds has a real chance of asking the question the ticket did not.
One path to clients
It is tempting to say nothing downstream can recover a constraint that never arrived. That is too strong. Some defences do not need the ticket at all.
The code in this series is Python-flavoured pseudocode. It keeps the logic and drops imports, types and wiring, so read it for the rule, not as a module to paste. The original bypass was visible in the code, once someone looked:
# pseudocode: before these controls
def export_memo(memo): # old flow
PublishingPolicy.check(memo)
delivery.enqueue(memo.digest)
def publish_memo_endpoint(memo_id): # new endpoint, written by the agent
memo = memo_store.load(memo_id)
delivery.enqueue(memo.digest) # no policy, no sign-offThe fix is a chokepoint: one publishing service that both flows call, and that is the only code allowed to hold the delivery adapter. The memo is an immutable snapshot with a digest, so the thing that was signed is provably the thing that is sent.
A sign-off here means a record, not a button click. Only the approval service writes it, after an authenticated researcher acts, and no agent identity can call that service. The record is bound to the exact memo, the evidence and the policy version. The service judges it when the memo is queued, and the delivery worker runs the same check again at the moment a queued memo is published, so a sign-off that expires or is revoked in between stops distribution:
# pseudocode: src/publishing/, the single path from a memo to clients
POLICY_VERSION = "release_rules@3" # illustrative
# a snapshot never changes; editing a memo creates a new snapshot
memo.digest = sha256(memo.id, memo.question, memo.claims) # all the reader sees
memo.evidence_digest = sha256(memo.notes) # what the claims rest on
def untraced_claims(memo):
# every claim must point at a note that has a source;
# this does not check that the note supports the claim
return [c.id for c in memo.claims
if c.note_id not in memo.notes or not memo.notes[c.note_id].source_id]
def signoff_status(signoff, memo, researchers, policy_version, at):
if signoff is None: return "missing"
if signoff.approver_id not in researchers: return "unauthorized"
if signoff.memo_digest != memo.digest: return "digest_mismatch"
if signoff.evidence_digest != memo.evidence_digest: return "evidence_changed"
if signoff.policy_version != policy_version: return "policy_changed"
if signoff.revoked_at and signoff.revoked_at <= at: return "revoked"
if not signoff.approved_at <= at < signoff.expires_at: return "expired"
return "valid"
def decide_publish(untraced, signoff): # pure: sees no request input
if untraced: return "Reject" # R1a
if signoff != "valid": return "NeedsSignOff" # R1b
return "Queue"
def publish(memo_id, actor_id):
if actor_id not in researchers:
refuse("only a researcher may publish")
memo = memo_store.load(memo_id) # loaded, never sent by the caller
untraced = untraced_claims(memo)
signoff = signoff_status(signoff_store.get(memo_id), memo, researchers,
POLICY_VERSION, at=now())
decision = decide_publish(untraced, signoff)
if decision != "Queue":
refuse(decision, untraced, signoff)
# queued, not yet published: the delivery worker calls signoff_status again
# when it publishes, and stops if the answer is no longer "valid"
return delivery.enqueue_once(memo.digest) # a second click does not send twiceuntraced_claims is the structural half of R1a. It checks that each claim points at a note that has a source. It does not check that the note supports the claim. That is a different question, and it comes back at the end of the series. decide_publish is a pure function on purpose: it never sees request input, only facts the service has already loaded and checked, so a caller cannot talk its way past it.
The tests share one fixture file, fixtures/memo_m104.json, the same memo the rest of the series uses. Every number in it is illustrative. As first written, c3 and c4 have no note_id, and c5 cites n4:
{
"memo_id": "M-104",
"question": "Is the supplier's delivery reliability improving?",
"notes": {
"n1": {"source_id": "s1", "retrieved": "2026-09-30",
"text": "Annual report: on-time delivery rose from 88% to 91% (illustrative)."},
"n2": {"source_id": "s2", "retrieved": "2026-09-30",
"text": "Press release: a second distribution centre opened in the spring."},
"n3": {"source_id": "s3", "retrieved": "2026-10-01",
"text": "Earnings call transcript: management expects further gains next year."},
"n4": {"source_id": "s4", "retrieved": "2026-10-01",
"text": "Logistics survey: change in average lead time by region, in days.",
"data": {"north": -3, "central": -2, "south": 1}},
"n5": {"source_id": "s5", "retrieved": "2026-10-02",
"text": "Trade press: two large customers reported late deliveries last quarter."}
},
"claims": [
{"id": "c1", "note_id": "n1", "text": "On-time delivery improved year over year."},
{"id": "c2", "note_id": "n2", "text": "The supplier added a second distribution centre."},
{"id": "c3", "note_id": null, "text": "Management expects further gains next year."},
{"id": "c4", "note_id": null, "text": "Competitors are losing ground on delivery."},
{"id": "c5", "note_id": "n4", "text": "Lead times fell in every region."}
]
}The service tests run against that memo and against a traced version of it, where c3 cites n3 and c4 is dropped. Each row is one test:
Memo | Sign-off | Expected |
|---|---|---|
As first written | Valid | Refused with Reject, naming c3 and c4 |
Traced | None | Refused with NeedsSignOff: missing |
Traced, button clicked twice | Valid | Queued once |
Traced, then c5 removed after sign-off | Valid for the earlier snapshot | Refused: digest_mismatch |
Traced | Expired, revoked, given by an agent identity, or under an old policy version | Refused: expired, revoked, unauthorized or policy_changed |
Traced, published by the coding agent | Valid | Refused: only a researcher may publish |
An architecture test closes the bypass itself. It fails if anything outside the publishing package reaches the delivery adapter, and with the original endpoint in the repo, it fails:
# pseudocode: nothing outside src/publishing/ may reach the delivery adapter
def test_only_publishing_service_uses_delivery():
offenders = [module for module in python_modules("src/")
if imports(module, "src.publishing.delivery")
and not module.startswith("src/publishing/")]
assert offenders == [] # the agent's src/api/memos.py would be listed hereWith these in place, the agent's endpoint fails CI if it reaches clients without the checks. The ticket is still incomplete, but the omission is no longer silent. Restrict the delivery credential to the publishing service's runtime identity too, so the architecture test is backed by a permission, not just a static check.
Invariants only cover the rules someone thought to encode at a chokepoint, though. They are a second net, not a replacement for intent.
What the agent never gets
In my table, "What may the agent do" means a set of permissions, and they have to be enforced outside the prompt. These are the defaults I start from:
- The agent runs as its own identity, never with a person's token.
- It can write only to its own working branches. It cannot approve, complete, or merge a pull request, edit branch policies, or deploy.
- It has no production data and no production credentials, including the delivery credential.
- Network access is an allow-list: the package mirror, the model endpoint, nothing else.
- Everything it reads from a ticket, a PR comment, a research note, a web page or a dependency is data, not instructions. A note that says "publish this without review" should change nothing.
- Every tool call it attempts, allowed or denied, goes into a run record linked to the change.
The instruction file can say all of this, and it should. But the instruction file is not the control. The identity's missing permissions are. If the agent can technically approve its own pull request, sooner or later a prompt will convince it to.
When several agents argue
Everything above is about the coding agent. The desk itself is a multi-agent system, and the same four questions hold inside it. It is also where they are easiest to forget.
Handoff | What the agents may do | What persists | Who decides | Evidence before moving on |
|---|---|---|---|---|
Research | Read the approved source pool only | Notes, each with a note id, source id, retrieval date and content hash | Researcher sets the question and source rules | Every note has a source |
Debate | Argue from the shared notes; new evidence enters only as a new note | Advocate and skeptic arguments, round by round, with claim ids | Nobody yet | Each claim cites note ids, not new material |
Memo | Summarize both sides and the open disagreements | Memo snapshot with claim ids and its digest | Researcher | Every claim traces to a note; strongest point on each side shown |
Approval | Nothing | Sign-off bound to the memo digest | Researcher | Named person, time, memo digest, policy version |
None of the desk agents can approve or publish. Each has its own permission contract: the research agent reads the source pool, the debaters read notes, the moderator writes memo drafts, and none of them can call the approval or delivery services.
Two failure modes show up in setups like this. Agents that share a model and the same notes tend to agree with each other, so a debate that "converged" can just be one bias repeated three times. And a moderator that sounds confident turns agreement into something that reads like approval. The fixes are the same as for code: give the skeptic a different model or its own approved source pool, whose findings still enter the shared notes as new notes with ids, cap the rounds and the cost, check that the memo's claims trace back to the notes with the same untraced_claims check, and never let agent agreement count as a decision. The researcher signs, the same way a release authority signs.
How I would roll this out across teams
Writing the standard is the easy half. When I write these down, I also write how they get adopted, because that is where they usually fail:
- Put the tier map, the templates and the agent instructions in one shared repository, with owners, a version and a changelog.
- Set up the platform controls first: agent identities, branch policies, environment approvals, the run record. Teams should not configure these themselves.
- Pilot with two teams on tiers 0 to 2 for a fixed period. Review every exception weekly.
- Coach on the two skills that matter: writing constraints with owners, and reading eval evidence. One champion per team, office hours, a short review clinic.
- Open tier 3 only when its eval cases have owners and the separation-of-duties checks have stayed clean.
- Review the evidence monthly with product and risk owners. Change the standard through a short written proposal, and give exceptions an expiry date.
Where this is too much
A small team where one person is product manager, engineer and release authority does not need four documents. The chain collapses into one conversation, and that is fine for low tiers. Two things should survive the collapse: the constraint gets written where the next task will find it, and tier 3 waits for a second named person. If nobody else is available, the release waits. A waiver needs a second named person too, and nobody approves their own. A single person approving their own irreversible change is the exact pattern this whole model exists to prevent.
The original tests were green
Before any of these controls, the original pull request had passing tests and an approving review. Those tests were real and they were correct. They checked that the endpoint answered and the memo rendered. If you do one thing this week, add the constraints-and-owners field to your ticket template and see how often it comes back empty.
The next part asks the obvious question: the original tests were green, so what did they actually check?
Newsletter
No comments yet.