Tasks are derived from the change
Coding agents take a ticket as the unit of work. Loft takes a change as two states of your product's specification: as published, and as it should be. The tasks can always be worked out from the two states. The two states can never be worked back out of the tasks.
One ticket, two readings
An online shop refunds an order when the customer asks within 30 days of delivery. The team decides that members should get 60 days, and the ticket reads: "Extend the refund window to 60 days for members." A coding agent picks it up, plans three steps, changes the eligibility check, updates the tests, and opens a pull request. The ticket closes.
The ticket names the work. What the shop does once the work ships has to be read out of the code: whether a member's request on the 61st day is refused, whether a gift-card order, refunded as store credit, gets the longer window too, and whether the refund email changes. A year later, the answer to "what are our refund rules?" is spread across every closed ticket that touched refunds, read in order.
Loft reads the same change as two states of the product's specification: the refund rules as last published, which are the product contract the team ratified and the code is built to, and the rules as they should be once members get 60 days. The change is the difference between them. Both states live in your repository, beside the code your coding agent works on.
Extend the refund window to 60 days for members
- Add a membership check to refund eligibility
- Allow 60 days for members
- Update the eligibility tests
Closed when the pull request merges.
Return an order
-
When a customer requests a refund within 30 days of delivery, or within 60 days for a member, the shop shall refund the order to the payment method it was paid with.changed
- A request on the 30th day after delivery is refunded.
- A request on the 31st day after delivery is refused for a customer who is not a member.changed
- A member's request on the 60th day after delivery is refunded.added
- A member's request on the 61st day after delivery is refused.added
- When a refund is issued, the shop shall email the customer the refunded amount.
- When a customer requests a refund of an order paid by gift card, the shop shall refund the order as store credit.
Published as the next version of the contract once the branch merges.
Where today's tools start
Coding agents take a task as their unit of work: an issue, a ticket, a prompt, or a thread. Copilot, Codex, Devin, and Cursor can each be handed an issue from a tracker. GitHub's agent reads the issue's title, description, and comments as they stand when it is assigned, and after that it "will not be aware of" new comments (GitHub's guide to assigning an issue (docs.github.com, opens in a new tab)). Plans and to-do lists last as long as the run.
Spec-driven tools add a written specification, usually one per change. Birgitta Böckeler's review of Kiro, Spec Kit, and Tessl (martinfowler.com, opens in a new tab) found that Spec Kit treats a spec "as a living artifact for the lifetime of a change request, not the lifetime of a feature."
OpenSpec comes closest. Its main specs "describe how your system currently behaves," and each change carries deltas that add, modify, or remove requirements, merged in when the change is archived (OpenSpec's concepts (github.com, opens in a new tab)). Its documentation gives the reason for writing deltas in place of the full spec:
"Reading a full spec, you'd have to diff it mentally against the current version."
OpenSpec, "Concepts"
That reason holds only while a person does the diff. OpenSpec moves it from the reader's head to the author's hand: a delta is the diff, written out. Loft runs the other way. The author writes the target state, and Loft computes the diff by key, with the same result every time. Our study of public OpenSpec projects showed the cost of folding finished work back in as a separate step: in projects with main specs, 39% of open changes had every task ticked and had never been archived.
Operations made this move years ago. The Kubernetes API conventions (github.com, opens in a new tab) ask that a resource's spec hold "the desired state, not actions intended to yield the desired state," and call the system's behavior "level-based rather than edge-based": a controller acts on how far the cluster is from its spec, whatever sequence of events led there. A ticket is an edge, and a target state is a level.
Why tasks come second
A task list is one way of getting from the published state to the target. It is worked out from the change, the code, and the path the team chooses, and the derivation runs in one direction only, for two reasons.
Tasks lose information. The published state and the target state are enough to plan the work, and usually enough for several valid plans. Going back fails: three ticked boxes record the steps of the work, and they leave out what the shop does afterward and what had to stay the same. The refund email and the gift-card rule appear nowhere in the member ticket, and both are in the target state.
The refund rules as published, and as they should be with 60 days for members.
The shop checks the window when a refund is requested.
- Read the customer's membership in the refund check
- Allow 60 days for members
- Test the 31st, 60th, and 61st days
The shop stores a refund deadline on each order at delivery.
- Set members' deadlines at 60 days
- Recompute the deadlines of delivered orders placed by members
- Test the 31st, 60th, and 61st days
- When a refund is issued, the shop shall email the customer the refunded amount.In neither plan
- When a customer requests a refund of an order paid by gift card, the shop shall refund the order as store credit.In neither plan
Neither plan mentions these. After the merge an agent can infer them from the code, but each reading can differ from the last, and each reports whatever was built, bugs included.
Once the work merges, the code holds both, and an agent can read them out of it. That reading is an inference, and it fails in two ways. Two agents, or one agent run twice, can describe the same code differently, and each description is an unratified guess. And it reports what was built: if Plan B refuses members on the 60th day through an off-by-one, the code says the 60th day is refused, and a faithful reading calls that the rule.
The target state is the deterministic side. Every reader gets the same ratified text, Loft computes the difference from it by key with the same result every run, and since it was written before the code, it can check the code.
| Property | Inferred from the code | Read from the target state |
|---|---|---|
| Who produces it | An agent, at each reading | The team, once, ratified |
| Two readings | Can differ | Identical |
| The 60th-day bug | Read as the rule | Fails the 60th-day criterion |
| Available | After the merge | Before the code is written |
Tasks also hold only from the state they were written against. In Promise Theory (markburgess.org, opens in a new tab), Jan Bergstra and Mark Burgess call a workflow built from one-off instructions fragile, since it is "driven by a particular initial state." Suppose that while the member ticket waits, a teammate publishes another change: refunds by bank transfer for orders paid by invoice, and a new name for the Feature, "Request a refund." The member ticket's plan was written for a shop without invoice refunds, and nothing in it says so. The target state still holds: git merges the teammate's publish into the member branch through Loft's merge driver, node by node, and Loft computes the difference again.
The merge also raises a question neither ticket asked: does a member's order paid by invoice get 60 days as well? Bertrand Meyer made the general point in 2018: "A user story describes one case; piling up case over case does not give you a specification" ("Making Sense of Agile Methods" (blog.arbinada.com, opens in a new tab)). Features interact, and the interactions show up only against the whole. With the invoice rule and the member window in one target state, the question comes up before any code is written.
A rename is a change
The strongest objection to working from states comes from database schema tools. Given two schemas, a tool can't tell a renamed column from a dropped column and a new one, and the wrong guess loses data. Prisma's documentation shows a renamed field turning into a migration that creates a new column and drops the old one with its data (Prisma, "Customizing migrations" (www.prisma.io, opens in a new tab)). In May 2026, Atlas moved the answer into the desired state: a rename is declared with a renamed_from attribute that "lives next to the renamed resource, is reviewable in pull requests, and is resolved without prompts" (Atlas, "Declarative Schema Renames" (atlasgo.io, opens in a new tab)).
Loft started from the same rule. Every node of a Loft specification carries a key that loft fmt writes once and that stays with the node through every edit, so Loft computes the difference by key, and a renamed or reworded node is still the same node. When the teammate renamed "Return an order" to "Request a refund," loft status reported one changed Feature. Here is the member branch after the merge:
$ loft status --base main
branch: feat/member-refunds
base: main at 8b2d04fd27: 2 added, 2 changed, 0 removed
added 01a1bd38…7a70 criterion A member's request on the 60th day after delivery is refunded.
added 01a1bd38…f5b0 criterion A member's request on the 61st day after delivery is refused.
changed 01a1bd38…8ba7 requirement When a customer requests a refund within 30 days of delivery, or within 60 days for a member, the shop shall refund the order to the payment method it was paid with.
changed 01a1bd38…dae2 criterion A request on the 31st day after delivery is refused for a customer who is not a member.
Two differences
The change is the difference between the published state and the target state. What has to be built is a second difference, between the target state and what the tests show the code already meets. Loft keeps them apart through reconciliation: linking each acceptance criterion to the tests that check it, and reporting which criteria have no current link.
Say the shop's code already has passing tests for the 30-day rule and for store credit, and none for the refund email. The change touches four nodes, and the work comes out differently. When a criterion changes, loft reconcile marks the links to its tests stale, so the changed 31st-day criterion is flagged for review, while the email criterion, untested before the change, is debt the code already carried.
| Criterion | In the change | Tests today | Work |
|---|---|---|---|
| A request on the 30th day after delivery is refunded. | as it was | passing | none |
| A request on the 31st day after delivery is refused for a customer who is not a member. | changed | link stale | review and update the test |
| A member's request on the 60th day after delivery is refunded. | added | none | test and code |
| A member's request on the 61st day after delivery is refused. | added | none | test and code |
| The email names the refunded amount. | as it was | none | debt from before, reported apart |
| The store credit equals the order total. | as it was | passing | none |
Where tasks belong
Tasks stay. Some work comes from somewhere other than the two states, and it belongs in the plan.
- The path
- Which step lands first depends on the code and on what is safe to run in between. Plan B's recomputed deadlines are one case. A column renamed under live traffic is another, done by parallel change, also called expand and contract (martinfowler.com, opens in a new tab): add the new form, move every reader to it, then remove the old one. The two end states name neither step in between.
- Discovery
- "Find out where memberships are stored" comes before the target state can be written, since its job is to settle what the target is. In Loft it is part of specifying the change.
In Loft, the state lives in spec/ and the path lives on the branch. Your coding agent breaks the build plan into bounded tasks, each naming the criteria it meets, and Yard keeps them in the branch's Yard Log as the record of how this change was built.
Try it on one change
Pick a change you were about to hand an agent as a ticket. Write it instead as the target state of the Feature it touches, run loft status --base to see the difference, and ask your coding agent to plan the work from there. After the merge, read the published state: it says what the product now does, in one place. The product example follows one change through the Loft loop, Author → Ratify → Implement → Reconcile → Decide. Once the tasks are done, the change is still there to read, as the next published, immutable version of the contract.