Skip to content

19 min read

Editing a plan demands validation that accepting it does not

On this page

A plan, here, is the ordered list of tool calls an agent has settled on and not yet executed, produced in one shot so that a person can see the whole of it before any of it happens. An approval gate is the check that suspends the run at that point and puts the plan in front of somebody. Two buttons are the usual design, and a third control — an edit box — arrives once a team has watched operators reject sound plans over a single wrong field. Letting the operator change the plan rather than only accept or reject it is better feedback and a wider hole, and the hole has nothing to do with the operator being untrustworthy. It is that the plan which comes back out of an edit box has not been near the checks the plan that went in had already passed.

Those checks are a validation pipeline — the fixed sequence a generated plan runs through between the moment the model returns it and the moment it is drawn on a screen: a schema check on shape and types, a tool allowlist, and the gate policy deciding which steps need a human. A warehouse operations agent at a freight company ran that pipeline over every plan it produced, and it worked. What it never saw was the plan an operator on the exceptions desk handed back, which had four steps where the generated one had three, and whose fourth step moved $1,489 against an invoice belonging to somebody else.

Where the edited plan enters the pipeline

Nothing in that outcome required an unusual accident. Treating it as one is the mistake, because any path that lets a person hand back a modified plan has the same shape, and the shape is the problem: the checks that make a generated plan safe run between generation and display, and an edit arrives after the display.

Flowchart: Model produces plan → Schema + tool allowlist; Schema + tool allowlist → Policy: which steps need a gate; Policy: which steps need a gate → Render card with plan; Render card with plan → Operator; Operator → Execute (accept); Operator → Stop (reject); Operator → Execute (edit).

The edge from the operator straight to execution is the whole defect, and it is drawn that way in a great many implementations because accept and edit feel like the same action with a parameter. They are not: accept means execute the artefact you already validated, and edit means execute a new artefact that has never been through anything. The correction is to route the edited plan back to the top of the same pipeline, which is cheap when the validators are a function and expensive when they are scattered through the generation code path.

The word "after" is carrying the whole argument in the paragraph above, so it is worth walking the sequence a link at a time. A generated plan is checked once, in the gap between the model returning it and the card being drawn. What those checks certify is a single object: this plan, with these steps, these tools and these arguments. The card is a rendering of that certified object, and rendering does not alter it, which is why the card is trustworthy. The edit box sits on the card, so an edit is submitted after every check in the pipeline has already run and finished.

The next link is where implementations differ, and it carries the rest. The resume endpoint receives a plan in the request body, and a request body is whatever the client chose to send — the server is not applying a patch to something it still holds, it is accepting a document, which the executor is then handed. Read forwards, the chain is: the checks certified one object, the operator was shown a rendering of that object, the operator submitted a different object, the endpoint accepted it, the executor ran it. Every link is checkable against the code in front of you, and every link is skippable. Skip the second and you believe the checks certify plans for this run rather than this plan, which leaves you unable to predict that a later plan for the same run is uncertified. Skip the fourth and you believe the server still holds the validated plan and merely applies the operator's changes to it, which leaves you unable to predict that a step never present in any generated plan is possible at all.

The reason that edge gets drawn is a belief, and the belief is defensible right up until somebody writes it down. Accept and edit produce requests that agree on nearly everything: the same run, the same endpoint, the same plan schema, the same steps in most positions. What varies is provenance — which bytes in the body were written by the model and which by a person — and provenance is precisely what an artefact does not carry. The plan's shape is a quantity that stays constant while the dangerous property varies. An endpoint dispatching on the shape cannot tell the two cases apart, because the difference between them was never in the shape.

python
def submit_edit(run, edited_plan: dict, editor: User) -> Resume:
    plan = PlanSchema.model_validate(edited_plan)       # shape, types, enums
    for step in plan.steps:
        if step.tool not in ALLOWED_TOOLS[run.agent]:
            raise Rejected(f"{step.tool} is not a tool this agent may call")
        check_argument_bounds(step)                      # amount <= invoice balance, etc.
        if not editor.may_author(step.tool):             # authority to *write* the step
            raise Rejected(f"{editor.id} may not author a {step.tool} step")

    diff = semantic_diff(run.plan, plan)
    if diff.is_empty():
        return resume_with(run, plan)

    # every step the edit touched loses its approval by construction:
    # its payload digest changed, so the gate will fire again
    for step in diff.touched_steps():
        run.approvals.invalidate(step.digest_before)
    return present_for_reapproval(run, plan, diff)

Three of those checks exist already, since the generated plan went through them. The one that is new is may_author, and it is new because acceptance and authorship are different permissions. An exceptions-desk operator can reasonably be trusted to approve a refund the agent proposed, having read the case, and not to be the person who introduces a refund step into a plan that did not contain one. Separating the two is what stops an edit box from becoming a general-purpose console for whatever tools the agent holds credentials for, which is what it is if may_author is missing.

Some of what that function does is a requirement and the rest is a decision, and separating the two is worth the paragraph, because a requirement can be argued from the situation while a decision has to be argued from what it buys. That an edited plan is revalidated is a requirement: the plan is input from a client, and input from a client is checked. Nothing about the operator's seniority changes that. Separating may_author from the authority to approve is a decision. It buys a reach for the edit box bounded by the operator's authoring rights rather than by the agent's credential set, and it costs a second permission model that somebody maintains and that will be wrong for somebody in their first week.

The invalidation loop is the part that comes for free from the payload binding established in the first chapter. Changing an argument changes the digest, so a step that was approved before the edit no longer verifies, and the gate fires again at execution time whether or not the edit UI remembered to ask. That property is worth relying on deliberately: it means the correctness of the re-approval does not depend on the edit endpoint being careful, only on the gate being in front of the effect.

Working that claim out in steps shows why it holds without anybody remembering to make it hold. The digest is computed over the tool name and the resolved arguments. An edit that changes an argument changes the digest. The gate checks the digest immediately before the effect. So a step whose arguments were edited cannot execute under its old approval, whether or not the edit UI raised a new card. What that licenses is a narrower dependency than teams think they have: re-approval rests on the gate sitting in front of the effect, not on the edit path being written carefully.

The residual has to be said in the same breath, because a property that strong invites being over-read. The identity covers only steps that had a digest to begin with. A step introduced during the edit was never approved, so there is no earlier approval for it to fail against, and nothing about it fails to verify — it arrives as a step the gate has not seen, which is the position every generated step starts from. Whether that is safe depends entirely on the gate policy firing on it and on may_author having been consulted about who wrote it. The digest answers whether these are the arguments somebody approved. It says nothing about whether this person should have been permitted to write this step at all, and an implementation leaning on it for both questions has answered one.

A diff nobody can read is the same as no diff

Validation stops a step the policy forbids. It does not stop a step the policy permits and the operator did not intend, which is what happened to the warehouse agent's plan — issue_refund was on the agent's tool list, the amount was within the target invoice's balance, and every check above would have passed if may_author allowed refunds. What catches that case is showing the person what their edit did, in a form where an extra step is impossible to miss.

The gap there is worth naming, because a team that has just finished building the validator will believe it is covered. Every check in submit_edit asks a question whose answer is a property of the agent and its policy: is this a tool the agent may call, is this amount within the invoice balance, does this step need a gate. Those answers are the same for every plan the agent will ever produce. The permitted set is a quantity that stays constant while the dangerous property varies, the dangerous property being whether a particular step was intended by anybody. Tightening the validator does not help, since the tighter it gets the more it constrains the model's legitimate output while leaving the operator's unintended output exactly where it was.

text
YOUR EDIT CHANGED 2 THINGS

  step 2  reschedule_delivery
          window        2026-04-02  →  2026-04-06

+ step 4  issue_refund                                    ← STEP ADDED
          invoice_id    in_74119   (account: Halvorsen Freight)
          amount        $1,489.00
          note: this account is not the one on ticket #74991

  Re-approval required for: step 4 (added), step 2 (changed)
  [Approve both]  [Remove step 4]  [Discard my edits]

Three properties make that render useful. Added and removed steps are visually distinct from changed fields, because a changed field is the expected case and a new step almost never is. The cross-reference on the added step comes from your own data rather than from the plan, which is what makes this account is not the one on ticket #74991 possible at all. And the buttons include a way to keep part of the edit, since an operator whose only options are approve-everything or discard-everything will approve everything to avoid redoing the date.

Two operators editing the same pending plan is the other thing the endpoint has to handle, and the fix is the ordinary one: the edit carries the version of the plan it was derived from, and a submission against a stale version is refused with the newer plan shown rather than merged. Merging two edits to a plan that will execute against production systems is a feature nobody needs and a source of steps neither person wrote. The same version check catches the slower kind of staleness, where an operator opened the card, went to a meeting, and submitted an edit forty minutes later against a plan whose underlying invoice has since been paid.

Refusing rather than merging is a decision and not a requirement, and it buys the property that every plan reaching the executor was seen whole by one person. A merge produces a plan that is a function of two people's edits and was read in full by neither, and no rendering repairs that, since the diff each would need is against a version the other never had. The cost is an operator who loses their work and starts again, which is the cost people object to and the one worth paying.

Computing a diff over a plan is only easy if the plan is a structure. Comparing two free-text plans means comparing two paragraphs of English, where a step can be added by inserting a subordinate clause, and no diff algorithm will tell you that "and issue the refund while you are there" is a new irreversible action.

The difficulty there is not that English is harder to diff than JSON, and reading it that way leads to the wrong fix. A word-level diff over two paragraphs works perfectly well, and it highlights the inserted clause in green exactly as it highlights a corrected typo in green. What it cannot do is say which of those two insertions adds a step, because the category step does not exist in the representation — the plan is a paragraph, and a paragraph is made of words and sentences and nothing else. A diff reports changes in the units its input is made of. Give it text and it reports changed text; give it a list of typed steps and it reports an added step. So the property that makes an added refund impossible to miss is not a property of the diff tool. It was fixed when somebody decided what a plan is.

What the plan is written in decides what editing costs

The representation is chosen early, usually for reasons about how easy it is for the model to produce, and it determines every property that matters later.

RepresentationDiffableValidatableEdit ergonomicsCharacteristic failure
Prose plan in the model's own wordsBarely — word-level diffs on EnglishNoPleasant to write, ambiguous to executeA new action hides in a clause
Prose plus a JSON blockOnly the JSON halfHalfTwo sources of truthThe prose and the JSON disagree and nobody knows which ran
JSON step list in a textareaStructurally, per fieldFully, on resubmitFragile: syntax errors, clipboard accidentsEdits that are valid JSON and wrong
Typed form fields per stepPer field, by constructionFully, per keystrokeConstrained, slower for bulk changesCannot express a change the form did not anticipate
A small executable DSL, a domain-specific language you define for plansPer statementFully, by parsingPowerful for experts, opaque to everyone elseBecomes a language your team maintains

That table is best read as gaps rather than as levels. The rows are not a ranking, and reading downwards is not improvement — every row holds the same two quantities traded against each other in different proportions. Expressiveness is how much of what an operator might legitimately want to say the representation can hold. Checkability is how much of what it holds a machine can decide about. Prose has the first at its maximum and the second at zero; typed form fields have the reverse. The characteristic-failure column is the bill for each row's position, and the two bills differ in kind: a representation that can express anything will eventually express an action nobody meant to authorise, and one that can check everything will eventually refuse a change that was correct.

One row does not fit that reading, and it is the informative one. Prose plus a JSON block does not sit between its neighbours; it pays both bills at once. The prose can hide a step, the JSON can be edited into something valid and wrong, and nothing makes two separate documents agree. Its characteristic failure gives that away, since which one ran is a question none of the other four rows can even ask. The last row escapes the trade rather than sitting on it: a small executable DSL buys expressiveness and checkability together and pays in a currency none of the columns measures, which is the standing work of owning a language.

Typed form fields are the strong default for anything with an irreversible effect, because the class of accident that produced the $1,489 refund cannot occur: there is no field into which a step can be pasted, the date input takes a date, and adding a step is a deliberate action that goes through the same policy check as the generator's output. The cost is that an operator who wants to change something the form does not model has to reject and re-run, and teams underestimate how often that happens in the first month.

The choice also constrains the model, which is the part that gets discovered late. A plan expressed as typed steps has to be generated as typed steps, so the schema has to cover every action the agent might legitimately want to take, and the first time a genuinely novel case arrives the model will either force it into the nearest available step or fail to produce a plan at all. Prose does not have that failure, which is exactly why prose is so hard to gate: expressiveness and checkability are traded against each other, and the trade is worth making toward checkability wherever an irreversible effect is in scope, and worth making the other way for a research assistant that only reads.

The pragmatic arrangement most teams land on is typed fields for arguments with consequences and a free-text area for genuinely free text, such as the body of a customer reply, with that body passing through the same content checks the generated version would have. A send_reply whose body was edited by a human still gets the commitment scan from the second chapter, because the rule is about what the customer will receive rather than about who typed it.

What editing changes that nobody plans for

An edit is a claim that the operator's version is better, and it is usually right, but there is no mechanism that makes it right. Operators edit toward what they would have done, which reflects their own experience, their own risk appetite, and the last incident they were blamed for. A team that measured this on a shipping agent found that 6% of plans were edited, and that a quarter of those edits made no difference to the outcome, half improved it, and the remainder produced a worse outcome than the original plan — an overcorrection toward compensating the customer that cost more than the exception it resolved. The only way to know which quarter you are in is to record the outcome after execution and attribute it to the edit, which is telemetry most teams do not have until they go looking for it.

That remainder is worth understanding rather than deploring, because the mechanism behind it is not carelessness. An operator sees one case and the agent's policy sees a distribution. Compensating a customer more generously than the rules require resolves the case in front of the operator, visibly and at once, while the cost lands on a margin nobody at the exceptions desk is shown. The evidence available to the person editing is systematically the evidence that favours editing upward. So the fraction of edits that made things worse is a property of the arrangement rather than of the individuals in it, and the fix is a rule the agent carries rather than a conversation with the desk.

Attribution changes too. Once an operator has authored a step, the audit record has two authors, and "the agent issued a refund" stops being a true sentence about that run. The record needs to say which steps came from the model, which came from the human, and who that human was, or the incident review will spend its first hour reconstructing it. Storing the pre-edit plan, the post-edit plan and the diff — three small JSON documents, a few kilobytes — makes that reconstruction a lookup.

The subtler cost is that an edit box creates pressure to fix rather than refuse. When rejecting means the run dies and the ticket comes back to the operator as manual work, and editing means the run continues with the operator's correction, the incentive points at editing even when the plan is wrong in a way that suggests the agent has misread the situation. A plan that needs three of its four steps rewritten is evidence about the agent, and salvaging it converts that evidence into a quiet success. Watching the distribution of edit sizes is a cheap defence: a rise in the fraction of plans where more than half the steps are touched says the agent has drifted into a case it does not handle, and no amount of operator diligence at the gate will fix that upstream.

The diff corpus itself turns out to be the most valuable artefact of the whole arrangement. A rejection tells you a plan was wrong; a diff tells you what right looked like, in the same representation the model produces, which makes it directly usable as evaluation cases and as few-shot material, the worked examples you paste into a prompt to show the model what a good plan looks like. Teams that keep it find that a handful of recurring edits — always adding a note to the ticket before sending, never scheduling into a destination holiday — are rules the agent should have had, and each one converted into a rule removes a class of edit from the queue permanently.

The three mechanisms are cheap and they are not interchangeable, because each closes a different link of the same chain. Revalidation closes the link where an unchecked artefact reaches the executor. may_author closes the link where every check passes and the step was still nobody's to write. The visible diff closes the link where both of those hold and the person has not noticed what they submitted. What decides whether any of them can be built is the representation, chosen months earlier by somebody optimising for how easily a model produces a plan.

All three moves so far assume the operator is there. Accept, reject and edit are the paths where somebody looked at the card, and the path that gets designed last, if at all, is the one where the card is opened on a Friday evening and answered by nobody.

Human in the loop — read the current version at https://daviddominguez.dev/learn/en/human-in-the-loop/editing-a-plan-demands-validation-that-accepting-it-does-not

Back to top