Scope Contracts and Task Boundaries
The model doesn't know where the work ends. A scope contract is a per-task file that says where the work begins, where it ends, and how to roll back if it spills. It turns "stay in scope" from a wish into a check.
Learning Objectives
- Write a scope contract that an agent reads at task start and a verifier reads at task end.
- Specify allowed files, forbidden files, acceptance criteria, rollback plan, and approval boundaries.
- Implement a scope checker that compares a diff against the contract and flags violations.
- Make scope creep visible, automatic, and reviewable.
The Problem
Agents creep. The task is "fix the login bug." The diff touches the login route, the email helper, the database driver, the README, and the release script. Each touch had a plausible reason in the moment, but together they make a different change than the one that was reviewed.
Scope creep is easy to miss in agent work because the agent narrates each step in good faith. A stricter prompt doesn't fix that. A contract on disk that records what was promised, plus a check that compares the result against the promise, does.
The Concept
A scope contract is a per-task file that records where the work begins, where it ends, and how to roll back if it spills. The agent reads it at task start. A verifier reads it at task end.
What goes in a scope contract
| Field | Purpose |
|---|---|
task_id | Links to the task on the board |
goal | One sentence the reviewer can verify |
allowed_files | Globs the agent may write |
forbidden_files | Globs the agent must not touch even by accident |
acceptance_criteria | Test commands or assertion lines that prove done |
rollback_plan | One paragraph the operator can execute if a halt is required |
approvals_required | Actions outside scope that need explicit human sign-off |
A contract without forbidden_files is incomplete. The negative space is half the contract.
Globs, not raw paths
Repos move files. Pin the contract to globs (app/**/*.py, tests/test_signup*.py) and a refactor between sessions won't invalidate it.
Rollback is part of scope
Writing down the rollback forces the author to think about what could go wrong. If you can't roll back from a contract, don't approve it.
Scope check is a diff check
The agent writes a diff. The checker takes that diff, the allowed globs, the forbidden globs, and the list of acceptance commands that ran. Every violation becomes a tagged finding that the verification gate can refuse.
Two altitudes of scope: the feature list and the task contract
The scope contract bounds one task, not the project. An agent can stay perfectly inside the contract for the login fix and still decide, on the next turn, that the project also needs a settings page, a dark mode toggle, and a rewrite of the router. The contract answers which files are in scope for the task. It says nothing about which work is in scope for the project.
That second altitude needs its own primitive: a feature_list.json the agent reads at session start. It's the project backlog as an ordered, machine-readable file. The agent picks exactly one feature whose status is todo, writes its id into the active scope contract, and may not start a second feature in the same session. "One feature at a time" stops being a line in the prompt that the agent can rationalize past. It becomes a value read off disk and a check the gate enforces.
{
"project": "knowledge-base",
"active": "import-pdf",
"features": [
{ "id": "import-pdf", "status": "in_progress", "goal": "import a PDF into the library", "done_when": "pytest tests/test_import.py && a sample PDF appears in the library view" },
{ "id": "full-text-search", "status": "todo", "goal": "search document text and rank hits", "done_when": "query returns ranked results with snippets" },
{ "id": "cite-answers", "status": "todo", "goal": "answers carry source citations", "done_when": "every answer renders at least one clickable citation" }
]
}| Field | Purpose |
|---|---|
active | The single feature the current session may touch; empty means pick one and set it |
features[].id | Stable slug the scope contract's task_id points at |
features[].status | todo, in_progress, done, blocked; only one in_progress at a time |
features[].goal | One sentence the reviewer can verify |
features[].done_when | The acceptance line that flips in_progress to done |
Two rules keep the list load-bearing instead of decorative. First, "at most one in_progress" is itself a startup check (Phase 14 · 33). If the list shows two, the session refuses to start until a human resolves it. Second, the feature list is a file, not a chat message. Chat scrolls out of context, while the file persists across sessions and across agents. The handoff (Phase 14 · 40) writes the finished feature's status back to done, so the next session opens to an accurate board instead of re-deriving what's left.
The contract and the list compose by least privilege, using the same least-privilege merge that the production-patterns section spells out later: the task contract's allowed_files must sit inside whatever the active feature touches, never outside it.
Build It
code/main.py implements four pieces:
- A
ScopeContractdataclass (glob lists for allowed, forbidden and soft docs paths, plus acceptance commands, rollback plan, approvals and the time, network and violation budgets) and amerge_contractsfunction for the least-privilege merge. - A
RunSummarydataclass holding the touched files, the commands run, the elapsed minutes and the network hosts. There is no diff parser; the demo builds both summaries by hand. - A
scope_checkthat compares aRunSummaryagainst the contract and returns aScopeReport: writes sorted into in-scope, off-scope, soft off-scope and forbidden, plus severity-tagged findings. - Two demo runs: one stays in scope, one creeps. The checker flags the creep with the exact file and reason.
Run it:
python3 code/main.py
The output shows the contract, the two runs, the per-run verdicts, and the reports archived to closed/T-001.json (both runs share a task id, so the creep run overwrites the clean one).
Production patterns in the wild
One write-up (a single dev.to post) reports that a practitioner running "specsmaxxing" (scope contracts in YAML before invoking the agent) saw the rabbit-hole rate drop from 52% to 21% in three weeks without changing the agent. The post credits the contract, not the model. Four patterns make the gain stick.
Violation budgets, not binary failures. agent-guardrails (the OSS merge gate used by Claude Code, Cursor, Windsurf, Codex via MCP) ships a violationBudget per task. Minor scope slips within budget surface as warnings, and the merge gate refuses only once the budget is exceeded. Pair it with violationSeverity: "error" | "warning". The budget is the difference between a gate that ships and a gate that gets disabled by the team that hated it.
Severity asymmetry by path family. Off-scope writes to docs/** are usually warn. The demo goes one step softer: paths matching docs_paths_soft (docs/**, README.md, **/*.md) produce an info finding (scope.soft_off_scope), and any other off-scope write is a warn that counts against the budget. Writes to scripts/**, migrations/**, config/prod/** are always block, which in the demo means listing them in forbidden_files. That asymmetry belongs in the contract, not the runtime, because it's project-specific and changes per task.
Time and network budgets next to file budgets. A time_budget_minutes field bounds the wall clock, and the runtime refuses to continue past it without re-approval. A network_egress allowlist on hostnames stops the agent from quietly hitting an external API that wasn't part of the task. These are scope dimensions too. File globs are necessary but not sufficient.
Multi-contract merge semantics (least privilege). When two scope contracts apply (say, a project-wide one plus a task-specific one), the merge works like this:
- Intersect
allowed_files: both contracts must permit the path. - Union
forbidden_files: either contract can prohibit it. time_budget_minutestakes the most restrictive value (min).approvals_requiredaccumulates.network_egressisNonefor no enforcement,[]for deny-all, and[...]as an allowlist. Under merge,Nonedefers to the other side, two lists intersect, and deny-all stays deny-all.
State this in the contract schema so the merge is mechanical and reviewable.
Use It
Three places this shows up in production:
- Claude Code slash commands. A
/scopecommand writes the contract and pins it as session context. Subagents read the contract before acting. - GitHub PRs. Push the contract as a JSON file in the PR body or as a checked-in artifact, and let CI run the scope checker against the merge diff.
- LangGraph interrupts. A scope violation triggers an interrupt. The handler asks the human whether the contract needs to grow or the agent needs to back off.
The contract travels with the task. When the task closes, archive it under outputs/scope/closed/.
Ship It
outputs/skill-scope-contract.md generates a scope contract from a task description, along with a glob-aware checker that runs in CI on every agent diff.
I'd start a contract with forbidden_files and the rollback plan, and fill in the goal after. The lesson says the negative space is half the contract, and I take that at face value: an allowed list alone leaves half of it unwritten. I also treat the rollback paragraph as an approval test. If I can't write down how to undo the task, I don't approve the contract.
My bet on enforcement is a violation budget with severity tiers rather than a hard stop on every slip. A gate that gets disabled by the team that hated it isn't checking anything, so I'd let docs/** slip with at most a warning and keep migrations/** blocking.
Exercises
- Add a
network_egressfield listing allowed external hosts. Refuse runs that touch other hosts. - Extend the checker to fail soft on
docs/**and hard onscripts/**. Justify the asymmetry. - Make the contract derive
allowed_filesfrom agoalfield using a static rule set (no LLM). What goes wrong on the first edge case? - Add a
time_budget_minutesand refuse to continue once the wall clock exceeds it. - Run two contracts against the same diff. What is the right merge semantics when both apply?
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Scope contract | "The task brief" | Per-task file (a dataclass in this lesson's build; YAML in the specsmaxxing example) listing allowed/forbidden files, acceptance, rollback |
| Scope creep | "It also touched..." | Files outside the contract changed in the same task |
| Rollback plan | "We can revert" | The one-paragraph operator runbook for halting |
| Approval boundary | "Needs sign-off" | An action listed in the contract as requiring explicit human approval |
| Diff check | "Path audit" | Comparing touched files against the contract globs |
Further Reading
- LangGraph human-in-the-loop interrupts
- OpenAI Agents SDK tool approval policies
- logi-cmd/agent-guardrails, merge gates and scope validation: violation budgets, severity tiers
- Dev|Journal, Preventing AI Agent Configuration Drift with Agent Contract Testing:
--strictmode without external deps - Agentic Coding Is Not a Trap (production logs): specsmaxxing receipts, 52% → 21%
- OpenCode permission globs: fine-grained per-permission scope
- Knostic, AI Coding Agent Security: Threat Models and Protection Strategies: scope as part of least privilege
- Augment Code, AI Spec Template: three-tier boundary system (must/ask/never)
- Phase 14 · 27: prompt injection defenses that pair with scope locks
- Phase 14 · 33: the rule set this contract specializes per task
- Phase 14 · 38: the verification gate the checker reports into
Code
- main.py (python, 237 lines)