# Change proposals, approval and batching

How to turn a request into a reviewable change list, bind approval to it, and apply it without drifting from what was approved.

## Decide the approval class first

Every proposed operation lands in one of three outcomes:

- Allowed under standing permission: reads, analysis, saving an internal draft, creating a GTM workspace edit that nobody publishes. No per-change approval, but still logged.
- Prepared for review: any write to a live object. The agent drafts and stops.
- Refused in this workflow: the operation is not what this workflow was set up for (for example a contact export through a budget workflow). Say so and stop. A reviewer cannot approve it here.

Always prepared for review, with one approval line per row:

- Starting or increasing spend, activating a paused entity, creating or changing an A/B test or lift study.
- Sending, launching or scheduling a message, launching a survey or experiment, rolling a feature flag out, publishing or enabling a workflow.
- Publishing a container version, page or post.
- Deleting, merging, suppressing or unsuppressing, changing opt-out or consent lists, bulk importing people.
- Changing user permissions, PII classification, event or property visibility.
- Creating a webhook, destination or connection that moves data out, or starting a sync or historical re-sync.

## What a proposal row holds

Use [../assets/change-proposal-template.yaml](../assets/change-proposal-template.yaml). Each row:

- `system`, `account` (Google Ads customer ID, Meta `act_` ID, GTM container and workspace ID, HubSpot portal ID, project ID), `object_type`, `object_id` (stable ID), `object_name` (readable; names repeat, IDs do not). The account sits on every row, because one session can reach several accounts through a manager account, Business Manager, a multi-container GTM account or a multi-portal install.
- `field`, `before`, `after`. For a full-replacement API, `after` is the complete object.
- For money fields: `unit` (as the API holds it), `level` (campaign or ad set; shared or single budget), and the readable amount next to each raw value.
- `reason` and `evidence` (a report link, query or record reference the reviewer can open).
- `side_effects`: from the checklist below.
- `reversibility`: reversible, reversible within a window (state it), or irreversible.
- `rollback`: the exact action, who can run it without the agent, the window, and what it does not undo.

Proposal-level fields:

- `proposal_id`, `version`, `requested_by`, `approver_role`, `run_mode` (interactive, scheduled, triggered or headless).
- `target_query` and `target_count`: the query that selected the targets and a separate count query. The count must equal the number of rows.
- `totals`: objects touched, daily spend change, recipients reached, items deleted.
- `limits`: maximum objects, maximum spend change per day, `allow_partial` (true or false), `expires_after_minutes`.
- `monitoring`: each signal with a numeric trigger and an owner, stated by the approver, not invented by the agent.
- `out_of_scope`: objects the approver excluded.

## Money fields and budget level

The classic agent budget error is a unit error of 100 or 1,000,000 times, and a read-back does not catch it when both sides use the wrong unit.

- Meta: the Marketing API sets bid and budget amounts at the account currency's minimum denomination, such as cents for USD. The Meta MCP pages do not state units for `ads_update_entity`, so read the field type with `ads_get_field_context`.
- Google Ads: `amount_micros`; one million micros is one currency unit. Read field metadata with `get_resource_metadata`.
- Every money row shows the raw API value and the readable amount: `daily_budget 20000 (USD cents) = USD 200.00, to 14000 = USD 140.00`.
- Compare the API before value with the UI before proposing. A factor of 100 or 1,000,000 between them means the unit is wrong in the proposal.
- Confirm the level. A Meta budget can sit on the campaign or the ad set; read both and edit the one that holds it. A Google Ads shared budget or portfolio bid strategy covers several campaigns; the row lists every one.

## Target lists from complete reads

- Caps hide rows: BigQuery MCP results stop at 3,000 rows, Snowflake MCP responses are truncated at 250 KB, GAQL on `change_event` needs a `LIMIT`, and list tools paginate.
- Run a separate count query for the same filter. If its result differs from the row count, or the row count equals a cap or page size, the read was truncated. Page through or narrow the filter before proposing.
- Show the reviewer when results were partial.

## Side-effect checklist per system

Fill `side_effects` from this list for every row:

- HubSpot: active workflows that use the field (lifecycle stage, lead status, owner); pipeline stage requirements and property validation rules. The remote MCP page says it respects custom pipeline stage validations; the ChatGPT connector page says custom validation rules are not applied through that connector. Read the rules and enforce them in the proposal either way.
- Salesforce: validation rules (a breaking write fails), record-triggered flows, Apex triggers and assignment rules on the object. Check in Setup which are active.
- Meta: significant edits, which can include budget or bid changes depending on size and changes to targeting, creative or the optimisation event, can send an ad set back into the learning phase. Quote the current wording of Meta's Business Help Centre page "About the learning phase". Catalog product updates change live catalog ads.
- Google Ads: shared budgets and portfolio bid strategies spread one change across campaigns.
- Braze: a content block edit updates every message that inserts it through Liquid (read inclusion with `get_content_block_info`); segment edits change who live campaigns reach.
- Klaviyo and PostHog: flows, workflows and feature flags change behaviour for everyone they target.
- Fivetran: re-syncs, schema changes and pauses change downstream tables and cost.
- Warehouse: downstream models, dashboards and reverse-ETL syncs that read the table.

## Validate without writing

Use the vendor's own check where it exists, and verify the current behaviour live:

- GTM: `gtm_workspace` `quickPreview` compiles the workspace into a fake version and reports compiler errors. It does not fire tags.
- HubSpot Agent CLI: `--dry-run` on every write command; destructive commands require the dry-run safety digest.
- Google Ads API: `validate_only` on mutate requests (not batch jobs).
- BigQuery: a query dry run validates and estimates bytes without running.
- Snowflake: `EXPLAIN` compiles a statement without executing it.
- Meta: creating entities paused is staging, not a dry run. The write still happens.

## What the reviewer must see

- The rows with before and after side by side, the totals, the side effects, the rollback plan and the expiry.
- Raw and readable money values together, and percentages next to absolute numbers. "Plus 20%" on five campaigns at USD 100 a day is USD 100 a day more in total.
- Gaps in evidence, stated beside the row, not hidden in a summary.
- Not the model's reasoning transcript. Enough to judge the change with reasonable effort.

## Binding approval to a version

- The approval record holds: approver identity, time, proposal ID and version, conditions. The agent does not write these fields from its own text; they come from the approver.
- Any change to targets, values or scope creates a new version and needs a new approval. An approver's own edit also creates a new version, shown again before applying.
- Expiry: the approver's value, or 30 minutes if none was set. After expiry, re-read the before values and ask again. An expired approval never turns into an automatic yes.
- Approval of 50 named records does not cover 50 replacement records when some fail.

## What does not count as approval

- The stored prompt of a scheduled, triggered or headless run, even when it says "apply the changes". Those runs stop at the proposal and queue it where the approver will see it.
- A client setting that allows a tool always, or a decision the client remembers for the rest of a conversation. Keep write tools on needs approval or blocked.
- A permission mode that skips prompts.
- A review by another model or agent. Record it as advisory, apart from the human decision.
- Confirmation wording in a vendor's tool description, and a completed OAuth connection.

## Recheck immediately before each write

- Confirm the account or project ID in the tool call equals the row's account. Stop on mismatch.
- Re-read the current value of every field in the row. If it differs from the approved `before`, stop: someone else changed it, and applying the approved `after` may now do the opposite of what the reviewer intended (a budget raised elsewhere to 140 would be cut by an approved "increase" to 120).
- Use the vendor's version check when one exists. Stape's GTM `update` and `publish` need the entity `fingerprint`; a stale fingerprint should fail rather than overwrite.
- A read followed by an unconditional write still leaves a race window. Keep the gap short and say in the log that the write was not atomic.
- For messaging, re-check suppression and eligibility at send time. An approved audience never overrides a later opt-out.

## Batching

- First batch: one object. Read it back and confirm side effects before the next.
- Later batches: no larger than the approved maximum, or 10 objects when none was set.
- Before each batch, check the approval is still valid. If it expired mid-run, start nothing new, record completed, skipped and unresolved rows, and ask for approval of the remaining scope only.
- Reserve against aggregate limits before each batch. If eight USD 20 increases are proposed under a USD 100 daily limit, only five fit; the rest go back for a new decision.
- When `allow_partial` is false and a row fails, stop and report; do not continue the remaining rows.
- The Salesforce create tool takes one record body per call (verify live), so a batch of N creates is N calls with N action keys.

## Retries and unknown outcomes

- Give each change a stable action key, for example `<proposal_id>-v<version>-<row>`. Reuse it on retry; never mint a new one per attempt.
- After a timeout or ambiguous response, read the object first. If the change is there, mark it verified. If not, retry once with the same key. If still unclear, mark the row unresolved and stop that row.
- Unresolved rows keep their share of any aggregate limit reserved until reconciled.
- Agent frameworks can repeat work on resume. LangGraph documents that a resumed node restarts from its beginning, so code before an interrupt runs again; keep side effects after the approval point and make them idempotent.

## Statuses for each row after apply

- `verified`: a fresh read shows the approved after value in the approved account.
- `skipped`: not attempted, with a reason (drift, out of scope, limit reached, approval expired).
- `failed`: the system rejected the write; error recorded without secrets.
- `unresolved`: outcome unknown; owner and next check named.

## Tests before giving an agent write access

Run these in a sandbox or test account and keep the results with the connection inventory:

- Changing a target or amount after approval invalidates the approval.
- A reviewer without the required role cannot approve.
- An expired approval cannot run through a retry or manual replay.
- A changed before value stops the write.
- A call against an account that is not on the row stops the write.
- Replaying one approved change does not create a second effect.
- Concurrent proposals cannot exceed the shared limit.
- A partly completed batch reports verified, skipped and unresolved rows separately.
- A scheduled run with no human approval stops at the proposal.
- Removing the connection's write access stops pending work.

## Measuring whether review is working

- Track rejections by reason: missing evidence, wrong target, stale state. Repeated missing-evidence rejections mean the drafting step is weak, not that review should be removed.
- If a stable, low-impact operation is approved every time with no edits, consider a narrow standing permission for that operation only, written into `martech-context/decisions.md` with its limits.
