# Recommendations

> Cost optimization recommendations generated from usage patterns, idle detection, and best practices — 337+ audit rules across AWS, GCP, and Azure with workflow remediation.

Source: https://zop.dev/developer-docs/operations/recommendations

---

ZopNight analyzes your cloud resources and generates cost optimization recommendations
based on usage patterns, idle detection, and best practices.

## How Recommendations Work

The recommender service subscribes to resource discovery events via Redis Streams. When new
or updated resources are discovered, it runs audit rules across all three cloud providers
to generate actionable recommendations.

## Recommendation Categories

| Category | Description |
| --- | --- |
| `idle` | Resources with low or no utilization that could be stopped or terminated |
| `rightsizing` | Resources that are over-provisioned and could use a smaller instance type |
| `schedule` | Resources that would benefit from a start/stop schedule or autoscaler policy |
| `orphan` | Unattached resources (volumes, snapshots, IPs) with ongoing costs |
| `compliance` | Resources missing required tags or not following naming conventions |
| `discount` | Commitment-based savings opportunities (Reserved Instances, Savings Plans) |
| `governance` | Best practice violations (e.g., public access, missing encryption) |

## Severity Levels

Severity reflects **urgency**, and how it's derived depends on the finding:

- **Cost findings** (`idle`, `rightsizing`, `orphan`, `schedule`, `discount`) — computed from the savings at stake **relative to your organisation's own spend**, tempered by how confident we are in the measurement. A higher band means more recoverable money.
- **Security, compliance, governance, reliability and performance findings** — reflect the **risk of the finding itself**. These usually carry **no dollar figure**, so `critical` here means high exposure, not high cost.

{(() => {
  const rows = [
    ['critical', 'Cost: large recoverable spend for your org. Non-cost: severe risk/exposure needing immediate attention.'],
    ['high', 'Cost: significant savings opportunity. Non-cost: important risk to address soon.'],
    ['medium', 'Cost: moderate optimization potential. Non-cost: moderate risk.'],
    ['low', 'Cost: minor savings. Non-cost: low risk / best-practice improvement.'],
    ['info', 'Informational — no direct cost or risk impact.'],
  ];
  return (
    <table>
      <thead>
        <tr><th>Severity</th><th>Description</th></tr>
      </thead>
      <tbody>
        {rows.map(([sev, desc]) => (
          <tr key={sev}>
            <td>
              <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
                sev === 'critical' ? 'bg-red-100 text-red-700' :
                sev === 'high' ? 'bg-orange-100 text-orange-700' :
                sev === 'medium' ? 'bg-amber-100 text-amber-700' :
                sev === 'low' ? 'bg-blue-100 text-blue-700' :
                'bg-gray-100 text-gray-700'
              }`}>{sev}</span>
            </td>
            <td>{desc}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
})()}

## List Resource Summaries

### GET /recommendations/resources

List distinct resources with aggregate recommendation stats. Used for the resource view.

#### Query Parameters

| Parameter | Description |
| --- | --- |
| `status` | Filter by recommendation status (open, applied, dismissed, optimised) |
| `severity` | Filter by severity level |
| `category` | Filter by category |
| `provider` | Filter by cloud provider |
| `resource_type` | Filter by resource type (ec2, rds, disk, etc.) |
| `cloud_account_id` | Filter by cloud account ID (comma-separated for multiple) |
| `search` | Search by resource name or UID |
| `sort_by` | Sort column (savings_usd, updated_at, severity). Default: savings_usd |
| `sort_order` | Sort direction (asc, desc). Default: desc |
| `page` | Page number (default: 1) |
| `size` | Items per page (default: 10, max: 100) |

```json title="Response"
{
  "data": {
    "items": [
      {
        "resourceUid": "i-0abc123def456",
        "resourceName": "idle-dev-server",
        "resourceType": "ec2",
        "provider": "aws",
        "cloudAccountId": "123456789012",
        "cloudAccountName": "AWS Prod",
        "recommendationCount": 3,
        "openCount": 2,
        "totalSavingsUsd": 52.56
      }
    ],
    "total": 45,
    "page": 1,
    "limit": 10,
    "hasMore": true
  }
}
```

## List Rule Summaries

### GET /recommendations/rules

List distinct rules with aggregate recommendation stats. Used for the rule view.

#### Query Parameters

Same filters as `/recommendations/resources` plus:

| Parameter | Description |
| --- | --- |
| `rule_id` | Filter by specific rule ID (e.g., RC-212) |

```json title="Response"
{
  "data": {
    "items": [
      {
        "ruleId": "RC-212",
        "title": "Azure managed disk is unattached — verify and delete if not needed",
        "category": "orphan",
        "severity": "low",
        "resourceCount": 36,
        "openCount": 35,
        "totalSavingsUsd": 5.00
      }
    ],
    "total": 8,
    "page": 1,
    "limit": 10,
    "hasMore": false
  }
}
```

## List Recommendations

### GET /recommendations

List individual recommendations with filtering and pagination. Use resource_uid or rule_id to scope to a specific group.

#### Query Parameters

Same filters as `/recommendations/resources` plus:

| Parameter | Description |
| --- | --- |
| `resource_uid` | Filter by resource UID (used when expanding a resource card) |
| `rule_id` | Filter by rule ID (used when expanding a rule card) |

```json title="Response"
{
  "data": {
    "items": [
      {
        "id": "rec_001",
        "resourceUid": "i-0abc123def456",
        "resourceName": "idle-dev-server",
        "resourceType": "ec2",
        "ruleId": "RC-001",
        "title": "Stop idle instance i-0abc123def456",
        "titleParams": { "from": "i-0abc123def456", "to": "" },
        "description": "This instance has had less than 5% CPU utilization over the past 14 days.",
        "currentCostUsd": 52.56,
        "optimizedCostUsd": 0.00,
        "savingsUsd": 52.56,
        "status": "open",
        "severity": "high",
        "category": "idle",
        "remediation": "Consider stopping or terminating this instance if it is not needed.",
        "consoleUrl": "https://console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceId=i-0abc123def456",
        "provider": "aws",
        "cloudAccountId": "123456789012",
        "cloudAccountName": "AWS Prod",
        "actionType": "stop",
        "evidence": {
          "metric": "CPUUtilization",
          "windowDays": 14,
          "p95": 3.1
        },
        "generatedAt": "2025-01-20T08:00:00Z"
      }
    ],
    "total": 36,
    "page": 1,
    "limit": 5,
    "hasMore": true
  }
}
```

### Recommendation Title

`title` is the complete, human-readable sentence for the recommendation, and it is
composed entirely server-side. It leads with the action — "Resize db.r5.4xlarge →
db.r5.2xlarge", "Delete unattached 512 GB disk" — so the finding is readable without
opening anything. Render it as-is: it is the single source of the wording, and every
client that re-derives a verb from `category` or `actionType` will drift from it.

`titleParams` carries the spec tokens that already appear **verbatim inside** `title`,
so a client can style them (monospace, click-to-copy, a highlighted target) without
re-composing the sentence:

| Field | Description |
| --- | --- |
| `from` | The current value the recommendation is moving away from (instance class, volume type, size). Empty when the finding has no "current" spec. |
| `to` | The target value. Empty for recommendations with no target — deletes, stops, and advisory findings. |

Rules for consumers:

- **Find the tokens inside `title`, don't assemble a sentence from them.** Locate each
  token as a substring of `title` and style that span. Never splice a token into
  wording that does not already contain it, and never compose your own sentence out of
  `from` / `to` — that is how a second, drifting copy of the wording gets created.
- **`titleParams` is optional and is frequently absent.** It is derived from the
  recommendation's stored resource snapshot, which was added on 2026-06-18 with no
  backfill. Every recommendation generated before that date has no snapshot and
  therefore no `titleParams` — a large share of an established backlog. Fall back to
  rendering `title` plainly; there is nothing missing from the sentence itself.
- A token may be present in `titleParams` but absent from the wording for a given rule.
  Treat that as "no chip for that token", not an error.

## Recommendation Summary

### GET /recommendations/summary

Get aggregate recommendation statistics.

```json title="Response"
{
  "data": {
    "totalOpen": 23,
    "totalOpenCost": 19,
    "totalSavings": 1250.80,
    "criticalCount": 2,
    "highCount": 8,
    "resourceCount": 18,
    "awsResources": 10,
    "gcpResources": 5,
    "azureResources": 3,
    "appliedCount": 12,
    "dismissedCount": 5,
    "optimisedCount": 3
  }
}
```

## Resource Recommendations (Deprecated)

### GET /recommendations/resources/{resourceUID}

Get all recommendations for a specific resource.

**Warning**

Use `GET /recommendations?resource_uid={resourceUID}` instead, which supports pagination. Target removal: 2026-10-01.

## Update Recommendation

### PATCH /recommendations/{recommendationID}

Mark a recommendation as applied or dismissed, or reopen one that was previously applied or dismissed.

```bash title="Request"
curl -X PATCH https://zopnight.com/api/recommendations/rec_001 \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "status": "applied" }'
```

Valid status transitions:

- `open` -> `applied`
- `open` -> `dismissed`
- `applied` -> `open` (reopen)
- `dismissed` -> `open` (reopen)

**Info**

The `totalSavingsUSD` field on the *Applied* and *Auto-Resolved*
recommendation cards reflects realised savings, not just open opportunity. The
`/recommendations/summary` endpoint exposes the same numbers via
`appliedSavingsUSD` and `optimisedSavingsUSD`.

## Filter Fields

### GET /recommendations/filter-fields

Get available filter field values for building filter UIs.

```json title="Response"
{
  "data": {
    "statuses": ["open", "applied", "dismissed", "optimised"],
    "severities": ["critical", "high", "medium", "low", "info"],
    "categories": ["idle", "rightsizing", "schedule", "orphan", "compliance", "discount", "governance"],
    "providers": ["aws", "gcp", "azure"]
  }
}
```

## Provider Breakdown

### GET /recommendations/summary/providers

Get recommendation counts and savings broken down by cloud provider.

```json title="Response"
{
  "data": [
    { "provider": "aws", "totalOpen": 12, "totalSavings": 820.50, "criticalCount": 1, "highCount": 5 },
    { "provider": "gcp", "totalOpen": 6, "totalSavings": 310.00, "criticalCount": 0, "highCount": 2 },
    { "provider": "azure", "totalOpen": 5, "totalSavings": 120.30, "criticalCount": 1, "highCount": 1 }
  ]
}
```

## Recommendation Detail

### GET /recommendations/{recID}/detail

Get full detail for a single recommendation including metrics and remediation steps.

### Fields returned only by the detail endpoint

The list endpoints stay deliberately lean. The fields below are resolved per
recommendation at read time and are returned **only** by the detail endpoint — they are
not merely empty in a list response, they are absent from it. Build your UI so each one
is optional, and do not show a placeholder value for one that has not been fetched.

| Field | Type | Description |
| --- | --- | --- |
| `specs` | object | The resource's current cloud-side spec as flat key/value strings — `instance_class`, `engine`, `multi_az`, `storage`, `instanceType`, `machineType`, `volumeType`, and so on. Exactly what the resource looks like today. This is **never the target**: it is the "before" side of the recommendation. Keys vary by provider and resource type. |
| `targetSpec` | object | The structured target for rules that recommend one, e.g. `{"instanceType": "m5.large"}`. Absent for advisory recommendations and for anything with no concrete target. |
| `region` | string | The resource's cloud region. |
| `resourceGroup` | string | The resource's grouping identifier — the Azure resource group. Empty for providers and resource types without one. |
| `resourceTags` | object | The customer's own cloud resource tags as key/value pairs. Distinct from `implementationTags`, which are ZopNight-derived. |
| `tierRates` | object | Live discount-tier rates for the resource's SKU. Commitment recommendations (Reserved Instances, Savings Plans, CUDs) only. |

**Warning**

A recommendation outlives the resource it describes. When the resource has been deleted
since the recommendation was generated, the detail response still returns the
recommendation but leaves these resource-derived fields blank. Absent means "not
resolved", never "the resource has none".

## Refresh Recommendations

### POST /recommendations/refresh

Trigger a recommendation recomputation for the organization.

**Warning**

Refreshing recommendations re-evaluates all rules against all resources. Use sparingly.

## Auto-Remediation Workflows

For auto-remediable rules (rightsizing, resize, oversized) a recommendation can be
applied through a multi-step workflow that the provisioner executes against the
cloud provider. Workflows are previewed, started, and approved through the endpoints
below. See [Provisioning](https://zop.dev/docs/operations/provisioning) for the apply-side detail.

### GET /recommendations/{recID}/workflow

Preview the workflow plan that would be created for this recommendation — steps, target spec, and approval gates — without persisting anything.

### POST /recommendations/{recID}/workflow

Start a remediation workflow for this recommendation. Returns a workflow job ID; poll /workflows/{jobID} for status.

### GET /workflows/pending-approval

List workflow steps across the org that are waiting on a human approver. Used by the approvals queue widget.

### GET /workflows/{jobID}

Get a workflow

### POST /workflows/{jobID}/cancel

Cancel an in-flight workflow. Steps already applied are not rolled back; subsequent steps are skipped.

### POST /workflows/{jobID}/steps/{stepName}/approve

Approve a paused workflow step by its declared name. Use the by-id variant when you have the step

### POST /workflows/{jobID}/steps/{stepName}/reject

Reject a paused workflow step. The workflow halts and no further steps are dispatched.

### POST /workflows/{jobID}/steps-by-id/{stepID}/approve

Approve a paused workflow step by its stable ID. Prefer this when the UI already has the step ID — it survives template renames.

### POST /workflows/{jobID}/steps-by-id/{stepID}/reject

Reject a paused workflow step by its stable ID.

## Smart Tags

ZopNight derives **virtual tags** from tagging policies you define and stores them per
resource (pending until you accept). They power tag-based cost attribution and are never
written back to the cloud. The full API — `GET/PATCH /smart-tags` plus the tagging-policy
catalog and validation endpoints — lives on its own page:
[Smart Tags](https://zop.dev/docs/operations/smart-tags).

The recommender runs 337+ audit rules across AWS (155), GCP (75), and Azure (107),
including six autoscaler rules (RC-ASC-001..006) that feed into
[VM Autoscaling](https://zop.dev/docs/operations/vm-autoscaling).
See [Cloud Support Matrix](https://zop.dev/docs/cloud-setup/cloud-support) for
provider-specific details.
