# Changes reference

> The change ledger record field by field — sources, statuses, classes, risk tiers, windows, last-known-good records, rollback paths, harvest sources and the audit actions a change emits.

_Reference · Updated 2026-10-28 · Describes release 0.1.164_

Exact facts about the change ledger. Procedures live in [Change windows, emergency changes and rollback](/help/change-control.md), [Review execution in Changes](/help/review-execution-in-changes.md#review-execution-in-changes), [Runbooks and automatic fixes](/help/runbooks-and-automatic-fixes.md) and [Automatic API fixes](/help/automatic-api-fixes.md); the approval rules are owned by [Approval policy and change tiers](/help/approval-policy.md).

## The change record {#change-record}

One row per change the platform executed, prepared, declared or observed. `GET /api/aiops/{workspace}/changes` returns the public copy of each row (credentials masked structurally, the platform's own webhook token redacted, the internal `execution_intent` / `rollback_intent` keys removed from `meta`). On screen a change is quoted as `CHG-` plus the first 8 characters of its id.

| Field | Meaning | Set by |
|---|---|---|
| `id` | Row id; the display id is `CHG-<first 8>` | platform |
| `source` | Where the row came from — see [Sources, origins and modes](#sources-origins-modes) | the minting path |
| `origin_type`, `origin_id` | What the change is about (`incident` + the incident id for cross-links, `finding`, `control`, …) | the minting path |
| `service` | Primary target service — the key the incident card and `change_lookup` query by; never rewritten after the fact | the mint; a harvest links extra services instead |
| `services` | Every service the change touches: impacted services of the origin incident, or the services a resource map / self link tied a harvested write to. Only the second kind pins an alert's incident on the change or folds an alert by `change-window`: a platform change's impacted services are what it may affect, not what it changed, so it pins and folds on its own `service` and resource only | the mint, `change_resource_map`, the self link |
| `title`, `summary` | What and why, PII-scrubbed at write | the mint |
| `status` | Lifecycle state — see [Statuses](#statuses) | the lifecycle |
| `risk_tier` | `L0`–`L3`, stamped at plan time; a class or a reclassify may raise it | `tiers.classify`, the class |
| `plan` | The remediation plan (`steps`, `commands`, `execution_steps`, `file_changes`, `manual_commands`, `verification`, `rollback`, `generated_by`, `plan_investigation_id`); a released change also carries `_execution_review` (the reviewed action, shown only while its digest is the approved one) | the wrap-up, the operator handoff, the review |
| `execution_spec` | The compiled executable specification of a prepared plan: `steps[]` (operation, target, mode, params, connection), `selected_connections`, `change_window`, `risk_tier`, `verification_checks`, `rollback_commands`, `impact` | `planned_execution.review` |
| `lkg` | The last-known-good capture — see [Last-known-good records](#last-known-good) | the capture before the write |
| `rollback` | The rollback the platform holds — see [Rollback paths](#rollback) | synthesized at execution |
| `route_id` | The remediation route used (empty for a prepared plan or an observed change) | a route run |
| `mode` | Execution channel or ingested event kind — see [Sources, origins and modes](#sources-origins-modes) | the mint |
| `approved_by` | Distinct user ids that released the current reviewed action | the release |
| `approvals_required` | Quorum for `risk_tier` (1, or 2 at L3) | the tier policy |
| `authorized`, `authorization_note` | `false` = an unauthorized external change (denied write, freeze window, principal check, no reference) or a refused release; the note says why; a change kept as legitimate turns `true` with the keeper's note | the harvest, `authorize()`, **Keep this change (legitimate)** |
| `executed_at` | When the write happened (for an observed change: the event time, which may be days before the row) | execution / the event |
| `execute_result` | `{ok, detail, uncertain, …}` from the executor | execution |
| `verify_verdict` | `cleared`, `still_present` or `uncertain` | the recheck, a human verify |
| `verify_ref` | The `IncidentRecheck` id, a verify investigation id, or `manual:<user id>` | the verdict |
| `external_key` | Dedupe key of an observed change (commit sha, PR id, sync revision, audit event id) | the harvest / webhook |
| `external_url` | Link to the PR, commit or ticket (sanitised before store) | the harvest / webhook |
| `actor` | A user id, `system:auto` (an automatic lane), `agent:<pillar>` (a drafted request) | the mint |
| `meta` | Side facts — the keys below | several |
| `created_at`, `updated_at` | Row times | platform |

`meta` keys the API exposes:

| Key | Meaning |
|---|---|
| `change_class`, `change_class_source`, `change_class_reason`, `release_class`, `change_class_auto`, `class_overrides[]`, `class_signature` | The change class and its provenance — see [Change classes](#change-classes) |
| `proposed_from_wrapup` | The request an escalated analysis minted (id derived from the investigation, so a Request click never opens a second row) |
| `scope[]`, `scope_total`, `scope_fixes` | The alerts and findings the request covers (at most 50 listed, excluded and left-out ones first) and how many of them the fix covers |
| `scope_deferred[]` | The resources a person left out of the run the request was drafted from, in full; the review, the automatic runbook lane and a later **Generate plan** read it here |
| `approvals[]` | The approval ledger: `{user_id, kind, at, digest, note?}` with `kind` = `requested`, `approved` (a vote that did not run the change: short of the quorum, or an **Approve** without running it), `released` (the person who released it to run: the vote that completed the quorum and ran it, or the run of an approved change by someone else, recorded when it dispatches), `rejected` or `lapsed`. Approved by one person and released by another is two entries, two people. An operator release records `note: standard change released by an operator`; its review digest binds that release policy. |
| `approved_action_digest` | The reviewed action the votes belong to; a different review clears the votes |
| `impact` | Blast radius from the estate ontology (`basis`: `estimated` < `runtime` < `computed` < `certified`), computed lazily when the request is opened. `basis_reason` is the one sentence on what the numbers rest on — for an estimate, *computed from the target's direct neighbours in the estate map; this operation has no exact before/after delta.* unless a more specific reason applies (the target not found, the scope too large to reason over); the page and the document show it once, as *Estimate: …*, the specific reason instead of the default (a report stored before 0.1.161 that joined both reads *Estimate: target not found in the harvested estate*). `coverage_gaps[]` are the map's gaps in words, one line per kind and only when non-zero, each against the whole map: *N of M resources in the estate map: no relationship model yet (types without a typed reader: …)*, *… stale after the last harvest, left out of reasoning*, *… inconsistent between sources, left out of reasoning*, never overlapping (a report stored before 0.1.161 folded them into *N entities and M relations flagged inconsistent or stale left out of reasoning*: its relations read in the per-kind line, its entities — stale and inconsistent counted together, with no total — as *Coverage gap recorded before 0.1.161, not split by kind: N resources in the estate map stale or inconsistent between sources, left out of reasoning*); a gap on the touched resource itself comes first and makes the basis an estimate. An estimate's *affected* list is the target's direct neighbours — unmodelled or unexpanded ones included, stale or inconsistent ones left out — never the account, subscription, team or owner it is co-located under. `mode` is `cheap` on the report computed when the request is first opened; the reviewed one has none |
| `window_bypassed` | `{at, by, window, quorum}` — an emergency released through a closed window |
| `window_exception` | `{requested_by, requested_at, start, end, reason, status, decided_by, decided_at, action_digest, used_at, used_by}` — a separate, digest-bound one-time window; approval is not a release vote |
| `pir` | The post-implementation review: `{status, opened_at, due_at, owner, note, trigger, landed_at, outcome}` |
| `principal` | Who made an observed cloud write — see [Principal classes](#principals) |
| `detail` | The harvested event: provider, operation, resource id, region, request parameters, the before/after diff, `before_source`, `link` |
| `route_revision` | For a route run: the route revision, who certified it and the release kind (`person`, `auto_pr`, `auto_direct`) |
| `validation`, `verify` | The post-change metric window and the recheck's evidence stamped on the change |
| `snapshots[]` | `{after_step, operation, at, resources[{step, target, operation, status, image, note, read_at, artifact_id}], not_read?, note?}` — every resource the plan writes, read again after each write step of a multi-step plan (at most 10 per step, 40 per change; evidence only) — see [Resource snapshots](/help/change-request-document.md#snapshots) |
| `post_state` | `{captured_at, steps[]}` — the post-change read the last-known-good record takes once the change is verified |
| `rollback_intent`, `rollback_result`, `rollback_receipt_recorded`, `rollback_reconciled`, `rollback_uncertain` | Rollback bookkeeping; any of them means the sweep will not replay a rollback |
| `rollback_attempts` | Rollbacks that wrote nothing for certain — the provider refused the call before acting (a 4xx other than 408/409), or the release stopped before dispatch: `{at, released_at, by, reserved_at, ok: false, detail}` (`at` = the refused release, `released_at` = when the reservation was given back), the last 10. Such an attempt gives its reservation back, so the change offers Roll back again (same tier gate) — the rollback sweep never replays it either; an uncertain or partial rollback keeps its reservation until it is reconciled |
| `reverts`, `undone_by` | The re-break link: this captured write put back what a platform fix cleared, or this fix was undone by that write (within 14 days) |
| `deploys`, `sync_status`, `application` | For an observed deployment: the platform change it deployed, the sync state, the application evidence |
| `reference` | What accounted for an applied write the audit log captured, while incidents for changes with no reference are on: `{kind: platform, receipt}`, `{kind: allowlist, pattern}`, `{kind: itsm, change_id, ref, title}` or `{kind: change_request, change_id}` — see [Unauthorized changes and denied access](#unauthorized) |
| `itsm` | An ITSM change ticket's `{state, ci, start, end}`, refreshed by every re-send of the ticket |
| `unauthorized_incident` | The incident an unauthorized change or a denied attempt opened or joined |
| `kept` | `{by, at, note, incident_id}` — an approver kept this unauthorized change as legitimate |

### What the detail endpoint adds {#change-detail}

`GET /api/aiops/{workspace}/changes/{id}` (`incidents.view`) returns:

| Key | Content |
|---|---|
| `change` | The public record above, plus `execution_review` when the approved digest matches |
| `rollback_first` | The pinned change's rollback when the workspace's rollback-first switch is on and this request is not that change |
| `approvals` | `meta.approvals` with user ids resolved to emails |
| `route_certifier` | Who certified the route revision a route run used |
| `artifacts` | Last-known-good captures, rollback plans, PR evidence (`kind` = `lkg`, `rollback`, …) |
| `incident` | The origin incident, when `origin_type` is `incident`; for an unauthorized change or a denied attempt, the incident it opened (`meta.unauthorized_incident`) |
| `external_executions` | Operator reports of work done outside the platform (computer-assist mode): `performed_by`, `executed_at`, `outcome` `succeeded` / `failed` / `unknown`, `summary`, evidence links — reported, never verified |
| `tier_policy` | `{approvals, permission, label}` for `risk_tier` |
| `snapshots` | Every resource the plan writes: its before-state, the read after each step, the post-change read — `{phase, label, step, target, status, state, at, changed, artifact_id}`, `changed` against the before-state (`null` = not comparable) |
| `upgrade_warning` | An upgrade's warning (section 2.4 of its [change request document](/help/change-request-document.md)): the change management agent's words, else exactly `unknown, not checked`; `null` for a change that is no upgrade |
| `verify` | `{kind: manual|agent, verdict, by/at}` or the recheck's `summary`, `checks`, `model`, `cost_usd` |
| `trail` | Every audit event targeting this change (newest 200), plus the origin incident's `remediation.pr_merged` |

## Sources, origins and modes {#sources-origins-modes}

| `source` | Written by | Typical `origin_type` |
|---|---|---|
| `incident` | An escalated analysis (the proposed change request), a route run from the incident page, a prepared plan released from the incident | `incident` |
| `manual` | `POST …/changes` — a declared change request (`incidents.act`), optionally copying an investigation's recorded plan; the Test Lab | `manual`, `incident`, `testfactory` |
| `compliance` | A control's "request a change" and the fix risk decision (`avoid`; `mitigate` on decisions made before fix / suppress) (through the change hub; compliance holds no write path) | `control`, `finding` |
| `lkg_draft` | The revert draft the incident's last-known-good record offers | `incident` |
| `ingested` | The harvest (cloud audit logs, watched repositories) and the change webhooks; status `recorded`, no lifecycle | `webhook` |

`mode` on a platform change is the execution channel: `gitops_pr`, `api`, `cli`, `mcp`, `network`, or `external` (an operator reported it). On an `ingested` row it is the event kind:

| Kind | Source |
|---|---|
| `push` | A commit pushed to a watched repository, or sent by the GitHub / Gitea / GitLab / Azure DevOps webhook |
| `pr_merged` | A pull request merged (same senders) |
| `deploy` | A deployment reported to the change webhook |
| `sync` | An Argo CD sync or drift event (`argocd:<app>@<revision>`; `:drift` when `OutOfSync`) |
| `ticket_change` | A change ticket from ServiceNow or Jira |
| `cloud-write` | A write read from CloudTrail, the EKS audit log, the Azure activity log or the Kubernetes audit webhook |
| `config` | A setting changed inside an application, posted to the change webhook (`detail {key, old, new}`; a secret-looking key keeps only its name) |

List filters: `kind=push,pr_merged,deploy,sync` (the Deployments chip; an unknown kind is a 400), `mode=platform` (everything the platform executed, i.e. not `cloud-write`), `source=`, `status=`, `service=`, `unauthorized=true` (unauthorized changes, the Unauthorized chip; never a denied call), `denied=true` (calls the cloud refused — unauthorized access, the Denied attempts chip), `requests=true` (every request, proposals included, the ones waiting on a person first), `include_proposed=true`, `q=` (display id, title or service).

## Statuses {#statuses}

| Status | Meaning | On screen (Requests view) |
|---|---|---|
| `draft` | Declared without a plan | — |
| `planned` | A plan exists; nobody has asked for approval yet | Awaiting request |
| `pending_approval` | Requested; the quorum is not complete | Awaiting approval |
| `approved` | Quorum complete, not yet dispatched | — |
| `dispatching` | The write was reserved and sent | — |
| `executing` | The write ran; a pull request is open | — |
| `verifying` | Waiting for the post-fix verdict. Its incident stays *remediating* meanwhile: a scanner clear or an alert that resolves at its source does not resolve the incident by itself — it lets the platform read the validation plan at once (below), the change's verdict decides, the change completes and the incident resolves after it (observed live: a scanner clear at 20:49Z, the resolve at 20:55Z) | — |
| `completed` | Verified cleared, or the PR merged and validated. A change whose every step acted on the resource of an alert merged into the incident, none on its own, completes with the verdict `uncertain` when the incident clears (`meta.cleared_externally`, shown as the change's note): the clear is not credited to it | — |
| `failed` | Execution failed, verification said `still_present`, or it stopped without a verdict (the verification ran out, or a person closed it without one) | — |
| `not_needed` | The run stopped because its only target already complied (an EC2 instance Patch Manager reads compliant): nothing was changed, so nothing rolls back. The reason is on the change page, the incident's remediation history and the change request (*Not needed — <reason>*) | — |
| `rolling_back`, `rolled_back` | A rollback is running / landed | — |
| `rejected` | Refused by an approver or by a structural check | — |
| `recorded` | An observed external change (never moves) | — |

Terminal states: `completed`, `rolled_back`, `not_needed`, `rejected`, `recorded`. A release is only possible from `draft`, `planned`, `pending_approval` or `approved`; an operator may report an external execution from those plus `failed` and `verifying`.

## Change classes {#change-classes}

The class is ITIL vocabulary decided in code at wrap-up, never by the model, and stamped as `meta.change_class` with `change_class_source` and a plain-words `change_class_reason`.

| Class | Tier floor | What it changes |
|---|---|---|
| `standard` | L1 | A matched-runbook standard fix at L1/L2 may be released by an operator (`incidents.act`) or an approver — a certified runbook route's at any priority, P1 included; other standard requests and L3 still need approvers. No automatic standard-release lane. |
| `normal` | L2 | The default for everything else |
| `emergency` | L3 | Two distinct approvers; a closed change window yields once both have approved (`meta.window_bypassed`, audit `change.window_bypassed`); a post-implementation review opens when the write lands (or at submission with `aiops_pir_at_submission: true`), due in `aiops_pir_due_h` hours (72) |

### Derivation rules {#class-derivation}

Evaluated in this order; the first rule that applies decides. `change_class_source` names it.

| Order | Condition | Class | `change_class_source` |
|---|---|---|---|
| 1 | `settings['change_class_policy'] = "standard_default"` | the inherited class if one exists, else `standard` | `inherited:<CHG id>` / `policy_default` |
| 2 | `aiops_runbook_standard_change` on and a certified `remediate-…` route declares every typed operation of the plan (each one also on `aiops_preapproved_operations`, no raw command or file change beside them, any `manual_commands` only restating a typed step) | the inherited class if one exists, else `standard` — released under the standard rules even for a P1 or critical incident (no two-approver rule; an older request that recorded `meta.release_class = emergency` keeps it) | `inherited:…` / `runbook_route` |
| 3 | The incident is `P1` or its severity is `critical` | `emergency` | `rule` |
| 4 | A person set a class on an earlier request with the same operations (`meta.class_signature`) | that class | `inherited:<CHG id>` |
| 5 | `aiops_runbook_standard_change` **off**, a runbook skill (not a process skill) was fetched during the analysis, the plan has typed `execution_steps`, every operation is on `aiops_preapproved_operations`, and there are no `commands` or `file_changes` — with the switch on (the default) a fetched skill alone never makes a change standard | `standard` | `rule` |
| 6 | Anything else | `normal` | `rule` |

A runbook route's standard class holds only while the runbook does. When the route is deleted, disabled, demoted, loses its certification or is edited before the request is released, the request reads as the class it has without the runbook (`normal`, or `emergency` for a P1 or critical incident) with source `runbook_withdrawn` on the incident and the change page — the class cell and the Timeline heading say why, in one vocabulary with the route row and the runbook catalog (`meta.change_class_cause`): *certification withdrawn by a person*, *runbook demoted after a rollback*, *after a failed validation* or *after a fix that did not hold*, *runbook disabled*, *deleted*, *no longer certified* or *changed since the request was drafted*; a row from before 0.1.159 carries no cause and reads *runbook no longer backs it* — at the tier that class raises it to (an emergency is L3, two approvers), the review releases it at that class, and the drop is recorded on the row (audit `change.class_dropped`, votes cleared) when a person next requests, approves or reclassifies it. An executed change keeps the class it ran under. A request is never stuck on its runbook: when the platform can no longer compile its steps without it — the runbook was deleted or disabled and its steps need it (an EC2 patch's isolation and patch steps run only inside a runbook), whatever the class — the release dialog shows **Runbook no longer backs it — run it by hand** with the runbook's steps in order: each check, the backup as its CLI command, each change as the command you run with your own access, each validate criterion and the rollback. Run them, then record what you did (on an incident, mark it resolved). A demoted or uncertified runbook still expands its steps: the request is a normal change the platform runs once a person releases it. A planned patch run whose runbook was deleted or disabled reads the same.

A route run from the incident page records `standard` with source `runbook_route` only when the run stays inside the route's declared scope (a GitOps route editing the file it declares, any other route running its own configured call); otherwise the row carries no class. The class only ever **raises** the release tier: `risk_tier = max(intrinsic tier, class floor)`.

### Standard by default {#standard-by-default}

Guardrails offers two policies for `change_class_policy`:

| Policy | Wording on Guardrails |
|---|---|
| `derived` (unset) | Emergency for P1/critical, standard when a pre-approved runbook fix, otherwise normal (people may raise a request, never lower it). |
| `standard_default` | Every request starts standard; people raise it to normal or emergency, and a class someone set on an earlier request with the same operations is inherited. |

The pre-approved operation list (`aiops_preapproved_operations`) is validated against the typed operation vocabulary (the cloud and Kubernetes operations plus the repository edit); whoever may edit routes cannot make a fix standard alone, because the owner's list on Guardrails must also name every operation.

### Reclassifying {#reclassify}

`POST …/changes/{id}/class` with `{change_class, note}` (`incidents.act`, note at least 5 characters):

| Rule | Effect |
|---|---|
| Allowed while `status` is `planned`, `pending_approval` or `approved` and the row carries a class | Otherwise 409 |
| Only a raise: `normal` → `emergency`, or a `standard` no runbook backs (the "Standard by default" policy, an inherited or a skill-rule class) → `normal` / `emergency` | Any lowering is a 409 *A person may only raise a change class (normal → emergency) …* |
| A certified runbook route's `standard` (`change_class_source = runbook_route`) | 409 *Standard because the certified runbook … covers this fix. To make it normal, demote or disable that runbook* |
| A runbook withdrawn since the draft | The drop to the class without it is recorded first (`change.class_dropped`), then the raise is judged |
| A route run's record (`change_class_source` `runbook_route` / `runbook_route_auto` without `proposed_from_wrapup`) | Refused: its class records what ran |
| The new class is a new approval basis | `approved_by` cleared; `planned` stays `planned`, anything else goes back to `pending_approval` |
| `meta.release_class` dropped, `change_class_source = reclassified`, the derived class kept once in `change_class_auto`, the override appended to `class_overrides[]` with the `source` it replaced | The window bypass and the review follow the new class |
| Raising a submitted request to `emergency` | Its review is owed from now (`change.pir_scheduled`, when `aiops_pir_at_submission` is on) |
| Audit | `change.reclassified` with `from`, `to`, `tier_from`, `tier_to`, `approvals_required` |

## Validation plan {#validation-plan}

Every change request an analysis drafts carries the list L6 checks, fixed at the draft (`meta.validation_plan`) and bound into the approval digest by the review (`execution_spec.validation_plan`) with the expected blast radius from the impact data (`expected_blast_radius`: the intended resources and the lost facts, never the affected list, so a harvest cannot stale an approval). At most 10 items, each `{id, target, read, expected, group, direction, hops}`:

| Group | Items |
|---|---|
| `primary` | Each changed resource (a typed step's target) with the read that observes it and what it must show, then the plan's declared criterion with its read-only commands. An isolation the same change restores (`aws.ec2.isolate` followed by `aws.ec2.restore_sgs` on the instance) is a phase, not an end state: only the restore is listed |
| `neighbour` | Every one-hop neighbour of the changed service on the dependency map, both ways: `upstream` = it calls the service, `downstream` = the service calls it; labelled with the edge's origin |

The post-fix recheck runs exactly that list: each changed resource whose executed step compiled a read is read first, by code, on the step's own connection (no sooner than 30 s after the write) — a start or reboot by its power state, the groups an isolation recorded by every network interface listing them again (an isolation left in place: only the isolation group), a patch by its own command's status (`ssm:GetCommandInvocation`) and Patch Manager's missing and failed counts (`ssm:DescribeInstancePatchStates`), a Lambda runtime upgrade by its function's configuration (`lambda:GetFunctionConfiguration`: the runtime the step sent, `LastUpdateStatus` Successful, `State` Active or Inactive) and by its alias (`lambda:GetAlias`: the version the run published), and a read that cannot answer is *not observed*, never *pass*; the verify turn is told only the listed items, with no web research; a row it adds for anything else is dropped; every item ends with a row (`item` = its id), observed or not; and a listed resource the platform read as failing decides the verdict (`still_present`). The verdict rests on the listed rows only: a `still_present` the turn based on a row it was not asked for reads `uncertain`, and so does a `cleared` turn while a listed item — a neighbour included — reads `fail`. A failing neighbour never makes the change `still_present` on its own. Once every alert of the incident cleared at its source after the write, the platform reads the list without a verify turn — when the alert source's clear arrives, and on each scheduled check after it — and lands `cleared` at $0 when every listed changed resource reads `pass` and no neighbour probe fails; anything else leaves the verdict to the turn (switch `verify_typed_check`). A standard change runs it too. A change drafted before the validation plan existed carries no list and is verified as before.

## Pre-change backups {#pre-change-backups}

With `aiops_backup_before_change` on (the default), the review puts a backup step before every reboot-type step on a stateful target — `aws.ec2.reboot` gets `aws.ec2.create_image` (an AMI with `NoReboot=true`), `azure.vm.restart` gets `azure.compute.disk_snapshot` (a snapshot of the VM's OS disk) — once per target, through that step's own write connection. `aws.ec2.create_snapshot` (an EBS volume) is the same kind of step for a runbook to declare. A [step runbook's](/help/runbooks-and-automatic-fixes.md#step-runbooks) own `backup` step is compiled the same way at its place in the runbook, bound to the write connection of the change step after it, even with the switch off; a target it already backs up gets no second backup. Backup operations are compiled by code and never offered to the analysis. A backup needs the native API connection: over a CLI connection the review refuses the plan and says why.

| Stage | What happens |
|---|---|
| Release | The backup step is reviewed and approved with the change; its tags are filled in when it runs: `aiops:retain-until` (today + `aiops_backup_retention_days`) and `aiops:change` (the change id) |
| Execution | The create call, then read-only reads until the image or snapshot is available (at most 30 minutes). Not available, failed or refused: the change stops before anything is written |
| Record | `meta.backups[]` `{step, operation, kind, source, backup_id, state, retain_until}`, the step's last-known-good capture (`method = backup`) and the rollback section (`rollback.backups`, `rollback.restore`) |
| Restore | One AWS image backup makes `aws.ec2.restore_root_volume` (CreateReplaceRootVolumeTask, the replaced volume kept) the rollback a person releases (`generated_by = backup_restore`); the sweep never runs it. An EBS or Azure disk snapshot restores through the operator lines shown (a new volume or disk, then the swap with the VM stopped) |

The platform never deletes a backup, expired or not: removing old images and snapshots is the workspace's own lifecycle rule. The write role needs `ec2:CreateImage`, `ec2:CreateSnapshot`, `ec2:CreateTags`, `ec2:CreateReplaceRootVolumeTask`, `ec2:DescribeImages`, `ec2:DescribeSnapshots`, `ec2:DescribeReplaceRootVolumeTasks` on AWS and `Microsoft.Compute/disks/read`, `Microsoft.Compute/snapshots/read`, `Microsoft.Compute/snapshots/write` on Azure (re-run the setup to add them). The restore reads its image or snapshot only when it carries the `aiops:change` tag, a backup the platform made: an AMI that exists is authorized by its own tags, never by the request tag the restore puts on its task.

## Risk tiers {#risk-tiers}

The tier stamped on a change is `tiers.classify` over the execution channel, the incident's production labels and its trusted blast radius, raised by the route's `tier_override` and by the change class; the policy per tier (approvers, permission, on-screen definition) is the table in [Approval policy and change tiers](/help/approval-policy.md#risk-tiers). What this page adds: where each tier is computed.

| Change | Tier computed by |
|---|---|
| Proposed change request (wrap-up) | `classify_planned` at the mint from the plan's inferred channel; `planned_execution.review` recomputes the release tier as the maximum of the intrinsic tier, the class floor and every compiled step's own tier |
| Route run from the incident | `tiers.classify(route.mode, labels, impacted, tier_override)` at the Remediate click or the automatic lane |
| Declared change (`POST …/changes` with a plan) | `classify_planned` from the plan |
| Observed change | None (`risk_tier` empty) |

## Change and freeze windows {#windows}

Both windows share one shape and one matcher; they read opposite policies off the same answer.

| Part | Rule |
|---|---|
| Shape | `{"days": ["mon", "tue", …], "hours": "HH:MM-HH:MM"}`, UTC; day names are matched on their first three letters |
| Days only | The whole of each listed day |
| Hours only | Every day, inside the range |
| Both | Both must match |
| Overnight range (`"22:00-06:00"`) | Wraps midnight |
| Empty / missing | A change window is always open; a freeze window never flags |
| Malformed hours | A change window fails **open** (a typo must never lock remediation out — the approval is still the gate); a freeze window errs toward **flagging** |

| Window | Lives on | Checked by | Not checked by |
|---|---|---|---|
| Change window | `params.change_window` of a route; `settings['change_windows']` first matching service glob, then `settings['change_window']` (or legacy `aiops_change_window`) for prepared plans — compliance control CHG-03 asks every live-infra route at L2/L3 to declare one | The human release of a legacy route at L2/L3 (403, audited `change.release_refused`); `authorize()` for every `api` / `cli` / `mcp` / `network` change at L2/L3 — at the request, the dispatch re-check and each step; the direct API lane before it releases | A pull request (inert until merged, so the auto path may open it any time; the human release of a `gitops_pr` change is gated only at the incident endpoint); the rollback sweep |
| One-time exception | `meta.window_exception` on one change, bound to `action_digest`; requested/decided on the change routes | The active UTC interval for that change only, with the same tier/class/quorum and emergency policy | Other changes, release votes and class/tier derivation |
| Freeze window | `settings['change_freeze']` | `record_external` for every observed change | Platform changes |

The emergency bypass: a change whose release class is `emergency` passes a closed change window once its **full** quorum is collected (L3 = two distinct people); the first pass stamps `meta.window_bypassed` and audits `change.window_bypassed` once, so the re-checks at dispatch and at each step are idempotent. A route run's row is never waved through: it carries no class, or a `standard` nobody can reclassify.

## Last-known-good records {#last-known-good}

Before a platform write the state it is about to change is captured and stored as an append-only artifact (`kind: lkg`); the change's `lkg` field says what was captured and how. The incident page's "Last-known-good record" card ([Read an incident and its analysis](/help/read-an-incident.md#last-known-good-record-and-recurring-tag)) reads the same versions.

### Capture methods {#lkg-methods}

| `lkg.method` | What was captured | Restorable |
|---|---|---|
| `gitops_file` | The file body the GitOps provider read before applying the edit (with `path`, `repo`, `sha`, `sha256` of the stored body) | Yes — the full-file restore PR |
| `gitops_derived` | The pre-image derived for a step of a prepared repository plan | Yes, unless the reviewed plan carries private values (then the capture is withheld with an honest receipt) |
| `gitops_create` | The file did not exist: `prestate_status: absent`, `completeness: complete`, no artifact | No content to restore (revert = delete, which the platform does not perform) |
| `route_snapshot` | The output of the route's declared read-only `params.snapshot` call | As a reference; the compiled inverse uses it for posture steps |
| `step_prestate` | A compiled step's pre-state read (one entry per step in `lkg.captures[]`) | Feeds the compiled inverse |
| `none` | Nothing capturable; `note` says why (a 404 on the exact target, a refused snapshot call, a redacted capture, a read that failed) | No |

Fields on a capture: `artifact_id`, `path`, `repo`, `sha`, `captured_at`, `prestate_status` (`present` / `absent` / `unknown`), `source_observed_at`, `source_version`, `completeness` (`complete` / `partial` / `unknown`), `health` (`{status: unverified}` until a verify reads it), `sha256`, `restorable: false` when the stored evidence is not byte-exact, and `evidence_artifact_id` for a redacted receipt. Compliance control CHG-02 counts a change as covered only with a real method (`gitops_file`, `gitops_create`, `route_snapshot`, or any `captures[]` entry other than `none`).

### Version sources {#lkg-sources}

Every ledger row yields resource versions (`lkg.versions_of`); the `source` says where the before-state came from and the record card labels it.

| `source` | Label | Before-state | Which rows |
|---|---|---|---|
| `platform` | platform pre-read | The capture above | Platform changes |
| `aws_config` | AWS Config | The configuration item AWS Config recorded before the write (`detail.before_state`, `before_captured_at`, `before_version`); the after-state is the write's diff applied to it | Harvested CloudTrail writes of a connection with `config_history: true` — `detail.before_source = "aws-config"` |
| `ledger` | earlier recorded change | For an RDS parameter-group write: the values earlier recorded writes set (only the parameters this write changed; `before_unknown` names the rest; a redacted value is unknown, never set back) | Harvested `ModifyDBParameterGroup` / `ModifyDBClusterParameterGroup` — `detail.before_source = "ledger"` |
| `change_analysis` | Azure Change Analysis | The `previousValue` of every property in the Resource Graph change diff; a truncated or redacted diff yields none; all-null = the resource was created | Harvested Azure activity writes |
| `git` | git | The parent commit (`before_sha`) of a harvested push | Watched-repository pushes |
| `reported` | webhook report | None — a sender's claim is never a restore source | Webhook-reported pushes, cloud writes and application settings |

A harvested AWS write without Config history reads "external AWS write — no before-state captured (AWS Config history off for this connection)"; one where Config answered but every capture it returned was after the write says so too.

### The before-state rule {#before-state-rule}

`settings['aiops_require_before_state']: true` — a write whose before-state cannot be captured never runs on the platform: the review refuses it up front (`manual_only`, with the operator-run steps), and a read that fails at execution stops before the write (audit `change.prestate_blocked`). On by default since 0.1.163 (`null` follows the platform default `AIOPS_REQUIRE_BEFORE_STATE`); `false` lets the write proceed with an honest `none`. A pull request is exempt: the repository's base commit is its before-state.

## Rollback paths {#rollback}

`rollback.kind` and `rollback.generated_by` say what the platform holds. A rollback is never auto-executed unless the sweep's matrix below says `execute`; a human releases one with `POST …/changes/{id}/rollback` (`approvals.decide`; an L3 rollback needs a second distinct approver; the original route must still exist, be enabled and keep its mode).

| `kind` | `generated_by` | What it is |
|---|---|---|
| `gitops_restore` | `code` | The reversed find/replace of the applied edit (`edit`) and the full-file restore from the LKG artifact (`artifact_id`) — a restore pull request, inert until merged |
| `script` | `operator` | The route's declared `params.rollback` command template |
| `script` | `compiled_inverse` | One posture step with a captured pre-image: the exact authorize / put that re-creates what the step removed (`operation`, `params`); human-released on the change page, the same read re-runs afterwards |
| `manual` | `code` | No executable rollback; `note` says why (a created file, no pre-image, a multi-step plan, a recovery operation whose inverse recreates the outage); `commands` lists the operator lines, last step first |
| `manual` | `lkg_draft`, `wrapup`, `operator_review_required` | A drafted plan or guidance for a person — never run by the platform |

An agent may draft a rollback for a `manual` change (the plan runner); the draft is shown, never executed. Every non-manual rollback also gets a `kind: rollback` artifact with the plan rendered for people (`artifact_id_doc`).

### The automatic rollback sweep {#rollback-sweep}

Scheduler tick over platform changes with status `failed` updated in the last `aiops_rollback_sweep_window_h` hours (24), 50 per tick, each decided once (audit markers `change.auto_rollback`, `change.rollback_escalated`, `change.rollback_skipped`). The decision is pure:

| Kind | How it failed | Decision |
|---|---|---|
| any | `mode: external` | escalate — an operator reviews and performs it |
| any | computer-assist mode | escalate |
| any | execution still `dispatching`, or its outcome `uncertain` | wait — reconcile the stopped execution first |
| any | a rollback already reserved, attempted or reconciled | skip — never replayed |
| `gitops_restore` | execution failed before a PR existed | skip — nothing went live |
| any | the provider refused the request before acting (a 4xx-before-write) | skip |
| any | a prepared plan stopped at its backup: every step receipt is a backup, no write step ran, and no person reconciled the write as executed | skip — nothing was applied |
| any live change | `verify_verdict: uncertain` | wait for a real verdict |
| `gitops_restore` | no failed verification | skip |
| `gitops_restore` | verification `still_present`, tier L3 | escalate — two people release an L3 |
| `gitops_restore` | verification `still_present`, tier ≤ L2 | **execute** — the restore PR opens |
| `script` / `compiled_inverse` | `aiops_auto_rollback_cloud` is `L1` or `L2`, the tier is within it, verification `still_present` **and** `meta.validation.status = failed` | **execute** — the captured before-state is restored |
| `script` / `compiled_inverse` | otherwise | escalate — human-released on the Changes page |
| `script` / `operator` | the forward action was a recovery operation | escalate |
| `script` / `operator` | `aiops_auto_rollback` off | escalate |
| `script` / `operator` | tier L3 | escalate |
| `script` / `operator` | on, tier ≤ L2 | **execute** |
| `manual` / missing | — | escalate — a human performs it |
| any | the change belongs to an unauthorized-change incident | escalate — a person rolls it back |

Escalation pushes the `rollback` notification event. The sweep deliberately re-checks no change window. After a rollback of a run executed through a pre-approved route, `settings['aiops_demote_on_rollback']: true` takes that route's pre-approval away at the first rollback (Needs you: `route_demoted`; audit `route.demoted`).

## Harvest sources {#harvest}

The scheduled harvest reads every eligible connection's write events since its last marker minus a 20-minute overlap (the ledger dedupes on the event id, and a re-seen event carrying a diff the first sighting lacked enriches its row). Interval: `aiops_change_harvest_interval_s` = 900 s platform default; `settings['aiops_change_harvest_interval_s']` overrides it, 0 or less disables the scheduled harvest for the workspace. Per-connection opt-out: `harvest_changes: false`. A write-role connection is never harvested.

| Source | Connection | What is read | Notes |
|---|---|---|---|
| AWS CloudTrail | `aws`, read role | Completed write events, per region: every region the connection declares (`scope.regions`, else `region`) **plus `us-east-1` always** (IAM, STS, CloudFront, Route 53 and the other global services write their trail there) | Noise (session plumbing, and every STS call: temporary credentials, never a resource change), service-identity (`AWSService`), read and failed events are dropped and counted; a denied write is kept as a denied attempt — unauthorized access, never an unauthorized change (`denied`); `invokedBy` is tagged `actor_kind: service` |
| AWS Config | `aws` with `config_history: true` | The configuration item before and after each harvested write (`before_source: aws-config`) | At most 50 Config calls per connection per tick, 4 history pages (28 days) back |
| EKS control-plane audit log | `aws` with `k8s_audit: true` | `kube-apiserver-audit-*` streams in CloudWatch Logs (`/aws/eks/<cluster>/cluster`), one Logs Insights query per cluster per tick | Completed write verbs (never a `dryRun=All`) by non-`system:` principals, and by the service accounts of the CI/CD namespaces in `k8s_ci_namespaces` ([CI/CD service accounts](#k8s-ci)); a 401/403 is a denied attempt. EKS's own service-linked role (`AWSServiceRoleForAmazonEKS`, the EKS Auto Mode controllers, matched on the identity the authenticator attaches as well as the user name) and EKS's reserved `eks:` users are the AWS service itself: dropped and counted as `service`, like CloudTrail's `AWSService` rows. Access reviews (`kubectl auth whoami`, `can-i`) are never a change |
| Azure activity log | `azure`, read role | Write operations per subscription, joined with the Resource Graph change diff of the same resource within 10 minutes | Read operations are dropped; a deployment validate / write counts only as a denied attempt (a Reader's portal create dies there, before any resource write); the diff gives the before-state |
| Watched repositories | `github`, `gitea`, `gitlab`, `azure-devops` with `repos` | Pushes and merges of up to 25 repositories per connection, paced by the provider's poll header | A repository connection with no `repos` is a credential (GitOps PRs), not a feed |
| Kubernetes audit webhook | `POST /webhook/kubernetes-audit/<slug>/<token>/<cluster>` with that cluster's audit token (`POST /api/aiops/{workspace}/k8s-audit-token/<cluster>`), never the webhook token | The API server's audit `EventList` (self-managed / on-prem clusters) | Treated as the platform's own audit (`trusted_audit`); the same people and CI/CD service-account rules as the EKS log |
| Change webhook | `POST /webhook/change/<slug>/<token>` | `{source?, events: [{kind, service?, repo?, ref?, sha?, id?, author?, title?, url?, at?, detail?}]}` with `kind` ∈ `push`, `pr_merged`, `deploy`, `sync`, `config` | Nothing is polled; a reported before-state is never a restore source |
| Source webhooks | `POST /webhook/<source>/<slug>/<token>` for `azure-devops` (git.push, git.pullrequest.merged), `argocd` (sync, drift), `github` / `gitea`, `gitlab`, `servicenow`, `jira` | The sender's own payload, mapped to the event kinds above | A payload that yields no change event falls through to the alert adapter of the same source |

### CI/CD service accounts on Kubernetes {#k8s-ci}

A pipeline that applies manifests with its own service account releases a change the same way a person running `kubectl` does, so its writes are recorded with `actor_kind: service` and the namespace as `ci_namespace`. Which namespaces count is the workspace setting `k8s_ci_namespaces`; the platform default is `flux-system`, `jenkins`, `gitlab-runner` and `tekton` (a Tekton pipeline often runs as a service account in its own pipeline namespace: add that namespace). `argocd` cannot be listed, because Argo CD syncs arrive on `/webhook/change/<slug>` and would be recorded twice, and `kube-system`, `kube-public` and `kube-node-lease` never can. Every other `system:` identity (nodes, controllers, other service accounts) stays plumbing.

What a pipeline writes that is not its change is left out: the pods and runs it spawns (`pods`, `taskruns`, `pipelineruns`, `customruns`, `runs`), status writes, objects of its own controller (`*.fluxcd.io`, `*.tekton.dev`) and the per-job objects GitLab Runner names `runner-<token>-project-<id>-concurrent-<n>…` (build pods, registry secrets, services). A server-side dry run (`dryRun=All` — Flux compares every object it manages this way on every reconcile) is never a change, for a person or a pipeline. On EKS the query leaves all of these out, so they are not counted. Per pull, an object the same account wrote many times is one row (the newest write). At most 50 objects are recorded per cluster, service account and clock hour (UTC), counted after the ledger's dedupe — what an earlier, overlapping pull already recorded in that hour counts first, in time order — so overlapping pulls agree on which 50; the rest are counted as noise, and the pull that reaches the cap writes the marker note once. A denied attempt (401/403) is never left out as one of the objects above, folded or counted toward the cap: it is recorded as a denied attempt and reaches SecOps like a person's (a pipeline refused creating pods in `kube-system` included). The audit webhook applies the same rules and has no marker: what it folds goes to the platform log. People and pipelines share the 1,000 audit rows one pull reads per cluster.

A pipeline's change is checked against freezes and references like any other; to let it through without a ticket, add `system:serviceaccount:<namespace>:*` to `change_authorized_principals`.

Caps per connection per tick: 500 rows recorded (1,500 raw read), request parameters and diffs scrubbed then cut at 2,048 characters; values written to Parameter Store, Secrets Manager, AppConfig or a Lambda / ECS environment are stored as `[not stored]` (which key changed is kept). The manual pull (`POST …/changes/harvest`, `pillars.manage`, "last N minutes") ignores the interval and lands under its own marker so it never blinds the next scheduled tick.

Service resolution for an observed change: the event's own `service` → `settings['change_service_map']` (`{repo_glob: service}`) → unmapped (recorded, listed under an empty service). `settings['change_resource_map']` (`{resource_glob: service | [services]}`, at most 200 globs, 10 services each) only **adds** linked services; editing it relinks the rows of the last 48 hours; a cloud write to an EC2 instance, a volume, an SQS queue or an SNS topic also links itself to a service named after the resource.

### Principal classes and unauthorized changes {#principals}

For every harvested cloud write that is not the echo of a platform receipt, `meta.principal` says who made it:

| `principal.kind` | Meaning |
|---|---|
| `platform` | The platform's own credential: an AWS role or access key it holds, or its Azure application. `receipt` = `time` (a typed platform step on the same resource within 10 minutes), `route` (a route change naming the resource or its service), `action` (an approved action inside an analysis, a route snapshot, a source-side suppression), or `none` |
| `outside` | A person or workload the platform does not hold a credential for |
| `unknown` | No identity in the event |

A row is flagged `authorized: false` when one of these holds. The last two are unauthorized changes (audit `change.unauthorized_detected`, the Unauthorized chip); a denied call is unauthorized access, not a change (audit `change.denied_attempt`, the Denied attempts chip), and the change statistics count it apart: never in the change total, a source, a service or the unauthorized changes:

| Rule | Note on the row |
|---|---|
| The cloud denied the write (`denied`) | "unauthorized access attempt: denied by the cloud provider (`<code>`) — attempted, never applied, so not an unauthorized change" |
| `settings['change_freeze']` matches and no platform change for the service is in flight (`approved` … `verifying`, not `external`). A write an audit log captured (CloudTrail, the Azure activity log, the Kubernetes API server) is judged at the time the log recorded it, so a write made during the freeze is flagged when a later pull reads it, and one made before the freeze is not flagged by a pull during it. Any other change (a push, a sync, your own feed) is judged at the time it arrived: a commit dated before the freeze never clears a push received during it, and one dated inside it never flags a push received after it | "external change during a declared freeze window with no in-flight platform change for this service" |
| `settings['aiops_change_principal_check']: true` and the principal is `platform` with `receipt: none` | Made with the platform's own cloud credential, but no platform change explains it |
| `settings['aiops_unauthorized_change_incidents']: true` and nothing accounts for an applied write — see [Unauthorized changes and denied access](#unauthorized) | "no reference found (no ticket, approval or signed change request)" |

A platform write seen back in the audit log within 10 minutes becomes the receipt of its change (`change.confirmed_by_audit_log`), never a second row. Only the platform's own harvest may fold a write this way; a webhook sender cannot stamp "confirmed by the cloud audit log" on a change.

### Unauthorized changes and denied access {#unauthorized}

The method per change source, in prose, with a sandbox demo: [How unauthorized changes are detected](/help/unauthorized-changes.md).

**Off by default.** `aiops_unauthorized_change_incidents` (platform default `AIOPS_UNAUTHORIZED_CHANGE_INCIDENTS`, off; the workspace's own `true`/`false` on **Guardrails → Unmanaged changes** → *Open an incident for every change with no reference*) makes every applied write the audit log captures — AWS CloudTrail, the Azure activity log, the EKS audit log or the Kubernetes audit webhook — need a reference. A busy account records thousands of writes a day made outside the platform: fill `change_authorized_principals` with your CI/CD roles **before** turning it on. Denied attempts are never judged by this rule; the existing noise, service-identity, read and failed-event filters still apply first.

A write has a reference when, checked in this order:

| Reference | `meta.reference.kind` | Rule |
|---|---|---|
| The platform made it | `platform` | The platform's own credential with a receipt (`time`, `route`, `approval`, `suppression`) |
| An automation principal | `allowlist` | The AWS role, user, Azure caller or Kubernetes user matches a glob of `settings['change_authorized_principals']` (case-insensitive; at most 100 globs, each naming at least 3 letters or digits besides glob characters and `arn:<partition>:`) |
| An approved ITSM change ticket | `itsm` | A ServiceNow or Jira change ticket in the ledger whose state is one of the approved values, whose CI names a service the write is linked to or the resource itself, and whose planned window covers the event time. A ticket without a planned start and end never matches. Only a ticket your ServiceNow or Jira webhook posted with the workspace's **ITSM webhook token** counts (see the note below the field table): one posted with the shared webhook token, or sent through your own change feed, is recorded and never covers a write. |
| A signed change request | `change_request` | A platform change request a person approved (`approved` or later) whose typed steps, reviewed action or operator commands name the resource (never only its service), opened before the write and updated within 24 hours of it. One the platform executed itself covers writes only until its execution ended (a minute of clock skew allowed): a later write on the resource is not its own |

With none, the row is flagged `authorized: false` with "no reference found (no ticket, approval or signed change request)" (audit `change.unauthorized_detected`).

ITSM ticket fields (`settings['itsm_change_match']`, each optional; a ticket's re-send refreshes the fields on its row, so a ticket approved after it was raised counts from then on):

| Field | ServiceNow default | Jira default |
|---|---|---|
| `state_field` | `approval` | `status` |
| `approved_values` | `["approved"]` | `["approved", "awaiting implementation", "implementing", "scheduled"]` |
| `ci_field` | `cmdb_ci` | `customfield_service` |
| `start_field`, `end_field` | `start_date`, `end_date` | none — set your instance's planned start and end custom field ids |

Times without a zone are read as UTC.

**The ITSM webhook token.** The shared webhook token is held by every alert and change integration, so it cannot vouch for an approval. `POST /api/aiops/{workspace}/itsm-webhook-token` (`members.manage`, MCP `mint_itsm_webhook_token`; audit `tenant.itsm_webhook_token`) returns a separate token once and stores only its hash; minting again invalidates the previous one. A personal access token needs the `admin` scope for this call. Send it from the ServiceNow or Jira webhook (`Authorization: Bearer <token>` on `/webhook/servicenow/<workspace-slug>` or `/webhook/jira/<workspace-slug>`, which also accept it for their alerts). A ticket and its re-sends refresh the ticket fields only when they carry this token. Tickets recorded before the workspace had one are not references: the next re-send with the token makes them count.

What an unauthorized change opens:

| Fact | Value |
|---|---|
| Grouping | One incident per **action**: the same principal and the same account (AWS account, Azure subscription, Kubernetes cluster) within 10 minutes of the action's first write. A later pull joins the open incident of its action. |
| Alerts | One alert row per resource (source `unauthorized-change`); a second write to the same resource adds its change id to that row |
| Incident | Pillar `change`, correlation key `unauthorized_change=<account>/<principal>@<epoch>`, the incident's `change_refs` list every change |
| Priority | From the touched resources' criticality (`criticality_map`): `mission-critical` = P1, `high` = P2, otherwise P3 |
| Analysis | None started — the receipt `incident.not_actioned` says why; a person may still use **Analyse** |
| Ticket | Filed through the first enabled on-open ticket route whose selector matches, whatever pillars a preset route covers. `settings['unauthorized_change_ticket']` = `{"assignment_group": "<group>"}` names the group on the ticket and, on ServiceNow, sets the incident's assignment group (looked up by name; the ticket is filed without one when the name is not exactly one group). No one is assigned. Without a ticket system the incident still opens. |
| Rollback | A revert drafted per change when the incident opens (at most 20; the rest from **Probable cause**): typed from the recorded before-state where it exists (a security group — a removed ingress rule re-created with its description, an added one revoked by the rule id CloudTrail's response named — an NSG, an RDS parameter group and the other inverses of the last-known-good draft), otherwise one manual step naming why. Status `planned` — it waits for an approver. The platform drafts the revert itself; with **Let the change management agent draft change requests** on, the agent then writes the request's narrative around it, as for any change request. |
| Never automatic | No pre-approved route, direct API fix or rollback sweep acts on it, whatever runbook is certified (skip reason `unauthorized_change`; the sweep escalates) |

The person's two answers, on the incident's **Unauthorized change** card (`approvals.decide`):

- **Approve rollback** opens the drafted revert's change page: review the exact action, approve and release it — validation and the incident's close follow the change as any other, the validation being the rollback's own read at no cost (see [Verify recovery](/help/verify-recovery.md)), and the incident's **Timeline** lists the rollback's request, approval and execution. A revert with no typed step has nothing the platform can run, so it shows **Open the command** instead: its change page holds the command; run it with your own access, then **Mark resolved**.
- **Keep this change (legitimate)** asks for a note (10 characters or more): every open revert is rejected, every change of the incident is marked `authorized: true` with "kept as legitimate: <note>" and `meta.kept`, and the incident closes as expected (noise, no verification). Audit `change.kept` per change and `incident.change_kept` on the incident. Refused (`409`) once a rollback ran or runs.

Denied attempts (unauthorized access): with `aiops_denied_access_to_secops` on (platform default `AIOPS_DENIED_ACCESS_TO_SECOPS`, **on**) and the SecOps agent enabled, every denied attempt opens or joins one SecOps incident per principal, account and clock hour — one alert row per attempt (source `denied-access`), correlation key `denied_access=<account>/<principal>@<hour>`, P3, no analysis started, no rollback (nothing changed). Its **Denied access attempts** card lists the refused calls.

On a workspace with a tag scope (the workspace scope, or a connection's `finding_tag_scope`), an AWS or Azure attempt opens a report only when it was made as the identity of one of the workspace's connections, or its target is an estate resource inside the scope: one account shared by several teams, another team's role refused on another team's resource is theirs. The rest stay on the ledger as denied attempts and are counted on the harvest's report (`denied_out_of_scope`). Kubernetes audit attempts come from the workspace's own cluster and always count.

### What is never captured {#not-captured}

The incident card, `change_lookup` and the last-change-check skill say this, so an empty list reads as "nothing in what we can see":

- settings changed inside an application (its admin screen or its own database), unless the application posts them to the change webhook
- Kubernetes objects edited with `kubectl` on a cluster without the audit webhook or EKS audit log — only pushes to watched repositories and Argo CD syncs are seen
- files, registry and packages inside a VM or container
- data-plane writes: S3 objects, DynamoDB items, Key Vault secrets or App Configuration key-values written through their own endpoint (their ARM writes are captured)
- the values written to Parameter Store, Secrets Manager, AppConfig or a Lambda or ECS environment (which key changed is kept)
- AWS writes in regions a connection does not declare (only the declared regions plus `us-east-1` are read)
- cloud control-plane writes at all, while no AWS or Azure connection is harvesting

## Post-implementation review {#pir}

| Fact | Value |
|---|---|
| When it opens | An emergency change's write lands: a direct mode at close of execution, a pull request at merge; or at submission when `settings['aiops_pir_at_submission']: true` |
| Owner | The releasing approver |
| Due | `aiops_pir_due_h` hours after opening (72); `0` turns reviews off |
| Where it waits | Needs you, source `pir` (overdue past the due date) |
| Closing | `POST …/changes/{id}/pir/close` with a note of at least 10 characters (`approvals.decide`); outcome `implemented`, `not_implemented` (refused, or the write failed before landing) or `pending` |
| Audit | `change.pir_opened`, `change.pir_scheduled`, `change.pir_withdrawn`, `change.pir_closed` |

## Audit actions {#audit-actions}

Every row targets the change id (the incident Timeline and `/audit` show the same rows with the labels below).

| Action | Label | Emitted when |
|---|---|---|
| `change.open` | Change request opened | A request, route run or declared change is minted |
| `change.proposed` | Change request drafted | The wrap-up mints the proposed request |
| `change.plan` | Change plan drafted | The change agent drafts a plan for a declared change |
| `change.cr_drafted` | Change request document drafted | The change management agent's draft of the change request document landed (`ok`, `plan` = Phase 2 plan landed, `cost_usd`) |
| `change.cr_draft_requested` | Change request document draft requested | A person asked the change management agent to draft the document again |
| `change.approval_requested` | Change approval requested | The operator's Request approval |
| `change.approved` | Change approved | The vote that completed the quorum |
| `change.approval_lapsed` | Approval no longer counts | A click on the change found a vote whose approver may no longer release the tier (role changed, member removed, support access ended): the vote is dropped, listed among the approvals as *lapsed*, and an approved change short of its quorum goes back to `pending_approval` (payload: `lapsed` people, `tier`, `approvals`, `required`, `was` and `status`) |
| `approval.released` | Approval recorded | A vote short of the quorum |
| `change.pending` | Parked — more approvals needed | A release parked at `pending_approval` |
| `change.rejected` | Change rejected | An approver refused, or a structural check failed |
| `change.closed_without_verdict` | Closed without a verdict | An approver closed a released change that went 24 hours without a verdict — none, or `uncertain` only (payload: the reason and since when it waited) |
| `change.release_refused` | Change release refused | Outside the window, missing permission, or the re-check at dispatch refused the release (`detail` = *refused at dispatch: …* with the reason; nothing was executed) |
| `change.reclassified` | Change reclassified | A person raised the class |
| `change.class_dropped` | Standard class dropped — runbook no longer backs it | The runbook a standard request rested on no longer holds before release; the heading says why (payload `cause`: *certification withdrawn by a person*, *runbook demoted after a rollback / a failed validation / a fix that did not hold*, *runbook disabled*, *deleted*, *no longer certified*, *changed since the request was drafted*) and the class without it was recorded |
| `change.class_restored` | Change class restored — the runbook was certified again | A late outside write gave a demoted runbook its certification back, and a request nobody has requested yet, classed `normal` while it was demoted, is `standard` again (payload `from`, `to`, `tier_from`, `tier_to`, `reason`; see [Runbook library](/help/runbook-library.md#demotion)) |
| `change.window_bypassed` | Change window bypassed — emergency | An emergency released through a closed window |
| `change.prestate_blocked` | Write refused — no before-state captured | The before-state rule stopped a write |
| `change.dispatching` | Approved change sent to run | The write was reserved |
| `change.executed` | Change executed | The write returned; *Change execution failed* when it failed (`ok: false`); *Change outcome unknown* when no receipt came back (`uncertain: true`, the change stays `dispatching` until a person reconciles it) |
| `change.execution_reconciled` | Change outcome recorded by a person | A stopped execution was reconciled |
| `change.external_execution_reported` | Operator reported external execution | Computer-assist report |
| `change.operator_handoff` | Operator handoff prepared | A plan was handed to an operator |
| `change.pr_merged`, `change.pr_closed`, `remediation.pr_merged` | Pull request merged / closed | The PR poll saw the change |
| `change.application_observed`, `change.application_reported` | Change seen applied / Deployment evidence recorded | An Argo CD sync or a person tied the deployment to the change |
| `change.post_state_captured` | Post-change state captured | The after-state read landed |
| `change.verify_requested`, `change.verified` | Verification requested / Change verified | A verify was asked for / its verdict landed |
| `change.rollback`, `change.rollback_dispatching`, `change.rollback_merged`, `change.rollback_verified`, `change.rollback_cancelled` | Rollback … | The human rollback path |
| `change.auto_rollback`, `change.rollback_escalated`, `change.rollback_skipped` | — | The sweep's one decision per failed change |
| `change.pir_opened`, `change.pir_scheduled`, `change.pir_withdrawn`, `change.pir_closed` | Post-implementation review … | The review lifecycle |
| `change.recorded` | — | An observed change landed |
| `change.unauthorized_detected` | — | An observed change was flagged unauthorized |
| `change.denied_attempt` | Denied access attempt recorded | The cloud refused a call: unauthorized access, never an applied change |
| `change.kept` | Change kept as legitimate | An approver kept an unauthorized change (target = the change) |
| `incident.change_kept` | Unauthorized change kept — incident closed | The same decision on the incident (target = the incident) |
| `change.confirmed_by_audit_log` | — | A platform write was seen back in the audit log |
| `change.harvested`, `change.harvested_manual` | — | The scheduled / manual harvest markers (target = the connection) |
| `change.relinked` | — | A resource-map edit reached recorded rows |
| `change.compliance_flagged`, `change.evaluated`, `change.reverts`, `change.undone_by_external` | — | Change-triggered compliance (see the [Compliance reference](/help/compliance-reference.md#change-triggered)) |
| `change.lkg_draft`, `lkg.anchor` | — | The revert draft / the last-known-good record anchor on an incident |
| `route.match_skipped` | Pre-approved route matched — not run | A pre-approved route matched but did not run (one row per route and reason) |
| `route.demoted` | — | A rollback took a route's pre-approval away |

## Settings {#settings}

Workspace settings that change how the ledger behaves (Guardrails unless noted). Platform defaults are the control plane's environment.

| Setting | Default | Effect |
|---|---|---|
| `change_class_policy` | `derived` | `standard_default` = every request starts standard |
| `aiops_preapproved_operations` | `[]` | Typed operations a standard change may contain |
| `aiops_runbook_standard_change` | on | A certified `remediate-…` route's declared operations record a standard change even at P1 |
| `change_window` / `aiops_change_window` | none | Release window for prepared plans |
| `change_freeze` | none | Freeze window for observed changes |
| `aiops_require_before_state` | on | No platform write without a captured before-state |
| `aiops_auto_rollback` | off | The sweep executes an operator-declared rollback script (≤ L2) |
| `aiops_auto_rollback_cloud` | unset | `L1` or `L2`: the sweep restores a captured before-state after a failed metric window |
| `aiops_demote_on_rollback` | off | The first rollback of a pre-approved route's run drops its pre-approval |
| `aiops_demote_on_deviation` | on | A certified runbook whose run fails its validation, or whose fix does not hold, loses its certification (audit `route.demoted_deviation`) — unless the platform's own cloud audit harvest recorded an outside write on the same resource between the fix and the recurrence: then it keeps it (audit `route.deviation_explained`); a write harvested after the recurrence opened gives back the certification that recurrence took (`route.deviation_explained` with `restored: true`, and `lkg_record.outside_explained` on the record) |
| `aiops_pir_at_submission` | on | An emergency request's review is owed from submission |
| `aiops_backup_before_change` | on | A reboot-type step on an EC2 instance or an Azure VM is preceded by a backup (AMI / OS-disk snapshot); see [Pre-change backups](#pre-change-backups) |
| `aiops_backup_retention_days` | 7 | The value of a new backup's `aiops:retain-until` tag, in days (1–365); nothing deletes a backup |
| `aiops_pir_due_h` (platform) | 72 | Review due time; 0 = reviews off |
| `aiops_rollback_sweep_window_h` (platform) | 24 | How far back the sweep looks |
| `aiops_change_harvest_interval_s` | 900 (platform) | Scheduled harvest interval; ≤ 0 off |
| `aiops_change_principal_check` | on | A platform-credential write with no receipt is flagged unauthorized |
| `aiops_unauthorized_change_incidents` | off | An applied write with no reference is flagged and opens an incident per action |
| `aiops_denied_access_to_secops` | on | A denied attempt opens a SecOps incident per principal, account and hour (SecOps agent enabled) |
| `change_authorized_principals`, `itsm_change_match`, `unauthorized_change_ticket` | `[]`, defaults above, `{}` | The automation allowlist, the ITSM ticket fields, the ticket's group |
| `change_service_map`, `change_resource_map` | `{}` | Service resolution and linking of observed changes |
| `aiops_rollback_first` | on | The incident's change request leads with the pinned change's rollback |
| `aiops_change_agent_cr` | on | The change management agent drafts the narrative sections of the [change request document](/help/change-request-document.md) |
| `aiops_change_agent_owns_plan` | off | Phase 2: the analysis drafts no plan; the change management agent drafts it |
| `cr_template` | `{}` | `organization` and `logo_text` of the change request document |
| `execution_mode` | `standard` | `computer_assist` = no platform write |

## API summary {#api}

| Method and path | Permission | Purpose |
|---|---|---|
| `GET …/changes`, `GET …/changes/{id}`, `GET …/changes/stats`, `GET …/changes/harvest`, `GET …/changes/watched-repos` | `incidents.view` | List, detail, 30-day statistics, harvest reports, watched repositories |
| `POST …/changes/stats` | `incidents.view` | The statistics saved as a markdown report artifact (**Export report**); a write operation, so a read-scope token cannot call it |
| `POST …/changes` | `incidents.act` | Declare a change (`title`, `summary`, `service`, `plan`, or `incident_id` + `investigation_id` to copy a recorded plan) |
| `POST …/changes/harvest` | `pillars.manage` | Pull now — last N minutes |
| `POST …/changes/{id}/plan` | `incidents.act` | Ask the change agent for a plan |
| `GET …/changes/{id}/document` | `incidents.view` | The change request document: `format=json` (sections + Markdown), `markdown` or `docx` |
| `POST …/changes/{id}/document/draft` | `incidents.act` | Ask the change management agent to draft the document again |
| `POST …/changes/{id}/execution-preview` | `incidents.act` | The apply preview (always runs; refusals carry every write connection and its status) |
| `POST …/changes/{id}/class` | `incidents.act` | Reclassify |
| `POST …/changes/{id}/approve` | `approvals.decide` | Vote / release |
| `POST …/changes/{id}/execute` | `approvals.decide` | Release and run the reviewed action (`review_digest`) |
| `POST …/changes/{id}/reconcile-execution` | `incidents.act` or `approvals.decide` | Record the outcome of a stopped execution |
| `POST …/changes/{id}/application` | `incidents.act` | Record deployment evidence (`merge_identity`, `evidence_ref`, `note`) |
| `POST …/changes/{id}/verify`, `…/rollback-verify` | `incidents.act` | Ask for verification / re-read after a rollback |
| `POST …/changes/{id}/external-executions` | `incidents.act` or `approvals.decide` | Report work done outside the platform (computer-assist) |
| `POST …/changes/{id}/rollback` | `approvals.decide` | Release the stored rollback |
| `POST …/changes/{id}/pir/close` | `approvals.decide` | Close the post-implementation review |
| `POST /webhook/change/<slug>/<token>` | webhook token | Report changes |
| `POST …/incidents/{id}/keep-change` | `approvals.decide` | Keep an unauthorized change as legitimate (`note`, 10–2000 characters) and close its incident |


## Next steps

- Next: [Compliance reference](/help/compliance-reference.md)
- [All Reference pages](/help/reference/llms.txt)
- [Documentation home](/help/)
