Outcome
Create the next immutable campaign autonomy policy version and reconcile pending approvals.
Before you start
- Verify the environment, credential, authorized organization and the published action permission workspace:write.
- Discover an accessible Engage workspace and bind resourceType=workspace and its resourceId separately from action input. Fixture identifiers grant no access.
- Approve the exact mutation and its effects before CLI --confirm or MCP confirm:true. This documentation does not authorize outreach.
Discover the target
- Discover and read the campaign, then retain the complete campaigns.policy.get response before planning.
- Discover allowed sending seats through seats.list. Use actual seats[].id in seatAllowlist; an empty list allows every seat. Review the pending queue before changing approval mode.
topolo whoami --json
topolo actions get app_topolo_engage.campaigns.policy.update --json
topolo workspaces list topolo-engage --json
Input fields
| Field | Required | Meaning and constraints |
|---|---|---|
id | Yes | Required discovered campaign id. |
approvalMode | Yes | Required per_message, per_batch or none. none also automatically approves messages already pending. |
allowedChannels | No | Optional non-empty channel array; currently only email is declared. Omit to retain current channels. |
dailyVolumeCap | No | Optional integer 1–10,000 or null for no campaign daily limit; omit to retain the current cap. |
maxTouches | No | Optional integer 1–20 or null for no campaign touch limit; omit to retain the current limit. |
seatAllowlist | No | Optional array of up to 100 discovered seat ids. Empty allows every seat, duplicates normalize, omission retains the current list. |
reason | No | Optional trimmed explanation, 1–500 characters, recorded for this new version. |
Validate, plan and execute
Set ENGAGE_WORKSPACE_ID to the identifier of your selected workspace. Prepare payload.json from the current input schema and discovered identifiers. Use the same payload for validation, planning and execution.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions validate app_topolo_engage.campaigns.policy.update --data @payload.json --json
topolo actions plan app_topolo_engage.campaigns.policy.update --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json
Review the plan and obtain approval for the exact effects before executing the unchanged request.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions call app_topolo_engage.campaigns.policy.update --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json
For MCP, use topolo_get_action, topolo_validate_action, topolo_plan_action and topolo_call_action. Pass the selected resource context separately; confirm:true is required for mutations.
Worked requests and responses
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 2,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: task steps in sequences > stores linkedin_task and call_task steps with instructions and a due offset, and copies them into a new version. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "per_batch",
"dailyVolumeCap": 20,
"maxTouches": 3,
"seatAllowlist": [
"eng_seat_9",
"eng_seat_9"
],
"reason": "pilot"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 2,
"approvalMode": "per_batch",
"allowedChannels": [
"email"
],
"dailyVolumeCap": 20,
"maxTouches": 3,
"seatAllowlist": [
"eng_seat_9"
],
"emergencyStop": false,
"changeReason": "pilot",
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: campaign autonomy policy > versions every change, keeps history and audits it. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "per_message",
"dailyVolumeCap": null
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 3,
"approvalMode": "per_message",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": 3,
"seatAllowlist": [
"eng_seat_9"
],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: campaign autonomy policy > versions every change, keeps history and audits it. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_legacy",
"approvalMode": "none"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_legacy",
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"version": 1,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: campaign autonomy policy > gives a campaign created before policies the built-in version 0 until its first change. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_2",
"approvalMode": "none"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_2",
"version": 2,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: engagement analytics > reports first-touch rates by campaign: each contact once, bounced never replied, test campaigns excluded. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_3",
"approvalMode": "none"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_3",
"version": 2,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: engagement analytics > reports first-touch rates by campaign: each contact once, bounced never replied, test campaigns excluded. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "per_batch"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 2,
"approvalMode": "per_batch",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: message approval > per_batch: approves every pending message of one step and render date at once. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "per_message",
"dailyVolumeCap": 1
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 2,
"approvalMode": "per_message",
"allowedChannels": [
"email"
],
"dailyVolumeCap": 1,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 1
}
Source test: message approval > a policy change reaches only work that is not approved yet. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 3,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": 1,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 1
}
Source test: message approval > a policy change reaches only work that is not approved yet. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_legacy",
"approvalMode": "per_message",
"maxTouches": 5
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_legacy",
"approvalMode": "per_message",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": 5,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"version": 1,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: message approval > a message rendered after a change is approved under the new version; an unstored version falls back to the current one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none",
"maxTouches": 1
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 3,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": 1,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: message approval > maxTouches ends the enrollment before a later step. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none",
"dailyVolumeCap": 1
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 3,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": 1,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: the runner on a WhatsApp template step > holds at the campaign volume cap until the next day. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none",
"seatAllowlist": [
"eng_seat_other"
]
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 3,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [
"eng_seat_other"
],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: emergency stop and seat allowlist > enforces the seat allowlist of the current policy at send time. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none",
"seatAllowlist": [
"eng_seat_1"
]
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 4,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [
"eng_seat_1"
],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-06T08:30:00.000Z"
},
"pendingMoved": 0
}
Source test: emergency stop and seat allowlist > enforces the seat allowlist of the current policy at send time. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 200
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none"
}
Actual local response
{
"policy": {
"campaignId": "eng_cmp_1",
"version": 2,
"approvalMode": "none",
"allowedChannels": [
"email"
],
"dailyVolumeCap": null,
"maxTouches": null,
"seatAllowlist": [],
"emergencyStop": false,
"changeReason": null,
"createdBy": "user_1",
"createdAt": "2026-10-05T09:00:00.000Z"
},
"pendingMoved": 0
}
Source test: render ahead for approval > leaves messages beyond the look-ahead and campaigns that need no approval for the due-time path. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Effects and response meaning
- Creates a new immutable policy version. approvalMode is required; omitted optional policy fields retain current values. Duplicate seatAllowlist ids are normalized to one occurrence. The emergency stop is unchanged.
- Moves pending-approval messages to the new policy version. When approvalMode=none, those pending messages are automatically approved and may become eligible for the sender runner. Already approved messages retain the policy version recorded at approval; later rendering uses the new version.
- Returns policy and pendingMoved, the number of pending rows moved by this operation, and records an audit entry. This count is not a send or delivery count.
- Removing caps or widening seatAllowlist affects later sending decisions. Review the real recipient and external-send effects before approval; policy success is not outreach authorization or delivery proof.
Verify the result
- Read campaigns.policy.get in the same workspace. Compare the new policy version, each supplied field, retained omitted fields and unchanged emergencyStop.
- Read approvals.list and retained enrollment message histories to inspect pendingMoved effects. When switching to none, reconcile automatically approved rows through enrollments.messages.list; they may have left the pending queue.
Recover
After an uncertain response, read current policy and message history first. A repeated update creates another version even when values match. Do not retry blindly. A reviewed restoration uses the previous approvalMode, channels, caps and seatAllowlist to create another immutable version after checking concurrent changes. It cannot recall messages already sent or undo an approval recorded under mode none; restoring per_message does not turn already-approved messages back into pending approval.
Refused responses
Captured campaigns.policy.update · HTTP 404
Action input
{
"id": "eng_cmp_nope",
"approvalMode": "none"
}
Actual local response
{
"error": {
"code": "not_found",
"message": "Campaign was not found in this workspace."
}
}
Source test: campaign autonomy policy > 404s an unknown campaign, refuses readers and invalid input, and 409s a concurrent change. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 422
Refused input; do not execute
{
"approvalMode": "sometimes",
"id": "eng_cmp_1"
}
Actual local response
{
"error": {
"code": "invalid_action_input",
"message": "Input for campaigns.policy.update is invalid.",
"details": [
{
"code": "invalid_value",
"values": [
"per_message",
"per_batch",
"none"
],
"path": [
"approvalMode"
],
"message": "Invalid option: expected one of \"per_message\"|\"per_batch\"|\"none\""
}
]
}
}
Source test: campaign autonomy policy > 404s an unknown campaign, refuses readers and invalid input, and 409s a concurrent change. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 403
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none"
}
Actual local response
{
"error": {
"code": "permission_denied",
"message": "The credential lacks workspace:write.",
"details": {
"requiredPermission": "workspace:write"
}
}
}
Source test: campaign autonomy policy > 404s an unknown campaign, refuses readers and invalid input, and 409s a concurrent change. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 409
Action input
{
"id": "eng_cmp_1",
"approvalMode": "none"
}
Actual local response
{
"error": {
"code": "state_conflict",
"message": "The policy changed at the same time. Reload and try again."
}
}
Source test: campaign autonomy policy > 404s an unknown campaign, refuses readers and invalid input, and 409s a concurrent change. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured campaigns.policy.update · HTTP 422
Refused input; do not execute
{
"approvalMode": "none",
"allowedChannels": [
"sms"
],
"id": "eng_cmp_1"
}
Actual local response
{
"error": {
"code": "invalid_action_input",
"message": "Input for campaigns.policy.update is invalid.",
"details": [
{
"code": "invalid_value",
"values": [
"email"
],
"path": [
"allowedChannels",
0
],
"message": "Invalid input: expected \"email\""
}
]
}
}
Source test: campaign autonomy policy > 404s an unknown campaign, refuses readers and invalid input, and 409s a concurrent change. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Error recovery
- HTTP 403
permission_denied: Stop and inspect the permission required by this action in the current credential. Do not broaden permissions or switch organization automatically. - HTTP 422
invalid_action_input: Correct the request against the current input schema and validate it again before planning. - HTTP 404
not_found: Rediscover the campaign using campaigns.list in the same authorized workspace, including status=archived when appropriate. Do not guess another organization or workspace. - HTTP 409
state_conflict: Read campaigns.policy.get again: another policy version won the concurrent write. Reconcile the intended change against the new version and re-plan only a still-authorized mutation.
Related actions
- Get campaign autonomy policy
- List messages awaiting approval
- List enrollment messages
- List sending seats
Evidence and limits
-
contracts/autonomy.mjs -
apps/api/src/autonomy/policy-domain.ts -
apps/api/src/autonomy/policy-handlers.ts -
apps/api/src/autonomy/policy-service.ts -
apps/api/src/autonomy/policy-repository.ts -
apps/api/src/autonomy/approvals-repository.ts -
apps/api/test/autonomy.test.ts -
Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
-
Widget/bootstrap use actual app handler but mocked worker runtime and app identity.
-
No gateway execution, live permissions, external delivery or deployed proof.
The examples are local route captures with synthetic context and simulated providers. They do not prove live authorization, CLI/MCP gateway execution, production state or delivery.