Skip to content
writing · engineering

The rehearsal is the contract

2026-08-17 · governed-change pipeline · all writing

Every live mutation now requires a confirmation token minted by its own dry run — an HMAC bound to the exact target and the exact before→after diff the preview showed, recomputed from fresh platform state at apply time. The preview is no longer advice. It is the contract.

article409 confirmation_required

When advertising becomes callable, mode: "live" is one parameter. That is the whole point of the interface — and the whole problem. An agent in a tool-use loop, a deploy script, a retry handler that replays a failed request: anything that can form the request can spend the money. The blast radius of a malformed campaign edit is not a stack trace. It is a budget.

AdvertisingOS has always defaulted every mutating operation to mode: "dry_run". A dry run validates the change, touches nothing, and returns the object being changed, a structured before→after diff, and a 7-day performance snapshot for context. That default is necessary. It is not sufficient. Nothing forced the caller to fetch the preview before going live. A caller — human or agent — could send mode: "live" cold, and the interface would honor it, guardrails permitting. Dry-run-by-default made rehearsal available. It did not make rehearsal load-bearing.

The observation

The fix was already sitting in the preview. The dry run computes everything a human needs to approve a change: which object, which fields, from what value to what value, and how the thing has performed for the last seven days. The missing piece was not more information. It was a mechanism that makes the live call depend on the preview — proof, at apply time, that this exact change was previewed, recently, against the state the platform is in right now.

The design: two-phase mutation

Every dry run of a governed mutation now mints a confirmToken alongside the diff:

{
  "dryRun": true,
  "platform": "meta",
  "validated": true,
  "object": { "type": "campaign", "id": "1202154712", "name": "spring-sale" },
  "changes": [
    { "field": "budget",
      "from": { "amountMicros": 25000000, "currency": "USD", "period": "daily" },
      "to":   { "amountMicros": 75000000, "currency": "USD", "period": "daily" } }
  ],
  "performance": { "window": { "start": "2026-08-10", "end": "2026-08-17" },
                   "impressions": 48210, "clicks": 933, "spendMicros": 161250000 },
  "confirmToken": "v1.eyJiIjoiZjNhYi4uLiIsImUiOjE3NTU0NTQ5MzF9.Xk3q...",
  "confirmExpiresAt": "2026-08-17T18:42:11.000Z"
}

The token is a stateless HMAC. Its payload carries a hash of the binding — {connectionId, accountId, platform, objectType, objectId, changes} — and an expiry 15 minutes out. Nothing is stored server-side; any API instance sharing AUTH_ENCRYPTION_KEY can verify a token another instance minted. The confirmation secret is domain-separated from the vault key, never the key itself; without a key, in dev, a per-process random secret is used.

The live call must carry the token back. Here is the part that makes it more than a checkbox: the binding is recomputed at apply time, and the diff inside it is built from a fresh read of the object’s current platform state. The token verifies only if the change you are about to apply is byte-identical to the change you previewed — same target, same fields, same from-values, same to-values. That makes it effectively compare-and-set for ad operations. Live without a token is refused. A token minted for a different change is refused: you never previewed this diff. If a field you are changing drifted since the preview — someone else moved the budget you are about to move — the fresh from-value no longer matches the hashed binding, and the token dies with the drift (drift on fields the patch does not touch is not part of the diff, so it is not caught). A token older than 15 minutes is refused: re-preview.

Every refusal is HTTP 409 with error code confirmation_required and an actionable message:

HTTP/1.1 409 Conflict

{ "error": {
    "code": "confirmation_required",
    "message": "Confirmation token does not match this campaign edit
      (1202154712) — the object or your patch changed since the preview,
      or the token belongs to a different change. Run the same operation
      with mode \"dry_run\" first, review the before→after changes it
      returns, then pass its confirmToken with the live call." } }

And the refusal is not silent. The gate runs inside the recorded span of the governed-change pipeline, so a live attempt that skipped or outlived its preview lands in the object change log as an error entry — same ledger as policy blocks and platform failures. A refused attempt is evidence, not absence.

What this means for agents

The gate is enforced in the pipeline every mutation flows through, so it holds identically across REST, the MCP tools, and the console. For agents specifically, it closes the gap that matters most: the model cannot skip the step where the diff exists. To obtain a valid token at all, the agent must run the dry run — and the dry run’s preview block instructs it to show the human the before→after verbatim (budget: 25.00 USD/daily → 75.00 USD/daily) and pass the token with the live call. The console does the same carry automatically, so the operator flow is unchanged: preview, read the X → Y statement, execute.

One approval covers one change. A retry with different values is a different binding, which is a different hash, which is a dead token. An agent that negotiated approval for a 75.00 budget cannot spend the approval on 750.00.

Honest edges

Three edges worth stating plainly, because the gate is a contract and contracts have terms.

Batch surfaces confirm at the operation level. A flight action mints one token bound to the patch × the exact target list; a pace run mints one token bound to the exact set of proposed moves, recomputed from fresh pacing data on every call. Adding or removing a target invalidates the flight token; shifted spend data that changes the proposed levers invalidates the pace token. What operation-level confirmation does not catch is per-target field drift inside the window — an individual campaign in a 40-campaign flight whose budget moved between preview and apply will still take the patch, because the per-target edit is exempt there: the flight-level gate owns the confirmation. Target-list drift is caught; per-target drift is not.

Applying an approved recommendation is exempt, by declaration. Every governed change must state its confirmation posture in code: { confirmToken } for external edits, or { exempt: reason } with the reason written at the call site. The only flow exempted from the token outright is applying a recommendation a human already approved — approval is the gate there, and requiring a second token would be ceremony, not safety. (The per-target legs of a flight action or pace run are exempt too, but only because their operation-level token already covers them, as above.)

A caller can automate both phases. Nothing stops a script from running dry_run and live back-to-back. That caller has bound itself to the diff — it provably fetched the preview, and the preview provably matches current state — but it has collapsed the human pause to zero. The gate guarantees rehearsal happened and still holds; it does not guarantee anyone read it. That is what the layers above are for: budget-cap guardrails and approval-requirement policies run after the confirmation gate on every live apply, and campaigns are still created paused.

Governance in the protocol

The lesson generalizes past advertising. Infrastructure that agents operate cannot keep its safety in the UI, because agents do not use the UI. A confirmation dialog protects exactly the callers who see it. A confirmation token protects every caller, because it is enforced where the mutation actually happens — in the protocol, verified cryptographically, refused with a status code, recorded in a ledger.

AdvertisingOS’s principles have said “rehearsed” from the start: every mutation can be rehearsed before it runs. As of this change, rehearsed is not a request. The rehearsal mints the only key that opens the live path, and the key fits only the door it was cut for. Rehearsed, declared, reversible — now enforced.

AdvertisingOS is open infrastructure by Surfside. The gate ships in the governed-change pipeline every object mutation flows through — one enforcement point behind a surface of 145 tools and 14 connectors plus a credential-free mock sandbox — and the contract is pinned by tests, the same suite that enforces one mutation, one ledger entry. Only Meta is live-verified against a real ad account today; everything else is built against documented specs and the contract suite, and we say so.