{
  "openapi": "3.1.0",
  "info": {
    "title": "AmpVerve Platform API",
    "version": "2026-10-15",
    "summary": "Telemetry ingestion, asset registry, market data and flexibility settlement for distributed energy resources.",
    "description": "The AmpVerve Platform API is the programmatic interface to the AmpVerve energy\ninfrastructure. It covers four things:\n\n1. **Ingestion.** Push time series telemetry from sites and assets into AmpVerve.\n2. **Registry.** Describe the sites and assets that telemetry belongs to.\n3. **Market data.** Read live UK tariff, wholesale and carbon intensity signals.\n4. **Flexibility.** Read DNO tenders and contracts, and submit flexibility offers.\n\n## Status labels\n\nEvery operation in this document carries an `x-ampverve-status` extension. Treat\nit as part of the contract.\n\n| Status | Shown as | Meaning |\n| ------ | -------- | ------- |\n| `live` | Live | Deployed and healthy in production, routed to the service that serves it, backed by database tables that exist in production, authenticated with the organisation taken from the verified credential (or public on purpose), and exercised by an automated test. Build against it within its documented country and capability scope. |\n| `awaiting-migration` | Available after scheduled maintenance | Deployed, routed, authenticated and tested, but a database change it depends on is scheduled and not yet applied in production. Until then it can answer with an error. Do not put it on a production path until it is `live`. |\n| `partner-gated` | Available on request | The route is deployed, but success also requires tenant credentials, commercial access, qualification and runtime activation for the market. Agree these with AmpVerve before you build. |\n| `demo-realm` | Demo environment | Listed under the Demo environment tag. Acts only on simulated devices in demo organisations (`demo-*`); a production organisation is refused (403) or the call records nothing. |\n\nOperations that return derived figures also carry `x-ampverve-data-basis`:\n`measured` (read from a meter or device), `stored` (records as written),\n`configured` (settings as set), `calculated` (derived from your own data and\nthe inputs the operation states), `forecast` (future values from a forecast\nmodel) or `simulated` (sandbox and demo environment only). For `calculated`,\n`forecast` and `simulated`, `x-ampverve-method` states in one sentence how the\nfigures are produced. A calculated or forecast figure is not a measurement:\ndo not use it for settlement or a financial claim unless the operation says so.\n\nThe evidence behind every status (service, route, tables and the migrations\nthat create them, authentication, and the tests that exercise the operation)\nis recorded per operation in `docs/api/production_readiness.json`.\n\nA partnership, connector, qualification, enrolled asset, dispatch, metering,\nsettlement and payout are separate states. Check the country capability and\nmarket lifecycle guides before making an availability or revenue claim.\n\n## How this document was produced\n\nThis specification is not hand-waved. The path set, parameters and response\nshapes for every operation were generated from the FastAPI\napplication objects at the exact container image SHAs running in production\n(`db74fad` for most services, `48b4c77` for hardware-gateway), then verified\nagainst production with live HTTP requests. The generator and the raw\nper-service output are committed alongside this file under `docs/api/`.\n\n## Additions of 2026-09-12\n\nThe site energy accounting, EMS reporting, alerting and control, data\nintegrity, site onboarding, charge library and pricing, PPA settlement,\ncapacity review, and bulk export operations were added on 2026-09-12. Their\npaths, parameters, request models and response shapes were derived from the\nroute code and Pydantic models on `main` at that date, and a test\n(`backend/tests/test_api_docs_coverage.py`) fails if any documented path stops\nbeing served by a registered router. Their current status is set by the\nproduction readiness review of 2026-10-06 below. Operations that act on or\nread the demo environment's simulated device model carry\n`x-ampverve-data-basis: simulated`.\n\n## Additions of 2026-10-03\n\nPartner API keys and signed webhooks. An\norganisation administrator issues keys (`/api/devtools/apikeys`) and registers\nHTTPS endpoints (`/api/devtools/webhooks`). A key authenticates the read\noperations under `/api/devtools/partner/v1` for its own organisation only; see\nthe `apiKeyAuth` security scheme. Webhook events and their signature scheme are\ndescribed under `webhooks`. These operations replace the earlier pre release\nkey and webhook registration, which stored keys in plaintext and delivered\nnothing. Keys issued by that earlier facility are not valid credentials.\n\n## Changes of 2026-10-03\n\n1. **New status `demo-realm`.** Eight EMS control operations act only on the\n   synthetic device model of a demo organisation and were relabelled\n   `demo-realm`: the setpoint write, the synthetic device behaviour\n   switch, the demo site scenario, the site balance, the export limitation\n   run, the two communications rehearsals and the buffer rehearsal.\n   `POST /api/optimiser/import-limit/{site_id}/run` now states that `dispatch=true` executes only in the demo realm. The EMS\n   control tag states what control acts on today and what it does not.\n2. **Removed** seven single tenant depot pilot dashboard operations and\n   their schemas. They were specific to one customer and are not part of\n   any partner contract. The fleet telematics operation they shared a tag\n   with is tenant agnostic and stays, under the tag Fleet telematics.\n3. **Corrected paths.** The bulk export operations were documented as\n   `/api/organisations/organisations/export/...`, which no route serves.\n   There is no ingress rule for `/api/organisations`; the console's\n   rewrite maps `/api/organisations/<path>` to the organisations service's\n   `/organisations/<path>`, so the public paths are\n   `/api/organisations/export/...`.\n4. **Alert history.** `GET /api/hardware/admin/alerts-history` is labelled\n   platform staff only (`x-ampverve-audience: platform-staff`); a partner\n   token receives 403. The statistics and the acknowledge, assign, resolve\n   and comment actions are scoped to your organisation.\n5. **Added** 19 tenant scoped operations: the paginated audit\n   trail, device health, avoided emissions, EMS alert routing (rules,\n   channels, dispatch, escalation check, delivery log), in app\n   notifications (list, unread count, mark read, preferences) and the\n   emission factor versions and change log. The routing webhook payload is\n   documented under `webhooks`. The EMS alert source lists now include\n   `asset_silence`, and the evaluation response documents `routing`.\n\nThese changes were derived from the route code on `main` on 2026-10-03.\nTheir status is now set by the production readiness review of 2026-10-06.\n\n## Documentation changes of 2026-10-05\n\nNo operation, parameter, schema or status changed, so `info.version` stays\n`2026-10-03`. The tags were regrouped by resource and declared in\n`x-tagGroups` in integration journey order (webhooks; connect; monitor;\noptimise; flex and markets; settle and report; developer tools; platform\nand access). The undeclared tag Platform status is now declared, the catch\nall tags Developer, Market data, Registry, Sites and EMS control were split\ninto resources, and the three scoped Partner API operations state their\nrequired scope in their security requirement. Three `422` response\ndescriptions under `/api/devtools`, which unquoted punctuation had split\ninto a stray key, now read as one sentence. Examples that named a real\ncompany now use neutral placeholders such as Example Solar Ltd.\n\n## Additions of 2026-10-05\n\nA partner sandbox. Each organisation has one\nsandbox organisation in the demo realm (`demo-sandbox-<16 hex>`), derived\nfrom its own identifier. An organisation administrator issues sandbox API\nkeys and registers sandbox webhook endpoints with the existing key and\nwebhook operations plus `environment=sandbox`. A sandbox key reads the\nPartner API operations exactly as a live key does, and gets generated data:\ntwo simulated sites, their devices, five minute telemetry and two alerts.\nIt never reads a stored device, alert or telemetry row, never reaches a\nnon-demo organisation, and it holds read scopes only, so it is refused by the\nonly other operations that accept an API key (the Home Assistant connector\nwrites, which need `telemetry:write`); no API key is accepted by any device\ncommand or market operation. Two operations were added:\n`GET /api/devtools/partner/v1/sandbox/scenario` describes the simulation, and\n`POST /api/devtools/partner/v1/sandbox/webhooks/test` sends a signed sample\n`alert.raised`, `alert.resolved`, `device.offline` or `webhook.test` event to\nthe sandbox's endpoints. `POST /api/devtools/webhooks/{endpoint_id}/test`\ntakes an optional `event_type`; types other than `webhook.test` are sent from\nthe sandbox only. `GET /api/devtools/partner/v1/me` now also returns\n`environment` (`live` or `sandbox`). There is no separate sandbox host: the\nbase URL is the production one, and the key selects the environment.\n\n## Error envelope and response headers of 2026-10-05\n\nEvery response from the developer tools and Partner API operations\n(`/api/devtools/...`) now carries `X-Request-Id`, and every JSON error from\nthem adds a machine readable `error` object beside the existing `detail`:\n`{\"detail\": ..., \"error\": {\"code\", \"message\", \"request_id\"}}`. `detail` is\nunchanged, so existing clients keep working; `code` is the stable contract\nand `message` is for people. Partner API requests (`/api/devtools/partner/v1`)\nalso carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and\n`RateLimit-Policy`, and a 429 carries `Retry-After`. See the `ErrorEnvelope`\nschema for the codes. Other operations are unchanged: they return `detail`\nonly, without `X-Request-Id`, until the envelope is extended to them.\n\n## Status page of 2026-10-05\n\n`GET /api/devtools/status` is public and read only. It reports the\nstate now and the observed availability over 24 hours, 7 days and 30 days\nof each public API component, counted from the diagnostics prober's\nrecorded `/healthz` checks and nothing else; a component with no recent\ncheck is `unobserved`, and a window with no checks has no percentage. The\npage at https://app.ampverve.com/api-docs/status renders it, and the docs\nlink to it as Status. The developer-tools service, which serves the Partner\nAPI and webhooks, was added to the prober's targets.\n\n## Additions of 2026-10-06\n\n`POST /api/notifications/intake/capacity-check` (tag Lead intake) is\nthe public request form of the Depot Capacity Check at\nhttps://app.ampverve.com/capacity-check. It needs no credential and writes a\nCRM lead into the one organisation the deployment names; it is not a partner\nintegration surface and accepts no organisation from the caller.\n\n`GET /api/notifications/studies/{study_id}/runs/{run_id}/proposal-pack`\n(tag Plan studies) returns a completed run's report, a covering\nletter and a manifest as one ZIP, to attach to an email sent from your\nown mailbox. It sends nothing.\n\nPlan study results, run summaries and the result envelope gain\n`display_label` next to `label`. `label` keeps its machine value\n`MODELLED`; `display_label` is the human-readable text, `Projection` for\nforward figures and `Calculated from your charging data` for the DepotTwin\nsavings audit, which is computed from the depot's own dated sessions. The\nstudy reports say \"Projection\" and \"Projected\", list a Methodology and\nassumptions section, and end with the AMPVERVE LTD footer.\n\n## Idempotency of 2026-10-06\n\n`POST /api/devtools/partner/v1/sandbox/webhooks/test`, the only operation that\nwrites with an API key, accepts an optional `Idempotency-Key` header. A repeat\nof the same request with the same key in the same organisation queues nothing\nand returns the first `202` body with `Idempotent-Replayed: true`; the same key\nwith a different request is `409`. The key is recorded, as a hash, on the event\nrow the request created, so no new storage was added. No other operation\naccepts the header yet; telemetry ingestion still has no deduplication.\n\n## Try it of 2026-10-06\n\nThe six operations a sandbox API key can call carry `x-ampverve-sandbox: true`:\n`GET /api/devtools/partner/v1/me`, `/alerts`, `/devices`, `/telemetry/latest`,\n`/sandbox/scenario` and `POST /api/devtools/partner/v1/sandbox/webhooks/test`.\nThe public docs page offers a Try it panel on exactly these. It sends requests\nto the production base URL with a key the reader pastes in, and refuses to send\nunless `GET /api/devtools/partner/v1/me` says the key's `environment` is\n`sandbox`. The developer-tools service now allows the origin\n`https://www.ampverve.com` to read the answers of these six operations, with\ntheir own method only, request headers `Authorization` and `Content-Type`\nonly, and no credentials; no other operation and no other origin gets a CORS\nheader.\n\n## Device last reported of 2026-10-06\n\nEvery device row in the device listings now says when the device last\nreported: `last_reported_at`, ISO 8601 UTC with a `Z` suffix, and\n`last_reported_source`, one of `ocpp_heartbeat`, `provider_reading`,\n`telemetry` or `provider_api`. Both are null when nothing is known; neither\nis ever the time of the request. They are on `GET /api/hardware/chargers`,\n`/solar`, `/battery`, `/heatpump`, `/smartmeter`, `/devices/health`,\n`/vehicles` and `/vehicles/telemetry/all`, and on `GET /api/hardware/appliances`.\nThe chargers, solar, battery and heat pump listings are documented here for\nthe first time. `last_seen_at` is unchanged and remains the connect time.\n\n## Alerts and events\n\nWhat exists today, and how to get it, is set out in the\nAlerts and events section of the Platform contracts guide\n(`docs/api/guides/PLATFORM_CONTRACTS.md`). In short: poll\n`GET /api/hardware/ems/alerts` for the ranked alert queue, or ask AmpVerve\nto configure an EMS routing rule with the `webhook` channel. For your\nown organisation's events, register an HTTPS endpoint with\n`POST /api/devtools/webhooks` (see Additions of 2026-10-03): it\nreceives the signed `alert.raised`, `alert.resolved` and `device.offline`\nevents described under `webhooks`.\n\n## Additions of 2026-10-03\n\nThe retail tariff operations (`/api/trading/retail-tariffs/...`) were added on\n2026-10-03, derived from the route code on the branch that adds them. Smart band\ndesign and cost plus pricing carry `x-ampverve-data-basis: calculated`.\n\n## Production readiness review of 2026-10-06\n\nEvery operation was assessed against five production criteria: its service is\ndeployed and healthy; the ingress routes its path to that service; every table,\ncolumn and module catalogue row it reaches exists in production (from the read\nonly migration plan of 5 October 2026 and `backend/migrations/applied_ledger_pins.json`);\nit is authenticated with the organisation taken from the verified credential, or\npublic on purpose; and an automated test exercises it. The evidence for each is in\n`docs/api/production_readiness.json`, and\n`backend/tests/test_api_production_readiness.py` keeps this document and that\nrecord in agreement.\n\n1. **Statuses.** The pre-release label is retired. Each operation is now `live`,\n   `awaiting-migration`, `partner-gated` or `demo-realm`; see Status labels.\n2. **Data basis.** `modelled` is replaced by `calculated`, `forecast` or\n   `simulated`, each with `x-ampverve-method`.\n3. **Demo environment.** The eight `demo-realm` operations are listed under\n   their own tag, Demo environment.\n4. **Removed: unbuilt operations.** `GET /api/platform/v1/capabilities`,\n   `POST /api/ingest/v1/telemetry` and `GET /api/telemetry/sites/{site_id}/health`\n   and the `api.ampverve.com` and `sandbox.ampverve.com` hosts are not built and\n   are no longer listed. They never resolved or answered.\n5. **Withdrawn pending correction.** `GET /api/trading/prices/wholesale` (the\n   upstream request is malformed and the route answers 502),\n   `GET /api/trading/grid/dno-region` (the postcode lookup returns the wrong\n   network operator for most postcodes) and\n   `POST /api/esg/passport/credentials` (the credential subject is fixed sample\n   data, not the organisation's) are withdrawn from this contract until each is\n   corrected and tested.\n\n## Partner API service level and deprecation of 2026-10-06\n\nThis applies to the Partner API (`/api/devtools/partner/v1`), API key and\nwebhook management (`/api/devtools/apikeys`, `/api/devtools/webhooks`) and\nthe webhook events.\n\n**No SLA yet.** No availability, latency or delivery time is committed.\nThese are measured, not promised. The observed availability of the\ndeveloper tools service, from the diagnostics prober's checks, is published\nat `GET /api/devtools/status` and https://app.ampverve.com/api-docs/status.\nAmpVerve monitors partner request errors and latency, webhook delivery lag,\ndead lettered deliveries and the webhook dispatcher, and alerts its own\noperators on them. A contractual service level, if any, is in the signed\npartner agreement.\n\n**Deprecation.** While these operations are available after scheduled\nmaintenance, a change that is not additive is recorded in a dated section of this description, naming what\nchanged and what replaces it, no later than the day it ships. Once an\noperation is `live`, a breaking change to it or its removal gets **at\nleast 6 months' notice**, counted from the announcement:\n\n- it is announced in a dated section of this description, the changelog,\n  naming the replacement, the announcement date and the last date it is\n  served (the sunset date, at least 6 months later);\n- it is marked `deprecated: true` in this document, with\n  `x-ampverve-deprecated-on`, `x-ampverve-sunset` and\n  `x-ampverve-replacement`;\n- from the announcement, its responses carry a `Deprecation` header\n  (RFC 9745) and a `Sunset` header (RFC 8594) giving the sunset date;\n- each organisation with an active API key able to call it gets an\n  organisation alert, and the owners of registered API keys are told by\n  email;\n- the operation stays served unchanged until the sunset date.\n\nAdditive changes (a new operation, a new optional request field, a new\nresponse field or header) need no notice, so clients must ignore what they\ndo not recognise.\n\n## Cursor pagination of 2026-10-06\n\nEvery Partner API list operation, `GET /api/devtools/partner/v1/alerts`,\n`/devices` and `/telemetry/latest`, and the webhook delivery log\n`GET /api/devtools/webhooks/{endpoint_id}/deliveries`, now pages the same\nway. Each takes `limit` (the page size; its default and maximum are\nunchanged and stated on the operation) and an optional opaque `cursor`, and\nits answer adds `next_cursor` (`null` on the last page) and `has_more` beside\nthe list, which keeps its name. Following `next_cursor` until it is `null`\nreads every row once, newest first, ties on time broken by the row's\nidentifier. A cursor that cannot be read, belongs to another operation, or\ncomes with different filters is `400` with the new error code\n`invalid_cursor`. The telemetry window is fixed by the first page. The\nsandbox pages the same way.\n\nNothing was removed or renamed. A request without `cursor` returns the same\nrows as before, plus the two new fields. Paging by moving `since` (alerts) or\nraising `hours` (telemetry) still works, but is deprecated as a way to page;\nboth stay as filters. The API key and webhook endpoint lists return the whole\nset and are not paginated. The Python and TypeScript SDKs add helpers that\nfollow `next_cursor`.\n\n## Deprecation headers and alerts of 2026-10-06\n\nThe deprecation policy above is now carried out by the developer-tools\nservice. No operation is deprecated today, so no response changes.\n\nAn operation is deprecated by marking it in this document with\n`deprecated: true` and three extensions: `x-ampverve-deprecated-on` (the\nannouncement date), `x-ampverve-sunset` (the last day it is served) and\n`x-ampverve-replacement` (the `operationId` to use instead). The service\nkeeps its own list of deprecated operations, and a test requires that list\nto equal these marks and refuses a sunset less than 6 calendar months after\nthe announcement. From then on, every response of the operation, success or\nerror, carries:\n\n- `Deprecation: @<Unix seconds>`, 00:00 UTC on the announcement day\n  (RFC 9745);\n- `Sunset: <HTTP-date>`, 23:59:59 GMT on the sunset day (RFC 8594);\n- `Link: <https://www.ampverve.com/api-docs/#deprecation>; rel=\"deprecation\"; type=\"text/html\"`.\n\nThe docs page's Try it origin may read all three. Each organisation with an\nactive API key able to call the operation (not revoked, not expired, and\nholding its scope) gets one open `partner_api_operation_deprecated` alert,\nresolved after the sunset or when no such key remains. AmpVerve does not\nrecord which organisation called which operation, so the alert goes to\nevery organisation whose keys could call it.\n\n## Changes of 2026-10-07\n\nFourteen Plan studies, outreach and opportunities operations are now `live`.\nA read only catalogue check of production found every table, column and\nnamed constraint their revisions define. The 32 CRM and lead intake\noperations stay `awaiting-migration`: their tables exist, but the CRM check\nconstraints do not yet. No path, parameter or schema changed.\n\n## Changes of 2026-10-15\n\nThree changes to the partner endpoints: the Partner API and API key and\nwebhook management (`/api/devtools/...`), telemetry ingestion\n(`POST /api/telemetry/ingest`) and the Home Assistant connector's API key\noperations.\n\n1. **Ingestion reports what happened to every record.** `200` now means\n   every record was stored. A record the store cannot take as sent (no\n   `asset_id` or `device_id`, a missing or unreadable `ts`, a measurement\n   that is not a number, a `status` that is not a string) is rejected by\n   name and reason instead of failing the whole batch, and an exact copy of\n   an earlier record in the same batch is stored once. When any record was\n   rejected or was a duplicate and the rest were stored, the answer is\n   `207 Multi-Status` with a `results` entry per record. When every record\n   was rejected, it is `422` `all_records_rejected` and nothing was stored.\n   A store that does not persist what it was handed is still `503`; retry\n   it. Every answer adds `accepted`, `rejected` and `duplicates` beside\n   `count` and `stored`. Before this change one such record made the whole\n   batch answer `503 writer_error`, so a retry could never succeed and the\n   good records in it were lost with it. A `403 cross_tenant_record` no\n   longer repeats the organisation a record named (`rejected[].named`);\n   `index` and `field` still locate each record.\n2. **One device status vocabulary.** A telemetry reading in\n   `GET /api/devtools/partner/v1/telemetry/latest` gains\n   `status_canonical`, one of `charging`, `discharging`, `producing`,\n   `importing`, `exporting`, `idle`, `online`, `offline`, `fault` or\n   `unknown`. Two vocabularies overlapped on `status`: operating states\n   (`available`, `idle`, `charging`, ...) and connectivity states\n   (`online`, `unavailable`, `offline`, `unknown`), so `available` and\n   `idle`, or `unavailable` and `offline`, named the same condition.\n   `status` itself is unchanged, for live and sandbox keys alike; see the\n   deprecation below. Ingestion accepts any `status` string, as before.\n3. **RFC 9457 problem details on every partner error.** Every error from\n   these endpoints carries `type`, `title`, `status`, `detail`,\n   `instance`, a stable `code` and the `request_id` (the `X-Request-Id`\n   header). Send `Accept: application/problem+json` to receive\n   `application/problem+json`, in which `detail` is always a string and\n   structured information is in extension members (`errors` for field\n   validation, `required_scope` for a missing scope). Without that header\n   the answer stays `application/json` and keeps `detail` and `error`\n   exactly as before, with the problem members added. The Home Assistant\n   connector's API key operations and ingestion now also carry\n   `X-Request-Id`, and the connector's operations the `RateLimit-*`\n   headers.\n\n**Compatibility.** Items 2 and 3 are additive: no field changes its values\nand no error changes its status, media type, `detail` or `error`. Item 1\ncorrects ingestion: a batch holding a malformed or duplicated record, which\nwas answered `503` with nothing stored, is now `207` or `422`, and the cross\ntenant `403` no longer repeats the organisation a record named\n(`rejected[].named`). Making `application/problem+json` the default would be\na breaking change and would be announced under the deprecation policy\nabove; it is not announced.\n\n## Deprecation of 2026-10-15\n\n`status` on the readings of `GET /api/devtools/partner/v1/telemetry/latest`\nreturns the stored value, in whichever vocabulary its source used. `status`\nwill switch to the canonical set on or after 2027-04-15; read\n`status_canonical` now. Until that date `status` is served unchanged, for\nlive and sandbox keys. The operation itself is not deprecated, so its\nresponses carry no `Deprecation` or `Sunset` header; the field is marked\n`deprecated: true` in its schema.\n\n## What this document deliberately does not contain\n\nEndpoints that exist in the platform but are internal, operator-only, single\ntenant, or debug surfaces are excluded. Their absence here is intentional and\nthey are not part of any partner contract.\n",
    "contact": {
      "name": "AmpVerve API Support",
      "email": "api@ampverve.com",
      "url": "https://www.ampverve.com/api-docs/"
    },
    "license": {
      "name": "Proprietary. Use governed by your AmpVerve integration agreement.",
      "url": "https://www.ampverve.com/terms/"
    },
    "x-ampverve-docs-revision": "2026-10-05"
  },
  "servers": [
    {
      "url": "https://app.ampverve.com",
      "description": "Production. This is the only base URL that resolves today. Verified 2026-07-31.\n"
    }
  ],
  "externalDocs": {
    "description": "Published API documentation",
    "url": "https://www.ampverve.com/api-docs/"
  },
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Webhook events",
      "description": "Events AmpVerve sends to an HTTPS endpoint you register, described as the\nrequest AmpVerve makes to your endpoint. Each delivery is signed with\n`AmpVerve-Signature`; verify it over the raw body before parsing.\n"
    },
    {
      "name": "Webhook endpoints",
      "description": "Register, change, test and delete the HTTPS endpoints that receive webhook\nevents, rotate their signing secrets and read their delivery log.\nOrganisation administrators only.\n\nYour organisation is told, as an open alert in its own alert stream, when an\nendpoint is disabled after repeated failures (`partner_webhook_disabled`) and\nwhen an API key will expire within 14 days and has not been rotated\n(`partner_api_key_expiring`). Each alert resolves itself when the endpoint\nis re-enabled or deleted, or the key is rotated, revoked or expires.\n"
    },
    {
      "name": "Site onboarding",
      "description": "Templated site creation with device discovery, a proposed point mapping to\nconfirm, and the elapsed time from start to confirmation.\n"
    },
    {
      "name": "Assets",
      "description": "The asset registry. An asset is the thing telemetry is about. A site groups\nassets at one physical location; `site_id` is your own identifier. The\noperations under `/api/twin/registry` (`twin` module) add asset\nclasses, a site's single line topology and device import.\n"
    },
    {
      "name": "Devices",
      "description": "Connected chargers, solar, batteries, heat pumps, vehicles and smart meters\nof the organisation, when each last reported, and the connectivity and\nfreshness of every connected device.\n"
    },
    {
      "name": "Fleet telematics",
      "description": "The latest measured position and ignition state of each vehicle from the\norganisation's vendor telematics feed.\n"
    },
    {
      "name": "Ingestion",
      "description": "Push telemetry into AmpVerve. This is the surface a data provider integrates\nagainst. Read the Ingestion guide in the published documentation before building:\nthe canonical field mapping, the anomaly contract, and the current absence of\nserver side deduplication all materially affect how you should write your client.\n"
    },
    {
      "name": "Forecasting",
      "description": "Per site weather, solar and demand forecasts as the optimiser uses them: the\nmethod behind each, whether it is validated for the site by backtest, forecast\nagainst measured, and freshness. Nothing here sends a command to a device.\n"
    },
    {
      "name": "AI Brain",
      "description": "The optimiser's awareness layers per site: each of the twelve layers reported\nlive, fallback or missing with its source, freshness and validation, and\nshadow plans that record which layers they used. Shadow only: nothing here\nsends a device command or a market write.\n"
    },
    {
      "name": "Data integrity",
      "description": "Commissioning checks and the asset inventory they run against."
    },
    {
      "name": "Gateway link and buffering",
      "description": "The site gateway's communications watchdog and its offline buffer.\n\n**Communications.** Heartbeat, status and check accept production\norganisations, where they record state only; the automatic watchdog\nwalks demo sites only. Gateways authenticate with a user style bearer\ntoken; there is no device credential yet.\n\n**Buffer replay** writes production telemetry. The rehearsal operations\nact on the demo environment only and are listed under Demo environment.\n"
    },
    {
      "name": "Home Assistant connector",
      "description": "The AmpVerve Home Assistant connector runs in a home next to Home Assistant and pushes\nnormalised telemetry with an organisation API key (scope `telemetry:write`). The\n`v1` operations are the connector's; the others are for people in the organisation\nto review the discovery report, confirm the mapping and watch connectivity health.\nPhase 1 is read only: no operation sends or queues a command.\n"
    },
    {
      "name": "Telemetry",
      "description": "Read back what you and your devices have written."
    },
    {
      "name": "Site energy",
      "description": "Site energy accounting from stored telemetry: the self-consumption and export\nsplit, battery state, the portfolio league and the capacity review. Every\nfigure carries its quality and provenance.\n"
    },
    {
      "name": "EMS reporting",
      "description": "EMS sites and their settings, reports with figure level lineage back to the\nreadings they came from, and PV underperformance with the loss priced.\n"
    },
    {
      "name": "EMS alerts",
      "description": "The alert queue ranked by pound impact per day, its evaluation, and the\norganisation's severity thresholds.\n"
    },
    {
      "name": "EMS alert routing",
      "description": "Rules that deliver EMS alerts by site, role and severity floor to one\nchannel each (email, a Microsoft Teams incoming webhook, or a generic\nsigned webhook), the escalation check, and the delivery log.\n\nDelivery happens only when something calls it: newly raised alerts are\nrouted by `POST /api/hardware/ems/alerts/evaluate`, open alerts by\n`POST /api/hardware/ems/routing/dispatch`, and escalations by\n`POST /api/hardware/ems/routing/escalations/check`. There is no scheduler\nthat calls these yet. Each webhook delivery is a single attempt with a 10\nsecond timeout and no retry; a non 2xx answer is recorded as `failed`.\nThe webhook signing secret is held by AmpVerve: ask your AmpVerve contact\nto provision one for a rule. Requires the `ems/routing` module.\n"
    },
    {
      "name": "Alerts",
      "description": "The alert lifecycle actions (acknowledge, assign, resolve, comment) and\nthe queue statistics, scoped to your organisation. The full alert history\nlist is for AmpVerve platform staff only; read your organisation's alerts\nwith `GET /api/hardware/ems/alerts`, which returns every alert row of the\norganisation, including those raised by the device health poller.\n"
    },
    {
      "name": "Notifications",
      "description": "In app notifications and notification preferences of the calling user.\nEvery list is the caller's own; there is no organisation wide view here.\n"
    },
    {
      "name": "Field",
      "description": "AmpVerve Field: work orders raised by hand, from an alert or from a stale device\nfeed, assigned to a named person or contractor, moved through an enforced\nlifecycle against per organisation SLA policies, with the dispatch list and the\nSLA report. Requires the `field` module.\n"
    },
    {
      "name": "Fleet readiness",
      "description": "Departure assurance for any fleet organisation: the readiness board (every\nvehicle against its next departure), the depot operating envelope and the\nflexibility envelopes, and the departure requirements that drive both. Sites\nare the organisation's own workspaces of type site; no tenant is named in code.\n"
    },
    {
      "name": "Tariffs and prices",
      "description": "UK electricity import and wholesale price signals, sourced live from Octopus\nEnergy and Elexon BMRS, the GSP region codes, and the distribution network\noperator for a postcode.\n"
    },
    {
      "name": "Charges and pricing",
      "description": "The GB charge library (DUoS, TNUoS, BSUoS, CCL, capacity bands), MPAN\nconfiguration, the charge stack for an MPAN and the pricing engine that puts a\npound figure, or an honest reason for its absence, beside every kWh.\n"
    },
    {
      "name": "Retail tariffs",
      "description": "AmpVerve Tariff: retail tariff definitions (versioned, append only), line item\nbills from stored half hourly readings or an uploaded CSV, tariff comparison,\nsmart band design from a cost curve and cost plus pricing. The arithmetic is in\nbackend/core/tariff; every rate is stated and none is defaulted.\n"
    },
    {
      "name": "Hub",
      "description": "Public charging site operations on the OCPP charger stack: grid aware price\nschedules with published floor, ceiling and change limits, per connector quotes,\nitemised session costs from stored meter values, Public Charge Point Regulations\n2023 checks, reliability and utilisation, and import limit sharing. Load sharing\nsends charging profiles on the demo realm only.\n"
    },
    {
      "name": "Optimisation",
      "description": "Import limit planning and the day-ahead battery tariff schedule.\n\n**Optimisation is advisory and on demand.** The tariff schedule and\nimport limit runs produce a plan when called. There is no approve or\nactivate API and no rolling re-optimisation. The forecast basis is\nmeasured persistence: the site's own last stored half hours carried\nforward. `POST /api/optimiser/import-limit/{site_id}/run` with\n`dispatch=true` executes only in the demo realm.\n"
    },
    {
      "name": "EMS control",
      "description": "Setpoint writes, export limitation and human overrides.\n\n**What control acts on today.** Control writes go through one function,\n`verified_setpoint_write`: send the command, record the device's\nacceptance, read the device back independently, and return a verdict of\n`verified`, `readback_mismatch` (accepted but not applied), `unconfirmed`\n(device unreachable) or `rejected`. Today it acts on synthetic demo\ndevices only. There is no real device adapter behind it yet. Production\ndevice commands use a separate, gated path that is not part of this API.\nOperations that act only on demo devices carry\n`x-ampverve-status: demo-realm` and are listed under Demo environment.\n\n**Export limitation.** The background loop runs every 30 seconds for\ndemo realm sites only. It is cloud hosted, not type tested and not G100\ncertified.\n"
    },
    {
      "name": "EMS grid connection",
      "description": "A site's connection terms as executable guardrails: the limit in force\nand the obligation that set it, compliance of the stored readings, the\ncompliance suite the terms generate, and a check of a proposed change.\nThe connection agreement read into proposed terms a person confirms (not\nsaved yet), and one connection shared across the site's chargers and\nbatteries. Read and planning only: nothing is sent to a device.\n"
    },
    {
      "name": "Decision log",
      "description": "Dispatch decision records with their inputs, constraints, objective and\nalternatives, and a replay that re-runs the pure decision on the stored\ninputs.\n"
    },
    {
      "name": "Flexibility",
      "description": "DNO flexibility tenders, awarded contracts, and offer submission, via UKPN\nLocalFlex and SSEN ElectronConnect.\n"
    },
    {
      "name": "VPP enrolment",
      "description": "A site's enrolment in AmpVerve's own virtual power plant, its withdrawal,\nand the current signal for an enrolled site.\n"
    },
    {
      "name": "Energy ledger and PPA",
      "description": "The stored energy ledger per settlement period with its full source tree,\nits rebuild, and PPA contracts and monthly settlement statements.\n"
    },
    {
      "name": "Carbon and emissions",
      "description": "GB grid carbon intensity and its forecast (National Grid ESO), the stored\nhalf hourly intensity for a site's region, Scope 2 on both the location and\nmarket based methods, avoided emissions, emission factor versions and their\nchange log, and a household's import carbon footprint.\n"
    },
    {
      "name": "Certificates",
      "description": "Signed energy receipts and W3C Verifiable Credentials.\n"
    },
    {
      "name": "Settlement",
      "description": "AmpVerve Settle. Ed25519 signed receipts for delivered flexibility, chained per\norganisation, verifiable by a counterparty from the published keys, and\nreconciled against the counterparty's settlement statement. Receipts state\nwhat was recorded; they move no money and submit nothing to any market.\n"
    },
    {
      "name": "Fleet savings statements",
      "description": "A signed monthly statement per fleet depot: charging cost against a baseline the\ncustomer agreed in advance, with the method, the data coverage and every excluded\nsession, signed with the settlement receipt key so a finance team can check it was\nnot changed. Public verification returns no customer data.\n"
    },
    {
      "name": "Data export",
      "description": "Bulk export of one organisation's data in open formats, requested and executed\nby the customer. See `docs/api/export-schema.md` for the collection schema. The\npublic paths are `/api/organisations/export/...`; see `getExportManifest`.\n"
    },
    {
      "name": "API keys",
      "description": "Issue, list, rotate and revoke organisation API keys (`avk_...`).\nOrganisation administrators only. A key is shown once, at creation.\n"
    },
    {
      "name": "Partner API",
      "description": "Read operations authenticated by an organisation API key (`Authorization: Bearer avk_...`),\nscoped to the key's organisation and to the key's scopes. A sandbox key gets\nsimulated data from the same operations; see `Sandbox`.\n\n**Rate limit.** Each key has a per minute limit (600 by default), counted\nacross every server, and reported in the `RateLimit-*` headers; over it, `429`\nwith `Retry-After`. If the shared counter store is briefly unreachable, reads\nare still answered and limited per server, and every operation that writes\nwith an API key (the sandbox test event and the Home Assistant connector\npushes) answers `503` with `Retry-After: 30` and error code\n`service_unavailable` rather than accept a write without its limit. Retry a\n`503` after `Retry-After`; it is never a sign the key is wrong.\n"
    },
    {
      "name": "Sandbox",
      "description": "A demo environment organisation of simulated data for building and testing an\nintegration without touching any real home, site, device or market.\n\n1. An organisation administrator issues a sandbox key with\n   `POST /api/devtools/apikeys?environment=sandbox`, and registers a sandbox\n   webhook endpoint with `POST /api/devtools/webhooks?environment=sandbox`. Every\n   key and webhook operation takes `environment=sandbox`.\n2. The key is an ordinary `avk_` key. Its organisation is the caller's sandbox\n   organisation, `demo-sandbox-<16 hex>`, derived from the caller's own\n   organisation; `GET /api/devtools/partner/v1/me` returns it with\n   `environment: sandbox`.\n3. Every Partner API read answers a sandbox key from a deterministic simulator:\n   a depot with two chargers and a solar and battery site, their devices,\n   five minute telemetry for six assets and two alerts. The same request at the\n   same moment gives the same answer. Nothing is measured and nothing is stored.\n4. `POST /api/devtools/partner/v1/sandbox/webhooks/test` sends a signed sample\n   event to the sandbox's endpoints, delivered and retried exactly as a live one.\n\n**Guarantees.** A sandbox key never reaches a non-demo organisation: its\norganisation is fixed on the key row and no header, parameter or body changes it.\nIt never reaches a real device or a market write: the Partner API operations\nanswer it without reading any stored device, alert or telemetry row; a sandbox\nkey holds read scopes only (`alerts:read`, `devices:read`, `telemetry:read`), so\nthe only other operations that accept an API key, the Home Assistant connector\nwrites, refuse it for lacking `telemetry:write`; every other operation refuses\nan API key with 401; and the only Partner API operation that writes is the\nsandbox test event. Expiry, revocation and the rate limit apply as for a live\nkey.\n"
    },
    {
      "name": "Authentication",
      "description": "Obtain and refresh the bearer token that authorises every other call, and\nMicrosoft Entra ID single sign-on.\n"
    },
    {
      "name": "Site access",
      "description": "Site personas and memberships: what each user may do on each site.\n"
    },
    {
      "name": "Audit log",
      "description": "Your organisation's audit trail, the catalogue of actions it records, and\nits CSV export.\n"
    },
    {
      "name": "Platform",
      "description": "Liveness, readiness, country capability and operational signals."
    },
    {
      "name": "Platform status",
      "description": "Platform status, observed availability and incident history visible to your\norganisation. Opening and updating incidents is for platform administrators.\n`GET /api/devtools/status` is the public, unauthenticated summary by API\ncomponent, rendered at https://app.ampverve.com/api-docs/status.\n"
    },
    {
      "name": "Plan studies",
      "description": "AmpVerve Plan: DepotTwin (depot EV capacity), Power Passport (firm and\nflexible capacity for a large site) and Plan (battery and solar sizing).\nCreate a study, upload the site's data, run the engine as a background job\nand download the report. Every forward figure is a projection: results\ncarry the machine label `label: \"MODELLED\"` and the human-readable\n`display_label: \"Projection\"`. Gaps in data are refused, never filled, and\nevery cost carries its source.\n"
    },
    {
      "name": "CRM",
      "description": "Sales pipeline for an organisation's own business development: accounts,\ncontacts, deals by stage, activities, tasks and a probability weighted\nforecast. Needs the `crm` module enabled for the organisation. Contacts are\npersonal data: suppression and opt out from the outreach flow are respected,\ncontacts can be deleted on request, and prospects with no engagement are\nerased after the organisation's retention period.\n"
    },
    {
      "name": "Demo environment",
      "description": "Operations that act only on simulated devices in a demo organisation\n(`demo-*`): the verified setpoint write and the device model it acts on, the\nexport limitation loop, and the communications and buffer rehearsals. A\nproduction organisation is refused with 403 or the call records nothing. Use\nthem to see the control path end to end without touching hardware.\n"
    },
    {
      "name": "Lead intake",
      "description": "The public request form of the Depot Capacity Check. No credential: it\nrecords a request for a DepotTwin study as a CRM lead in the one\norganisation the deployment names, for a person to answer. It opens no\nother CRM operation.\n"
    }
  ],
  "x-tagGroups": [
    {
      "name": "Webhooks",
      "tags": [
        "Webhook events",
        "Webhook endpoints"
      ]
    },
    {
      "name": "Connect",
      "tags": [
        "Site onboarding",
        "Assets",
        "Devices",
        "Fleet telematics",
        "Ingestion",
        "Data integrity",
        "Gateway link and buffering",
        "Home Assistant connector"
      ]
    },
    {
      "name": "Monitor",
      "tags": [
        "Telemetry",
        "Site energy",
        "EMS reporting",
        "EMS alerts",
        "EMS alert routing",
        "Alerts",
        "Field",
        "Notifications",
        "Fleet readiness"
      ]
    },
    {
      "name": "Optimise",
      "tags": [
        "Tariffs and prices",
        "Charges and pricing",
        "Retail tariffs",
        "Hub",
        "Forecasting",
        "Optimisation",
        "AI Brain",
        "EMS control",
        "EMS grid connection",
        "Decision log",
        "Plan studies"
      ]
    },
    {
      "name": "Flex and markets",
      "tags": [
        "Flexibility",
        "VPP enrolment"
      ]
    },
    {
      "name": "Settle and report",
      "tags": [
        "Energy ledger and PPA",
        "Carbon and emissions",
        "Certificates",
        "Settlement",
        "Fleet savings statements",
        "Data export"
      ]
    },
    {
      "name": "Commercial",
      "tags": [
        "CRM",
        "Lead intake"
      ]
    },
    {
      "name": "Developer tools",
      "tags": [
        "API keys",
        "Partner API",
        "Sandbox"
      ]
    },
    {
      "name": "Platform and access",
      "tags": [
        "Authentication",
        "Site access",
        "Audit log",
        "Platform",
        "Platform status"
      ]
    },
    {
      "name": "Demo environment",
      "tags": [
        "Demo environment"
      ]
    }
  ],
  "paths": {
    "/api/telemetry/healthz": {
      "get": {
        "operationId": "getIngestionLiveness",
        "summary": "Ingestion liveness",
        "description": "Returns 200 while the ingestion service process is running. It does not\ncheck dependencies. Use `/api/telemetry/readyz` to determine whether the\ningestion path can actually accept and durably store data.\n",
        "tags": [
          "Platform"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Process is alive.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "ok"
                    }
                  }
                },
                "example": {
                  "status": "ok"
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/readyz": {
      "get": {
        "operationId": "getIngestionReadiness",
        "summary": "Ingestion readiness and dependency health",
        "description": "The authoritative machine readable signal for whether the ingestion layer is\nhealthy. This is the endpoint a consumer should poll to detect degradation,\nand it is the endpoint AmpVerve measures the ingestion uptime commitment\nagainst.\n\nReturns **200** with `status: \"ready\"` when the ingestion path is accepting\ntraffic. Returns **503** with `status: \"degraded\"` otherwise.\n\nThe `checks` object reports each dependency independently:\n\n| Check      | Meaning if false |\n| ---------- | ---------------- |\n| `kafka`    | The streaming bus is unreachable. Writes will fail. Hard failure. |\n| `redis`    | The live event stream is unreachable. Writes will fail. Hard failure. |\n| `database` | The time series store is unreachable. **This does not set `status` to `degraded`.** Ingestion continues to accept and stream data, but `stored` in the ingest response will be `0`. This is the single most important degradation mode for a consumer to detect, because the request still returns 200. |\n\nSee the Reliability section of the published documentation for what to do\nabout each case.\n",
        "tags": [
          "Platform"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Ingestion path is ready.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Readiness"
                },
                "example": {
                  "status": "ready",
                  "kafka_bootstrap": "kafka:9092",
                  "topics": {
                    "raw": "telemetry.raw",
                    "normalized": "telemetry.normalized",
                    "anomalies": "telemetry.anomalies"
                  },
                  "timeseries_table": "telemetry",
                  "live": {
                    "kafka_producer": true,
                    "kafka_consumer": true,
                    "engine": true,
                    "redis_stream": true
                  },
                  "checks": {
                    "kafka": true,
                    "redis": true,
                    "database": true
                  }
                }
              }
            }
          },
          "503": {
            "description": "Ingestion path is degraded. Do not discard data. Buffer and retry with\nexponential backoff.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Readiness"
                },
                "example": {
                  "status": "degraded",
                  "checks": {
                    "kafka": false,
                    "redis": true,
                    "database": true
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/metrics": {
      "get": {
        "operationId": "getIngestionMetrics",
        "summary": "Prometheus metrics for the ingestion layer",
        "description": "Prometheus text exposition format. Includes `telemetry_ingest_total`\n(cumulative records accepted) and `telemetry_readiness` (1 ready, 0 degraded).\nUseful if you scrape AmpVerve into your own observability stack.\n",
        "tags": [
          "Platform"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Prometheus exposition.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/login": {
      "post": {
        "operationId": "login",
        "summary": "Exchange credentials for an access token",
        "description": "Returns a 60 minute access token and a 7 day refresh token.\n\nThe access token is a JWT carrying your `tenant_id` (your organisation id),\nyour `roles`, and `exp`. Every subsequent request must send it as\n`Authorization: Bearer <access_token>`.\n\nRate limited to 5 requests per minute per source IP, shared across all\nauthentication endpoints. This is deliberately tight. Do not call it per\nrequest. Cache the access token and refresh it on expiry.\n\nAfter 5 consecutive failed attempts the account locks for 15 minutes and\nreturns `423`.\n",
        "tags": [
          "Authentication"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  },
                  "organisation_id": {
                    "type": "string",
                    "description": "Only required if your user belongs to more than one organisation.\n"
                  }
                },
                "example": {
                  "email": "integration@example.com",
                  "password": "<your password>"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "423": {
            "description": "Account locked after repeated failures. Retry after the lockout window.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Account locked. Try again later."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/auth/token/refresh": {
      "post": {
        "operationId": "refreshToken",
        "summary": "Rotate an access token",
        "description": "Exchanges a refresh token for a new access token and a **new** refresh token.\nThe refresh token you present is revoked in the same operation. Refresh tokens\nare single use. Store the new one.\n\nA refresh token is only valid while its `jti` is present in the AmpVerve token\nallowlist. Calling `/api/auth/logout` removes it immediately.\n",
        "tags": [
          "Authentication"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "refresh_token"
                ],
                "properties": {
                  "refresh_token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rotated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "operationId": "logout",
        "summary": "Revoke a refresh token",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "refresh_token"
                ],
                "properties": {
                  "refresh_token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "ok"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/sso/entra/exchange": {
      "post": {
        "operationId": "entraSsoExchange",
        "summary": "Exchange a Microsoft Entra ID token for an AmpVerve session",
        "description": "Single sign-on (row G8). The browser signs the user in with Entra (MSAL)\nand presents the ID token here. The token becomes a session only when:\nits `tid` matches an enabled provider row an organisation administrator\nconfigured (`PUT /api/auth/sso/entra/provider`); its signature verifies\nagainst that authority's published keys; issuer, audience (the\nconfigured application id) and validity check out; and at least one of\nits `groups` maps to a role in the organisation's group to role\nmappings. The roles on the session are the union of the mapped roles.\n\nRefusals carry `detail.error: sso_refused` and a plain `detail.reason`:\n`404` when no organisation is configured for the token's tenant (the\nconsole then keeps its existing Microsoft path), `401` for a token that\ndoes not verify, `403` when no group maps to a role (the group ids are\nlisted) or the token signals groups overage, which is refused rather\nthan completed from Graph. Password login is a separate route and is\nunchanged. The sign in is recorded in the audit chain as\n`auth.sso.login`.\n",
        "tags": [
          "Authentication"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "id_token"
                ],
                "properties": {
                  "id_token": {
                    "type": "string",
                    "description": "The Entra ID token (JWT) MSAL obtained."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "An AmpVerve session with the mapped roles.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "access_token",
                    "refresh_token",
                    "token_type",
                    "organisation_id",
                    "user_id",
                    "roles"
                  ],
                  "properties": {
                    "access_token": {
                      "type": "string"
                    },
                    "refresh_token": {
                      "type": "string"
                    },
                    "token_type": {
                      "type": "string",
                      "const": "bearer"
                    },
                    "organisation_id": {
                      "type": "string"
                    },
                    "user_id": {
                      "type": "string"
                    },
                    "roles": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "groups_matched": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "provisioned": {
                      "type": "boolean",
                      "description": "true when the user was created on this sign in"
                    },
                    "auth_method": {
                      "type": "string",
                      "const": "entra_sso"
                    },
                    "audience": {
                      "type": "string",
                      "enum": [
                        "b2c",
                        "b2b",
                        "b2b2c"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "The token did not verify (signature, issuer, expiry).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No group maps to a role, groups overage, or the email belongs to another organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No organisation is configured for the token's Entra tenant.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The authority's OpenID metadata or keys could not be read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/sso/entra/provider": {
      "get": {
        "operationId": "getEntraSsoProvider",
        "summary": "The organisation's Entra provider configuration",
        "description": "The Entra tenant id, authority and application id the organisation's\nSSO tokens must carry, or null when SSO is not configured. Roles:\nPLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The provider row.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "provider": {
                      "$ref": "#/components/schemas/SsoProvider"
                    },
                    "providers": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "roles": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "rule": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "putEntraSsoProvider",
        "summary": "Set the organisation's Entra provider configuration",
        "description": "Creates or replaces the provider row. The Entra tenant id is unique\nacross organisations (409 when another organisation already routes it).\nRecorded in the audit chain as `auth.sso.provider.update`. Roles:\nPLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "entra_tenant_id",
                  "authority",
                  "client_id"
                ],
                "properties": {
                  "provider": {
                    "type": "string",
                    "enum": [
                      "entra_external_id",
                      "entra_workforce"
                    ],
                    "default": "entra_external_id"
                  },
                  "entra_tenant_id": {
                    "type": "string"
                  },
                  "authority": {
                    "type": "string",
                    "description": "e.g. https://contoso.ciamlogin.com/<tenant id>/v2.0"
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "enabled": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored row.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "provider": {
                      "$ref": "#/components/schemas/SsoProvider"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A value is outside its vocabulary or the authority is not https.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "That Entra tenant is already routed to another organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/sso/entra/mappings": {
      "get": {
        "operationId": "listEntraGroupRoleMappings",
        "summary": "Entra group to role mappings",
        "description": "Every mapping of the organisation. Roles PLATFORM_ADMIN, ORG_ADMIN.",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The mappings.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "mappings": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SsoGroupRoleMapping"
                      }
                    },
                    "roles": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "addEntraGroupRoleMapping",
        "summary": "Add an Entra group to role mapping",
        "description": "Maps one Entra group object id to one organisation role. A group is\nmapped once; remove the mapping to change its role. Recorded in the\naudit chain as `auth.sso.group_mapping.create`. Roles PLATFORM_ADMIN,\nORG_ADMIN.\n",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "group_id",
                  "role"
                ],
                "properties": {
                  "group_id": {
                    "type": "string",
                    "description": "Entra group object id"
                  },
                  "role": {
                    "type": "string",
                    "enum": [
                      "ORG_ADMIN",
                      "OPERATOR",
                      "ANALYST",
                      "RESEARCHER",
                      "VIEWER"
                    ]
                  },
                  "note": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The mapping.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "mapping": {
                      "$ref": "#/components/schemas/SsoGroupRoleMapping"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The role is outside its vocabulary or the group id is missing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "That group is already mapped.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/sso/entra/mappings/{mapping_id}": {
      "delete": {
        "operationId": "removeEntraGroupRoleMapping",
        "summary": "Remove an Entra group to role mapping",
        "description": "Recorded in the audit chain as `auth.sso.group_mapping.delete`. Roles PLATFORM_ADMIN, ORG_ADMIN.",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "mapping_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The removed mapping.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "removed": {
                      "$ref": "#/components/schemas/SsoGroupRoleMapping"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No mapping with that id in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/ingest": {
      "post": {
        "operationId": "ingestTelemetry",
        "summary": "Ingest telemetry records",
        "description": "The primary data ingestion endpoint. Accepts a single record or an array of\nrecords, normalises each one onto the canonical AmpVerve telemetry schema,\nwrites them to the time series store, publishes them to the live event stream,\nand returns a per batch anomaly report.\n\n### The request body is intentionally open\n\nThe endpoint accepts **any** JSON object, or any array of JSON objects. Fields\nAmpVerve recognises are mapped onto canonical columns. Fields it does not\nrecognise are preserved verbatim in a `raw` JSONB column and are queryable\nlater. You do not lose data by sending extra fields, and you do not need to\nstrip your native payload down before sending it.\n\nYou should nonetheless populate the canonical fields, because only canonical\nfields are indexed, aggregated, and surfaced through the read endpoints.\n\n### Canonical field mapping\n\n| Canonical field | Accepted source keys | Type | Unit |\n| --------------- | -------------------- | ---- | ---- |\n| `asset_id`  | `asset_id`, `device_id` | string | identifier |\n| `tenant_id` | `tenant_id`, else the `X-Tenant-ID` header | string | identifier |\n| `ts`        | `ts` | string | ISO 8601 |\n| `power_kw`  | `power_kw` | number | kW, signed |\n| `soc_pct`   | `soc_pct` | number | percent, 0 to 100 |\n| `voltage`   | `voltage` | number | volts |\n| `current`   | `current` | number | amperes |\n| `temp_c`    | `temp_c` | number | degrees Celsius |\n| `status`    | `status` | string | free text, defaults to `unknown` |\n| `run_id`    | `run_id` | string | correlation identifier |\n| `intent_id` | `intent_id` | string | correlation identifier |\n| `raw`       | the entire original object | object | preserved as sent |\n\n### Sign convention\n\n`power_kw` is signed. Positive is power **flowing into** the asset, that is\nimport or charge. Negative is power **flowing out of** the asset, that is\nexport, generation or discharge. Solar generation is therefore negative at the\ninverter asset. Be consistent, because aggregation across assets sums this\nfield directly.\n\n### Timestamps\n\n`ts` must be an ISO 8601 string. Send it with an explicit UTC offset. A value\nwith no offset is parsed as naive local time and will land in the wrong bucket.\nCorrect: `2026-07-31T14:05:00Z` or `2026-07-31T15:05:00+01:00`. Incorrect:\n`2026-07-31 14:05:00`.\n\nA record whose `ts` is missing is rejected (`missing_ts`), and one whose `ts`\ncannot be read as ISO 8601 is rejected (`invalid_ts`). The other records of the\nbatch are stored.\n\n### Batching\n\nSend an array. Every record is checked first: a record the store cannot take as\nsent is rejected, by name and reason, and an exact copy of an earlier record in\nthe same batch is stored once. The records left are written in one database\ntransaction, so that write is atomic: either every one of them lands or none\ndo. Recommended batch size is 100 to\n1000 records. There is no server enforced maximum today; the practical ceiling\nis the request body size your client and the ingress will tolerate. Treat 5 MB\nas a safe upper bound.\n\n### Backfill\n\nBackfill works. `ts` is client supplied, so writing historical records is the\nsame call with older timestamps. Two constraints:\n\n- The time series store compresses chunks older than 30 days. Writing into an\n  already compressed chunk may be rejected by the storage layer. Backfill\n  within the last 30 days freely; coordinate with AmpVerve support for anything\n  older.\n- Backfilled records are published to the live event stream exactly as fresh\n  records are. If you are backfilling a large window, expect downstream live\n  consumers to see it.\n\n### Deduplication and idempotency, read this\n\n**There is no server side deduplication across requests, and no idempotency\nkey.** The write is a plain insert. Within one batch, an exact copy of an\nearlier record is stored once and reported as a `duplicate`. If you send the\nsame record again in a later request, it is stored twice, and it will be\ncounted twice in aggregates.\n\nDeduplication is the client's responsibility. Practically:\n\n- Make your retry logic idempotent at the batch level. On a timeout or a 5xx,\n  you do not know whether the write landed. Prefer a small deterministic batch\n  window that you can safely re-send only after confirming absence via\n  `/api/telemetry/live/events`.\n- Or accept at-least-once and deduplicate on read using `(asset_id, ts)`.\n\n### Ordering\n\nThere is no ordering guarantee. Records are written in the order they appear in\nthe array within one batch, but concurrent batches interleave, and the live\nevent stream is ordered by arrival, not by `ts`. All read endpoints order by\n`ts`, so out of order arrival does not corrupt query results.\n\n### Anomalies\n\nThe response includes an `anomalies` array, computed synchronously for every\nstored record. **A record that appears in `anomalies` is still stored.** Anomaly\ndetection annotates, it does not reject.\n\n| Reason code | Condition |\n| ----------- | --------- |\n| `soc_out_of_range`     | `soc_pct` was below 0 or above 100. Usually a unit error, a fraction sent where a percentage was expected. |\n| `power_out_of_range`   | The absolute value of `power_kw` exceeded 5000. Usually a unit error, watts sent where kilowatts were expected. |\n| `voltage_out_of_range` | `voltage` was negative. |\n\nA well behaved integration treats a non-empty `anomalies` array as an alert,\nnot as noise.\n\n### What the status tells you\n\nThe status says what happened to the batch, and the body says what happened\nto each record. No 2xx is returned for a record that could have been stored and\nwas not.\n\n| Status | Meaning | What to do |\n| ------ | ------- | ---------- |\n| `200` | Every record was stored: `stored` equals `count`. | Nothing. |\n| `207` | Every record that could be stored was stored. At least one was rejected or was a duplicate; `results` gives each record's `outcome`. | Fix and resend the `rejected` records only. Do not resend the batch. |\n| `422` | Every record was rejected (`all_records_rejected`). Nothing was stored. | Fix the records; `results` gives each reason. |\n| `403` | The caller has no verified organisation, or a record names another organisation (`cross_tenant_record`). Nothing was stored. | Do not retry unchanged. |\n| `503` | The store did not persist the batch (`writer_unavailable`, `writer_error`, `partial_write`). `stored` says how many records landed. | Buffer and replay with backoff. Check `/api/telemetry/readyz`. |\n\nRejection reasons: `missing_asset_id`, `invalid_asset_id` (not a string),\n`missing_ts`, `invalid_ts`, `non_numeric_value` (`field` names the measurement)\nand `invalid_status` (not a string). A rejected record fails the same way on\nevery retry.\n\nBefore 2026-10-15 a single such record made the whole batch answer\n`503 writer_error` and nothing was stored.\n\nEvery error is RFC 9457 problem details (`ProblemDetails`): send\n`Accept: application/problem+json` for `application/problem+json`; otherwise\nthe same members come as `application/json`.\n",
        "tags": [
          "Ingestion"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "X-Tenant-ID",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Organisation identifier. Optional. Only used as a fallback for records that\ndo not carry `tenant_id` themselves. If supplied it must match the\n`tenant_id` in your bearer token, or the request is rejected with 403.\n"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/TelemetryRecord"
                  },
                  {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/TelemetryRecord"
                    }
                  }
                ]
              },
              "examples": {
                "batch": {
                  "summary": "A batch of three records from one site",
                  "value": [
                    {
                      "asset_id": "soflo-site-0412-inverter-1",
                      "ts": "2026-07-31T14:00:00Z",
                      "power_kw": -4.812,
                      "voltage": 241.3,
                      "current": 19.9,
                      "temp_c": 44.1,
                      "status": "producing",
                      "site_ref": "0412"
                    },
                    {
                      "asset_id": "soflo-site-0412-battery-1",
                      "ts": "2026-07-31T14:00:00Z",
                      "power_kw": 2.4,
                      "soc_pct": 68.5,
                      "temp_c": 27.8,
                      "status": "charging",
                      "site_ref": "0412"
                    },
                    {
                      "asset_id": "soflo-site-0412-meter-1",
                      "ts": "2026-07-31T14:00:00Z",
                      "power_kw": -1.208,
                      "status": "exporting",
                      "site_ref": "0412"
                    }
                  ]
                },
                "single": {
                  "summary": "A single record",
                  "value": {
                    "asset_id": "soflo-site-0412-inverter-1",
                    "ts": "2026-07-31T14:05:00Z",
                    "power_kw": -5.104,
                    "status": "producing"
                  }
                },
                "withAnomaly": {
                  "summary": "A record that will be flagged, and still stored",
                  "value": {
                    "asset_id": "soflo-site-0412-battery-1",
                    "ts": "2026-07-31T14:05:00Z",
                    "soc_pct": 0.685,
                    "power_kw": 2400
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Every record was stored. `stored` equals `count`. Inspect `anomalies`.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IngestResult"
                },
                "examples": {
                  "clean": {
                    "summary": "Clean batch, fully persisted",
                    "value": {
                      "count": 3,
                      "accepted": 3,
                      "stored": 3,
                      "rejected": 0,
                      "duplicates": 0,
                      "streamed": 3,
                      "anomalies": []
                    }
                  },
                  "flagged": {
                    "summary": "Persisted, but two quality problems detected",
                    "value": {
                      "count": 1,
                      "accepted": 1,
                      "stored": 1,
                      "rejected": 0,
                      "duplicates": 0,
                      "streamed": 1,
                      "anomalies": [
                        {
                          "asset_id": "soflo-site-0412-battery-1",
                          "reasons": [
                            "soc_out_of_range",
                            "power_out_of_range"
                          ],
                          "record": {
                            "asset_id": "soflo-site-0412-battery-1",
                            "ts": "2026-07-31T14:05:00Z",
                            "soc_pct": 0.685,
                            "power_kw": 2400
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "207": {
            "description": "Every record that could be stored was stored (`stored` equals `accepted`), and\nat least one record was rejected or was a duplicate. `results` gives every\nrecord's outcome. Resend only the `rejected` records, fixed.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IngestResult"
                },
                "example": {
                  "count": 3,
                  "accepted": 1,
                  "stored": 1,
                  "rejected": 1,
                  "duplicates": 1,
                  "streamed": 1,
                  "anomalies": [],
                  "results": [
                    {
                      "index": 0,
                      "outcome": "stored"
                    },
                    {
                      "index": 1,
                      "outcome": "rejected",
                      "reason": "missing_ts",
                      "field": "ts"
                    },
                    {
                      "index": 2,
                      "outcome": "duplicate",
                      "duplicate_of": 0
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "No credential, or an invalid or expired token. Problem details.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-authentication-required",
                  "title": "Unauthorized",
                  "status": 401,
                  "detail": "Authorization header missing",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "authentication_required",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "error": {
                    "code": "authentication_required",
                    "message": "Authorization header missing",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              }
            }
          },
          "403": {
            "description": "Nothing was stored. The role on the token does not satisfy the operation\n(`insufficient_role`), an `X-Tenant-ID` header disagrees with the token\n(`tenant_mismatch`), the caller has no verified organisation\n(`no_verified_organisation`), or a record names an organisation other than\nthe caller's (`cross_tenant_record`, with `rejected` giving each record's\n`index` and `field`; the organisation it named is not repeated). Problem\ndetails.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-cross-tenant-record",
                  "title": "Forbidden",
                  "status": 403,
                  "detail": "1 of 2 records name an organisation other than the caller's; the whole batch was rejected and nothing was stored",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "cross_tenant_record",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "outcome": "cross_tenant_record",
                  "organisation_id": "your-organisation",
                  "rejected": [
                    {
                      "index": 1,
                      "field": "tenant_id"
                    }
                  ],
                  "count": 2,
                  "stored": 0,
                  "error": {
                    "code": "cross_tenant_record",
                    "message": "1 of 2 records name an organisation other than the caller's; the whole batch was rejected and nothing was stored",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              }
            }
          },
          "422": {
            "description": "Nothing was stored. Either every record was rejected\n(`all_records_rejected`; the counts and `results` give each reason), or the\nbody is not a JSON object or an array of objects (`validation_error`).\nProblem details.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-all-records-rejected",
                  "title": "Unprocessable Content",
                  "status": 422,
                  "detail": "all 1 records were rejected; nothing was stored. See results for each record's reason",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "all_records_rejected",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "outcome": "all_records_rejected",
                  "count": 1,
                  "accepted": 0,
                  "stored": 0,
                  "rejected": 1,
                  "duplicates": 0,
                  "results": [
                    {
                      "index": 0,
                      "outcome": "rejected",
                      "reason": "invalid_ts",
                      "field": "ts"
                    }
                  ],
                  "error": {
                    "code": "all_records_rejected",
                    "message": "all 1 records were rejected; nothing was stored. See results for each record's reason",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "503": {
            "description": "The store did not persist the batch: `writer_unavailable`, `writer_error` or\n`partial_write`. `stored` says how many records landed and `unstored` how\nmany did not; `results` marks each valid record `not_stored`. Buffer and\nreplay with backoff. Problem details, with `retryable: true`.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-writer-unavailable",
                  "title": "Service Unavailable",
                  "status": 503,
                  "detail": "no time series writer is configured; nothing was persisted",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "writer_unavailable",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "outcome": "writer_unavailable",
                  "retryable": true,
                  "count": 2,
                  "accepted": 2,
                  "stored": 0,
                  "rejected": 0,
                  "duplicates": 0,
                  "unstored": 2,
                  "results": [
                    {
                      "index": 0,
                      "outcome": "not_stored"
                    },
                    {
                      "index": 1,
                      "outcome": "not_stored"
                    }
                  ],
                  "error": {
                    "code": "writer_unavailable",
                    "message": "no time series writer is configured; nothing was persisted",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/live/events": {
      "get": {
        "operationId": "listLiveEvents",
        "summary": "Read the most recent telemetry events",
        "description": "Returns the most recent telemetry records in reverse chronological order.\nUse it to confirm that a write landed, and as a low effort tail of the stream.\n\nThis is a recency window, not a query API. For a bounded time range and\naggregation, use the asset history endpoint.\n",
        "tags": [
          "Telemetry"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 500
            }
          },
          {
            "name": "asset_id",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Restrict to a single asset."
          }
        ],
        "responses": {
          "200": {
            "description": "Recent events, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "source": {
                      "type": "string",
                      "const": "timeseries"
                    },
                    "events": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LiveEvent"
                      }
                    }
                  }
                },
                "example": {
                  "source": "timeseries",
                  "events": [
                    {
                      "ts": "2026-07-31T14:05:00+00:00",
                      "type": "telemetry",
                      "payload": {
                        "asset_id": "soflo-site-0412-inverter-1",
                        "power_kw": -5.104,
                        "soc_pct": null,
                        "voltage": 241.1,
                        "current": 21.2,
                        "temp_c": 44.6,
                        "status": "producing"
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/telemetry/assets/{asset_type}/{asset_id}/history": {
      "get": {
        "operationId": "getAssetHistory",
        "summary": "Bucketed history for one asset",
        "description": "Returns a chart ready, time bucketed series for one asset, with a summary.\n\nThe `(asset_type, metric)` pair selects both the source field and the unit.\nAmpVerve resolves the metric across several source field names, including\nfields nested inside the preserved `raw` object, so a series still resolves\nwhen your payload used a vendor native field name.\n\n| asset_type    | metric        | Unit | Source fields tried, in order |\n| ------------- | ------------- | ---- | ----------------------------- |\n| `solar`       | `generation`  | kW   | `power_kw`, `solar_generation_kw`, `generation_kw`, `production_kw`, `production_w` |\n| `battery`     | `soc`         | %    | `soc_pct`, `battery_soc`, `soc` |\n| `battery`     | `charge`      | kW   | `charge_kw`, `charging_kw`, `battery_charge_kw`, `power_kw` |\n| `battery`     | `discharge`   | kW   | `discharge_kw`, `discharging_kw`, `battery_discharge_kw`, `power_kw` |\n| `ev`          | `soc`         | %    | `soc_pct`, `vehicle_soc`, `battery_level`, `usable_soc_pct` |\n| `ev`          | `sessions`    | kWh  | `session_kwh`, `charge_energy_added_kwh`, `energy_kwh` |\n| `heat-pump`   | `kwh`         | kWh  | `energy_kwh`, `heat_kwh`, `consumption_kwh` |\n| `heat-pump`   | `flow-temp`   | degC | `flow_temp_c`, `flow_temperature_c`, `temp_c` |\n| `heat-pump`   | `return-temp` | degC | `return_temp_c`, `return_temperature_c` |\n| `smart-meter` | `import`      | kW   | `import_kw`, `grid_import_kw`, `power_kw` |\n| `smart-meter` | `export`      | kW   | `export_kw`, `grid_export_kw`, `power_kw` |\n| `cost-savings`| `savings`     | GBP  | `savings_gbp`, `cost_savings_gbp`, `estimated_savings` |\n\nA source field ending in `_w` is divided by 1000 and returned in kW.\n\nBucketing is determined by `range`:\n\n| range        | Lookback | Bucket    |\n| ------------ | -------- | --------- |\n| `live`       | 1 hour   | 5 minutes |\n| `hourly`     | 24 hours | 1 hour    |\n| `daily`      | 30 days  | 1 day     |\n| `weekly`     | 12 weeks | 1 week    |\n| `historical` | 365 days | 30 days   |\n\nEach bucket value is the **arithmetic mean** of the samples in that bucket.\nBuckets with no samples are omitted rather than returned as null, so the\nseries is sparse. Do not assume a fixed length.\n\nThe `source` field in the response tells you whether you are looking at real\ndata. If it is `timeseries_unavailable`, the store was unreachable and `series`\nis empty. That is a degradation signal, not an empty dataset.\n\nResults are always scoped to the organisation in your bearer token.\n",
        "tags": [
          "Telemetry"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "asset_type",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "solar",
                "battery",
                "ev",
                "heat-pump",
                "smart-meter",
                "cost-savings"
              ]
            }
          },
          {
            "name": "asset_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "range",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "live",
                "hourly",
                "daily",
                "weekly",
                "historical"
              ],
              "default": "hourly"
            }
          },
          {
            "name": "metric",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Defaults per asset type: solar to `generation`, battery to `soc`, ev to\n`soc`, heat-pump to `kwh`, smart-meter to `import`, cost-savings to\n`savings`.\n"
          }
        ],
        "responses": {
          "200": {
            "description": "Bucketed series.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AssetHistory"
                },
                "example": {
                  "asset_id": "soflo-site-0412-inverter-1",
                  "asset_type": "solar",
                  "metric": "generation",
                  "range": "hourly",
                  "unit": "kW",
                  "series": [
                    {
                      "t": "2026-07-31T12:00:00+00:00",
                      "v": -4.211
                    },
                    {
                      "t": "2026-07-31T13:00:00+00:00",
                      "v": -4.907
                    },
                    {
                      "t": "2026-07-31T14:00:00+00:00",
                      "v": -5.104
                    }
                  ],
                  "summary": {
                    "count": 36,
                    "min": -5.42,
                    "max": -0.11,
                    "avg": -3.918,
                    "latest": -5.104
                  },
                  "source": "timeseries"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Organisation context missing"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/twin/": {
      "get": {
        "operationId": "listAssets",
        "summary": "List assets",
        "description": "Returns every asset registered to your organisation. Scoped by bearer token.\nNo pagination today; the full set is returned.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Assets.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Asset"
                  }
                },
                "example": [
                  {
                    "id": "a4f1c9e2-1d3b-4c8a-9f21-0b7e5d2c1a44",
                    "site_id": "soflo-site-0412",
                    "type": "inverter",
                    "name": "Rooftop inverter 1",
                    "details": {
                      "vendor": "SolarEdge",
                      "rated_kw": 7.6
                    }
                  }
                ]
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "operationId": "createAsset",
        "summary": "Register an asset",
        "description": "Registers an asset against a site. `site_id` is the grouping key for a physical\nlocation and is free form: use your own stable site identifier.\n\nRegistering an asset here is what makes `site_id` meaningful. Note the current\nlimitation: **telemetry rows do not carry `site_id`.** The join from a telemetry\nrecord to a site is by `asset_id` through this registry. Keep `asset_id` in your\ntelemetry payloads identical to the `name` or an id you can map, and keep that\nmapping stable.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site_id",
                  "type",
                  "name"
                ],
                "properties": {
                  "site_id": {
                    "type": "string",
                    "description": "Your stable identifier for the physical location."
                  },
                  "type": {
                    "type": "string",
                    "description": "Asset class. Free form. Conventional values are `inverter`,\n`battery`, `charger`, `meter`, `heat_pump`, `vehicle`,\n`transformer`.\n"
                  },
                  "name": {
                    "type": "string"
                  },
                  "details": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Arbitrary metadata. Stored verbatim."
                  }
                }
              },
              "example": {
                "site_id": "soflo-site-0412",
                "type": "inverter",
                "name": "Rooftop inverter 1",
                "details": {
                  "vendor": "SolarEdge",
                  "serial": "7E1F-22B9",
                  "rated_kw": 7.6,
                  "commissioned": "2026-03-14"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Asset"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/twin/{asset_id}": {
      "put": {
        "operationId": "updateAsset",
        "summary": "Update an asset",
        "description": "Partial update. Only supplied fields are changed.",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "asset_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "details": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Asset"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteAsset",
        "summary": "Delete an asset",
        "description": "Removes the registry entry. Telemetry already written for the asset is not\ndeleted and remains queryable by `asset_id`.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "asset_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "deleted": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/twin/registry/vocabulary": {
      "get": {
        "operationId": "getTwinRegistryVocabulary",
        "summary": "Twin asset classes, allowed parents and freshness bands",
        "description": "The vocabulary the registry enforces: the ten asset classes, which class may\nsit under which on a site single line (`(none)` means the class may have no\nparent), the freshness bands and the depth cap. Build pickers from this rather\nthan hardcoding the table.\n\nEvery `/api/twin/registry` operation needs the `twin` module enabled for your\norganisation. Otherwise it answers 403 with `detail.error` set to\n`module_not_enabled`.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The vocabulary.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "asset_classes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TwinAssetClass"
                      }
                    },
                    "structural_classes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "allowed_parents": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      }
                    },
                    "freshness_bands": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "max_depth": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          }
        }
      }
    },
    "/api/twin/registry/sites": {
      "get": {
        "operationId": "listTwinSites",
        "summary": "Your organisation's sites with registry counts",
        "description": "Every site (workspace) of your organisation with the number of registry records\non it. A site of another organisation is never listed.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Sites.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "sites": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "type": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "asset_count": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          }
        }
      }
    },
    "/api/twin/registry/assets": {
      "get": {
        "operationId": "listTwinAssets",
        "summary": "List registry assets, with filters",
        "description": "One record per connection point, meter, board and asset of your organisation,\neach with its make, model, serial, ratings, site, parent, integration (from the\nlinked device), live freshness and evaluated control posture.\n\nFreshness is never assumed: a record with no device is `not_linked`, a linked\ndevice with no reading is `never_reported`, and otherwise the band is judged in\nmissed cycles of the device's own cadence (healthy below 3, stale below 12,\ndark from 12). Control posture comes from the device's evaluated capability\nprofile; with none it is `NOT_EVALUATED`, and a structural record with no\ndevice is `NOT_APPLICABLE`.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "asset_class",
            "in": "query",
            "required": false,
            "description": "Comma separated asset classes.",
            "schema": {
              "type": "string",
              "example": "charger,battery"
            }
          },
          {
            "name": "make",
            "in": "query",
            "required": false,
            "description": "Case insensitive substring of the make.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "freshness",
            "in": "query",
            "required": false,
            "description": "Comma separated freshness bands.",
            "schema": {
              "type": "string",
              "example": "stale,dark"
            }
          },
          {
            "name": "posture",
            "in": "query",
            "required": false,
            "description": "Comma separated control postures.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Substring of the name, make, model, serial or external reference.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 2000,
              "default": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Assets.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "total": {
                      "type": "integer"
                    },
                    "assets": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TwinAsset"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "operationId": "createTwinAsset",
        "summary": "Register an asset",
        "description": "Needs the ORG_ADMIN or OPERATOR role. The registry refuses, with 409 and the\nreason: a parent whose class does not allow this class, a parent on another\nsite (an asset belongs to exactly one site), a placement deeper than eight\nlevels, a device on another site, and a device already registered. A site,\nparent or device that is not your organisation's is 404. Every successful write\nis appended to your organisation's audit chain.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwinAssetWrite"
              },
              "example": {
                "site_id": "site-0412",
                "asset_class": "charger",
                "name": "Bay 3 charger",
                "parent_id": "4b1f0c2e9d7a4e0f8c1b2a3d4e5f6a7b",
                "make": "Kempower",
                "model": "S-series",
                "serial_number": "KP-2026-00431",
                "rated_power_kw": 50,
                "device_id": "dev-7f3a"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registered.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TwinAsset"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/TwinConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/twin/registry/assets/{asset_id}": {
      "parameters": [
        {
          "name": "asset_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getTwinAsset",
        "summary": "One asset with live freshness and posture",
        "description": "The record, its path from the top of the site's single line, and the records it\nfeeds. Re-read it to refresh freshness; nothing is cached server side.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The asset.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/TwinAsset"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "path": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string"
                              },
                              "name": {
                                "type": "string"
                              },
                              "asset_class": {
                                "type": "string"
                              }
                            }
                          }
                        },
                        "children": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "string"
                              },
                              "name": {
                                "type": "string"
                              },
                              "asset_class": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updateTwinAsset",
        "summary": "Edit, move or re-link an asset",
        "description": "Partial update. An absent field is left as it is; a present null clears it (a\nnull `parent_id` unplaces the asset, a null `device_id` unlinks it). A change of\nsite, parent or class re-runs every placement rule; an asset that feeds others\ncannot move to another site. Needs ORG_ADMIN or OPERATOR. Audited with the\nbefore and after of every changed field.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwinAssetWrite"
              },
              "example": {
                "parent_id": "9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f",
                "serial_number": "KP-2026-00431"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TwinAsset"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/TwinConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "operationId": "deleteTwinAsset",
        "summary": "Delete a childless asset",
        "description": "A record that feeds others is refused with 409; move or delete its children\nfirst. The linked device and its telemetry are untouched. Needs ORG_ADMIN or\nOPERATOR. Audited.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/TwinConflict"
          }
        }
      }
    },
    "/api/twin/registry/sites/{site_id}/topology": {
      "get": {
        "operationId": "getTwinSiteTopology",
        "summary": "The site's single line as a tree, with validation findings",
        "description": "The site's records as a tree from the connection point down, with each node\ncarrying the same fields as a listed asset plus `depth` and `children`. Records\nnot yet placed are roots of their own. `findings` reports what the tree cannot\nrepair: `error` for a stored parent loop, a parent not on this site, a class\npair the rules refuse, a linked device that no longer exists or that sits on\nanother site; `warning` for an unplaced asset or a site with no connection\npoint; `info` for devices on the site with no registry record, which are listed\nin `unregistered_devices` with the class an import would give them.\n`hierarchy_path` is the site's place in your organisation tree\n(`/api/organisations/hierarchy`), empty when the site has no node and null when\nthe tree could not be read.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The topology.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TwinSiteTopology"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/twin/registry/sites/{site_id}/import-devices": {
      "post": {
        "operationId": "importTwinDevices",
        "summary": "Register every device on the site that has no record yet",
        "description": "Idempotent. One record per device of the site with no registry record, with the\nclass from the device's type, and make, model, serial and ratings copied only\nfrom fields the device row carries; nothing is inferred. Vehicles are skipped:\na vehicle moves between sites and is not a fixed asset of one. New records are\nunplaced until someone places them on the single line. Never deletes or moves.\nNeeds ORG_ADMIN or OPERATOR. Audited when anything is created.\n",
        "tags": [
          "Assets"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "What was registered.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": "string"
                    },
                    "created": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "asset_id": {
                            "type": "string"
                          },
                          "device_id": {
                            "type": "string"
                          },
                          "asset_class": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "already_registered": {
                      "type": "integer"
                    },
                    "skipped": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "device_id": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TwinForbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/trading/prices/octopus-agile": {
      "get": {
        "operationId": "getAgilePrices",
        "summary": "Octopus Agile half hourly import prices",
        "description": "Live half hourly import prices from the Octopus Energy public tariff API for a\nUK GSP region. Prices are pence per kWh including VAT, with the exclusive VAT\nfigure alongside.\n\nEach slot covers 30 minutes. A full day is 48 slots, published for the\nfollowing day at approximately 16:00 UK time.\n",
        "tags": [
          "Tariffs and prices"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "region",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "C"
            },
            "description": "Single letter GSP region code. See `/api/trading/prices/regions`.\n"
          },
          {
            "name": "period_from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "period_to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Price slots.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgilePrices"
                },
                "example": {
                  "region": "C",
                  "region_name": "London",
                  "product": "AGILE-24-04-03",
                  "tariff_code": "E-1R-AGILE-24-04-03-C",
                  "count": 48,
                  "prices": [
                    {
                      "valid_from": "2026-07-31T14:00:00Z",
                      "valid_to": "2026-07-31T14:30:00Z",
                      "price_pence_per_kwh": 37.191,
                      "price_exc_vat": 35.42
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Unrecognised region code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream tariff provider was unavailable. Retry with backoff.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/prices/regions": {
      "get": {
        "operationId": "listRegions",
        "summary": "UK GSP region codes",
        "description": "The 14 Grid Supply Point regions, A through P, used by every UK tariff endpoint.\n",
        "tags": [
          "Tariffs and prices"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Regions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "regions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "regions": [
                    {
                      "code": "A",
                      "name": "Eastern England"
                    },
                    {
                      "code": "C",
                      "name": "London"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/prices/current": {
      "get": {
        "operationId": "getCurrentPrice",
        "summary": "Current price slot and next cheap window",
        "tags": [
          "Tariffs and prices"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "region",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "C"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current slot.",
            "content": {
              "application/json": {
                "example": {
                  "region": "C",
                  "current_price": 0.37191,
                  "current_price_pence": 37.191,
                  "current_slot": {
                    "valid_from": "2026-07-31T14:00:00Z",
                    "valid_to": "2026-07-31T14:30:00Z"
                  },
                  "next_cheap_window": "2026-08-01T02:00:00Z",
                  "next_cheap_price_pence": 8.41
                }
              }
            }
          },
          "404": {
            "description": "No price data available for the region right now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/carbon-intensity": {
      "get": {
        "operationId": "getCarbonIntensity",
        "summary": "Current GB grid carbon intensity",
        "description": "Live national carbon intensity from National Grid ESO, in gCO2 per kWh, for the\ncurrent half hour settlement period.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Current intensity.",
            "content": {
              "application/json": {
                "example": {
                  "from": "2026-07-31T15:30Z",
                  "to": "2026-07-31T16:00Z",
                  "intensity": {
                    "forecast": 84,
                    "actual": 101,
                    "index": "low"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/carbon-intensity/forecast": {
      "get": {
        "operationId": "getCarbonForecast",
        "summary": "Carbon intensity forecast",
        "tags": [
          "Carbon and emissions"
        ],
        "security": [],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 24,
              "minimum": 1,
              "maximum": 48
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Half hourly forecast.",
            "content": {
              "application/json": {
                "example": {
                  "count": 8,
                  "forecast": [
                    {
                      "from": "2026-07-31T16:00Z",
                      "to": "2026-07-31T16:30Z",
                      "forecast": 103,
                      "index": "low"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/vpp/vpp/flex/tenders": {
      "get": {
        "operationId": "listFlexTenders",
        "summary": "Open DNO flexibility tenders",
        "description": "Open flexibility tenders, fetched live from UKPN LocalFlex and SSEN\nElectronConnect.\n\nThe public path repeats the `vpp` segment. The ingress strips `/api/vpp` and\nthe service mounts its routers at `/vpp/flex`, so `/api/vpp/flex/tenders`, the\npath this document listed before 2026-09-12, is rewritten to `/flex/tenders`\nand is not served.\n\nReturns `503` when AmpVerve's credentials for the requested DNO platform are\nnot configured for your environment.\n",
        "tags": [
          "Flexibility"
        ],
        "x-ampverve-status": "partner-gated",
        "x-ampverve-country-scope": [
          "GB"
        ],
        "x-ampverve-prerequisites": [
          "tenant partner credentials",
          "applicable DNO access"
        ],
        "parameters": [
          {
            "name": "dno",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "UKPN",
                "SSEN"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tenders.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Tender"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "description": "DNO platform credentials not configured.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/vpp/vpp/flex/contracts": {
      "get": {
        "operationId": "listFlexContracts",
        "summary": "Awarded flexibility contracts",
        "description": "The public path repeats the `vpp` segment for the reason given on\n`/api/vpp/vpp/flex/tenders`.\n",
        "tags": [
          "Flexibility"
        ],
        "x-ampverve-status": "partner-gated",
        "x-ampverve-country-scope": [
          "GB"
        ],
        "x-ampverve-prerequisites": [
          "tenant partner credentials",
          "awarded contract visible to the organisation"
        ],
        "parameters": [
          {
            "name": "dno",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "UKPN",
                "SSEN"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contracts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "dno": {
                        "type": "string"
                      },
                      "platform_id": {
                        "type": "string"
                      },
                      "reserve_object_id": {
                        "type": "string"
                      },
                      "quantity_mwh": {
                        "type": "number"
                      },
                      "clearing_price_gbp_per_mwh": {
                        "type": "number"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "description": "DNO platform credentials not configured."
          }
        }
      }
    },
    "/api/vpp/vpp/flex/offers": {
      "post": {
        "operationId": "submitFlexOffer",
        "summary": "Submit a flexibility offer",
        "description": "Submits a priced flexibility offer to the configured DNO partner API. A\nsuccessful HTTP response records partner acceptance of the request. It\ndoes not prove award, dispatch, delivery, settlement or customer payout.\n\nThe public path repeats the `vpp` segment for the reason given on\n`/api/vpp/vpp/flex/tenders`.\n\nFor SSEN, `tender_platform_id` is mandatory and the request is rejected with\n`400` without it. For UKPN it is optional.\n",
        "tags": [
          "Flexibility"
        ],
        "x-ampverve-status": "partner-gated",
        "x-ampverve-country-scope": [
          "GB"
        ],
        "x-ampverve-prerequisites": [
          "tenant partner credentials",
          "approved market and organisation activation",
          "eligible reserve object and tender",
          "authorised operator"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "dno",
                  "reserve_object_id",
                  "direction_turn_up",
                  "intervals"
                ],
                "properties": {
                  "dno": {
                    "type": "string",
                    "enum": [
                      "UKPN",
                      "SSEN"
                    ]
                  },
                  "reserve_object_id": {
                    "type": "string"
                  },
                  "direction_turn_up": {
                    "type": "boolean",
                    "description": "`true` to offer turn up, that is increased consumption or reduced\nexport. `false` to offer turn down.\n"
                  },
                  "tender_platform_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Required for SSEN."
                  },
                  "intervals": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": [
                        "start_utc",
                        "duration_minutes",
                        "price_gbp_per_mwh",
                        "capacity_mw"
                      ],
                      "properties": {
                        "start_utc": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "duration_minutes": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 1440
                        },
                        "price_gbp_per_mwh": {
                          "type": "number",
                          "minimum": 0
                        },
                        "capacity_mw": {
                          "type": "number",
                          "exclusiveMinimum": 0
                        }
                      }
                    }
                  }
                }
              },
              "example": {
                "dno": "SSEN",
                "reserve_object_id": "RO-88213",
                "tender_platform_id": "TND-2026-0731-01",
                "direction_turn_up": false,
                "intervals": [
                  {
                    "start_utc": "2026-08-01T17:00:00Z",
                    "duration_minutes": 120,
                    "price_gbp_per_mwh": 185.0,
                    "capacity_mw": 0.45
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Offer request accepted by the configured DNO platform.",
            "content": {
              "application/json": {
                "example": {
                  "dno": "SSEN",
                  "platform_offer_id": "OFF-559120",
                  "status": "submitted"
                }
              }
            }
          },
          "400": {
            "description": "Missing `tender_platform_id` for an SSEN offer, or an invalid interval.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "description": "DNO platform credentials not configured."
          }
        }
      }
    },
    "/api/vpp/vpp/portfolio": {
      "get": {
        "operationId": "getPortfolio",
        "summary": "Aggregate portfolio capacity",
        "description": "Fleet size and aggregate capacity across your organisation.\n\nThe public path repeats the `vpp` segment for the reason given on\n`/api/vpp/vpp/flex/tenders`.\n\n`available_capacity_mw` and `availability_rate` are calculated from a fixed\navailability assumption rather than measured per asset availability. `regions` is not yet segmented. Do not use\nthese two fields for settlement or for any financial claim.\n",
        "tags": [
          "Flexibility"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Fleet size and rated capacity from your enrolled assets; available capacity and availability rate apply one fixed availability assumption, not per asset measured availability.",
        "responses": {
          "200": {
            "description": "Portfolio.",
            "content": {
              "application/json": {
                "example": {
                  "status": "ok",
                  "portfolio": {
                    "fleet_size": 42,
                    "total_capacity_mw": 1.86,
                    "available_capacity_mw": 1.209,
                    "availability_rate": 0.65,
                    "connected_vehicles": 31,
                    "charging_vehicles": 7,
                    "idle_vehicles": 24,
                    "regions": {
                      "Global": 42
                    }
                  },
                  "timestamp": "2026-07-31T16:08:13Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/receipts": {
      "get": {
        "operationId": "listSettleReceipts",
        "summary": "List signed settlement receipts",
        "description": "Your organisation's Ed25519 signed settlement receipts, newest first, each\nverified on read, plus the verification of the whole per-organisation chain.\nOne receipt per delivered flexibility event the platform recorded: event,\nasset or portfolio, window, baseline method and value, metered actual,\ndelivered kWh, payment rate and amount (or why it is unpriced) and\ncounterparty.\n\nRequires the `settlement/receipts` module and the `ORG_ADMIN`, `ANALYST` or\n`PLATFORM_ADMIN` role. The public path repeats the `vpp` segment for the\nreason given on `/api/vpp/vpp/flex/tenders`.\n\nAdded 2026-10-03. No receipt exists for a production organisation until a\ndelivery is recorded; demo realm organisations receive synthetic events\nmarked `basis: synthetic` inside the signed body.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "market",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "DSO",
                "DFS",
                "BM"
              ]
            }
          },
          {
            "name": "counterparty",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Counterparty key, for example ssen_flexible_power or neso_dfs."
          }
        ],
        "responses": {
          "200": {
            "description": "Receipts and chain verification.",
            "content": {
              "application/json": {
                "example": {
                  "organisation_id": "0123456789abcdef0123456789abcdef",
                  "total": 1,
                  "limit": 100,
                  "offset": 0,
                  "receipts": [
                    {
                      "id": "6f1c0b2e9a3d4c58b1e7a0f2c4d6e8a1",
                      "chain_seq": 0,
                      "receipt_hash": "9c1d...e04a",
                      "key_id": "settle-2026-10",
                      "issued_at": "2026-10-03T09:00:00+00:00",
                      "basis": "metered",
                      "market": "DSO",
                      "counterparty_key": "ssen_flexible_power",
                      "event_ref": "flexpower:FP-1001",
                      "asset_ref": "device-1",
                      "window_start": "2026-09-21T16:00:00+00:00",
                      "window_end": "2026-09-21T17:00:00+00:00",
                      "delivered_kwh": "5.000",
                      "payment_status": "unpriced",
                      "currency": null,
                      "amount": null,
                      "verification": {
                        "status": "verified",
                        "trusted": true,
                        "problems": 0
                      },
                      "shared": false
                    }
                  ],
                  "chain": {
                    "intact": true,
                    "receipts": 1,
                    "head_hash": "9c1d...e04a",
                    "failed_receipts": 0,
                    "first_break": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/receipts.csv": {
      "get": {
        "operationId": "exportSettleReceiptsCsv",
        "summary": "Export signed settlement receipts as CSV",
        "description": "Every receipt of your organisation in chain order, with its hash, previous\nhash, key id, signature and verification status. `X-Content-SHA256` carries\nthe digest of the file. The export is recorded in the audit chain. Cells\nthat a spreadsheet would execute as a formula are prefixed with an apostrophe.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "CSV file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/receipts/issue": {
      "post": {
        "operationId": "issueSettleReceipts",
        "summary": "Issue receipts for recorded deliveries",
        "description": "Signs a receipt for every delivered event of your organisation that has none\nyet. Reads delivery records the platform already holds and nothing else;\nidempotent. A counterparty's own statement is never receipted. Moves no\nmoney and submits nothing to any market.\n\nRequires `ORG_ADMIN` or `PLATFORM_ADMIN`. Answers 503 when no signing key\nis provisioned: a deployed service refuses to sign with a development key.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Issuance result.",
            "content": {
              "application/json": {
                "example": {
                  "organisation_id": "0123456789abcdef0123456789abcdef",
                  "key_id": "settle-2026-10",
                  "development_key": false,
                  "events_seen": 1,
                  "issued_count": 1,
                  "issued": [
                    {
                      "receipt_id": "6f1c...",
                      "receipt_hash": "9c1d...",
                      "chain_seq": 0,
                      "event_ref": "flexpower:FP-1001"
                    }
                  ],
                  "already_issued": 0,
                  "skipped": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "The configured key id is already published with a different public key, or is revoked."
          },
          "503": {
            "description": "No signing key is provisioned."
          }
        }
      }
    },
    "/api/vpp/vpp/settle/receipts/{receipt_id}": {
      "get": {
        "operationId": "getSettleReceipt",
        "summary": "One receipt with its verification",
        "description": "The signed body (parsed and canonical), hash, signature, the published key\nthat signed it and the full verification: canonical form, hash, Ed25519\nsignature, key status and every indexed column against the body.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "receipt_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Receipt and verification."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No receipt with this id in your organisation."
          }
        }
      }
    },
    "/api/vpp/vpp/settle/receipts/{receipt_id}/verify": {
      "get": {
        "operationId": "verifySettleReceipt",
        "summary": "Verify one receipt",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "receipt_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Verification.",
            "content": {
              "application/json": {
                "example": {
                  "receipt_id": "6f1c...",
                  "status": "verified",
                  "trusted": true,
                  "key_id": "settle-2026-10",
                  "key_status": "active",
                  "problems": [],
                  "warnings": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No receipt with this id in your organisation."
          }
        }
      }
    },
    "/api/vpp/vpp/settle/receipts/{receipt_id}/share": {
      "post": {
        "operationId": "shareSettleReceipt",
        "summary": "Publish a receipt for counterparty verification",
        "description": "Receipts are private by default. Sharing publishes this one receipt at the\nunauthenticated `/api/vpp/vpp/settle/public/receipts/{receipt_hash}` until it\nis unshared. Only a receipt that verifies with a production key can be\nshared. Requires `ORG_ADMIN` or `PLATFORM_ADMIN`; recorded in the audit chain.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "receipt_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Shared, with the public path."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No receipt with this id in your organisation."
          },
          "409": {
            "description": "The receipt does not verify with a production key."
          }
        }
      },
      "delete": {
        "operationId": "unshareSettleReceipt",
        "summary": "Stop publishing a receipt",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "receipt_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Unshared."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No receipt with this id in your organisation."
          }
        }
      }
    },
    "/api/vpp/vpp/settle/chain/verify": {
      "get": {
        "operationId": "verifySettleReceiptChain",
        "summary": "Verify your organisation's receipt chain",
        "description": "Recomputes every receipt and every link. Pass `expected_head`, a head hash\nhanded to a counterparty earlier, to detect removed receipts at the end of\nthe chain or rewritten history.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "expected_head",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Chain verification.",
            "content": {
              "application/json": {
                "example": {
                  "intact": false,
                  "receipts": 3,
                  "head_hash": "...",
                  "failed_receipts": 0,
                  "first_break": {
                    "kind": "deleted_receipt",
                    "chain_seq": 1,
                    "receipt_id": null,
                    "detail": "chain_seq 1 is missing; the next present receipt is 2"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/verify": {
      "post": {
        "operationId": "verifyPresentedSettleReceipt",
        "summary": "Verify a presented copy of a receipt",
        "description": "Verifies a receipt someone presents (for example a copy attached to a\ndispute) against the published keys, and says whether your organisation\nholds a receipt with the same hash.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body",
                  "signature"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "The canonical JSON body exactly as issued."
                  },
                  "signature": {
                    "type": "string",
                    "pattern": "^[0-9a-f]{128}$"
                  },
                  "receipt_hash": {
                    "type": "string",
                    "pattern": "^[0-9a-f]{64}$"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verification, with held_by_this_organisation."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/reconciliation": {
      "get": {
        "operationId": "getSettleReconciliation",
        "summary": "Reconcile receipts against counterparty statements",
        "description": "Every receipt and every ingested counterparty statement line in exactly one\nline: `matched`, `short_paid`, `over_paid`, `missing` (side `statement`: we\ndelivered and nothing paid it; side `receipt`: a line paid for a window we\nhold no receipt for) or `unreconcilable` (the receipt does not verify, the\ncurrencies differ, or neither side gives a rate), each with its reasons. An\nunpriced receipt is compared at the statement line's own rate, so a short\nsettled volume is still caught. A finding for a person; changes nothing.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "counterparty",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Reconciliation report.",
            "content": {
              "application/json": {
                "example": {
                  "summary": {
                    "matched": {
                      "count": 3,
                      "by_currency": {
                        "GBP": {
                          "expected": "251.49",
                          "paid": "251.49",
                          "difference": "0.00"
                        }
                      }
                    },
                    "short_paid": {
                      "count": 1,
                      "by_currency": {
                        "GBP": {
                          "expected": "2.28",
                          "paid": "1.80",
                          "difference": "-0.48"
                        }
                      }
                    },
                    "over_paid": {
                      "count": 1,
                      "by_currency": {
                        "GBP": {
                          "expected": "120.00",
                          "paid": "126.00",
                          "difference": "6.00"
                        }
                      }
                    },
                    "missing": {
                      "count": 2,
                      "by_currency": {
                        "GBP": {
                          "expected": "2.76",
                          "paid": "90.00",
                          "difference": "0.00"
                        }
                      }
                    },
                    "unreconcilable": {
                      "count": 0,
                      "by_currency": {}
                    }
                  },
                  "lines": [
                    {
                      "category": "short_paid",
                      "counterparty": "ssen_flexible_power",
                      "receipt_kwh": "7.600",
                      "statement_kwh": "6.000",
                      "expected_amount": "2.28",
                      "paid_amount": "1.80",
                      "difference": "-0.48",
                      "reasons": [
                        "volume: the statement settles 6.000 kWh against 7.600 kWh on our receipt (-1.600 kWh)",
                        "short by 0.48 GBP"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "from or to is not an ISO 8601 date or timestamp."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/reconciliation.csv": {
      "get": {
        "operationId": "exportSettleReconciliationCsv",
        "summary": "Export the reconciliation as CSV",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "counterparty",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "CSV file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/settle/keys": {
      "get": {
        "operationId": "listSettleSigningKeys",
        "summary": "Published receipt verification keys",
        "description": "Every Ed25519 key that has ever signed a settlement receipt, by key id, with\nits status: `active`, `retired` (still verifies what it signed before its\nretirement time) or `revoked`. No authentication. A counterparty verifies a\nreceipt by SHA-256 of the canonical body bytes and an Ed25519 check of the\nsignature over the 32 digest bytes with `public_key_hex`.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "security": [],
        "responses": {
          "200": {
            "description": "Keys.",
            "content": {
              "application/json": {
                "example": {
                  "algorithm": "Ed25519",
                  "keys": [
                    {
                      "key_id": "settle-2026-10",
                      "algorithm": "Ed25519",
                      "public_key_hex": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
                      "status": "active",
                      "development": false,
                      "first_used_at": "2026-10-03T09:00:00+00:00",
                      "retired_at": null,
                      "revoked_at": null,
                      "revocation_reason": null
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/vpp/vpp/settle/public/receipts/{receipt_hash}": {
      "get": {
        "operationId": "getSharedSettleReceipt",
        "summary": "A shared receipt, for counterparty verification",
        "description": "A receipt its organisation chose to share, with its verification. No\nauthentication. An unknown, private or unshared hash all answer the same 404.\nRate limited per client.\n",
        "tags": [
          "Settlement"
        ],
        "x-ampverve-status": "live",
        "security": [],
        "parameters": [
          {
            "name": "receipt_hash",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The shared receipt, its signature, key and verification."
          },
          "404": {
            "description": "No shared receipt has this hash."
          },
          "429": {
            "description": "Rate limit exceeded."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/sites/{site_id}/baselines": {
      "get": {
        "operationId": "listFleetBaselines",
        "summary": "Baseline versions for a depot",
        "description": "Every version of the depot's savings baseline, newest first: method, tariff,\nperiod and parameters as canonical terms with their SHA-256, who proposed it,\nand the customer user who agreed it and when. A site of another organisation\nanswers 404.\n\nRequires the `fleet/savings` module and the `ORG_ADMIN`, `ANALYST`, `OPERATOR`\nor `PLATFORM_ADMIN` role. Added 2026-10-15. See docs/api/savings-statements.md.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The site's baseline versions."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such site in this organisation."
          }
        }
      },
      "post": {
        "operationId": "proposeFleetBaseline",
        "summary": "Propose a baseline version for a depot",
        "description": "Proposes a new version of the depot's baseline. Its terms are fixed from this\nmoment and it counts for statements only once an organisation administrator of\nthe customer agrees it. Methods: `charge_on_arrival` (each session priced as if\nthe van charged at its charger's rated power from plug in, at the agreed tariff)\nand `historical_unit_cost` (each kWh at an agreed average cost per kWh from a\nreference period before optimisation). Unknown fields are refused, not ignored.\nRoles: `ORG_ADMIN`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "method": "charge_on_arrival",
                "tariff": {
                  "kind": "time_of_day",
                  "timezone": "Europe/London",
                  "currency": "GBP",
                  "source": "Supply contract unit rates, contract ref ABC-123",
                  "bands": [
                    {
                      "start": "07:00",
                      "end": "23:00",
                      "p_per_kwh": 25.0
                    },
                    {
                      "start": "23:00",
                      "end": "07:00",
                      "p_per_kwh": 12.0
                    }
                  ]
                },
                "period": {
                  "effective_from": "2026-09-01"
                },
                "parameters": {
                  "default_charger_kw": 22,
                  "interval_tolerance_pct": 5,
                  "carbon_region_id": 12
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The proposed version, with its terms hash."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such site in this organisation."
          },
          "422": {
            "description": "The terms cannot be agreed as written; the detail says why."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/baselines/{baseline_id}": {
      "get": {
        "operationId": "getFleetBaseline",
        "summary": "One baseline version",
        "description": "One baseline version of this organisation, with `terms_intact` recomputed from the\nstored terms. Roles: `ORG_ADMIN`, `ANALYST`, `OPERATOR`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "baseline_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The baseline version."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such baseline in this organisation."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/baselines/{baseline_id}/agree": {
      "post": {
        "operationId": "agreeFleetBaseline",
        "summary": "Agree a proposed baseline",
        "description": "The customer's organisation administrator agrees a proposed version, recorded with\ntheir user id and the time. Only a proposed version can be agreed, once; the\ndatabase refuses any later change to its terms or status. Send the `terms_hash`\nthe administrator was shown: a different hash is refused. A platform account is\nrefused (403): AmpVerve cannot agree a baseline on the customer's behalf.\nRole: `ORG_ADMIN` of the organisation.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "baseline_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "example": {
                "terms_hash": "571b2094fcf471835b1fe139874435387698a64356a1e1327a487d302573b422"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The agreed version."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such baseline in this organisation."
          },
          "409": {
            "description": "Already agreed or withdrawn, or the terms hash differs."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/baselines/{baseline_id}/withdraw": {
      "post": {
        "operationId": "withdrawFleetBaseline",
        "summary": "Withdraw a proposed baseline",
        "description": "Withdraws a version that has not been agreed. An agreed version cannot be\nwithdrawn; a change is a new version. Roles: `ORG_ADMIN`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "baseline_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The withdrawn version."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such baseline in this organisation."
          },
          "409": {
            "description": "Only a proposed baseline can be withdrawn."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/sites/{site_id}/statements": {
      "post": {
        "operationId": "issueFleetSavingsStatement",
        "summary": "Issue a month's savings statement for a depot",
        "description": "Computes the closed month's statement for the depot against its agreed baseline:\nactual cost, baseline cost and saving in GBP, energy in kWh, data coverage, every\nexcluded session with its reason, departures that met their state of charge, and\ncarbon only where a stored intensity series covers every half hour. Signed with\nthe settlement receipt key (Ed25519, published at `/api/vpp/vpp/settle/keys`) when\none is configured; otherwise issued as an UNSIGNED draft, marked so, with no\nsignature. A later issue for the same month is a new revision naming the one it\nsupersedes. Refused (409) without an agreed baseline covering the whole month,\nor for a month that has not ended. A demo realm organisation's statement is\nsimulated and says so in the signed body.\nRoles: `ORG_ADMIN`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Sessions recorded at the depot's chargers in the month, priced at the agreed tariff from interval readings (actual) and under the agreed baseline method (baseline); see docs/api/savings-statements.md.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "month": "2026-09"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The statement summary, its status and verification."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such site in this organisation."
          },
          "409": {
            "description": "No agreed baseline covers the month, or the month has not ended."
          },
          "422": {
            "description": "month is not YYYY-MM."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/statements": {
      "get": {
        "operationId": "listFleetSavingsStatements",
        "summary": "List savings statements",
        "description": "This organisation's statements, newest month first, each verified on read, and the\nverification of the organisation's statement chain. Optional `site_id`.\nRoles: `ORG_ADMIN`, `ANALYST`, `OPERATOR`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Statements and chain verification."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/statements/{statement_id}": {
      "get": {
        "operationId": "getFleetSavingsStatement",
        "summary": "One savings statement as JSON",
        "description": "The statement as issued (`canonical_body`, the exact bytes that were hashed and\nsigned, and the parsed `statement`), its hash, signature and key, its\nverification, the recorded inputs, and `recomputation`: the figures recomputed\nfrom those inputs, which must match byte for byte.\nRoles: `ORG_ADMIN`, `ANALYST`, `OPERATOR`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "statement_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The statement, signature, verification and recomputation."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such statement in this organisation."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/statements/{statement_id}/print": {
      "get": {
        "operationId": "printFleetSavingsStatement",
        "summary": "One savings statement as a printable page",
        "description": "A self-contained HTML page (no script, no external asset) rendered from the signed\nbody, with an A4 print stylesheet: print it, or save it as PDF from the browser.\nAn UNSIGNED draft and a simulated statement carry a banner.\nRoles: `ORG_ADMIN`, `ANALYST`, `OPERATOR`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "statement_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The printable statement.",
            "content": {
              "text/html": {}
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such statement in this organisation."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/public/verify/{statement_hash}": {
      "get": {
        "operationId": "verifyFleetSavingsStatementHash",
        "summary": "Check a savings statement by hash (public)",
        "description": "Whether AmpVerve issued a statement with this SHA-256 hash, whether it is signed or\nan UNSIGNED draft, when, and whether the signature verifies against the published\nkey. No authentication. Returns no organisation, site, session or figure. Rate\nlimited per client. An unknown hash answers 404.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "security": [],
        "parameters": [
          {
            "name": "statement_hash",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The verification.",
            "content": {
              "application/json": {
                "example": {
                  "statement_hash": "b6b748d95a987268a39e215f1bf7c44be33f9ed47dc55dc402528b5ab9bd2f71",
                  "issued_by_ampverve": true,
                  "status": "signed",
                  "result": "verified",
                  "trusted": true,
                  "issued_at": "2026-10-15T09:00:00Z",
                  "problems": []
                }
              }
            }
          },
          "404": {
            "description": "AmpVerve issued no statement with this hash."
          },
          "429": {
            "description": "Rate limit exceeded."
          }
        }
      }
    },
    "/api/vpp/vpp/fleet-statements/public/verify": {
      "post": {
        "operationId": "verifyPresentedFleetSavingsStatement",
        "summary": "Check a savings statement file (public)",
        "description": "Verifies the statement JSON a holder presents, byte for byte: its hash, its\ncanonical form, whether AmpVerve issued it, and its signature. No authentication.\nReturns the verification only, never any of the statement's content. Rate limited.\n",
        "tags": [
          "Fleet savings statements"
        ],
        "x-ampverve-status": "awaiting-migration",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "body": "{\"baseline\":{...}}",
                "signature": "77e38ac0...0f"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The verification: verified, unsigned_draft or failed, with problems."
          },
          "422": {
            "description": "The request body is not valid."
          },
          "429": {
            "description": "Rate limit exceeded."
          }
        }
      }
    },
    "/api/esg/": {
      "post": {
        "operationId": "createReceipt",
        "summary": "Create a signed energy receipt",
        "description": "Stores an arbitrary JSON payload as an ESG receipt with a content hash.\n\nThe hash is computed over a Python native string rendering of the payload,\nnot over canonical JSON, so it is not reproducible from another language\nruntime. Treat the returned `hash` as an AmpVerve side identifier, not as a\ncross platform content commitment.\n",
        "tags": [
          "Certificates"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "payload"
                ],
                "properties": {
                  "site_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "payload": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              },
              "example": {
                "site_id": "soflo-site-0412",
                "payload": {
                  "period_start": "2026-07-01T00:00:00Z",
                  "period_end": "2026-07-31T23:59:59Z",
                  "exported_kwh": 812.4,
                  "avoided_co2_kg": 168.1
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Receipt.",
            "content": {
              "application/json": {
                "example": {
                  "id": "b8e2c110-4d77-4a19-9f01-2a5cc31f7742",
                  "site_id": "soflo-site-0412",
                  "hash": "9f2c...c41a",
                  "payload": {
                    "exported_kwh": 812.4
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "get": {
        "operationId": "listReceipts",
        "summary": "List energy receipts",
        "description": "Every receipt for your organisation. No pagination today.",
        "tags": [
          "Certificates"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Receipts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/devtools/apikeys": {
      "get": {
        "operationId": "listApiKeys",
        "summary": "List this organisation's API keys",
        "description": "Organisation administrators only (`ORG_ADMIN` on a signed token). Lists every\nkey ever issued to the organisation in the token, with its public prefix,\nscopes and state. The key itself and its stored hash are never returned.\n",
        "tags": [
          "API keys"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "responses": {
          "200": {
            "description": "Keys, the scopes a key can hold, and the limits.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "api_keys": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PartnerApiKey"
                      }
                    },
                    "supported_scopes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "alerts:read",
                        "devices:read",
                        "telemetry:read"
                      ]
                    },
                    "limits": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createApiKey",
        "summary": "Create an API key (shown once)",
        "description": "Organisation administrators only. Refused with 403 for other roles, for platform\naccounts and for unsigned demo sessions.\n\nThe full key is returned **once**, in the `key` field of this response. AmpVerve\nstores only its public prefix and an HMAC-SHA256 of the key under a server\nsecret, so a lost key cannot be recovered; rotate or revoke it and issue another.\n\nKey format: `avk_<12 hex>_<43 base64url characters>`. Send it as\n`Authorization: Bearer avk_...` to the `/api/devtools/partner/v1` operations.\nThe key reads only its own organisation's data, only within its scopes, at most\n600 requests a minute.\n\nAt most 25 active keys per organisation. Expiry is required: 1 to 365 days,\ndefault 90.\n",
        "tags": [
          "API keys"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "scopes"
                ],
                "additionalProperties": false,
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120
                  },
                  "scopes": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "enum": [
                        "alerts:read",
                        "devices:read",
                        "telemetry:read"
                      ]
                    }
                  },
                  "expires_in_days": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 365,
                    "default": 90
                  }
                }
              },
              "example": {
                "name": "Example Solar Ltd production",
                "scopes": [
                  "alerts:read",
                  "devices:read"
                ],
                "expires_in_days": 90
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created. Store `key` now; it is not shown again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "api_key": {
                      "$ref": "#/components/schemas/PartnerApiKey"
                    },
                    "key": {
                      "type": "string",
                      "example": "avk_3f9a1c2b7d4e_Zr0...43 characters"
                    },
                    "warning": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "The organisation already has the maximum number of active keys."
          },
          "422": {
            "description": "Unknown scope, empty scopes or an expiry outside 1 to 365 days."
          },
          "503": {
            "description": "Key issuance is not configured on the service (fails closed)."
          }
        }
      }
    },
    "/api/devtools/apikeys/{key_id}/rotate": {
      "post": {
        "operationId": "rotateApiKey",
        "summary": "Rotate an API key (new key shown once)",
        "description": "Issues a new key with the same name, scopes and rate limit, and retires the old\none: immediately when `grace_period_hours` is 0 (the default), otherwise after\nthat many hours (at most 72). The new key is returned once. A key belonging to\nanother organisation answers 404.\n",
        "tags": [
          "API keys"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "key_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "grace_period_hours": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 72,
                    "default": 0
                  },
                  "expires_in_days": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 365
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "New key issued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "api_key": {
                      "$ref": "#/components/schemas/PartnerApiKey"
                    },
                    "key": {
                      "type": "string"
                    },
                    "previous_key": {
                      "$ref": "#/components/schemas/PartnerApiKey"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such key in your organisation."
          },
          "409": {
            "description": "The key is revoked or expired."
          }
        }
      }
    },
    "/api/devtools/apikeys/{key_id}/revoke": {
      "post": {
        "operationId": "revokeApiKey",
        "summary": "Revoke an API key",
        "description": "Requests using the key are refused with 401 from this moment. A key belonging to\nanother organisation answers 404.\n",
        "tags": [
          "API keys"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "key_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 200
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "api_key": {
                      "$ref": "#/components/schemas/PartnerApiKey"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such key in your organisation."
          },
          "409": {
            "description": "Already revoked."
          }
        }
      }
    },
    "/api/devtools/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "summary": "List this organisation's webhook endpoints",
        "description": "Organisation administrators only. Signing secrets are never returned here, only\na short hint of the current one. The response also states the signature scheme.\n",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "responses": {
          "200": {
            "description": "Endpoints.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhooks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PartnerWebhookEndpoint"
                      }
                    },
                    "supported_event_types": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "alert.raised",
                        "alert.resolved",
                        "device.offline"
                      ]
                    },
                    "signature": {
                      "type": "object"
                    },
                    "limits": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "summary": "Register a webhook endpoint (signing secret shown once)",
        "description": "Organisation administrators only. AmpVerve delivers this organisation's events,\nand no other organisation's, to the URL, signed as described under `webhooks`\nin this document. The signing secret (`whsec_...`) is returned **once**.\n\nThe URL is refused (422) unless it is `https`, on port 443 or 8443, with no\nuser name, password or fragment, on a fully qualified public host name or\npublic IP literal, and every address the host resolves to is publicly\nroutable. Private, loopback, link-local (including 169.254.169.254), carrier\ngrade NAT, multicast and reserved addresses are refused, as are internal names\nsuch as `.internal`, `.local` and `.svc`. The same check runs again against\nfresh DNS before every delivery, the connection is pinned to a checked\naddress, and redirects are not followed.\n\nAt most 10 endpoints per organisation.\n",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url",
                  "event_types"
                ],
                "additionalProperties": false,
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "maxLength": 2048
                  },
                  "event_types": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "enum": [
                        "alert.raised",
                        "alert.resolved",
                        "device.offline"
                      ]
                    }
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 200
                  }
                }
              },
              "example": {
                "url": "https://hooks.example-solar.example/ampverve",
                "event_types": [
                  "alert.raised",
                  "alert.resolved",
                  "device.offline"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registered. Store `secret` now; it is not shown again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/PartnerWebhookEndpoint"
                    },
                    "secret": {
                      "type": "string",
                      "example": "whsec_Hk2...43 characters"
                    },
                    "warning": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "The organisation already has the maximum number of endpoints."
          },
          "422": {
            "description": "The URL breaks an outbound rule, or an event type is unknown. The detail names the rule."
          },
          "503": {
            "description": "Webhook signing is not configured on the service (fails closed)."
          }
        }
      }
    },
    "/api/devtools/webhooks/{endpoint_id}": {
      "patch": {
        "operationId": "updateWebhook",
        "summary": "Change, disable or re-enable a webhook endpoint",
        "description": "Any of `url`, `event_types`, `description` and `enabled`. A new URL is checked\nexactly as at registration. Disabling abandons pending deliveries; enabling an\nendpoint that was disabled automatically resets its failure count.\n",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "endpoint_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "maxLength": 2048
                  },
                  "event_types": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string"
                    }
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated (or unchanged).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/PartnerWebhookEndpoint"
                    },
                    "changed": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such endpoint in your organisation."
          },
          "422": {
            "description": "The URL breaks an outbound rule, or an event type is unknown."
          }
        }
      },
      "delete": {
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook endpoint",
        "description": "Pending deliveries are abandoned and the stored secret is erased.",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "endpoint_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such endpoint in your organisation."
          }
        }
      }
    },
    "/api/devtools/webhooks/{endpoint_id}/rotate-secret": {
      "post": {
        "operationId": "rotateWebhookSecret",
        "summary": "Rotate a webhook signing secret (new secret shown once)",
        "description": "For `grace_period_hours` (default 24, at most 72; 0 for none) every delivery is\nsigned with both the new and the previous secret, as two `v1=` entries in\n`AmpVerve-Signature`, so the receiver can switch without dropping a delivery.\n",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "endpoint_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "grace_period_hours": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 72,
                    "default": 24
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New secret issued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/PartnerWebhookEndpoint"
                    },
                    "secret": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such endpoint in your organisation."
          }
        }
      }
    },
    "/api/devtools/webhooks/{endpoint_id}/deliveries": {
      "get": {
        "operationId": "listWebhookDeliveries",
        "summary": "Delivery log for one endpoint",
        "description": "Most recent first. `status` is `pending` (queued or waiting to retry),\n`succeeded`, `failed` (retries exhausted) or `abandoned` (endpoint disabled or\ndeleted first). Response bodies from your endpoint are not stored.\n\nPaginated with `limit` and `cursor`, as the Partner API lists: newest first,\nties on time broken by `id`. See the Pagination page.\n",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "endpoint_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size. Default 50, at most 200.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Deliveries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "deliveries",
                    "next_cursor",
                    "has_more"
                  ],
                  "properties": {
                    "deliveries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PartnerWebhookDelivery"
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    },
                    "has_more": {
                      "$ref": "#/components/schemas/HasMore"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidCursor"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such endpoint in your organisation."
          }
        }
      }
    },
    "/api/devtools/webhooks/{endpoint_id}/test": {
      "post": {
        "operationId": "sendWebhookTestEvent",
        "summary": "Queue a signed test event to one endpoint",
        "description": "Queues a signed `webhook.test` event to the endpoint, delivered and retried like any\nother event. In the sandbox (`environment=sandbox`) `event_type` can also be\n`alert.raised`, `alert.resolved` or `device.offline`: a generated sample in the\nshape of the live event, with `data.sandbox` set to `true`. A live endpoint is\nsent `webhook.test` only, so a sample can never be mistaken for a real event.\n",
        "tags": [
          "Webhook endpoints"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "endpoint_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Environment"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "event_type": {
                    "type": "string",
                    "enum": [
                      "webhook.test",
                      "alert.raised",
                      "alert.resolved",
                      "device.offline"
                    ],
                    "default": "webhook.test"
                  }
                }
              },
              "example": {
                "event_type": "alert.raised"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Queued. It is delivered within a few seconds and appears in the delivery log.",
            "content": {
              "application/json": {
                "example": {
                  "event_id": "evt_5c0f...",
                  "event_type": "alert.raised",
                  "delivery_id": "4be1...",
                  "status": "queued"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such endpoint in your organisation",
            "or in your sandbox when `environment=sandbox`.": null
          },
          "409": {
            "description": "The endpoint is disabled",
            "or it is not subscribed to `event_type`.": null
          },
          "422": {
            "description": "An unknown event_type, or a type other than webhook.test sent to a live endpoint."
          }
        }
      }
    },
    "/api/devtools/partner/v1/me": {
      "get": {
        "operationId": "partnerWhoAmI",
        "summary": "The organisation and key a partner API key resolves to",
        "description": "Any valid key, no scope needed. Use it to check a key. The organisation is the\none the key was issued to; no header, parameter or body can change it.\n`environment` is `sandbox` for a sandbox key and `live` otherwise.\n`key.expires_in_days` and `key.expiring_soon` (true from\n`key.expiry_warning_days` before expiry) say when to rotate; every Partner\nAPI response also carries `AmpVerve-Key-Expires-At`, and\n`AmpVerve-Key-Expiry-Warning` once the key is expiring soon.\n",
        "tags": [
          "Partner API"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-sandbox": true,
        "security": [
          {
            "apiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Key context.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "AmpVerve-Key-Expires-At": {
                "$ref": "#/components/headers/KeyExpiresAt"
              },
              "AmpVerve-Key-Expiry-Warning": {
                "$ref": "#/components/headers/KeyExpiryWarning"
              }
            },
            "content": {
              "application/json": {
                "example": {
                  "organisation_id": "demo-partner",
                  "environment": "live",
                  "key": {
                    "id": "9b1e...",
                    "prefix": "avk_3f9a1c2b7d4e",
                    "name": "Example Solar Ltd production",
                    "scopes": [
                      "alerts:read"
                    ],
                    "expires_at": "2027-01-01T00:00:00Z",
                    "expires_in_days": 87,
                    "expiring_soon": false,
                    "expiry_warning_days": 14
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/devtools/partner/v1/alerts": {
      "get": {
        "operationId": "partnerListAlerts",
        "summary": "Alerts for the key's organisation",
        "description": "Scope `alerts:read`. Identifiers, type, severity, title and times. Operator free\ntext (alert messages and context) is not exposed. A sandbox key gets the two\nsimulated alerts.\n\nPaginated with `limit` and `cursor`: newest first, ties on time broken by\n`id`. Send the same `status` and `since` with every page. See the Pagination\npage.\n",
        "tags": [
          "Partner API"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-sandbox": true,
        "security": [
          {
            "apiKeyAuth": [
              "alerts:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "resolved",
                "all"
              ],
              "default": "open"
            }
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "description": "Only alerts created at or after this time. A filter. Moving `since` back to\nread older alerts was the way to page before `cursor`; that still works but\nis deprecated as a way to page. Use `cursor`.\n",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size. Default 50, at most 200.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Alerts, newest first.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "alerts",
                    "next_cursor",
                    "has_more"
                  ],
                  "properties": {
                    "alerts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PartnerAlert"
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    },
                    "has_more": {
                      "$ref": "#/components/schemas/HasMore"
                    }
                  }
                },
                "example": {
                  "alerts": [
                    {
                      "id": "a7c2...",
                      "alert_type": "comms_lost",
                      "severity": "warning",
                      "title": "Depot charger 2 stopped reporting",
                      "device_id": "dev-2",
                      "provider": "ocpp",
                      "resolved": false,
                      "created_at": "2026-10-06T09:00:00Z",
                      "acknowledged_at": null,
                      "resolved_at": null
                    }
                  ],
                  "next_cursor": "eyJ2IjoxLCJvIjoicGFydG5lckxpc3RBbGVydHMiLCJmIjoiLi4uIn0",
                  "has_more": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidCursor"
          },
          "422": {
            "$ref": "#/components/responses/PartnerValidationError"
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/devtools/partner/v1/devices": {
      "get": {
        "operationId": "partnerListDevices",
        "summary": "Devices registered to the key's organisation",
        "description": "Scope `devices:read`. A sandbox key gets the simulated devices.\n\nPaginated with `limit` and `cursor`: newest first, ties on time broken by\n`id`, devices without a registration time first. See the Pagination page.\n",
        "tags": [
          "Partner API"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-sandbox": true,
        "security": [
          {
            "apiKeyAuth": [
              "devices:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size. Default 100, at most 500.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Devices, newest first.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "devices",
                    "next_cursor",
                    "has_more"
                  ],
                  "properties": {
                    "devices": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "device_type": {
                            "type": "string"
                          },
                          "protocol": {
                            "type": "string",
                            "nullable": true
                          },
                          "v2x_capable": {
                            "type": "boolean"
                          },
                          "live_enabled": {
                            "type": "boolean"
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    },
                    "has_more": {
                      "$ref": "#/components/schemas/HasMore"
                    }
                  }
                },
                "example": {
                  "devices": [
                    {
                      "id": "dev-1",
                      "name": "Depot charger 1",
                      "device_type": "CHARGER",
                      "protocol": "OCPP_16",
                      "v2x_capable": false,
                      "live_enabled": true,
                      "created_at": "2026-09-01T10:00:00Z"
                    }
                  ],
                  "next_cursor": null,
                  "has_more": false
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidCursor"
          },
          "422": {
            "$ref": "#/components/responses/PartnerValidationError"
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/devtools/partner/v1/telemetry/latest": {
      "get": {
        "operationId": "partnerLatestTelemetry",
        "summary": "Recent stored telemetry readings for the key's organisation",
        "description": "Scope `telemetry:read`. Readings stored in the last `hours` (default 24, at most\n168), newest first, optionally for one `asset_id`. An asset of another\norganisation returns an empty list, not an error. A sandbox key gets generated\nfive minute readings for the simulated assets.\n\nPaginated with `limit` and `cursor`: newest first, ties on time broken by the\norder the readings were stored. The window is fixed by the first page: later\npages read the same `hours` before the first page's time, so it does not\nslide while you page. Send the same `asset_id` and `hours` with every page.\nSee the Pagination page.\n",
        "tags": [
          "Partner API"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-sandbox": true,
        "security": [
          {
            "apiKeyAuth": [
              "telemetry:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "asset_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 200
            }
          },
          {
            "name": "hours",
            "in": "query",
            "required": false,
            "description": "The window, in hours before the first page's time. A filter. Raising `hours`\nto read further back was the way to page before `cursor`; that still works\nbut is deprecated as a way to page. Use `cursor`.\n",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 168,
              "default": 24
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size. Default 100, at most 500.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Readings.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "readings",
                    "next_cursor",
                    "has_more"
                  ],
                  "properties": {
                    "readings": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "asset_id": {
                            "type": "string"
                          },
                          "asset_type": {
                            "type": "string",
                            "nullable": true
                          },
                          "provider": {
                            "type": "string",
                            "nullable": true
                          },
                          "ts": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "power_kw": {
                            "type": "number",
                            "nullable": true
                          },
                          "soc_pct": {
                            "type": "number",
                            "nullable": true
                          },
                          "energy_kwh": {
                            "type": "number",
                            "nullable": true
                          },
                          "status": {
                            "type": "string",
                            "nullable": true,
                            "deprecated": true,
                            "description": "The device's status at the reading exactly as its source sent it\nand as stored, in whichever vocabulary that source uses (for\nexample `available`, `Charging`, `unavailable`). The sandbox\nreturns its own values. Deprecated as free text: `status` will\nswitch to the canonical set on or after 2027-04-15; read\n`status_canonical` now.\n"
                          },
                          "status_canonical": {
                            "type": "string",
                            "enum": [
                              "charging",
                              "discharging",
                              "producing",
                              "importing",
                              "exporting",
                              "idle",
                              "online",
                              "offline",
                              "fault",
                              "unknown"
                            ],
                            "description": "The device's status at the reading, from one vocabulary for every\nsource: `charging` (taking energy in), `discharging` (a battery or\nvehicle delivering energy), `producing` (generating), `importing`\nand `exporting` (a meter, to and from the grid), `idle`\n(connected, not transferring energy), `online` (reporting, no\noperating state given), `offline` (not reporting, or reported\nunavailable), `fault`, and `unknown` (no status, or one not\nrecognised). Mapped from `status`: for example `available` and\nOCPP `SuspendedEV` are `idle`, `unavailable` is `offline`, OCPP\n`Faulted` is `fault`.\n"
                          }
                        }
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    },
                    "has_more": {
                      "$ref": "#/components/schemas/HasMore"
                    }
                  }
                },
                "example": {
                  "readings": [
                    {
                      "asset_id": "charger-1",
                      "asset_type": "charger",
                      "provider": "ocpp",
                      "ts": "2026-10-03T12:00:00Z",
                      "power_kw": 7.2,
                      "soc_pct": null,
                      "energy_kwh": 14.1,
                      "status": "Charging",
                      "status_canonical": "charging"
                    }
                  ],
                  "next_cursor": "eyJ2IjoxLCJvIjoicGFydG5lckxhdGVzdFRlbGVtZXRyeSIsImYiOiIuLi4ifQ",
                  "has_more": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidCursor"
          },
          "422": {
            "$ref": "#/components/responses/PartnerValidationError"
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/devtools/partner/v1/sandbox/scenario": {
      "get": {
        "operationId": "sandboxScenario",
        "summary": "What the sandbox simulates",
        "description": "Sandbox keys only; a live key gets 403 `sandbox_only`. No scope needed. The\nsimulated sites, the device and asset identifiers at each, how each asset's\ntelemetry is generated, the alert identifiers and the event types the sandbox\ncan send. Use it to write assertions in an integration test.\n",
        "tags": [
          "Sandbox"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-sandbox": true,
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Deterministic sandbox simulator: two simulated sites, their devices and five minute telemetry. Nothing is measured or stored.",
        "security": [
          {
            "apiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The simulation.",
            "content": {
              "application/json": {
                "example": {
                  "environment": "sandbox",
                  "data_basis": "simulated",
                  "generated_at": "2026-10-05T13:07:00Z",
                  "telemetry_interval_seconds": 300,
                  "timezone": "UTC",
                  "sites": [
                    {
                      "site_id": "sbx-site-solar",
                      "name": "Sandbox solar and storage site",
                      "description": "A 50 kW solar array, a 100 kWh battery, a boundary meter and a gateway.",
                      "devices": [
                        "sbx-dev-solar-battery",
                        "sbx-dev-solar-meter",
                        "sbx-dev-solar-gateway"
                      ],
                      "assets": [
                        {
                          "asset_id": "sbx-solar-inverter",
                          "asset_type": "inverter",
                          "behaviour": "50 kW peak, a half sine from 06:00 to 18:00 UTC. Generation is negative."
                        }
                      ]
                    }
                  ],
                  "alerts": [
                    "sbx-alert-charger-2-offline",
                    "sbx-alert-solar-underperformance"
                  ],
                  "webhook_test_event_types": [
                    "alert.raised",
                    "alert.resolved",
                    "device.offline",
                    "webhook.test"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SandboxOnly"
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/devtools/partner/v1/sandbox/webhooks/test": {
      "post": {
        "operationId": "sandboxSendTestEvent",
        "summary": "Send a signed sample event to the sandbox's webhook endpoints",
        "description": "Sandbox keys only; a live key gets 403 `sandbox_only`. No scope needed. Queues one\nevent of `event_type` (default `webhook.test`) for every active sandbox endpoint\nsubscribed to it, or for `endpoint_id` only. The event is a generated sample in\nthe shape of the live event with `data.sandbox` set to `true`, signed with the\nendpoint's secret, and delivered and retried exactly as a live event. Register a\nsandbox endpoint first with `POST /api/devtools/webhooks?environment=sandbox`.\n\n**Idempotency.** Send an `Idempotency-Key` header to make a retry safe. A repeat\nof the same request (same `event_type` and `endpoint_id`) with the same key, in\nthe same organisation, queues nothing and returns the first `202` body again with\n`Idempotent-Replayed: true`. The same key with a different request is `409`. Only\na request that queued an event is remembered, so a retry after a `4xx` or `5xx`\nis a fresh attempt. Keys are scoped to the organisation, not to the API key, and\nare remembered as long as the event is kept; AmpVerve stores a hash of the key,\nnever the key. This is the only operation that writes with an API key, and so\nthe only one that accepts the header.\n",
        "tags": [
          "Sandbox"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-sandbox": true,
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "A signed sample event generated by the sandbox simulator.",
        "security": [
          {
            "apiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "1 to 255 visible ASCII characters, unique per intended request, such as a\nUUID. Optional.\n",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "pattern": "^[\\x21-\\x7e]+$"
            },
            "example": "3f6c1d2e-8a4b-4c5d-9e7f-0a1b2c3d4e5f"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "event_type": {
                    "type": "string",
                    "enum": [
                      "alert.raised",
                      "alert.resolved",
                      "device.offline",
                      "webhook.test"
                    ],
                    "default": "webhook.test"
                  },
                  "endpoint_id": {
                    "type": "string"
                  }
                }
              },
              "example": {
                "event_type": "device.offline"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Queued, or the replay of an earlier request with the same `Idempotency-Key`.\nEach delivery appears in the endpoint's delivery log.\n",
            "headers": {
              "Idempotent-Replayed": {
                "description": "`true` when this is the stored answer to an earlier request with the same `Idempotency-Key` and nothing new was queued. Absent otherwise.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "true"
                  ]
                }
              }
            },
            "content": {
              "application/json": {
                "example": {
                  "event_id": "evt_5c0f...",
                  "event_type": "device.offline",
                  "delivery_ids": [
                    "4be1..."
                  ],
                  "status": "queued"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SandboxOnly"
          },
          "404": {
            "description": "No active sandbox endpoint has that `endpoint_id` (`not_found`). Problem details.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "No active sandbox endpoint is subscribed to `event_type`, or the `Idempotency-Key` was already used with a different request (`conflict`). Problem details.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "Validation failed: an unknown `event_type` or field, or an `Idempotency-Key` that is not 1 to 255 visible ASCII characters (`validation_error`). Problem details.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/v1/sites/{site_id}/mapping": {
      "get": {
        "operationId": "haConnectorGetMapping",
        "summary": "The confirmed entity mapping for a connector site",
        "description": "Called by the AmpVerve Home Assistant connector running in a home. Scope\n`telemetry:write`. Returns the mapping a person confirmed for this site in the\nkey's organisation, or `status: unconfirmed` (a report exists, nothing is\nconfirmed) or `status: unknown` (no such site in the key's organisation). The\nconnector sends readings only for a confirmed mapping. Phase 1 is read only:\nnothing on this surface returns or queues a command.\n",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "security": [
          {
            "apiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The mapping, or an empty one with its status.",
            "content": {
              "application/json": {
                "example": {
                  "site_id": "home-1",
                  "status": "confirmed",
                  "version": "20261005T120000.000000Z",
                  "mapping": [
                    {
                      "metric": "site.grid.import_power_w",
                      "entity_id": "sensor.grid_power",
                      "transform": "positive_part"
                    },
                    {
                      "metric": "battery.soc_percent",
                      "entity_id": "sensor.battery_soc",
                      "transform": "as_is"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/v1/sites/{site_id}/report": {
      "post": {
        "operationId": "haConnectorPostReport",
        "summary": "Send the entity discovery and proposed mapping report",
        "description": "Scope `telemetry:write`. The connector's discovery of the home's Home Assistant:\nentity ids, friendly names, domains, units, device and state classes and\nintegrations (never state or attribute values; people, trackers, zones and\ncameras are left out), keyword groups, a proposed mapping onto the normalised\nmodel, and a human readable rendering in `text`. Stored per site in the key's\norganisation. A body naming an organisation (`tenant_id`, `organisation_id`,\n`org_id`) is refused with 403 and nothing is stored.\n",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "security": [
          {
            "apiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "schema": "ampverve.home_assistant.mapping_report.v1",
                "connector_version": "0.1.0",
                "entity_count": 1,
                "entities": [
                  {
                    "entity_id": "sensor.grid_power",
                    "domain": "sensor",
                    "unit_of_measurement": "kW",
                    "device_class": "power",
                    "state_class": "measurement",
                    "platform": null,
                    "available": true,
                    "numeric": true,
                    "attribute_keys": [
                      "unit_of_measurement"
                    ]
                  }
                ],
                "proposed_mapping": [
                  {
                    "metric": "site.grid.import_power_w",
                    "entity_id": "sensor.grid_power",
                    "transform": "positive_part",
                    "confidence": "medium"
                  }
                ],
                "text": "AmpVerve Home Assistant entity mapping report ..."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored.",
            "content": {
              "application/json": {
                "example": {
                  "site_id": "home-1",
                  "stored": true,
                  "mapping_status": "proposed"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "description": "Missing scope (`insufficient_scope`), or the body names an organisation\n(`cross_tenant_record`). Nothing was stored. Problem details.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "The report or its proposed mapping is malformed (`validation_error`). Problem details.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/v1/sites/{site_id}/telemetry": {
      "post": {
        "operationId": "haConnectorPostTelemetry",
        "summary": "Push normalised readings and connector health",
        "description": "Scope `telemetry:write`. Up to 2000 readings in the canonical units of the\nnormalised model (power W, energy kWh, prices GBP/kWh, temperatures degrees\nCelsius), plus a `connector` health block. Every reading's metric must be in the\nsite's confirmed mapping (409 when nothing is confirmed, 422 for a metric\noutside it). A reading with `quality` `unavailable` or `invalid` must have\n`value: null` and is stored with no value and quality `missing` or `invalid`,\nnever as zero. Readings are stored in the telemetry store under the key's\norganisation, as asset `ha:{site_id}:{metric}`. An empty `readings` list is a\nheartbeat. 200 means every reading is persisted; 503 means nothing (or not\neverything) was, and the connector keeps the batch and retries.\n",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "security": [
          {
            "apiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "connector": {
                  "connector_version": "0.1.0",
                  "ha_connected": true,
                  "entities_total": 412,
                  "entities_mapped": 24,
                  "unavailable_entities": [],
                  "buffered": 0,
                  "dropped": 0,
                  "control_enabled": false
                },
                "readings": [
                  {
                    "metric": "site.grid.import_power_w",
                    "value": 0.0,
                    "unit": "W",
                    "quality": "measured",
                    "ts": "2026-10-05T12:00:00Z",
                    "entity_id": "sensor.grid_power"
                  },
                  {
                    "metric": "battery.soc_percent",
                    "value": null,
                    "unit": "%",
                    "quality": "unavailable",
                    "ts": "2026-10-05T12:00:00Z",
                    "entity_id": "sensor.battery_soc"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Every reading persisted.",
            "content": {
              "application/json": {
                "example": {
                  "count": 2,
                  "stored": 2
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiKeyUnauthorized"
          },
          "403": {
            "description": "Missing scope (`insufficient_scope`), or the body names an organisation\n(`cross_tenant_record`). Nothing was stored. Problem details.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The site has no confirmed mapping (`conflict`). Nothing was stored. Problem details.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "A reading is malformed, in the wrong unit, or not in the confirmed mapping\n(`validation_error`; `rejected` or `not_in_confirmed_mapping` names them).\nNothing was stored. Problem details.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/ApiKeyRateLimited"
          },
          "503": {
            "description": "The telemetry store did not persist the batch (`writer_unavailable`,\n`writer_error` or `partial_write`, with `stored` and `retryable: true`).\nRetry it. Problem details.\n",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              },
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/sites": {
      "get": {
        "operationId": "haConnectorListSites",
        "summary": "Home Assistant connector sites of the caller's organisation",
        "description": "Each site's mapping status and connectivity health.",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Sites.",
            "content": {
              "application/json": {
                "example": {
                  "sites": [
                    {
                      "site_id": "home-1",
                      "mapping_status": "confirmed",
                      "health": {
                        "status": "healthy",
                        "connected": true,
                        "last_message_at": "2026-10-05T12:00:02Z",
                        "entities_total": 412,
                        "entities_mapped": 24,
                        "stale_metrics": [],
                        "control_enabled": false
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/sites/{site_id}": {
      "get": {
        "operationId": "haConnectorGetSite",
        "summary": "A connector site's report, proposed and confirmed mapping, and health",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The site."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No connector site with this id in the caller's organisation."
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/sites/{site_id}/health": {
      "get": {
        "operationId": "haConnectorGetHealth",
        "summary": "Connectivity health of a connector site",
        "description": "`status` is `awaiting_report`, `awaiting_confirmation`, `offline` (nothing heard\nfor 3 minutes), `degraded` (Home Assistant disconnected, unavailable entities or\nmapped metrics not heard for 15 minutes) or `healthy`.\n",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Health.",
            "content": {
              "application/json": {
                "example": {
                  "site_id": "home-1",
                  "status": "degraded",
                  "connected": true,
                  "home_assistant_connected": true,
                  "last_message_at": "2026-10-05T12:00:02Z",
                  "entities_total": 412,
                  "entities_mapped": 24,
                  "unavailable_entities": [
                    "sensor.ev_session_energy"
                  ],
                  "stale_metrics": [
                    {
                      "metric": "evse.session_energy_kwh",
                      "entity_id": "sensor.ev_session_energy",
                      "last_seen_at": null
                    }
                  ],
                  "readings_received": 18230,
                  "control_enabled": false
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No connector site with this id in the caller's organisation."
          }
        }
      }
    },
    "/api/telemetry/connectors/home-assistant/sites/{site_id}/mapping": {
      "put": {
        "operationId": "haConnectorConfirmMapping",
        "summary": "Confirm (or edit and confirm) a connector site's mapping",
        "description": "Roles `ORG_ADMIN`, `OPERATOR` or `PLATFORM_ADMIN`. A person confirms the mapping\nthe connector proposed, or an edited one. Until this is done the connector sends\nno readings. Each entry names one metric of the normalised model, one Home\nAssistant entity, and optionally an attribute, an end entity (for a window), a\ntransform (`as_is`, `invert`, `positive_part`, `negative_part`) and the text\nstates that mean true and false for a boolean.\n",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "mapping": [
                  {
                    "metric": "site.grid.import_power_w",
                    "entity_id": "sensor.grid_power",
                    "transform": "positive_part"
                  },
                  {
                    "metric": "site.grid.export_power_w",
                    "entity_id": "sensor.grid_power",
                    "transform": "negative_part"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmed. Returns the site."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The caller's role cannot confirm a mapping."
          },
          "404": {
            "description": "No connector site with this id in the caller's organisation."
          },
          "422": {
            "description": "The mapping is malformed."
          }
        }
      },
      "delete": {
        "operationId": "haConnectorWithdrawMapping",
        "summary": "Withdraw a connector site's confirmed mapping",
        "description": "The connector stops sending readings at its next mapping refresh (within 5 minutes).",
        "tags": [
          "Home Assistant connector"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "description": "The site's identifier within the caller's organisation.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Withdrawn. Returns the site."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No connector site with this id in the caller's organisation."
          }
        }
      }
    },
    "/api/telemetry/sites": {
      "get": {
        "operationId": "listSites",
        "summary": "Sites of this organisation and the assets each carries",
        "description": "Returns every workspace of the organisation with its solar, meter and\nbattery assets, read from the organisation's own device records. A site\nwith none of the three roles is still listed, so a client can say why a\nsite has no generation split rather than hiding it.\n\nInstalled PV capacity (`pv_kwp`) and battery nameplate\n(`battery_nameplate_kwh`) come from the site profile where one is set,\notherwise from the sum of the per device figures on the device records,\nand each carries a `_source` field saying which. `profile_readable` is\nfalse when the `site_profiles` table could not be read (migration not\napplied), so \"not available\" can be told apart from \"not set\".\n\nTwo branches. When the telemetry store is not connected, `source` is\n`timeseries_unavailable` and `sites` is empty. Otherwise `source` is\n`device_records`.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site energy"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Sites of the organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteList"
                },
                "example": {
                  "organisation_id": "smoke-test",
                  "sites": [
                    {
                      "site_id": "smoke-site-1",
                      "name": "Smoke site",
                      "assets": {
                        "solar": [
                          "smoke-site-1-solar"
                        ],
                        "meter": [
                          "smoke-site-1-meter"
                        ],
                        "battery": [
                          "smoke-site-1-battery"
                        ]
                      },
                      "pv_kwp": 100.0,
                      "pv_kwp_source": "device_record",
                      "battery_nameplate_kwh": 200.0,
                      "battery_nameplate_source": "device_record",
                      "profile": {
                        "floor_area_m2": 1200.0,
                        "opening_hours_per_week": null,
                        "source": "test"
                      },
                      "profile_readable": true
                    }
                  ],
                  "source": "device_records"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Organisation context missing"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/self-consumption": {
      "get": {
        "operationId": "getSiteSelfConsumption",
        "summary": "Generation split into self-consumed and exported",
        "description": "Splits a site's PV generation into self-consumed and exported energy per\nhalf hour, local day or calendar month, in kWh and per cent, with the\nquality of the inputs each figure was built from.\n\nPer half hour slot: `exported = min(generation, grid_export)` and\n`self_consumed = generation - exported`. Export above generation in a\nhalf hour (a battery discharging to the grid) is reported separately as\n`export_beyond_generation_kwh`, never attributed to the PV. A slot is\ncomplete only when both the inverter and the meter were heard from.\nBucket kWh figures are sums over complete slots only; `coverage_pct`\nsays what share of the expected slots were complete and `quality` is\nthe worst input quality in the bucket. An incomplete bucket is reported\nas incomplete with the figures it has, never padded.\n\nCalendar: `hh` buckets are labelled with the GB settlement day and\nperiod (1 to 50) and are settlement aligned. `daily` and `monthly`\nbuckets are Europe/London local days and months. The window start is\nsnapped down to the bucket boundary. Default lookback when `from` is\nabsent: 2 days for `hh`, 30 days for `daily`, 365 days for `monthly`.\n\nThree branches, all with status 200. `source: timeseries_unavailable`:\nthe store is not connected, `site` is null, `buckets` is empty and\n`total` is null. `source: device_records`: the site has no solar\ninverter or no grid meter on its device record, `buckets` is empty,\n`total` is null and `unavailable_reason` says which is missing.\n`source: timeseries`: the split was computed; `unavailable_reason` is\nset when no half hour in the window had both readings.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site energy"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^(hh|daily|monthly)$",
              "default": "daily"
            },
            "description": "hh: GB settlement periods, labelled with settlement day and period.\ndaily and monthly: Europe/London local days and calendar months.\n"
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Window start, ISO 8601. Snapped down to the bucket boundary. A naive timestamp is read as UTC."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Window end, exclusive, ISO 8601. Defaults to now."
          }
        ],
        "responses": {
          "200": {
            "description": "The split, or an honest empty envelope with the reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SelfConsumption"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token; `from` or `to` is not a\nvalid ISO 8601 timestamp; `to` is not after `from`; the window would\nreturn more than 5000 buckets at this resolution (the series is\nnever truncated); or the window covers more than 200000 stored\nsamples for the site's assets.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/battery/state": {
      "get": {
        "operationId": "getSiteBatteryState",
        "summary": "Battery state and health for a site",
        "description": "State of charge, instantaneous power, throughput, cycle count,\ntemperature spread and a state of health figure for every battery on\nthe site, each with its basis stated.\n\nThe state of health shown is either the vendor BMS value passed through\nfrom the adapter's stored row (basis `vendor_bms`, unverified by\nAmpVerve), or computed by AmpVerve from the stored series as median\nmeasured capacity over nameplate (basis `computed`), or absent with the\nreason. Both bases are returned when both exist and `soh.shown.basis`\nsays which one is the headline. A computed figure is withheld when no\ncharge or discharge segment swings at least 30 SoC points, when more\nthan 10 per cent of sample pairs disagree between power and state of\ncharge, when there is no nameplate, or when the result exceeds 105 per\ncent of nameplate. When a site level nameplate covers more than one\nbattery it is not split per battery; `nameplate_source` is then\n`site_total_not_split_per_battery` and per battery health is not\nderived from it.\n\nThroughput is integrated from the signed power series over the window\n(positive charging, negative discharging), so it is a windowed figure.\nEquivalent full cycles are windowed discharge kWh over nameplate kWh and\nare null without a nameplate. Vendor lifetime figures, where the adapter\nwrote them, are returned alongside and labelled.\n\nThree branches, all status 200. `source: timeseries_unavailable`: the\nstore is not connected, `site` is null, `batteries` is empty.\n`source: device_records`: the site has no battery on its device\nrecord, `batteries` is empty. `source: timeseries`: one report per\nbattery; `unavailable_reason` is set when none had a sample in the\nwindow.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site energy"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "From the stored battery series: throughput integrated from power, cycles over nameplate, and state of health either passed through from the vendor BMS or computed as median measured capacity over nameplate, each with its basis.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            },
            "description": "Window of stored history to read, ending now."
          }
        ],
        "responses": {
          "200": {
            "description": "Battery reports with the basis of every figure.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatteryState"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or the window covers\nmore than 200000 stored samples for the site's batteries.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/portfolio/league": {
      "get": {
        "operationId": "getPortfolioLeague",
        "summary": "Every site in the organisation, ranked on a stated basis",
        "description": "One call, every site, ranked by specific yield: kWh generated in the\nwindow per kWp of installed PV. The kWp comes from the site profile\nwhere the operator has entered one and from the solar device record\notherwise, and each row says which. Generation is the daily\nself-consumption split summed over the window, complete half hours\nonly, so the league table and the site view cannot disagree.\n\nTwo further normalisations apply only where the site profile carries\nthe attribute: generation per square metre (`floor_area_m2`) and grid\nimport per opening hour (`opening_hours_per_week`). Each row lists what\nit was and was not normalised for. Weather is not normalised for on any\nsite because there is no irradiance feed on the site record.\n\nA site that cannot be ranked (no inverter, no meter, no kWp on record,\nor no complete half hour in the window) is listed under `unranked` with\n`unranked_reason` and `rank: null`, not dropped. A ranked site with\ncoverage below 80 per cent keeps its rank and carries a caveat. Ranked\nrows are ordered by specific yield descending, then generation, then\nname, and numbered from 1.\n\nThree branches, all status 200. `source: timeseries_unavailable`: the\nstore is not connected, both lists are empty. `source: device_records`:\nno sites are registered for the organisation. `source: timeseries`: the\ntable was built.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site energy"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 366,
              "default": 30
            },
            "description": "Window in Europe/London days ending now."
          }
        ],
        "responses": {
          "200": {
            "description": "The league table.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortfolioLeague"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or the window covers\nmore than 200000 stored samples across the organisation's assets.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/ppa/statement": {
      "get": {
        "operationId": "getSitePpaStatement",
        "summary": "PPA settlement statement for one calendar month",
        "description": "One line per GB settlement period of the month, priced from the site's\nPPA contract. Each line carries the kWh of PV consumed on site and\nexported (the self-consumption series at half hour resolution, not\nrecomputed here), the contract row and version the rates came from, the\npounds as kWh times rate, the quality and provenance of each input, and\nthe ids of the stored readings that fed the half hour so the line can\nbe traced to rows.\n\nA half hour without both an inverter reading and a meter reading is a\n`missing` line: kWh and pounds absent, reason stated, never zero. A\nsettlement day no contract row covers is a `no_contract` line: kWh\nshown, pounds absent. The contract applied to a day is the row whose\nvalidity covers it at the highest version. Line pounds are kept to six\ndecimal places; totals are the sum of the unrounded lines rounded to\nthe penny, so adding the printed lines can differ from the total by\nless than a penny per thousand lines.\n\nThree branches, all status 200. `source: timeseries_unavailable`: the\nstore is not connected, `site` is null, `lines` is empty, `totals` is\nnull. `source: device_records`: the site has no solar inverter or no\ngrid meter, `lines` is empty, `totals` is null, the contract rows are\nstill listed. `source: timeseries`: the statement was built;\n`unavailable_reason` is set when the `ppa_contracts` table could not\nbe read, when no contract row exists for the site, or when no half hour\nin the month had both readings.\n\nWith `format=csv` the same statement is returned as `text/csv` with a\n`Content-Disposition: attachment` header: a preamble of `#` comment\nlines (site, period, organisation, calendar and window, metering\nboundary, each contract applied, method, rounding, and any unavailable\nreason), then a header row with the columns listed on the response,\none row per line, and a final `TOTAL` row. An absent value is written\nas the word `missing`, never as 0 or an empty cell.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Energy ledger and PPA"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "period",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Calendar month, YYYY-MM, in the Europe/London calendar."
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^(json|csv)$",
              "default": "json"
            },
            "description": "json (default) or csv."
          }
        ],
        "responses": {
          "200": {
            "description": "The statement as JSON, or as CSV when `format=csv`.",
            "headers": {
              "Content-Disposition": {
                "description": "Only on the CSV branch. `attachment; filename=\"ppa-statement-{site_id}-{period}.csv\"`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PpaStatement"
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string",
                  "description": "Preamble lines start with `#`. Columns, in order:\n`settlement_day`, `settlement_period`, `period_start_utc`,\n`period_end_utc`, `status`, `consumed_kwh`,\n`consumed_rate_gbp_per_kwh`, `consumed_gbp`, `exported_kwh`,\n`export_rate_gbp_per_kwh`, `exported_gbp`, `line_gbp`,\n`contract_ref`, `contract_version`, `contract_id`,\n`solar_quality`, `meter_quality`, `provenance`,\n`export_beyond_generation_kwh`, `solar_reading_ids`,\n`meter_reading_ids`, `reason`. Reading id columns are space\nseparated. The last row has `settlement_day` = `TOTAL`, the\ntotals in the kWh and pound columns, a status of `complete`\nor a count of priced, missing and no contract lines, and the\nworst quality in the last column. Absent values are the word\n`missing`.\n"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token; `period` is not\nYYYY-MM; the month is not 01 to 12; the period has not started yet\n(nothing to settle); or the month covers more than 200000 stored\nsamples for the site's assets.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "period must be YYYY-MM, for example 2026-08."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/ppa/contracts": {
      "get": {
        "operationId": "listSitePpaContracts",
        "summary": "PPA contract rows for one site, every version",
        "description": "Every `ppa_contracts` row for the site in this organisation, ordered by\n`valid_from` then `version`. Rates are pounds per kWh as stored.\n\nTwo branches, both status 200. `source: timeseries_unavailable`: the\nstore is not connected and `contracts` is empty. `source:\nppa_contracts`: the rows were read; `unavailable_reason` is set when\nthe table could not be read (migration not applied), in which case\n`contracts` is empty for that reason and not because there are none.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Energy ledger and PPA"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contract rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PpaContracts"
                },
                "example": {
                  "organisation_id": "smoke-test",
                  "site_id": "smoke-site-1",
                  "contracts": [
                    {
                      "id": "smoke-test|smoke-site-1|PPA-TEST-1|1",
                      "mpan": "1400000000011",
                      "contract_ref": "PPA-TEST-1",
                      "counterparty": "Test Solar SPV",
                      "consumed_rate_gbp_per_kwh": 0.142,
                      "export_rate_gbp_per_kwh": 0.055,
                      "metering_boundary": "PV metered at the inverter; export at the grid meter; consumed = generation - export per half hour.",
                      "valid_from": "2026-01-01",
                      "valid_to": null,
                      "version": 1,
                      "source": "test fixture",
                      "note": null
                    }
                  ],
                  "source": "ppa_contracts",
                  "unavailable_reason": null
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Organisation context missing"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/capacity": {
      "get": {
        "operationId": "getSiteCapacityReview",
        "summary": "Agreed supply capacity against measured maximum demand",
        "description": "Is the agreed supply capacity on this site's MPAN the right size for\nwhat the site draws, and what does the answer cost in pounds?\n\nAgreed capacity, the assigned residual (TCR) band, voltage level, DUoS\ntariff code and power factor come from the `mpan_charge_config` row in\nforce for the site in this organisation (written at\n`/api/trading/cost/mpans`). Maximum demand is the highest half hour\naverage import at the site's grid meter(s) over the window, integrated\nwith the same hold rule as the self-consumption series and using only\nhalf hours the meter was heard from for at least half the slot. kVA is\nkW divided by the configured power factor; with no power factor the\ncomparison is in kW, `unit` says `kW`, and the response states that the\ntrue kVA is higher. Nothing is assumed.\n\nRates are DUoS capacity and exceeded capacity p/kVA/day rows for the\nMPAN's region and tariff, published rows plus this organisation's own,\ntenant row first, then latest effective date, then latest version. A\nrow without a value, or no row, is reported as not priced with the\npublication it must be confirmed from; no number is substituted. The\nexcess charge follows the CDCM rule: (month maximum demand minus agreed\ncapacity) times the exceeded rate times every day of the calendar month\nin which the breach occurred. The recommendation is maximum demand\ntimes 1.15 rounded up to the next 5 kVA, with the implied change in the\nannual availability charge. The band block compares the assigned band\nwith the band the agreed capacity and the measured demand imply under\nthe TCR banding table and flags each disagreement.\n\nTwo branches, both status 200. `source: timeseries_unavailable`: the\nstore is not connected; `site`, `mpan`, `agreed_capacity`, `demand`,\n`utilisation`, `excess_charge`, `recommendation` and `band` are all\nnull. `source: timeseries`: blocks are filled as far as the inputs\nallow. `mpan`, `band` and `agreed_capacity.availability_charge_per_year`\nare null or absent without an MPAN row; `demand` is null without a\nmeter or a complete half hour (`demand_reason` says which);\n`utilisation`, `excess_charge` and `recommendation` are null unless\nboth an agreed capacity and a maximum demand exist.\n`unavailable_reason` carries the MPAN reason first, else the demand\nreason.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site energy"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Maximum demand from the site's metered half hours, compared with the configured agreed capacity and priced at published DUoS capacity rates.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 366,
              "default": 90
            },
            "description": "Rolling window length in days, ending at the last complete half hour."
          }
        ],
        "responses": {
          "200": {
            "description": "The capacity review.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CapacityReview"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or the window covers\nmore than 200000 stored samples for the site's meters.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/capacity/peaks": {
      "get": {
        "operationId": "getSiteCapacityPeaks",
        "summary": "Peak demand analysis and a peak shaving what-if",
        "description": "When the site's peaks happen, how peaky its load is, and what holding\ndemand under a cap would have taken, from the same half hourly demand\nseries as `GET /api/telemetry/sites/{site_id}/capacity`: the same\nledger rows, the same hold rule and the same coverage rule (a half hour\nis used only when every grid meter was heard for at least half of it).\nA half hour with no usable reading is left out and counted in\n`coverage`, never read as zero. Figures are in kW at the meter; the\nreview converts to kVA.\n\n`peak_days` is the highest half hour of each local day, ranked, with\nits local time and settlement period. `duration_curve` is the demand\nthe site was at or above for each share of measured half hours,\nnearest rank, so every point is a measured value. `peak_hour_histogram`\ncounts the days whose peak started in each local hour. `statistics`\ncarries the maximum, mean and minimum half hour and the load factor\n(mean over maximum).\n\n`what_if` tests a cap: without `cap_kw`, caps 5, 10, 15 and 20 per cent\nbelow the measured maximum; with it, that cap only. For each cap, the\nhalf hours and days above it, the energy above it, the events\n(consecutive half hours above the cap; a missing half hour ends an\nevent) and a lower bound for the battery power and usable energy that\nwould have held it, assuming the battery is full at the start of each\nevent. Losses, depth of discharge, recharge headroom and missing half\nhours are not included. Nothing is priced: the pounds of a lower peak\nare a revised agreed capacity, which the capacity review prices.\n\nTwo branches, both status 200. `source: timeseries_unavailable`: the\nstore is not connected and every analysis block is empty. Otherwise\nthe blocks are filled as far as the data allows; with no meter or no\ncomplete half hour they are empty and `unavailable_reason` says which.\n\nScoped to the organisation in your bearer token, and needs the\n`cost/capacity` module. Roles allowed: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site energy"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 366,
              "default": 90
            },
            "description": "Rolling window length in days, ending at the last complete half hour."
          },
          {
            "name": "top",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 10
            },
            "description": "How many peak days to list."
          },
          {
            "name": "cap_kw",
            "in": "query",
            "schema": {
              "type": "number",
              "exclusiveMinimum": 0
            },
            "description": "A demand cap in kW to test. Without it the what-if runs caps 5, 10, 15 and 20 per cent below the measured maximum."
          }
        ],
        "responses": {
          "200": {
            "description": "The peak analysis.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CapacityPeaks"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or the window covers more stored samples than the store reads in one request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/ledger": {
      "get": {
        "operationId": "getSiteEnergyLedger",
        "summary": "The stored energy ledger for a site, per settlement period or bucketed",
        "description": "The canonical energy ledger: one stored row per site per GB settlement\nperiod carrying the energy on every input (PV generation, grid import\nand export, battery charge and discharge, site consumption with the\nbasis it was arrived at on), the balance identity with its residual\nwritten down and never absorbed into another column, the pounds for\nthe half hour through the charge library at the site's MPAN with the\ntariff version, the carbon on both Scope 2 methods with the factor\nrows used, the worst input quality and provenance, the ids of the\nreadings the row was built from, and the corrections (B7) applied.\n\nRows are computed by the telemetry service's builder from the same\nA3 functions the self-consumption series uses, under the corrections\noverlay, and stored with `calculation_version`. A row is marked\n`stale` (with the reason: reading, correction, tariff or factor) when\nan input changes after it was computed; the scheduled pass rebuilds\nstale rows and fills the last two days on the poller cadence, and\n`POST .../ledger/rebuild` rebuilds any window on request. A stale row\nis returned as stored and excluded from the totals and buckets.\n\n`resolution=hh` returns the rows; `daily` and `monthly` return\nbuckets over fresh rows with the period counts (`periods_expected`,\n`periods_in_ledger`, `periods_stale`, `periods_missing`) so a bucket\nthat the ledger does not fully hold is visibly incomplete. Three\n`source` values: `ledger`, `timeseries_unavailable` (store not\nconnected) and `ledger_unavailable` (migration 20260916_energy_ledger\nnot applied); the last two carry `unavailable_reason` and empty rows.\n\nThe self-consumption series, the PPA statement, the capacity review\nand the Scope 2 view read these rows where they exist and state\n`ledger`, `computed` or `mixed` on their own `source`.\n\nScoped to the organisation in your bearer token; the table enforces\nrow level security on `organisation_id`. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER. Requires the\n`sites/ledger` module on the organisation.\n",
        "tags": [
          "Energy ledger and PPA"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "hh",
                "daily",
                "monthly"
              ],
              "default": "hh"
            }
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window start, snapped down to the half hour. Defaults to 2 days (hh), 30 days (daily) or 365 days (monthly) before `to`."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window end, exclusive. Defaults to now."
          }
        ],
        "responses": {
          "200": {
            "description": "The ledger rows or buckets, with the window total.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EnergyLedger"
                }
              }
            }
          },
          "400": {
            "description": "The window is inverted, or holds more than 20000 ledger rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation. The same response is returned whether the site belongs to another organisation or does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/telemetry/ledger/period/{site_id}/{period_start}": {
      "get": {
        "operationId": "getEnergyLedgerPeriod",
        "summary": "One ledger row with its full source tree",
        "description": "The stored row for one half hour and its source tree in the shape the\nreport lineage page renders: one term per input with the readings by\nid that fed it (with the stored value and correction ids where a\ncorrection applied), the pricing lines and the factor rows used.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER. Requires the\n`sites/ledger` module.\n",
        "tags": [
          "Energy ledger and PPA"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "period_start",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "The half hour start, ISO 8601; snapped down to the half hour."
          }
        ],
        "responses": {
          "200": {
            "description": "The row and its tree.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EnergyLedgerPeriod"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation, or no ledger row exists for the half hour (rebuild the window to create one).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The telemetry store is not connected, or the energy_ledger table could not be read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/telemetry/sites/{site_id}/ledger/rebuild": {
      "post": {
        "operationId": "rebuildSiteEnergyLedger",
        "summary": "Recompute and store the ledger rows for a site over a window",
        "description": "Recomputes every complete half hour in the window from the readings,\ncorrections, tariff rows and factor rows as they stand now, upserts\nthe rows, and records the rebuild in `energy_ledger_rebuilds` with\nthe caller, the reason and the calculation version, anchored in the\norganisation's hash chained audit log where that store is present.\nAt most 92 days per request.\n\nScoped to the organisation in your bearer token. Roles allowed:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR. Requires the `sites/ledger`\nmodule.\n",
        "tags": [
          "Energy ledger and PPA"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "from": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Window start. Defaults to 2 days before `to`."
                  },
                  "to": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Window end",
                    "exclusive. Defaults to now.": null
                  },
                  "reason": {
                    "type": "string",
                    "description": "Recorded with the rebuild."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rebuild summary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EnergyLedgerRebuild"
                }
              }
            }
          },
          "400": {
            "description": "The window is inverted or longer than 92 days.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site is not in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The telemetry store is not connected.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/sites": {
      "get": {
        "operationId": "listEmsSites",
        "summary": "List EMS sites",
        "description": "Returns every site workspace in your organisation, with a count of the\nclassified assets at each (`meter`, `solar`, `battery`) and the site's EMS\nsettings row, or `null` when none has been written yet.\n\nA workspace whose `type` is set to something other than `site` is omitted.\nDevices are classified from `details.asset_type`, then from `details.pv_kwp`\n(solar) or `details.battery_kwh` (battery), then from the device type as a\nfallback; a device that classifies as none of the three is not counted.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The organisation's sites.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsSiteList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/sites/{site_id}/settings": {
      "get": {
        "operationId": "getEmsSiteSettings",
        "summary": "Read one site's EMS settings",
        "description": "Returns the site's EMS configuration row (coordinates, PV capacity,\nperformance ratio, grid region, carbon factor set, MPANs and the\nunderperformance thresholds) alongside the PV capacity summed from the\nsite's solar device details, so the two sources of `pv_kwp` can be compared.\n\n`settings` is `null` when no row has been written for the site.\n\nScoped to the organisation in your bearer token. A site belonging to another\norganisation is reported as not found. Roles: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The site's settings.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "site_id",
                    "site_name",
                    "pv_kwp_from_devices",
                    "settings"
                  ],
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "site_name": {
                      "type": "string"
                    },
                    "pv_kwp_from_devices": {
                      "type": [
                        "number",
                        "null"
                      ],
                      "description": "Sum of `details.pv_kwp` over the site's solar devices, in kWp.\nNull when no solar device carries a numeric `pv_kwp`.\n"
                    },
                    "settings": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/EmsSiteSettings"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "put": {
        "operationId": "putEmsSiteSettings",
        "summary": "Write one site's EMS settings",
        "description": "Creates or updates the site's EMS configuration row. Only the keys listed in\nthe request schema are honoured; any other key in the body is ignored rather\nthan written, so a typo cannot create an undocumented setting. Keys you omit\nare left as they are on an existing row.\n\nThe response carries the row as re-read after the write.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsSiteSettingsInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The settings row after the write.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "site_id",
                    "settings"
                  ],
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "settings": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/EmsSiteSettings"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A value could not be written, for example text where the column takes a\nnumber. `detail` carries the underlying error text. The write is rolled\nback.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/report": {
      "get": {
        "operationId": "getEmsReport",
        "summary": "Energy and carbon report for one site",
        "description": "Every figure in the energy and carbon report for one site over one window:\nPV generation, grid import, grid export, battery charge, battery discharge,\nsite load, PV self consumed, self consumption per cent, and Scope 2 on the\nlocation based and market based methods.\n\nEach figure carries a `figure_id` of the form\n`key|site_id|window_start|window_end`. Pass that string, URL encoded, to\n`GET /api/hardware/ems/lineage` and it is expanded into the full tree: the\nformula, one term per half hour, the readings (with ids and quality words)\nbehind each term, and the factor rows used. The report gives the numbers;\nthe lineage route gives their provenance.\n\nFigures follow policy `AV-EMS-LIN-1`: a half hour with no reading for a\nrequired input contributes nothing and is counted as `missing`, never\ninterpolated or treated as zero; a figure with any missing half hour is\nlabelled `partial`; a figure with no usable half hour has a null `value`\nand status `no_data`. Coverage counts use the four word vocabulary\n`AV-EMS-QUALITY-4` (`measured`, `estimated`, `interpolated`, `missing`).\n\nThe window is one of three forms, in this order of precedence:\n`window_start` and `window_end` together (ISO 8601, naive values are read as\nUTC); `date` plus `days`; or nothing, meaning the `days` whole UTC days that\nended yesterday (a day still in progress would be partial by construction).\nBoth ends are snapped to the UTC half hour and the window may not exceed 31\ndays. The echoed `window_start` and `window_end` are the snapped values.\n\n`settings` is the site's EMS settings row, or an empty object when none\nexists.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 1
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          },
          {
            "name": "corrections",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "applied",
                "stored"
              ],
              "default": "applied"
            },
            "description": "`applied` overlays the site's audited historical corrections (row B7);\n`stored` returns the figures as the readings were stored, for a\nrestatement.\n"
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "html"
              ],
              "default": "json"
            },
            "description": "`html` renders the same figures as a print ready document in the\norganisation's white-label branding (name, colours, logo and support\naddress from its branding row, or the platform default when it has\nnone, stated on the document) with every figure's lineage reference\nprinted (row G3).\n"
          }
        ],
        "responses": {
          "200": {
            "description": "The report as JSON, or as a branded HTML document when `format=html`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsReport"
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected: `window_start` without `window_end` (or the reverse),\na value that is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not\nafter `window_start`, or a window longer than 31 days.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/exports/interval.csv": {
      "get": {
        "operationId": "getEmsIntervalCsv",
        "summary": "Half hourly interval data for one site, as CSV",
        "description": "One row per half hour of the window: `period_start_utc`,\n`period_start_local` (Europe/London), `settlement_date`,\n`settlement_period` (counted from local midnight, so a clock change day\nhas 46 or 50), grid import and export, PV generation, battery charge\nand discharge and site load in kWh, the mean meter kW, and the quality\nword of the meter, PV and battery inputs. Policy `AV-EMS-EXPORT-1`.\n\nBuilt from the same half hourly periods as\n`GET /api/hardware/ems/report`, B7 corrections applied unless\n`corrections=stored`. An unknown figure is an empty cell, never zero,\nand its quality word is `missing`. A site with no PV or no battery has\nthat term zero by construction and the quality column says\n`no_asset`. The coverage statement is in the `X-AmpVerve-Coverage`\nheader (JSON), with `X-AmpVerve-Half-Hours`,\n`X-AmpVerve-Meter-Missing`, `X-AmpVerve-Load-Unknown` and\n`X-AmpVerve-Corrections`. Nothing is priced.\n\nThe window is resolved as for the report, with `days` defaulting to 7\nand at most 31. Needs the `reports` module and read permission on the\nsite. Scoped to the organisation in your bearer token. Roles:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/EmsExportSiteId"
          },
          {
            "$ref": "#/components/parameters/EmsExportWindowStart"
          },
          {
            "$ref": "#/components/parameters/EmsExportWindowEnd"
          },
          {
            "$ref": "#/components/parameters/EmsExportDate"
          },
          {
            "$ref": "#/components/parameters/EmsExportDays"
          },
          {
            "$ref": "#/components/parameters/EmsExportCorrections"
          }
        ],
        "responses": {
          "200": {
            "description": "The CSV, as an attachment.",
            "headers": {
              "X-AmpVerve-Coverage": {
                "description": "The coverage statement as compact JSON, the `EmsExportCoverage` schema.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected, as for the report.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/exports/interval": {
      "get": {
        "operationId": "getEmsIntervalExportInfo",
        "summary": "What the interval CSV would contain, without the rows",
        "description": "The columns, their definitions and the coverage statement of the\ninterval CSV for the same parameters, so a page can state the coverage\nbefore the file is downloaded. Same scope, module, permission and roles\nas `GET /api/hardware/ems/exports/interval.csv`.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/EmsExportSiteId"
          },
          {
            "$ref": "#/components/parameters/EmsExportWindowStart"
          },
          {
            "$ref": "#/components/parameters/EmsExportWindowEnd"
          },
          {
            "$ref": "#/components/parameters/EmsExportDate"
          },
          {
            "$ref": "#/components/parameters/EmsExportDays"
          },
          {
            "$ref": "#/components/parameters/EmsExportCorrections"
          }
        ],
        "responses": {
          "200": {
            "description": "The description of the export.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsIntervalExportInfo"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected, as for the report.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/exports/energy-summary": {
      "get": {
        "operationId": "getEmsEnergySummary",
        "summary": "Monthly energy summary for one site, per local day",
        "description": "One row per Europe/London day of a calendar month: grid import and\nexport, PV generation, site load and battery charge and discharge in\nkWh, the day's highest half hour grid import in kW with its local time,\nand the half hours in the day, without a meter reading and with site\nload unknown. Month totals and the month's peak half hour import.\n\nTotals sum only the half hours where a figure is known; a day with no\nknown half hour has a null total, never zero. Built from the same half\nhourly periods as the interval CSV. Nothing is priced.\n\n`format=json` (default) returns the `EmsEnergySummary` body,\n`format=csv` the per day rows as an attachment with the same coverage\nheaders as the interval CSV, and `format=html` a print ready document\nin the organisation's branding. Without `month`, the last complete\nmonth. A month in the future is refused.\n\nNeeds the `reports` module and read permission on the site. Scoped to\nthe organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "$ref": "#/components/parameters/EmsExportSiteId"
          },
          {
            "name": "month",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "pattern": "^[0-9]{4}-[0-9]{2}$"
            },
            "description": "Calendar month `YYYY-MM`, Europe/London. Default the last complete month."
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "csv",
                "html"
              ],
              "default": "json"
            }
          },
          {
            "$ref": "#/components/parameters/EmsExportCorrections"
          }
        ],
        "responses": {
          "200": {
            "description": "The summary as JSON, CSV or HTML.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsEnergySummary"
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "`month` is not `YYYY-MM`, or is in the future.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/lineage": {
      "get": {
        "operationId": "getEmsFigureLineage",
        "summary": "Lineage tree under one report figure",
        "description": "Expands one figure from `GET /api/hardware/ems/report` into the tree that\nproduced it: the formula as text, one term per half hour naming the readings\n(by id, with timestamp and quality word) that fed it and what it contributed,\nthe factor rows used by the carbon figures, and the figures it depends on.\n\nThe figure is recomputed from the readings at request time, using the site\nand window encoded in `figure_id`. The `figure_id` contains `|` and, in its\ntimestamps, `+`; URL encode it.\n\nEach term's `inputs` is keyed by asset type (`meter`, `solar`, `battery`)\nfor the energy and carbon figures, and by `self_consumed_pv_kwh` and\n`pv_generation_kwh` for the self consumption ratio. A site with no battery\nasset carries a zero battery input with a note saying so; that is a fact\nabout the site, not a gap. Each reading id can be resolved with\n`GET /api/hardware/ems/lineage/reading`.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "figure_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "key|site_id|window_start|window_end, as returned by /ems/report"
          }
        ],
        "responses": {
          "200": {
            "description": "The figure with its full tree.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsLineage"
                }
              }
            }
          },
          "400": {
            "description": "`figure_id` is not of the form `key|site_id|window_start|window_end`, or\nits key is not a report figure, or the decoded window is invalid.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site in `figure_id` is not in your organisation, or the figure key\ncould not be computed for it.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/lineage/reading": {
      "get": {
        "operationId": "getEmsReadingDetail",
        "summary": "One source reading by natural key",
        "description": "Returns one stored telemetry reading, located by asset id and timestamp\nwithin your organisation. Telemetry rows have no primary key; the\n`reading_id` printed in lineage trees is a deterministic hash of the natural\nkey and `id_derivation` states the construction so the row can be found by\neither.\n\nIf `reading_id` is given it must equal the id of the row found, otherwise\nthe reading is reported as not found. An id minted under another\norganisation never matches, by construction.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "asset_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "ts",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "ISO 8601 timestamp of the reading"
          },
          {
            "name": "reading_id",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "if given, must match the row found"
          }
        ],
        "responses": {
          "200": {
            "description": "The reading.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "reading",
                    "id_derivation"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "reading": {
                      "$ref": "#/components/schemas/EmsReading"
                    },
                    "id_derivation": {
                      "type": "string",
                      "description": "How `reading_id` is built. Always `sha256(organisation_id|asset_id|ts_utc_iso)[:32]`."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`ts` is not ISO 8601.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No reading with that asset and timestamp in your organisation, or the\ngiven `reading_id` does not match the row found.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/scope2": {
      "get": {
        "operationId": "getEmsScope2",
        "summary": "Scope 2 on both methods for one site",
        "description": "Location based and market based Scope 2 emissions for one site over one\nwindow, side by side, in total and per day, each naming the factor set,\npublisher and vintage of the rows that produced it.\n\nLocation based: grid import per half hour multiplied by the stored regional\nintensity for the same half hour (`carbon_intensity_series`), for the region\nin the site's `neso_region_id` and the factor set in\n`carbon_factor_set_id` (default `neso-ci-api:regional`). A half hour with\nimport but no stored factor contributes nothing and is counted in\n`basis.factor_unavailable_periods`. When the site has no region configured\nthe figure has status `not_configured` and a null value.\n\nMarket based: grid import multiplied by the contractual instrument recorded\nfor the site's `import_mpan` (see the market factors routes). The instrument\nin force at the window start is used. When none is recorded the figure has\nstatus `not_configured` and a null value; a grid average is never\nsubstituted for a contract that was not recorded.\n\n`daily` is one row per UTC day with the import both methods priced, each\nmethod's kgCO2e (null when that method covered no half hour of the day), how\nmany half hours each covered, and the factor set vintages it used.\n\nThe window is resolved as for `GET /api/hardware/ems/report`, with `days`\ndefaulting to 7. `settings` is the site's EMS settings row, or an empty\nobject when none exists.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 7
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          }
        ],
        "responses": {
          "200": {
            "description": "Both methods, in total and per day.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsScope2"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected: `window_start` without `window_end` (or the reverse),\na value that is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not\nafter `window_start`, or a window longer than 31 days.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/intensity": {
      "get": {
        "operationId": "getEmsIntensity",
        "summary": "Stored half hourly grid intensity for a site's region",
        "description": "The stored regional grid carbon intensity for one site over one window, one row\nper half hour that has a stored factor, selected exactly as the location based\nfigure in `GET /api/hardware/ems/scope2` selects it: the region in the site's\n`neso_region_id`, the factor set in `carbon_factor_set_id` (default\n`neso-ci-api:regional`), and the same version rule.\n\nNothing is interpolated. A half hour with no stored row is absent from `rows`\nand counted in `summary.periods_missing`. A carried forward half hour is\nreturned with `carried_from_period_start` set, and a half hour stored with no\nvalue has a null `intensity_g_per_kwh` and is counted in\n`summary.periods_unavailable`, so a chart can label both.\n\nWhen the site has no region configured, `region` is null, `rows` is empty,\nevery expected half hour is counted as missing, and `unavailable_reason` says\nwhy. When a region is configured but nothing is stored for the window,\n`unavailable_reason` says so. Otherwise it is null.\n\nThe window is resolved as for `GET /api/hardware/ems/report`, with `days`\ndefaulting to 1.\n\nScoped to the organisation in your bearer token, and the caller must hold the\n`read` permission on the site. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR,\nANALYST, VIEWER.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 1
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          }
        ],
        "responses": {
          "200": {
            "description": "The stored series and a summary of its coverage.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsIntensitySeries"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected: `window_start` without `window_end` (or the reverse),\na value that is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not\nafter `window_start`, or a window longer than 31 days.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/avoided": {
      "get": {
        "operationId": "getEmsAvoidedEmissions",
        "summary": "Emissions avoided by PV self consumed and by PV exported",
        "description": "Two figures for one site over one window: emissions avoided by PV used on\nsite, and by PV exported. Each half hour's kWh is valued at the stored\nregional intensity for the same half hour, the same row, factor set and\nvintage the location based Scope 2 figure in\n`GET /api/hardware/ems/scope2` uses. A half hour with no stored factor\ncontributes nothing and is counted as factor unavailable.\n\n**Average regional intensity, not marginal.** The stored series does not\ncarry the marginal unit, so neither figure is a claim about which generator\nbacked off. No REGO or other certificate value is claimed, and nothing is\ndouble counted against Scope 2: self consumed kWh never appear in grid\nimport, and exported kWh are not netted off it. The two figures are\ndisjoint by construction. `method_notes` and each figure's `counterfactual`\nstate this in words.\n\n`total_kgco2e` is the sum of the two, null when neither has a value.\n`daily` is one row per UTC day with the kWh each figure valued, the kgCO2e,\nthe half hours each covered and the factor vintages used. When the site has\nno region configured both figures have status `not_configured`.\n\nThe window is resolved as for `GET /api/hardware/ems/report`, with `days`\ndefaulting to 7. Requires the `ems` module.\n\nScoped to the organisation in your bearer token, and the caller must hold\nthe `read` permission on the site. Roles: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Measured PV energy self consumed and exported, multiplied by the stored grid emission factors for the same half hours.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 7
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          }
        ],
        "responses": {
          "200": {
            "description": "Both figures, their inputs, per day rows and the method notes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsAvoided"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected: `window_start` without `window_end` (or the reverse),\na value that is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not\nafter `window_start`, or a window longer than 31 days.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/factors/versions": {
      "get": {
        "operationId": "listEmsFactorVersions",
        "summary": "Emission factor versions registered by your organisation",
        "description": "Every emission factor version of your organisation, current and\nsuperseded: which factor set and vintage applies to which dates, for the\nlocation based and market based methods. Ordered by method, factor set,\n`valid_from` descending, then `created_at` descending.\n\nA restatement never rewrites a reported figure: it registers a new version\nthat supersedes the old one, and the old row stays, marked `superseded`,\nnaming what replaced it and why (policy `AV-EMS-FAC-1`, stated in\n`policy`). With no registered version, the newest stored retrieval applies\nand the figure says it is unversioned.\n\nRequires the `carbon/factors` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "method",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "enum": [
                "location_based",
                "market_based",
                null
              ]
            },
            "description": "Restrict to one method."
          }
        ],
        "responses": {
          "200": {
            "description": "The versions, with current and superseded counts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "policy_id",
                    "policy",
                    "methods",
                    "count",
                    "current",
                    "superseded",
                    "versions"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "policy_id": {
                      "type": "string",
                      "const": "AV-EMS-FAC-1"
                    },
                    "policy": {
                      "type": "string",
                      "description": "The five rules F1 to F5 in words."
                    },
                    "methods": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "location_based",
                          "market_based"
                        ]
                      }
                    },
                    "count": {
                      "type": "integer"
                    },
                    "current": {
                      "type": "integer"
                    },
                    "superseded": {
                      "type": "integer"
                    },
                    "versions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsFactorVersion"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`method` is not `location_based` or `market_based`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/factors/changes": {
      "get": {
        "operationId": "listEmsFactorChanges",
        "summary": "Emission factor change log",
        "description": "Who registered, restated or superseded which factor version, and every\ncontractual (market based) factor write, with the row before and after.\nNewest first by `changed_at`.\n\nRequires the `carbon/factors` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The change log.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "count",
                    "changes"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "changes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsFactorChange"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/scope2/market-factors": {
      "get": {
        "operationId": "listEmsMarketFactors",
        "summary": "List contractual market factors",
        "description": "Returns every contractual instrument factor recorded for your organisation's\nMPANs, ordered by MPAN and then newest `valid_from` first, together with the\ninstrument types the platform accepts.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The organisation's market factors.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "factors",
                    "instrument_types"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "factors": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsMarketFactorRecord"
                      }
                    },
                    "instrument_types": {
                      "type": "array",
                      "description": "The values `instrument_type` accepts on the POST.",
                      "items": {
                        "type": "string",
                        "enum": [
                          "rego",
                          "supplier_residual_mix",
                          "supplier_specific",
                          "ppa"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "addEmsMarketFactor",
        "summary": "Record a contractual market factor",
        "description": "Records a contractual instrument (REGO backed tariff, supplier residual mix,\nsupplier specific factor or PPA) for one of your organisation's MPANs, for\nuse by the market based Scope 2 method.\n\nThe row id is derived from the organisation, `mpan`, `valid_from`,\n`factor_set_name` and `vintage`; posting the same combination again updates\nthat row in place rather than creating a second one.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsMarketFactorInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The factor as written.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "factor"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "factor": {
                      "type": "object",
                      "description": "The written row. Unlike the list route, `created_at` is not included.",
                      "required": [
                        "id",
                        "organisation_id",
                        "mpan",
                        "instrument_type",
                        "factor_g_per_kwh",
                        "factor_set_name",
                        "publisher",
                        "vintage",
                        "valid_from",
                        "valid_to",
                        "evidence_ref"
                      ],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "organisation_id": {
                          "type": "string"
                        },
                        "mpan": {
                          "type": "string"
                        },
                        "instrument_type": {
                          "type": "string",
                          "enum": [
                            "rego",
                            "supplier_residual_mix",
                            "supplier_specific",
                            "ppa"
                          ]
                        },
                        "factor_g_per_kwh": {
                          "type": "number",
                          "description": "Grams of CO2e per kWh imported."
                        },
                        "factor_set_name": {
                          "type": "string"
                        },
                        "publisher": {
                          "type": "string"
                        },
                        "vintage": {
                          "type": "string"
                        },
                        "valid_from": {
                          "type": "string",
                          "format": "date"
                        },
                        "valid_to": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date"
                        },
                        "evidence_ref": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A required field is missing or empty, `instrument_type` is not one of the\naccepted values, `factor_g_per_kwh` is negative or not numeric, or a date\nis not `YYYY-MM-DD`. `detail` names the problem. The write is rolled back.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/underperformance": {
      "get": {
        "operationId": "getEmsUnderperformance",
        "summary": "PV underperformance for one site",
        "description": "Weather normalised expected PV generation against actual, per day, with\nsustained shortfall episodes and the pound value of the lost generation.\nPolicy `AV-EMS-UPF-1`.\n\nExpected generation per half hour is `pv_kwp x performance_ratio x GHI /\n1000`. `basis` says where GHI came from: `irradiance_series` when the site\nhas stored irradiance rows in the window, otherwise `clear_sky_model`\n(Haurwitz clear sky GHI at the site's coordinates, what a cloudless sky\nwould deliver, so a cloudy day reads as a shortfall). One basis per\nevaluation, never a mixture. Expected is summed only over half hours that\nhave an actual reading, so a communications gap does not manufacture a\nshortfall.\n\nA day is `short` when actual over expected is below the ratio threshold\n(site setting `underperformance_ratio`, default 0.90). A run of at least\n`window_days` consecutive short days (site setting\n`underperformance_window_days`, default 3) is an episode; days inside one\nare flagged `sustained`, a lone short day `single_day`, a short run below\nthe window `below_window`, a day with no usable data `no_data`, and\neverything else `not_flagged`. A day with no reported generation breaks a\nrun rather than extending it.\n\nEach episode's `monetary` value prices the shortfall kWh as import that the\nlost generation would otherwise have offset, at the start of the episode's\nfirst day, on the site's `import_mpan`. It has two shapes: priced, with\n`gbp` and the pricing source; or not priced, with `gbp` null and a `reason`\nstarting `not priced:`. A price is never guessed.\n\nTwo response branches. `status: evaluated` carries the full evaluation.\n`status: not_configured` carries only `reason` and `settings`: no PV\ncapacity is known (set `pv_kwp` in the site settings or on the PV device),\nor there is no irradiance series and no site coordinates for the clear sky\nmodel.\n\nThe window is resolved as for `GET /api/hardware/ems/report`, with `days`\ndefaulting to 14. Set `half_hours=true` to include the half hour detail on\nevery day.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Measured PV output compared with expected output from stored irradiance and the site's performance ratio.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 14
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          },
          {
            "name": "half_hours",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "include the half hour detail per day"
          }
        ],
        "responses": {
          "200": {
            "description": "The evaluation, or the reason it could not run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsUnderperformance"
                }
              }
            }
          },
          "400": {
            "description": "Window rejected: `window_start` without `window_end` (or the reverse),\na value that is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not\nafter `window_start`, or a window longer than 31 days.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/underperformance/attribution": {
      "get": {
        "operationId": "getEmsLossAttribution",
        "summary": "Loss attribution for one site's PV shortfall",
        "description": "Every short half hour's lost kWh from `GET /api/hardware/ems/underperformance`\nclassified into exactly one cause, first match wins, from evidence the\nplatform holds. Policy `AV-EMS-ATT-1`.\n\nClasses: `curtailment` (an export limitation event or an accepted setpoint\ncommand to a PV device covering the half hour, evidence ids on the term),\n`stopped_asset` (the inverter reported and reported nothing while expected\ngeneration was material), `clipping` (the recorded AC rating was exceeded\nby expected and actual sat at the rating; with no rating recorded the class\nis reported as not assessable), and `unexplained` for everything else.\nSoiling, shading and planned outage are reported as `not_assessable` with\nthe reason, and their kWh stay in `unexplained` rather than being\ndistributed by assumption. Half hours with no PV reading are reported as\n`unobserved` and are never added to the shortfall.\n\nSums are per day, per sustained episode and over the window, with the same\nexpected figure, basis and performance ratio as the underperformance\nevaluation so the attribution sums to its episode shortfall. Set\n`half_hours=true` to include the classified half hours per day.\n\nScoped to the organisation in your bearer token and to the sites the\ncaller's site memberships permit. Roles: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "The measured PV shortfall split into causes from the site's own readings; causes the data cannot assess are reported as not assessable.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 14
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          },
          {
            "name": "half_hours",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "include the classified half hours per day"
          }
        ],
        "responses": {
          "200": {
            "description": "The attribution: `organisation_id`, `site`, the window, `policy`, the\nper class totals in kWh with evidence ids, `not_assessable` classes with\nreasons, `unobserved` and `over_expected` counts, and the per day and\nper episode breakdown.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Window rejected: `window_start` without `window_end` (or the reverse),\na value that is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not\nafter `window_start`, or a window longer than 31 days.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/out-of-hours": {
      "get": {
        "operationId": "getEmsOutOfHours",
        "summary": "Out-of-hours consumption and baseload for one site",
        "description": "How much the site used outside its operating hours, its always-on\nbaseload, and which sub-metered circuits ran out of hours. Policy\n`AV-EMS-OOH-1`.\n\nThe operating schedule is given with the request (`open`, `close`,\n`open_days`, all in Europe/London local time). Any part not given takes\nthe stated default, Monday to Friday 08:00 to 18:00, and\n`schedule.source` says `default` or `request`. A half hour is in hours\nwhen its local start falls on an open day at or after `open` and before\n`close`; local time is resolved per half hour, so both sides of a clock\nchange are right. An overnight schedule (close before open) is refused.\n\nSite load per half hour is meter plus PV minus battery, as in the\nenergy report. A site with no PV or no battery asset has that term\nzero by construction. When the site has an asset and that asset has no\nreading in a half hour, the load is unknown there: the half hour is\ncounted in `half_hours_unknown_in` or `half_hours_unknown_out` and left\nout, never read as zero.\n\n`baseload.kw` is the 10th percentile (nearest rank) of the known\nout-of-hours half hour loads. `above_baseload_kwh` is the out-of-hours\nenergy above that level: an indicator of what ran when closed, not a\nsaving. Each sub-meter's metered energy is split into in hours and out\nof hours, ranked by out-of-hours kWh, with its missing half hours\ncounted. Nothing is priced (`priced: false`).\n\nThe window is resolved as for `GET /api/hardware/ems/report`, with\n`days` defaulting to 7. Needs the `ems` module. Scoped to the\norganisation in your bearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "window_start",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 start of the window. Must be given with `window_end`."
          },
          {
            "name": "window_end",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31,
              "default": 7
            },
            "description": "Length of the window in whole days when `window_start` is not given."
          },
          {
            "name": "open",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Opening time `HH:MM`, Europe/London. Default 08:00."
          },
          {
            "name": "close",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Closing time `HH:MM`, Europe/London, after `open`. Default 18:00."
          },
          {
            "name": "open_days",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Open weekdays, comma separated from `mon,tue,wed,thu,fri,sat,sun`. Default `mon,tue,wed,thu,fri`."
          }
        ],
        "responses": {
          "200": {
            "description": "The analysis.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsOutOfHours"
                }
              }
            }
          },
          "400": {
            "description": "Schedule or window rejected: `open` or `close` not `HH:MM`, `close`\nnot after `open`, an unknown weekday, or a window rejected as for\nthe report.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/pv-calibration": {
      "get": {
        "operationId": "getEmsPvCalibration",
        "summary": "Applied PV performance ratio calibration and its history",
        "description": "The performance ratio the underperformance evaluation uses for one site\nand where it came from: a calibration fitted on the site's own readings,\na manual value in the site settings, or the platform default. `history`\nlists every calibration attempt, accepted or refused, with the window, the\nhalf hours used and the reason a fit was refused. `method` states the\nfit: ratio of sums over usable half hours, measured readings only, GHI at\nor above `rules.min_ghi_w_m2`.\n\nScoped to the organisation in your bearer token and to sites the caller\nholds the `read` site permission on. Roles: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Performance ratio fitted to the site's own measured PV output against stored irradiance.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The effective ratio, its source and the calibration history.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "site",
                    "effective_performance_ratio",
                    "effective_source",
                    "calibration",
                    "history",
                    "method",
                    "rules"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site": {
                      "type": "object",
                      "properties": {
                        "site_id": {
                          "type": "string"
                        },
                        "name": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    },
                    "effective_performance_ratio": {
                      "type": "number"
                    },
                    "effective_source": {
                      "type": "string",
                      "description": "Where the effective ratio came from (calibration, manual setting or platform default)."
                    },
                    "calibration": {
                      "type": [
                        "object",
                        "null"
                      ],
                      "description": "The applied calibration row, or null when none is applied."
                    },
                    "history": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "method": {
                      "type": "string"
                    },
                    "rules": {
                      "type": "object",
                      "properties": {
                        "min_half_hours": {
                          "type": "integer"
                        },
                        "min_ghi_w_m2": {
                          "type": "number"
                        },
                        "plausible_ratio": {
                          "type": "array",
                          "items": {
                            "type": "number"
                          }
                        },
                        "default_days": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "operationId": "runEmsPvCalibration",
        "summary": "Fit the PV performance ratio on the site's own readings",
        "description": "Fits the performance ratio over a window of `days` ending at today 00:00\nUTC (or `window_end`), records the attempt, and when the fit is accepted\nwrites the ratio into the site settings so every underperformance\nevaluation from then on uses it and says so. A refused fit (too few usable\nhalf hours, or a ratio outside the plausible range) is recorded with its\nreason and changes nothing. The run is recorded in the audit chain as\n`ems.pv_calibration.run`.\n\nScoped to the organisation in your bearer token and to sites the caller\nholds the `configure` site permission on. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS reporting"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Performance ratio fitted to the site's own measured PV output against stored irradiance.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site_id"
                ],
                "properties": {
                  "site_id": {
                    "type": "string"
                  },
                  "days": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 31,
                    "default": 28
                  },
                  "window_end": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "ISO 8601 end of the fit window, exclusive. Defaults to today 00:00 UTC."
                  },
                  "include_points": {
                    "type": "boolean",
                    "default": false,
                    "description": "Return the per half hour points the fit used."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The recorded calibration, accepted or refused.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "site",
                    "status",
                    "calibration"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site": {
                      "type": "object",
                      "properties": {
                        "site_id": {
                          "type": "string"
                        },
                        "name": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    },
                    "status": {
                      "type": "string",
                      "description": "applied, superseded or refused."
                    },
                    "calibration": {
                      "type": "object"
                    },
                    "points": {
                      "type": [
                        "array",
                        "null"
                      ],
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`site_id` missing, `days` not an integer between 1 and 31, `window_end`\nnot ISO 8601, or no PV capacity known for the site.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/devtools/status": {
      "get": {
        "operationId": "getPublicApiStatus",
        "summary": "Public API status by component (no credential)",
        "description": "Public and read only: no credential, no organisation. For each public API\ncomponent (Partner API and webhooks, authentication, telemetry, sites and\ndevices, optimisation, tariffs and markets, notifications), the state now and\nthe observed availability over `24h`, `7d` and `30d`.\n\nEvery figure is counted from the diagnostics prober's recorded checks of each\nservice's `/healthz` inside the cluster, every `probe_interval_seconds`, and\nfrom nothing else. `state` is `operational` or `down` from the latest check,\nand `unobserved` when there is none newer than three intervals. A window with\nno checks has `observed: false` and a null `availability_pct`, never 100.\n`overall` is `operational`, `degraded` (one component down), `major_outage`\n(more than one), `partially_observed` or `unobserved`. A healthy `/healthz`\nshows a service is answering; it does not prove every operation behind it\nworks. No uptime commitment is stated unless the operator configured one.\n\nIncidents are not public: signed in organisations read them with\n`GET /api/hardware/platform/status`. The answer is cached for 60 seconds.\nRendered at https://app.ampverve.com/api-docs/status.\n",
        "tags": [
          "Platform status"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "security": [],
        "responses": {
          "200": {
            "description": "The status by component.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "example": "public, max-age=60"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "generated_at",
                    "overall",
                    "components",
                    "method",
                    "uptime_commitment",
                    "incidents"
                  ],
                  "properties": {
                    "generated_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "overall": {
                      "type": "string",
                      "enum": [
                        "operational",
                        "degraded",
                        "major_outage",
                        "partially_observed",
                        "unobserved"
                      ]
                    },
                    "components": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "path_prefix": {
                            "type": "string"
                          },
                          "state": {
                            "type": "string",
                            "enum": [
                              "operational",
                              "down",
                              "unobserved"
                            ]
                          },
                          "last_checked_at": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "last_latency_ms": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "observed_since": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "availability": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "object",
                              "properties": {
                                "checks": {
                                  "type": "integer"
                                },
                                "passed": {
                                  "type": "integer"
                                },
                                "availability_pct": {
                                  "type": [
                                    "number",
                                    "null"
                                  ]
                                },
                                "observed": {
                                  "type": "boolean"
                                },
                                "mean_latency_ms": {
                                  "type": [
                                    "number",
                                    "null"
                                  ]
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "method": {
                      "type": "object"
                    },
                    "uptime_commitment": {
                      "type": "object"
                    },
                    "incidents": {
                      "type": "object"
                    },
                    "cache_seconds": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "generated_at": "2026-10-05T14:00:00Z",
                  "overall": "operational",
                  "components": [
                    {
                      "id": "partner-api",
                      "name": "Partner API, API keys and webhooks",
                      "path_prefix": "/api/devtools",
                      "state": "operational",
                      "last_checked_at": "2026-10-05T13:59:31Z",
                      "last_latency_ms": 4.2,
                      "observed_since": "2026-10-05T12:00:00Z",
                      "availability": {
                        "24h": {
                          "checks": 120,
                          "passed": 120,
                          "availability_pct": 100.0,
                          "observed": true,
                          "mean_latency_ms": 4.6
                        },
                        "7d": {
                          "checks": 120,
                          "passed": 120,
                          "availability_pct": 100.0,
                          "observed": true,
                          "mean_latency_ms": 4.6
                        },
                        "30d": {
                          "checks": 120,
                          "passed": 120,
                          "availability_pct": 100.0,
                          "observed": true,
                          "mean_latency_ms": 4.6
                        }
                      }
                    }
                  ],
                  "uptime_commitment": {
                    "target_pct": null,
                    "statement": "No uptime commitment is published. The observed figures are measurements, not a commitment."
                  },
                  "incidents": {
                    "public": false,
                    "statement": "Incident history is shown to signed in organisations at GET /api/hardware/platform/status."
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/platform/status": {
      "get": {
        "operationId": "getPlatformStatus",
        "summary": "Platform status, observed availability and incident history",
        "description": "The state of every service the diagnostics prober observes, from the\nprober's own health check rows and nothing else: the latest probe, the\nobserved availability over 24 hours, 7 days and 30 days (checks made,\nchecks passed, per cent, longest run of failed probes), and when\nobservation began. A window with no checks is reported as not observed\n(`availability_pct` null), never as 100 per cent. `overall` is derived\nfrom the latest probe per service only: `operational`, `degraded` (one\nservice down), `major_outage` (more than one) or `unobserved` (no\nrecent probe). `uptime_commitment` prints the operator's stated target\nwhen one is configured and otherwise says none is recorded.\n\nIncidents visible to the calling organisation: platform wide records\nand the organisation's own, open ones first, with the last 90 days of\nresolved ones. Scoped to the organisation in your bearer token. Roles:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER, RESEARCHER.\n",
        "tags": [
          "Platform status"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "responses": {
          "200": {
            "description": "The status payload.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "generated_at",
                    "overall",
                    "prober",
                    "uptime_commitment",
                    "services",
                    "open_incidents",
                    "recent_incidents"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "generated_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "overall": {
                      "type": "string",
                      "enum": [
                        "operational",
                        "degraded",
                        "major_outage",
                        "unobserved"
                      ]
                    },
                    "prober": {
                      "type": "object",
                      "properties": {
                        "configured": {
                          "type": "boolean"
                        },
                        "interval_seconds": {
                          "type": "integer"
                        },
                        "statement": {
                          "type": "string"
                        }
                      }
                    },
                    "uptime_commitment": {
                      "type": "object",
                      "properties": {
                        "target_pct": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "statement": {
                          "type": "string"
                        }
                      }
                    },
                    "services": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "service": {
                            "type": "string"
                          },
                          "state": {
                            "type": "string",
                            "enum": [
                              "operational",
                              "down",
                              "unobserved"
                            ]
                          },
                          "latest": {
                            "type": [
                              "object",
                              "null"
                            ]
                          },
                          "observed_since": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "windows": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "object",
                              "properties": {
                                "checks": {
                                  "type": "integer"
                                },
                                "passed": {
                                  "type": "integer"
                                },
                                "failed": {
                                  "type": "integer"
                                },
                                "availability_pct": {
                                  "type": [
                                    "number",
                                    "null"
                                  ]
                                },
                                "observed": {
                                  "type": "boolean"
                                },
                                "mean_latency_ms": {
                                  "type": [
                                    "number",
                                    "null"
                                  ]
                                },
                                "longest_failure_run": {
                                  "type": "object"
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "open_incidents": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformIncident"
                      }
                    },
                    "recent_incidents": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformIncident"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/platform/incidents": {
      "get": {
        "operationId": "listPlatformIncidents",
        "summary": "Incident history visible to this organisation",
        "description": "Platform wide incidents and this organisation's own, newest first, each\nwith its timeline of status updates. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST,\nVIEWER, RESEARCHER.\n",
        "tags": [
          "Platform status"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "open_only",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The incidents.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "incidents": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformIncident"
                      }
                    },
                    "severities": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "statuses": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createPlatformIncident",
        "summary": "Open an incident (platform administrators)",
        "description": "Opens an incident record with its first timeline entry. `scope`\n`platform` makes it visible to every organisation; `organisation`\nscopes it to the caller's organisation. Recorded in the audit chain as\n`platform.incident.create`. Roles: PLATFORM_ADMIN (a non platform\ncaller is refused with 403).\n",
        "tags": [
          "Platform status"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title",
                  "severity",
                  "summary"
                ],
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "severity": {
                    "type": "string",
                    "enum": [
                      "minor",
                      "major",
                      "critical"
                    ]
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "investigating",
                      "identified",
                      "monitoring",
                      "resolved"
                    ],
                    "default": "investigating"
                  },
                  "summary": {
                    "type": "string",
                    "description": "What users see and what is known."
                  },
                  "affected_services": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "started_at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time"
                  },
                  "scope": {
                    "type": "string",
                    "enum": [
                      "platform",
                      "organisation"
                    ],
                    "default": "platform"
                  },
                  "postmortem": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The incident as opened.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "incident": {
                      "$ref": "#/components/schemas/PlatformIncident"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A required field is missing or a value is outside its vocabulary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/platform/incidents/{incident_id}/updates": {
      "post": {
        "operationId": "updatePlatformIncident",
        "summary": "Post a status update to an incident (platform administrators)",
        "description": "Appends a timeline entry and moves the incident to `status`; `resolved`\nstamps `resolved_at`. A `postmortem`, when given, replaces the incident's\npost-incident summary (the timeline keeps every message). Recorded in\nthe audit chain as `platform.incident.update`. Roles: PLATFORM_ADMIN.\n",
        "tags": [
          "Platform status"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "incident_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status",
                  "message"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "investigating",
                      "identified",
                      "monitoring",
                      "resolved"
                    ]
                  },
                  "message": {
                    "type": "string"
                  },
                  "postmortem": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The incident after the update.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "incident": {
                      "$ref": "#/components/schemas/PlatformIncident"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A required field is missing or the status is outside its vocabulary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No incident with that id is visible to this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/platform/incidents/export.csv": {
      "get": {
        "operationId": "exportPlatformIncidents",
        "summary": "Incident history as CSV",
        "description": "One row per incident visible to this organisation, with scope, title,\nseverity, status, affected services, start and resolution, duration,\nsummary, post-incident summary and the number of timeline entries.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER, RESEARCHER.\n",
        "tags": [
          "Platform status"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "responses": {
          "200": {
            "description": "The CSV file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/alerts": {
      "get": {
        "operationId": "listEmsAlerts",
        "summary": "Alert queue ranked by pound impact",
        "description": "The organisation's alerts ranked by pound impact per day, with the\nthresholds that ranked them and where those thresholds came from.\n\nOrdering: priced alerts before unpriced, then more pounds per day first,\nthen higher severity, then newest. `impact_gbp_per_day` is the priced pounds\nover the alert window divided by the window length in days; windows shorter\nthan one day count as one day and are not extrapolated. `band_reason` states\nthe kWh, the window, the total, the per day figure and the threshold met, so\nthe arithmetic can be checked by hand. For an unpriced alert it states why\nthe kWh could not be priced.\n\nSeverity bands (policy `AV-EMS-SEV-1`): `critical`, `warning` and `info`\nare the highest threshold the per day figure meets; `low` is priced below\nthe info threshold, listed but not escalated; `unpriced` is listed with the\nreason and ranked below every priced alert, never treated as zero.\n\nThe queue is every open alert row for the organisation, so alerts written by\nother providers (for example the device health poller) appear as well. Those\nrows have no monetary fields; they are returned with `legacy: true`, a fixed\n`band_reason` saying so, and their own operational severity. `alert_type`\nfor EMS alerts is one of the `sources` listed in the response.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS alerts"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Energy at stake from the site's readings, priced at the site's configured tariff; energy in an unobserved gap is estimated from the site's or asset's own mean and the alert says so.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Restrict to one site."
          },
          {
            "name": "include_resolved",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Include resolved alerts. By default only open alerts are returned."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 200
            },
            "description": "Maximum rows fetched from the store before ranking."
          }
        ],
        "responses": {
          "200": {
            "description": "The ranked queue with the policy that ranked it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsAlertQueue"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/alerts/evaluate": {
      "post": {
        "operationId": "evaluateEmsAlerts",
        "summary": "Evaluate telemetry and raise or update alerts",
        "description": "Runs every telemetry derived alert source over the window for every site in\nthe organisation (or one site), prices each finding, bands it, and raises or\nupdates its alert.\n\nSources: `underperformance` (a sustained PV shortfall episode),\n`stopped_asset` (a PV inverter that reported zero through a day generation\nwas expected), `comms_gap` (a run of half hours with no meter reading, or a\ngateway heartbeat loss; the unobserved energy is estimated from the site's\nown mean import), `commissioning_failure` (a failed check from the latest\ncommissioning run; the energy through the affected channel is at stake) and\n`export_limit_breach` (measured export above the site's limit, priced at the\nexport rate). Each source is tried independently; one that raises is\nreported under `errors` for that site and does not stop the others.\n\n`asset_silence` (a run of half hours with no reading from the site's PV or\nbattery; the unobserved energy is estimated from that asset's own mean\npower and priced as import) is evaluated alongside the sources above.\n\nNewly raised alerts (not updated ones) are then routed under the\norganisation's EMS routing rules. The outcome is in `routing`; a routing\nfailure is reported there and does not undo the committed evaluation.\n\nIdempotent per condition: every alert carries an `alert_key`, and\nre-evaluating the same window updates the open alert with that key\n(`action: updated`) rather than raising a second one (`action: raised`).\nA resolved alert is left alone.\n\nThe pricing MPAN is the one the charge library has configured for the site\nwhen the library is available, otherwise the site settings `import_mpan`;\n`mpan_source` on each site says which. Without any MPAN every finding is\nunpriced.\n\nThe body is optional. The window is resolved as for\n`GET /api/hardware/ems/report`, with `days` defaulting to 14. Changes are\ncommitted as one transaction; on failure nothing is written.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alerts"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Energy at stake from the site's readings, priced at the site's configured tariff; energy in an unobserved gap is estimated from the site's or asset's own mean and the alert says so.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/EmsAlertEvaluateInput"
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What was evaluated, raised and updated, per site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsAlertEvaluation"
                }
              }
            }
          },
          "400": {
            "description": "`days` is not an integer or is outside 1 to 31, or the window is\nrejected (`window_start` without `window_end` or the reverse, a value\nthat is not ISO 8601, `date` not `YYYY-MM-DD`, `window_end` not after\n`window_start`, or a window longer than 31 days).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "The evaluation raised outside any single source. `detail` is\n`evaluation failed: <exception class name>`. Nothing was written.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/alerts/policy": {
      "get": {
        "operationId": "getEmsAlertPolicy",
        "summary": "Severity thresholds in force",
        "description": "The pounds per day thresholds in force for your organisation and the\nplatform defaults, with the band rules and the rate rule that turn a priced\nfinding into a severity.\n\n`thresholds.source` is `organisation policy` when the organisation has\nwritten its own row, otherwise `platform default`, in which case\n`thresholds` equals `defaults`. Platform defaults: info 5.00, warning 25.00,\ncritical 100.00 GBP per day.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS alerts"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The policy.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsAlertPolicy"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "putEmsAlertPolicy",
        "summary": "Set severity thresholds",
        "description": "Sets the organisation's thresholds in pounds per day. All three must be\ngiven: each must be a non negative number and they must be ordered\n`info <= warning <= critical`. A partial write is refused, so a missing\nfield is a 400 rather than a default.\n\nThe response is the policy as now in force, with `thresholds.source` equal\nto `organisation policy`. Existing alerts are not re-banded until the next\nevaluation.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alerts"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsAlertPolicyInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The policy after the write.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsAlertPolicy"
                }
              }
            }
          },
          "400": {
            "description": "A threshold is missing, not a number, negative, or the three are not\nordered `info <= warning <= critical`. `detail` names the field. The\nwrite is rolled back.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "operationId": "resetEmsAlertPolicy",
        "summary": "Reset severity thresholds to the platform defaults",
        "description": "Removes the organisation's thresholds row so the platform defaults apply\nagain. `removed` is `false` when there was no row to remove; the call\nsucceeds either way and the response carries the defaults as the policy now\nin force.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alerts"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The policy after the reset.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "required": [
                        "removed"
                      ],
                      "properties": {
                        "removed": {
                          "type": "boolean",
                          "description": "Whether an organisation row existed and was deleted."
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/EmsAlertPolicy"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/routing/rules": {
      "get": {
        "operationId": "listEmsRoutingRules",
        "summary": "Alert routing rules, with whether each rule's channel can deliver",
        "description": "Every routing rule of your organisation, oldest first, each with\n`channel_configured` and `channel_detail` saying whether its channel can\ndeliver at all and why not. The response also carries the routing policy\n(`AV-EMS-RTE-1`), the channel states, the severities and the rule kinds.\n\nA rule matches an alert of its own organisation when the rule is enabled,\nits `kinds` include `alert`, its `site_id` is empty or equals the alert's,\nand the alert's severity ranks at or above `min_severity` (`critical` >\n`warning` > `info` > `low` > `unpriced`). A rule whose channel is not\nconfigured records `not_configured` and sends nothing; it never falls back\nto another channel or address.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The rules and the routing metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/EmsRoutingMeta"
                    },
                    {
                      "type": "object",
                      "required": [
                        "count",
                        "rules"
                      ],
                      "properties": {
                        "count": {
                          "type": "integer"
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/EmsRoutingRuleState"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createEmsRoutingRule",
        "summary": "Create an alert routing rule",
        "description": "Creates one rule. `name` and `channel` are required. An `email` rule needs\na `role` (the organisation's active users holding that role are the\nrecipients) or a `target` address. A `teams_webhook` or `webhook` rule\nneeds an `https://` target URL. Quiet hours are `HH:MM` in UTC and need\nboth ends. `escalate_after_minutes` needs an `escalation_rule_id` naming\nanother rule of the organisation. `sms` is refused: no SMS provider is\nintegrated.\n\nA `webhook` rule is signed only when AmpVerve has provisioned a signing\nsecret for it; ask your AmpVerve contact. Partners cannot supply a secret\nvalue through this API.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsRoutingRuleInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rule as stored, with its channel state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "rule"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "rule": {
                      "$ref": "#/components/schemas/EmsRoutingRuleState"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The rule failed validation. `detail` says which rule it broke.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "the webhook channel needs an https target URL"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/routing/rules/{rule_id}": {
      "put": {
        "operationId": "updateEmsRoutingRule",
        "summary": "Update an alert routing rule",
        "description": "Merges the fields you send over the stored rule and validates the result\nas for create. A rule cannot escalate to itself. The response is the rule\nafter the update, without the channel state fields.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "rule_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EmsRoutingRuleInput"
                  }
                ],
                "description": "Any subset of the rule fields."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rule after the update.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "rule"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "rule": {
                      "$ref": "#/components/schemas/EmsRoutingRule"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The merged rule failed validation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No rule with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "no rule with that id in your organisation"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "operationId": "deleteEmsRoutingRule",
        "summary": "Delete an alert routing rule",
        "description": "Removes the rule.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "rule_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "removed"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "removed": {
                      "type": "string",
                      "description": "The id of the rule removed."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No rule with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/routing/channels": {
      "get": {
        "operationId": "getEmsRoutingChannels",
        "summary": "Which delivery channels can deliver",
        "description": "The channel states and routing metadata on their own. `configured` is a\nboolean for `email` (whether an email transport is set up on the service)\nand for `sms` (always false), and null for the two webhook channels, whose\nstate depends on each rule's target.\n\nRequires the `ems/routing` module. Roles: PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The routing metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsRoutingMeta"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/routing/dispatch": {
      "post": {
        "operationId": "dispatchEmsAlerts",
        "summary": "Route one alert, or every open alert, under the matching rules now",
        "description": "With `alert_id`, routes that alert under every matching rule. Without it,\nroutes every open alert of the organisation (optionally one site's).\nEach rule delivers an alert once: a rule that already sent it, or\nsuppressed it in quiet hours, is skipped and counted in\n`already_delivered`, unless `force` is true (honoured with `alert_id`\nonly).\n\nQuiet hours suppress `info`, `low` and `unpriced` deliveries and record\nthe suppression; `critical` and `warning` bypass them. Every attempt is\nrecorded in the delivery log with its outcome; a delivery is never\nclaimed that the transport did not confirm.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "alert_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Route this alert only."
                  },
                  "force": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "With `alert_id`",
                    "deliver again under rules that already sent.": null
                  },
                  "site_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Without `alert_id`",
                    "route only this site's open alerts.": null
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "With `alert_id`: one `EmsRoutingAlertResult`. Without: the batch\nresult, where `deliveries` is a count and `results` holds one\n`EmsRoutingAlertResult` per alert routed.\n",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "required": [
                        "organisation_id"
                      ],
                      "properties": {
                        "organisation_id": {
                          "type": "string"
                        }
                      }
                    },
                    {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/EmsRoutingAlertResult"
                        },
                        {
                          "type": "object",
                          "required": [
                            "routed",
                            "deliveries",
                            "by_status",
                            "results"
                          ],
                          "properties": {
                            "routed": {
                              "type": "integer"
                            },
                            "deliveries": {
                              "type": "integer",
                              "description": "Delivery rows written across every alert."
                            },
                            "by_status": {
                              "type": "object",
                              "additionalProperties": {
                                "type": "integer"
                              }
                            },
                            "results": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/EmsRoutingAlertResult"
                              }
                            }
                          }
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No alert with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "no alert with that id in your organisation"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "Dispatch raised; nothing was committed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "dispatch failed"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/routing/escalations/check": {
      "post": {
        "operationId": "checkEmsAlertEscalations",
        "summary": "Escalate alerts still unacknowledged after a rule's window",
        "description": "For every rule with `escalate_after_minutes`, finds deliveries sent at\nleast that long ago whose alert is still open and unacknowledged, and\ndelivers the alert once more under the rule's `escalation_rule_id`, with\nkind `escalation`. A delivery is escalated at most once, and two\ndeliveries of the same alert under one rule escalate once. Nothing calls\nthis on a schedule today; call it yourself.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "What was checked and escalated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "checked",
                    "escalated",
                    "deliveries",
                    "by_status",
                    "policy_id"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "checked": {
                      "type": "integer",
                      "description": "Deliveries examined."
                    },
                    "escalated": {
                      "type": "integer",
                      "description": "Escalation delivery rows written."
                    },
                    "deliveries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsRoutingDelivery"
                      }
                    },
                    "by_status": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      }
                    },
                    "policy_id": {
                      "type": "string",
                      "const": "AV-EMS-RTE-1"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "description": "The check raised; nothing was committed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "escalation check failed"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/routing/deliveries": {
      "get": {
        "operationId": "listEmsRoutingDeliveries",
        "summary": "Delivery log",
        "description": "Every delivery attempt of your organisation, newest first by\n`attempted_at`, optionally for one alert, with a count by status.\n\nRequires the `ems/routing` module. Scoped to the organisation in your\nbearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS alert routing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "alert_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The delivery log.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "organisation_id",
                    "count",
                    "by_status",
                    "deliveries"
                  ],
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "by_status": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      }
                    },
                    "deliveries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsRoutingDelivery"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/trading/cost/library": {
      "get": {
        "operationId": "getChargeLibrary",
        "summary": "Read the GB charge library",
        "description": "The GB non-commodity charge library as your organisation may see it:\nevery published reference row plus your organisation's own rows. Another\ntenant's rows are never included.\n\n`rates` holds the effective dated rate rows and `time_bands` the DUoS\nred, amber and green windows. `summary` counts rate rows by status so a\nscreen can say how much of the matrix is confirmed rather than letting\nthe shape of it imply completeness. A row whose `value` is null means\nthe component applies and its rate is to be confirmed from the named\npublication. It never means zero.\n\nRows are append only. A republication adds a row under a new version\nand never edits or deletes the old one, which is what keeps a price\ncomputed against an old row reproducible. Precedence when several rows\nare in force: your organisation's own row beats a published row, then\nthe latest effective date, then the latest version tag.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "component",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Restrict `rates` to one component code, for example `CCL`."
          },
          {
            "name": "dno_region_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 10,
              "maximum": 23
            },
            "description": "Restrict `rates` and `time_bands` to one GB distribution licence area.\nThe id is the MPAN distributor id, 10 to 23.\n"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "The library, scoped to your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChargeLibrary"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable in this process, or tenant verification\nis not available. The route refuses to guess an organisation or serve\na default rate.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/cost/library/rates": {
      "post": {
        "operationId": "republishChargeRate",
        "summary": "Append one rate row",
        "description": "Append one effective dated rate row. This is the republication path: when\na DNO, NESO, HMRC or Ofgem republishes a charge, add the new rate with\nthe new effective date and a new `version` tag. The old rows stay\nexactly as they were.\n\n`scope` selects who owns the row. `own` (the default) writes a row\nscoped to your organisation, such as a contracted supply rate. `published`\nwrites a public reference row and needs a platform token.\n\nValidation applied by the handler, each returning 400: `component` must be\none of the library's component codes (see `components` on\n`GET /api/trading/cost/library`); a `to_be_confirmed` row must not carry a\n`value`; any other status must carry one; a `synthetic` row must be tenant\nscoped; `scope` must be `own` or `published`.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Impersonation is read only. Supplying it on a POST is rejected with 403."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChargeRateRowIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The row as written, including its deterministic `id`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChargeRateRow"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, `scope` not `own` or\n`published`, or the row failed one of the value and status rules listed\nin the description. `detail` says which.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`scope` is `published` and the token is not a platform token\n(`published rows need a platform token`), an `X-Tenant-ID` header\ndisagrees with the token, or impersonation was attempted on a write.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "A row with this natural key already exists under this `version`.\nRepublish under a new version rather than overwriting.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable, or tenant verification is not available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/cost/library/time-bands": {
      "post": {
        "operationId": "addDuosTimeBands",
        "summary": "Append DUoS time band rows",
        "description": "Append a batch of DUoS time band rows (the red, amber and green windows\nfor one licence area and tariff). Rows are append only: a row whose\nnatural key and version already exist is skipped, not updated, and\n`written` counts only the rows actually inserted.\n\n`scope=published` writes public reference rows and needs a platform token.\nAny other value, including the default `own`, writes rows scoped to your\norganisation.\n\nTwo rules are checked before anything is written, each returning 400: a\n`synthetic` row must be tenant scoped, and a row whose status is not\n`to_be_confirmed` must carry both `start_local` and `end_local`.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "own"
            },
            "description": "`own` or `published`. `published` needs a platform token."
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Impersonation is read only. Supplying it on a POST is rejected with 403."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ChargeTimeBandIn"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Number of rows inserted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "written": {
                      "type": "integer",
                      "description": "Rows inserted. Rows whose key and version already existed are skipped and not counted."
                    }
                  },
                  "required": [
                    "written"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, a `synthetic` row sent with\n`scope=published`, or a row that is not `to_be_confirmed` without\n`start_local` and `end_local`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`scope=published` without a platform token, an `X-Tenant-ID` header that\ndisagrees with the token, or impersonation attempted on a write.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable, or tenant verification is not available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/cost/mpans": {
      "get": {
        "operationId": "listMpanConfigs",
        "summary": "List MPAN charge configurations",
        "description": "Every MPAN charge configuration row belonging to your organisation,\nordered by MPAN then latest `effective_from` first. Rows are effective\ndated and append only, so one MPAN can have several rows; the one in\nforce on a date is the latest `effective_from` at or before it whose\n`effective_to` is null or later.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "Your organisation's MPAN configurations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "mpans": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/MpanConfig"
                      }
                    }
                  },
                  "required": [
                    "organisation_id",
                    "mpans"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable, or tenant verification is not available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createMpanConfig",
        "summary": "Add an MPAN charge configuration",
        "description": "Insert a new effective dated configuration row for an MPAN. Never updates\nan existing row: to change a configuration, add a row with a later\n`effective_from`.\n\nValidation applied by the handler, each returning 400: `mpan` must be the\n13 digit core MPAN; `dno_region_id` must be one of the 14 GB distribution\nareas (10 to 23); the first two digits of the MPAN (the distributor id)\nmust equal `dno_region_id`.\n\nWhen `tnuos_zone` is omitted the zone of the licence area is used.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route; use the `workspace_id` body field."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Impersonation is read only. Supplying it on a POST is rejected with 403."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MpanConfigIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The configuration row as written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MpanConfig"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or the MPAN, region or\ndistributor id check failed. `detail` says which.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable, or tenant verification is not available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/cost/price": {
      "post": {
        "operationId": "priceEnergy",
        "summary": "Price a batch of half hours at an MPAN",
        "description": "The pricing engine. Prices each item (a half hour beginning at\n`period_start`, `kwh` in `direction`) at `mpan` against the charge\nlibrary, and returns one result per item in request order.\n\nThe contract every consumer must honour:\n\n* Every result carries `priced`. It is true only when every applicable\n  component of the stack had a rate. `gbp` is a price only when `priced`\n  is true. When it is false, `gbp` is the sum of the lines that could be\n  priced, a lower bound and not a price, and `partially_priced` is true if\n  at least one line was priced.\n* Nothing is ever defaulted to zero. A component without a rate appears in\n  `lines` with `rate` and `gbp` null, `status` `to_be_confirmed` and a\n  `reason` naming the publication it must be confirmed from, and is\n  repeated in `unpriced`. A component that does not apply (CCL on an\n  exempt MPAN, the Capacity Market charge outside its window) appears with\n  `status` `not_applicable`, `rate` 0 and `gbp` 0, with the rule in\n  `reason`.\n* If no configuration is in force for the MPAN on that date, the result has\n  `priced` false, `gbp` null, empty `lines` and a top level `reason`.\n* `totals.gbp` is null unless at least one result has a `gbp`, and\n  `totals.priced` is true only when every result is fully priced.\n\nFor `import` the stack is the contracted supply unit rate, the DUoS unit\nrate for the band the half hour falls in (Europe/London local time), CCL,\nthe indicative Renewables Obligation cost, BSUoS, AAHEDC and the Capacity\nMarket supplier charge (weekdays 16:00 to 19:00, November to February).\nFor `export` it is the contracted export rate only. Fixed and capacity\ncharges are per day and are reported by `GET /api/trading/cost/stack`.\n\n`tariff_version` identifies the library release, the configuration row and\na hash of every rate row used, so a price can be reproduced later.\n\nOnly published rows and your organisation's own rows are considered, and\nthe MPAN configuration is looked up under your organisation.\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Impersonation is read only. Supplying it on a POST is rejected with 403."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PriceRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One result per item, each with its own `priced` flag.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PriceResponse"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, an item `direction` that is\nnot `import` or `export`, or a `period_start` that is not an ISO 8601\ntimestamp.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable, or tenant verification is not available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/cost/stack": {
      "get": {
        "operationId": "getChargeStack",
        "summary": "Full charge stack for an MPAN at an instant",
        "description": "The full stack for one MPAN at one instant: 1 kWh priced for import and\nfor export, plus the per day fixed and capacity charges. The two per kWh\nresults follow the same contract as `POST /api/trading/cost/price`, so\nread `priced` before reading `gbp`.\n\n`per_day` covers the DUoS fixed charge (by TCR band), the DUoS capacity\ncharge (multiplied by the agreed supply capacity in kVA) and the TNUoS\ndemand residual. It returns the TCR band implied by the agreed capacity\nalongside the band assigned on the configuration, so a mismatch is\nvisible. `band_used` is the assigned band when there is one, else the\nimplied band. With no agreed supply capacity on the configuration the\ncapacity line is unpriced and says so.\n\n`per_day` has two shapes. When a configuration is in force it carries\n`config`, the bands, `gbp_per_day`, `priced`, `lines`, `unpriced` and\n`tariff_version`. When none is in force it carries `config` null, an\nempty `lines` array and a `reason`, and the other fields are absent.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Charges and pricing"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "mpan",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 13,
              "maxLength": 13
            },
            "description": "The 13 digit core MPAN."
          },
          {
            "name": "at",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "ISO8601 instant, default now"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "The stack at `at`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StackResponse"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or `at` is not an ISO 8601 timestamp.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The charge store is unreachable, or tenant verification is not available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs": {
      "get": {
        "operationId": "listRetailTariffs",
        "summary": "List the organisation's retail tariffs (latest version of each)",
        "description": "The highest version of every retail tariff definition your organisation has\nstored. Versions are append only: a correction is a new version and earlier\nversions stay readable. Requires the `cost/retail-tariffs` module.\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The latest version of each tariff, ordered by tariff id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "503": {
            "description": "The tariff store is unreachable or not yet migrated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createRetailTariff",
        "summary": "Store a new retail tariff as version 1",
        "description": "Validates and stores a tariff definition. Every banded schedule (import,\nexport, DUoS) must cover every half hour of every local day (Europe/London)\nin the validity exactly once, including the 46 and 50 half hour clock change\ndays. Nothing is defaulted: a missing rate is refused naming the field.\nRates are pence, exclusive of VAT; VAT rates are given per customer class.\nNeeds one of the roles ORG_ADMIN, OPERATOR, PLATFORM_ADMIN or PLATFORM_OWNER.\nThe write is appended to the organisation's audit hash chain.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RetailTariffWrite"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Stored as version 1.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffVersion"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "The tariff id already exists in your organisation; add a version instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "The definition is invalid; `detail.errors` names each field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffFieldErrors"
                }
              }
            }
          },
          "503": {
            "description": "The tariff store is unreachable or not yet migrated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs/validate": {
      "post": {
        "operationId": "validateRetailTariff",
        "summary": "Validate a retail tariff definition without storing it",
        "description": "Runs the same validation as a create and returns `valid` with every field\nerror. Nothing is stored and nothing is audited.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RetailTariffWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The validation result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "valid": {
                      "type": "boolean"
                    },
                    "errors": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RetailTariffFieldError"
                      }
                    },
                    "definition": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/trading/retail-tariffs/{tariff_id}": {
      "get": {
        "operationId": "getRetailTariff",
        "summary": "Every version of one retail tariff",
        "description": "Every stored version of one of your organisation's tariffs, oldest first,\nand the current (highest) version. A tariff id that exists only in another\norganisation is a 404, the same answer as one that does not exist.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "tariff_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The versions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "tariff_id": {
                      "type": "string"
                    },
                    "versions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RetailTariffVersion"
                      }
                    },
                    "current": {
                      "$ref": "#/components/schemas/RetailTariffVersion"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Your organisation has no tariff with this id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs/{tariff_id}/versions": {
      "post": {
        "operationId": "addRetailTariffVersion",
        "summary": "Store the next version of a retail tariff",
        "description": "Validates and stores the definition as the next version. The definition's\n`tariff_id` must equal the path. Same roles and audit as a create.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "tariff_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RetailTariffWrite"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Stored as the next version.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffVersion"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Your organisation has no tariff with this id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Another write to this tariff landed first; read it and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "The definition is invalid, or its tariff_id does not match the path.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffFieldErrors"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs/bill": {
      "post": {
        "operationId": "calculateRetailBill",
        "summary": "Line item bill for a stored tariff over a period",
        "description": "Prices half hourly import and export over [period_start, period_end) local\ndays under one stored tariff (the latest version unless `version` is given).\nConsumption comes from exactly one of `site_id` (the site's stored half\nhourly meter readings), `usage_csv` (CSV text: start, import_kwh, optional\nexport_kwh) or `usage` (inline half hours). A half hour of the period with no\nreading is refused with the gap named; nothing is filled.\n\nLines: standing charge, energy per band and rate, DUoS pass-through, capacity,\nexcess capacity per calendar month, levies, VAT on the sum of the rounded VAT\nbearing lines, export credit outside VAT. Each line is rounded once, half up,\nto 0.01p (`pence_2dp`) or to the whole penny (`pounds_2dp`); the total is the\nsum of the lines. `format=csv` returns the same lines as CSV. The result is\ncalculated from the supplied data and is not a supplier invoice.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "csv"
              ],
              "default": "json"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RetailBillRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The bill, as JSON or CSV.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailBill"
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such tariff or version, or the site has no boundary meter, in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The site's stored telemetry is finer than half hourly and is refused rather than summed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "An input is invalid or the period has a gap; `detail.errors` names each field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffFieldErrors"
                }
              }
            }
          },
          "503": {
            "description": "The tariff store is unreachable or not yet migrated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs/compare": {
      "post": {
        "operationId": "compareRetailTariffs",
        "summary": "The same consumption under several stored tariffs, ranked",
        "description": "Bills the same half hours under two to twenty stored tariffs and ranks them by\ntotal, cheapest first (ties by tariff id). Each difference from the cheapest\nis broken down by line category and the categories sum exactly to the\ndifference in totals. A tariff that cannot bill the period is listed under\n`excluded` with its field errors, never priced as zero.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RetailCompareRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The ranked comparison.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ranked": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    },
                    "excluded": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    },
                    "usage_source": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "A named tariff does not exist in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "An input is invalid; `detail.errors` names each field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffFieldErrors"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs/smart-bands": {
      "post": {
        "operationId": "designSmartBands",
        "summary": "Design time bands and rates from a cost curve",
        "description": "Given 48 costs in p/kWh (one per local clock slot from 00:00) or a half\nhourly cost series, optional kWh weights, a target number of bands and a\nminimum window length, returns contiguous bands (a band may have several\nwindows, each at least the minimum length) and rates that recover the target\nmargin (p/kWh or percent of cost), with the fit against the cost curve.\nDeterministic. The result is calculated from your inputs; nothing is stored.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Deterministic band design over the cost curve you supply, at the margin you state. Nothing is stored.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "target_bands",
                  "min_band_half_hours"
                ],
                "properties": {
                  "cost_curve": {
                    "type": "array",
                    "minItems": 48,
                    "maxItems": 48,
                    "items": {
                      "type": [
                        "string",
                        "number"
                      ]
                    }
                  },
                  "cost_series": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "start_utc": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "p_per_kwh": {
                          "type": [
                            "string",
                            "number"
                          ]
                        },
                        "kwh": {
                          "type": [
                            "string",
                            "number",
                            "null"
                          ]
                        }
                      }
                    }
                  },
                  "weights_kwh": {
                    "type": "array",
                    "minItems": 48,
                    "maxItems": 48,
                    "items": {
                      "type": [
                        "string",
                        "number"
                      ]
                    }
                  },
                  "target_bands": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 12
                  },
                  "min_band_half_hours": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 48
                  },
                  "margin_p_per_kwh": {
                    "type": [
                      "string",
                      "number",
                      "null"
                    ]
                  },
                  "margin_pct": {
                    "type": [
                      "string",
                      "number",
                      "null"
                    ]
                  },
                  "band_names": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The band design, its achieved margin and its fit.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "An input is invalid; `detail.errors` names each field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffFieldErrors"
                }
              }
            }
          }
        }
      }
    },
    "/api/trading/retail-tariffs/cost-plus": {
      "post": {
        "operationId": "costPlusRetailRates",
        "summary": "Unit rates per band from cost components",
        "description": "Unit rate per band = the sum of the p/kWh components for the band (a\nwholesale component may carry a loss factor) plus a margin in p/kWh or\npercent of cost; standing charge = the sum of the p/day components, passed\nthrough without margin. All four kinds (wholesale, network, policy,\noperating) are required; one that does not apply is supplied as an explicit\n0 with its source. Derived; nothing is stored.\n",
        "tags": [
          "Retail tariffs"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Unit rate per band from the cost components you supply plus your margin.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "bands",
                  "components"
                ],
                "properties": {
                  "bands": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "components": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "required": [
                        "name",
                        "kind",
                        "unit",
                        "source"
                      ],
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "kind": {
                          "type": "string",
                          "enum": [
                            "wholesale",
                            "network",
                            "policy",
                            "operating"
                          ]
                        },
                        "unit": {
                          "type": "string",
                          "enum": [
                            "p/kWh",
                            "p/day"
                          ]
                        },
                        "source": {
                          "type": "string"
                        },
                        "flat": {
                          "type": [
                            "string",
                            "number",
                            "null"
                          ]
                        },
                        "per_band": {
                          "type": [
                            "object",
                            "null"
                          ],
                          "additionalProperties": {
                            "type": [
                              "string",
                              "number"
                            ]
                          }
                        },
                        "losses_pct": {
                          "type": [
                            "string",
                            "number",
                            "null"
                          ]
                        }
                      }
                    }
                  },
                  "margin_p_per_kwh": {
                    "type": [
                      "string",
                      "number",
                      "null"
                    ]
                  },
                  "margin_pct": {
                    "type": [
                      "string",
                      "number",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The derived rates with each component line and its source.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "An input is invalid; `detail.errors` names each field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetailTariffFieldErrors"
                }
              }
            }
          }
        }
      }
    },
    "/api/organisations/export/manifest": {
      "get": {
        "operationId": "getExportManifest",
        "summary": "Preview what an export would contain",
        "description": "What an export would contain, with this organisation's current row\ncounts. A read only preflight: it creates nothing. It exists so the\nanswer to \"what will I get\" is available before anyone commits to\ndownloading it.\n\nThe public paths are `/api/organisations/export/...`. There is no ingress\nrule for `/api/organisations`; the console's rewrite forwards\n`/api/organisations/<path>` to the organisations service as\n`/organisations/<path>`, where the export router is mounted. Until\n2026-10-03 this document gave `/api/organisations/organisations/export/...`,\nwhich no route serves.\n\nThe response is the export schema itself: every collection with its\ncolumns, types and meanings, the redacted columns with their reasons,\nthe categories that are not included and every tenant table that is not\nexported, each with its reason. `settlement_interval_quality` tallies the\nBSC quality flag on settlement intervals (`A` metered, `E` estimated,\n`unknown` when the ledger predates the flag) and nests a\n`telemetry_quality` tally, so the proportion of estimated data is visible\nbefore a single line is parsed. `notes` are statements a reader needs in\norder not to over read the export.\n\nA deployment without the time series store reports zero for\n`telemetry_readings` rather than failing.\n\nFull schema and conventions: `docs/api/export-schema.md`.\n\nRequires role `ORG_ADMIN` or `PLATFORM_ADMIN` (the legacy `TENANT_ADMIN`\nalias is accepted). Scoped to the organisation in your bearer token.\n",
        "tags": [
          "Data export"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "The manifest with current row counts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportManifest"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token lacks `ORG_ADMIN` or `PLATFORM_ADMIN` (`Insufficient\npermissions`), the organisation is not active (suspended, cancelled,\ndeleted or past due), or an `X-Tenant-ID` header disagrees with the token.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/organisations/export": {
      "post": {
        "operationId": "createExport",
        "summary": "Create and execute an export job",
        "description": "Request and execute an export, in one call, on the caller's own\ncredentials. No ticket and no operator: the job is created, counted and\nmade downloadable inside this request, and the response carries the job,\nits row counts per collection and its download links. There is no\nrequest body.\n\nThe job records an `as_of` timestamp. Every download bounds its rows to\nthose created at or before it, so a download taken later returns the\nsame set of rows. `as_of` does not freeze a row's contents: a row edited\nafter `as_of` exports with its current values. This is not a\ntransactionally consistent snapshot.\n\nThe `downloads` paths are service relative and omit the `/api` prefix;\nprefix them with `/api` when calling through the public gateway.\n\nCreating a job writes `data.export` to the hash chained audit log in the\nsame transaction as the job row. Downloads are audited separately.\n\nFull schema and conventions: `docs/api/export-schema.md`.\n\nRequires role `ORG_ADMIN` or `PLATFORM_ADMIN` (the legacy `TENANT_ADMIN`\nalias is accepted). Scoped to the organisation in your bearer token.\n",
        "tags": [
          "Data export"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Impersonation is read only. Supplying it on a POST is rejected with 403."
          }
        ],
        "responses": {
          "201": {
            "description": "The job, ready to download.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportJobCreated"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token lacks `ORG_ADMIN` or `PLATFORM_ADMIN` (`Insufficient\npermissions`), the organisation is not active, an `X-Tenant-ID` header\ndisagrees with the token, or impersonation was attempted on a write.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/organisations/export/jobs/{job_id}": {
      "get": {
        "operationId": "getExportJob",
        "summary": "Re-read an export job",
        "description": "Re-read a job created earlier, so its download links survive a page\nreload. Same shape as the create response without the `collections`\nlist. Counts and quality tallies are the ones recorded when the job was\ncreated.\n\nA job belonging to another organisation returns 404, not 403, so job ids\nare not confirmable across tenants.\n\nFull schema and conventions: `docs/api/export-schema.md`.\n\nRequires role `ORG_ADMIN` or `PLATFORM_ADMIN` (the legacy `TENANT_ADMIN`\nalias is accepted). Scoped to the organisation in your bearer token.\n",
        "tags": [
          "Data export"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "The job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportJob"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token lacks `ORG_ADMIN` or `PLATFORM_ADMIN` (`Insufficient\npermissions`), the organisation is not active, or an `X-Tenant-ID`\nheader disagrees with the token.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No job with this id belongs to your organisation (`Export job not found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/organisations/export/jobs/{job_id}/download": {
      "get": {
        "operationId": "downloadExportBundle",
        "summary": "Download the whole export as JSON Lines",
        "description": "Everything, as one JSON Lines stream (`application/x-ndjson`, UTF-8, one\nJSON object per line). Rows are streamed from the live tables under the\njob's `as_of` bound, not from a stored copy.\n\nThe first line is the manifest, tagged `\"_collection\": \"_export_manifest\"`.\nIt carries the schema version, the job summary, the row counts, the\nredactions and the list of what is not included, so whoever ends up\nholding the file knows what is missing from it without this document\nalongside. Every subsequent line carries `\"_collection\"` naming the\ncollection the record belongs to, followed by that collection's columns\nin schema order. Collections are emitted in the order the manifest\nlists them.\n\nCSV is not offered for the bundle. One CSV file cannot hold records of\nforty different shapes without either inventing a union of every column\nor silently dropping fields. Per collection CSV is offered instead.\n\nThe response is sent as an attachment. `Content-Disposition` is\n`attachment; filename=\"ampverve-export-{organisation_id}-{job_id}.jsonl\"`,\nwhere any character outside `A-Z a-z 0-9 . _ -` in the organisation id or\njob id is replaced with an underscore. `X-AmpVerve-Export-Schema` carries\nthe schema version and `X-AmpVerve-Export-Job` the job id.\n\nEach download writes `data.export.download` to the audit log before the\nfirst byte is streamed, so a client that disconnects mid download still\nleaves a record that the transfer began.\n\nFull schema and conventions: `docs/api/export-schema.md`.\n\nRequires role `ORG_ADMIN` or `PLATFORM_ADMIN` (the legacy `TENANT_ADMIN`\nalias is accepted). Scoped to the organisation in your bearer token.\n",
        "tags": [
          "Data export"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "The bundle stream. The first line is the manifest object, every later\nline is one record with a `_collection` key.\n",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "`attachment; filename=\"ampverve-export-{organisation_id}-{job_id}.jsonl\"`, unsafe characters replaced with `_`."
              },
              "X-AmpVerve-Export-Schema": {
                "schema": {
                  "type": "string"
                },
                "description": "The export schema version the file was produced under, for example `ampverve-export-2`."
              },
              "X-AmpVerve-Export-Job": {
                "schema": {
                  "type": "string"
                },
                "description": "The job id."
              }
            },
            "content": {
              "application/x-ndjson": {
                "schema": {
                  "type": "string",
                  "description": "Newline separated JSON objects. Line 1 is an `ExportBundleManifestLine`;\nevery later line is a record whose `_collection` names its\ncollection and whose remaining keys are that collection's columns.\n"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token lacks `ORG_ADMIN` or `PLATFORM_ADMIN` (`Insufficient\npermissions`), the organisation is not active, or an `X-Tenant-ID`\nheader disagrees with the token.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No job with this id belongs to your organisation (`Export job not found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/organisations/export/jobs/{job_id}/collections/{collection}": {
      "get": {
        "operationId": "downloadExportCollection",
        "summary": "Download one collection as JSON Lines or CSV",
        "description": "One collection, as JSON Lines or CSV, streamed from the live tables under\nthe job's `as_of` bound.\n\nA single collection download contains only that collection's records,\nwith no manifest line and no `_collection` key, so it can be piped\nstraight into a loader. JSON Lines is `application/x-ndjson`, one object\nper line, keys in schema column order. CSV is `text/csv; charset=utf-8`,\nRFC 4180: comma separated, CRLF line endings, a header row of column\nnames, minimal quoting. In CSV a null is an empty field, booleans are\n`true` or `false`, timestamps are RFC 3339 in UTC, and JSON values are\nthe compact JSON text inside one quoted cell.\n\n`format` is checked first (400 if not `jsonl` or `csv`), then the\ncollection name (404 if unknown), then the job (404 if not yours).\n\nThe response is sent as an attachment. `Content-Disposition` is\n`attachment; filename=\"ampverve-export-{organisation_id}-{collection}-{job_id}.{format}\"`,\nwhere any character outside `A-Z a-z 0-9 . _ -` is replaced with an\nunderscore. `X-AmpVerve-Export-Schema`, `X-AmpVerve-Export-Job` and\n`X-AmpVerve-Export-Collection` identify what was sent.\n\nEach download writes `data.export.download` to the audit log, with the\ncollection and format, before the first byte is streamed.\n\nFull schema and conventions: `docs/api/export-schema.md`.\n\nRequires role `ORG_ADMIN` or `PLATFORM_ADMIN` (the legacy `TENANT_ADMIN`\nalias is accepted). Scoped to the organisation in your bearer token.\n",
        "tags": [
          "Data export"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "collection",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "organisations",
                "workspaces",
                "organisation_features",
                "i18n_profiles",
                "users",
                "user_preferences",
                "devices",
                "chargers",
                "ev_assets",
                "battery_packs",
                "battery_health_snapshots",
                "vehicle_connections",
                "vehicle_trips",
                "vehicle_location_logs",
                "learned_places",
                "travel_patterns",
                "runs",
                "scenarios",
                "results",
                "execution_runs",
                "arbitrage_strategies",
                "arbitrage_runs",
                "fleet_kpi_snapshots",
                "grid_service_programs",
                "grid_service_participations",
                "grid_dispatch_signals",
                "dispatch_events",
                "dispatch_replays",
                "settlements",
                "settlement_intervals",
                "telemetry_readings",
                "portfolio_positions",
                "pnl_entries",
                "billing_plans",
                "invoices",
                "usage_records",
                "trust_receipts",
                "trust_receipt_proofs",
                "waitlist_signups",
                "account_deletion_requests",
                "admin_alerts",
                "audit_logs"
              ]
            },
            "description": "A collection name from the manifest. The list above is the registry at\nschema version `ampverve-export-2`; read `GET /api/organisations/export/manifest`\nfor the current one.\n"
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "jsonl"
            },
            "description": "`jsonl` or `csv`, case insensitive."
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional workspace context. Not used by this route."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only, read requests only. Clients receive 403."
          }
        ],
        "responses": {
          "200": {
            "description": "The collection stream, in the requested format.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "`attachment; filename=\"ampverve-export-{organisation_id}-{collection}-{job_id}.{format}\"`, unsafe characters replaced with `_`."
              },
              "X-AmpVerve-Export-Schema": {
                "schema": {
                  "type": "string"
                },
                "description": "The export schema version the file was produced under."
              },
              "X-AmpVerve-Export-Job": {
                "schema": {
                  "type": "string"
                },
                "description": "The job id."
              },
              "X-AmpVerve-Export-Collection": {
                "schema": {
                  "type": "string"
                },
                "description": "The collection name."
              }
            },
            "content": {
              "application/x-ndjson": {
                "schema": {
                  "type": "string",
                  "description": "One JSON object per line, keys in schema column order. No manifest line, no `_collection` key."
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string",
                  "description": "RFC 4180, UTF-8, CRLF, header row of column names, then one row per record."
                }
              }
            }
          },
          "400": {
            "description": "`format` is not `jsonl` or `csv` (`Unsupported format`), or organisation\ncontext is missing from the token.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token lacks `ORG_ADMIN` or `PLATFORM_ADMIN` (`Insufficient\npermissions`), the organisation is not active, or an `X-Tenant-ID`\nheader disagrees with the token.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`collection` is not in the registry (`Unknown collection`), or no job\nwith this id belongs to your organisation (`Export job not found`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/control/sites": {
      "get": {
        "operationId": "listEmsControlSites",
        "summary": "Sites with their controllable device model",
        "description": "Returns every site (workspace) of your organisation with the devices registered\nto it. For each device the response carries the synthetic device model state\nwhen one exists (`model_state`) and the commands its asset class accepts\n(`commands`). A device with no model state has `model_state: null` and an\nempty `commands` list.\n\nCommands by asset class: `battery` takes `set_power_kw`, `inverter` takes\n`set_export_limit_kw`, `charger` takes `set_max_kw`, `meter` takes none.\n\nModel state figures (`setpoint_kw`, `measured_kw`, `state`) are the demo-realm\ndevice model's, not measurements from hardware.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Device registrations are stored; model state figures come from the demo environment device model, not from hardware.",
        "responses": {
          "200": {
            "description": "Sites and devices.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsControlSitesResponse"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/control/setpoints": {
      "post": {
        "operationId": "writeEmsSetpoint",
        "summary": "Verified setpoint write with independent read-back",
        "description": "Sends one setpoint command to a device, waits for the acknowledgement, then\nreads the device back through the adapter's read path and compares the\nread-back with the target. The three facts (sent, accepted, read back) are\nrecorded separately with their own timestamps and are never inferred from one\nanother.\n\nThe returned `command.status` names the outcome of the read-back, or why it\ncould not happen:\n\n| status              | Meaning |\n| ------------------- | ------- |\n| `verified`          | Read-back obtained and within `tolerance_kw` of `target_kw`. |\n| `readback_mismatch` | Read-back obtained and outside tolerance. This is the accepted-but-ignored case: the device acknowledged and still reads the old value. |\n| `unconfirmed`       | No read-back obtained, or no acknowledgement (device unreachable). Not a pass and not a fail. |\n| `rejected`          | The realm guard or the device refused the write. Returned as 403, not 200. |\n\nA write that reached no device is never reported as verified. An\nacknowledgement on its own never promotes a command past `accepted`; only a\nread-back within tolerance produces `verified`, and a read-back that cannot be\nobtained leaves the command `unconfirmed`. The transient states `sent` and\n`accepted` are recorded but are not what this call returns.\n\nWhen `tolerance_kw` is omitted it defaults to the larger of 0.25 kW and two\nper cent of `target_kw`. `settle_seconds` is a pause between the acknowledgement\nand the read-back.\n\nThe realm guard runs before anything is transmitted. It refuses unless the\norganisation is in the demo realm (identifier of the form `demo-<slug>`), the\ndevice row belongs to that organisation, and the device is marked synthetic\n(`details.provider == \"synthetic\"` and `details.synthetic == true`). A refused\nwrite is still recorded with status `rejected` and its reason, so the refusal is\nvisible in the command history. No code path here transmits a command to a\nproduction device.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Acts on the demo environment device model; read-back is the model's.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsControlSetpointRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The command record after the read-back, with `status` one of `verified`,\n`readback_mismatch` or `unconfirmed`, plus the full list of possible states.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "command": {
                      "$ref": "#/components/schemas/EmsControlSetpointCommand"
                    },
                    "states": {
                      "type": "array",
                      "description": "Every status a command can carry, in lifecycle order.",
                      "items": {
                        "type": "string",
                        "enum": [
                          "sent",
                          "accepted",
                          "verified",
                          "readback_mismatch",
                          "unconfirmed",
                          "rejected"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Either the role or tenant check failed (`detail` is a string, see the\nForbidden response), or the write was rejected. A rejected write returns\n`detail` as an object: `status` is `rejected`, `reason` says whether the\nrealm guard refused (organisation not in the demo realm, device not owned\nby the organisation, device not synthetic) or the device refused the command\n(the asset class does not take it), and `id` is the recorded command id\n(null when no organisation id was available to record it).\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "detail": {
                          "type": "object",
                          "properties": {
                            "status": {
                              "type": "string",
                              "enum": [
                                "rejected"
                              ]
                            },
                            "reason": {
                              "type": "string"
                            },
                            "id": {
                              "type": [
                                "string",
                                "null"
                              ]
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "The device does not exist in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "device not found in this organisation"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "get": {
        "operationId": "listEmsSetpoints",
        "summary": "Setpoint command history",
        "description": "Returns setpoint commands recorded for your organisation, newest first by\n`sent_at`, optionally filtered by site and device. Every command carries its\nsent, accepted and read-back timestamps separately. Commands issued by the\nexport limitation loop and the import limit run appear here too, with `source`\n`export_limit` or `import_limit`; console writes carry `manual`.\n\nRows normally settle to `verified`, `readback_mismatch`, `unconfirmed` or\n`rejected`. `sent` and `accepted` are transient and appear only if a write was\ninterrupted between steps.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "x-ampverve-method": "Command records as written; read-back values come from the demo environment device model.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "device_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Command history.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The filter that was applied, or null."
                    },
                    "commands": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsControlSetpointCommand"
                      }
                    },
                    "states": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "sent",
                          "accepted",
                          "verified",
                          "readback_mismatch",
                          "unconfirmed",
                          "rejected"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/control/demo/devices/{device_id}/behaviour": {
      "post": {
        "operationId": "setEmsDemoDeviceBehaviour",
        "summary": "Switch a synthetic device's failure behaviour (demo realm only)",
        "description": "Flips the failure switches on one synthetic device model so the verified write\nhas something to catch. `accept_but_ignore: true` makes the device acknowledge\nevery command and apply none (the read-back then shows the old value and the\ncommand lands as `readback_mismatch`). `online: false` makes commands fail and\nread-back unobtainable (the command lands as `unconfirmed`). Only the fields you\nsend are changed.\n\nThe realm guard rejects the call with 403 when the organisation is not in the\ndemo realm, when the device is not owned by the organisation, or when the device\nis not marked synthetic. A demo-realm device that has no model state (the seed\nhas not been run) is rejected with 409.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Switches the failure behaviour of a demo environment device model.",
        "parameters": [
          {
            "name": "device_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "accept_but_ignore": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "Acknowledge every command and apply none."
                  },
                  "online": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "False makes the device unreachable for commands and read-back."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The device model state after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "device": {
                      "$ref": "#/components/schemas/EmsControlDeviceModelState"
                    },
                    "asset_classes": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "battery",
                          "inverter",
                          "charger",
                          "meter"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role or tenant check failed, or the realm guard refused: organisation not in\nthe demo realm (or in no known realm), device not owned by the requesting\norganisation, or device not a synthetic demo device. `detail` is the guard's\nsentence.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The device does not exist in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "device not found in this organisation"
                }
              }
            }
          },
          "409": {
            "description": "The device has no synthetic model state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "device has no synthetic model state; run the demo control seed"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/control/sites/{site_id}/config": {
      "get": {
        "operationId": "getEmsSiteControlConfig",
        "summary": "Control configuration for a site",
        "description": "Returns the site's control configuration row: import threshold, export limit,\nthe behaviour the inverter is expected to apply on communications failure, the\ndeclared fail-safe state per device class, the gateway heartbeat timeout and\nthe import and export MPANs. `configured` is false and `config` is null when no\nrow exists for the site.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The configuration, or `configured: false`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "configured": {
                      "type": "boolean"
                    },
                    "config": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/EmsControlSiteConfig"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "putEmsSiteControlConfig",
        "summary": "Set control configuration for a site",
        "description": "Creates or updates the site's control configuration. Only the fields you send\n(non-null) are changed; a null or omitted field is left as it is.\n`fail_safe_states` is replaced whole rather than merged, because a declared\nfail-safe state is a statement about every device class.\n\nOn first creation the row is written with `export_comms_failure_mode`\n`safe_setpoint`, `fail_safe_states` `{}` and `heartbeat_timeout_s` 60 unless you\nsupply them, and the returned `config` contains only those defaults plus the\nfields you sent (other keys are absent, not null). On update the full row is\nreturned. `source` is recorded as `console`.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsControlSiteConfigRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The configuration after the write.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "config": {
                      "$ref": "#/components/schemas/EmsControlSiteConfig"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or every field in the body was\nnull or omitted (`detail`: `nothing to change`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The site does not exist in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "site not found in this organisation"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/control/sites/{site_id}/balance": {
      "get": {
        "operationId": "getEmsSiteBalance",
        "summary": "Boundary meter reading of the site's device model",
        "description": "Derives the boundary meter reading from the site's synthetic device states:\n`grid_kw = load_kw - pv_kw + battery_kw + charger_kw`, with load and available\nPV taken from the meter model, PV clipped by the inverter setpoint, and battery\nand charger from their measured values. Sign convention: meter positive is\nimport, battery positive is charging. The components are returned so the\nworking is visible.\n\nWhen the site has no synthetic device model the response is\n`available: false` with a `reason` and no `balance`.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Boundary meter reading derived from the demo environment device models.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Balance, or `available: false`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "available": {
                      "type": "boolean"
                    },
                    "reason": {
                      "type": "string",
                      "description": "Present only when `available` is false."
                    },
                    "balance": {
                      "$ref": "#/components/schemas/EmsControlSiteBalance"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/control/demo/sites/{site_id}/scenario": {
      "post": {
        "operationId": "setEmsDemoSiteScenario",
        "summary": "Set the simulated site load and available PV (demo environment only)",
        "description": "Writes the building load and the PV available to the site's meter model, and\ncopies `pv_available_kw` to every inverter model on the site, then returns the\nresulting boundary balance. Use it to put the model above the export limit or\nthe import threshold before running a loop.\n\nThe realm guard is applied to the site's meter device and rejects with 403 when\nthe organisation is not in the demo realm, the meter is not owned by the\norganisation, or the meter is not synthetic. A site with no synthetic meter\nmodel is rejected with 409.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Sets the simulated load and available PV of a demo environment site.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "load_kw": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "description": "Building load in kW."
                  },
                  "pv_available_kw": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "description": "PV output the sun currently allows, in kW, before any inverter cap."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The fields written and the balance afterwards.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "scenario": {
                      "type": "object",
                      "description": "Only the fields that were sent.",
                      "properties": {
                        "load_kw": {
                          "type": "number"
                        },
                        "pv_available_kw": {
                          "type": "number"
                        }
                      }
                    },
                    "balance": {
                      "$ref": "#/components/schemas/EmsControlSiteBalance"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or both fields were null or\nomitted (`detail`: `nothing to change`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role or tenant check failed, or the realm guard refused the site's meter\ndevice (organisation not in the demo realm, meter not owned by the\norganisation, or meter not synthetic).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The meter device row is missing from your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "meter device not found in this organisation"
                }
              }
            }
          },
          "409": {
            "description": "The site has no synthetic meter model.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "site has no synthetic meter model"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/export-limit/{site_id}/run": {
      "post": {
        "operationId": "runExportLimitIteration",
        "summary": "One iteration of the cloud export limitation loop",
        "description": "Runs one pass of the export limitation loop on the site's device model: read the\nboundary meter through the adapter, compare export against the site's\n`export_limit_kw`, and if it is over, issue curtailment setpoints through the\nverified setpoint write (battery charge first, then an inverter output cap),\nre-read the meter and record an event with timings and accuracy figures.\nBefore measuring, the site's comms-failure behaviour (`export_comms_failure_mode`\nand `export_safe_setpoint_kw`) is provisioned onto each inverter model, as a real\nscheme does at commissioning.\n\nThe loop is not type tested and is not G100 certified. Accuracy figures describe\nthe site they were measured on; on the synthetic model the response time is\nmilliseconds.\n\nResponse branches, by `outcome`:\n\n| outcome                      | action    | What happened |\n| ---------------------------- | --------- | ------------- |\n| `no_meter`                   | `none`    | No boundary meter model on the site. Nothing recorded, no `id`. |\n| `meter_unreachable`          | `none`    | The meter model is offline. Event recorded with `comms_failure: true` and `export_kw_detected: null`. |\n| `within_limit`               | `none`    | Export was already at or under the limit. Event recorded with no actions; `export_kw` carries the reading. |\n| `within_limit`               | `curtail` | Curtailment issued and the re-read is within the limit; `response_ms` is the time from detection to that re-read. |\n| `not_within_limit`           | `curtail` | Curtailment issued but export remains above the limit (insufficient controllable capacity). |\n| `comms_failure_safe_setpoint`| `curtail` | An actuator was unreachable; the device is expected to hold its stored safe setpoint. Unverifiable by the cloud until the link returns. |\n| `comms_failure_hold_last`    | `curtail` | An actuator was unreachable; the device is expected to hold the last setpoint it received. |\n\nA site with no `export_limit_kw` configured returns 409.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "One loop iteration against the demo environment device model.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The iteration result. Which fields are present depends on the branch, see the description.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportLimitRunResult"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "No export limit is configured for this site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "No export limit is configured for this site."
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/export-limit/{site_id}/events": {
      "get": {
        "operationId": "listExportLimitEvents",
        "summary": "Export limitation events with timings and accuracy",
        "description": "Returns the site's recorded export limitation events, newest first, with the\nconfigured limit and comms-failure behaviour and a summary of timing and\naccuracy over the interventions. In the summary, `interventions` counts events\nthat carried actions; `response_ms_median` and `response_ms_max` are taken over\ninterventions that came within the limit; error maxima are null when no event\ncarried a figure. `configured` is false when the site has no `export_limit_kw`.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "x-ampverve-method": "Recorded events of the export limitation loop, which runs on the demo environment device model.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Events and summary.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "configured": {
                      "type": "boolean"
                    },
                    "limit_kw": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "comms_failure_mode": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "safe_setpoint",
                        "hold_last",
                        null
                      ]
                    },
                    "safe_setpoint_kw": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "summary": {
                      "$ref": "#/components/schemas/ExportLimitSummary"
                    },
                    "events": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ExportLimitEvent"
                      }
                    },
                    "scheme": {
                      "type": "string",
                      "description": "The fixed scheme statement, see `ExportLimitSummary.scheme`."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/comms/{site_id}/heartbeat": {
      "post": {
        "operationId": "postCommsHeartbeat",
        "summary": "Gateway heartbeat",
        "description": "Records a heartbeat from the site's gateway (`last_seen_at` is set to the\nserver's clock, `seq` is stored if given) and then runs the watchdog check. If\na loss was open for the site the check closes it with a `restored` event, so the\nreturned `status.transition` may be `{kind: restored, id}`. In the demo realm\nthe restore also marks every device model on the site reachable again; for a\nproduction organisation the event is recorded and nothing is commanded.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Gateway link and buffering"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "gateway_id": {
                    "type": "string",
                    "default": "gateway-1"
                  },
                  "seq": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "The gateway's own heartbeat counter, if it keeps one."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The heartbeat row and the watchdog result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "heartbeat": {
                      "$ref": "#/components/schemas/CommsHeartbeat"
                    },
                    "status": {
                      "$ref": "#/components/schemas/CommsCheckResult"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/comms/{site_id}/status": {
      "get": {
        "operationId": "getCommsStatus",
        "summary": "Link state, declared fail-safe state and when it was last exercised",
        "description": "Read-only view of the site's gateway link: whether the link is `up`, `lost`\n(silent for longer than `heartbeat_timeout_s`) or `unknown` (no heartbeat ever\nrecorded), the fail-safe state declared per device class in the site\nconfiguration, and when that fail-safe was last exercised (the most recent loss\nevent, rehearsal or real; null if never). Also returns the recent communications\nevents and, for each device model on the site, whether it is online and the\nfail-safe provisioned to it. This call records nothing.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Gateway link and buffering"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Link state derived from recorded gateway heartbeats against the configured timeout; fail-safe states are as configured.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 20
            },
            "description": "Maximum number of communications events to return."
          }
        ],
        "responses": {
          "200": {
            "description": "Link status, events and device states.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "$ref": "#/components/schemas/CommsLinkStatus"
                    },
                    "events": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CommsEvent"
                      }
                    },
                    "devices": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "device_id": {
                            "type": "string"
                          },
                          "asset_class": {
                            "type": "string",
                            "enum": [
                              "battery",
                              "inverter",
                              "charger",
                              "meter"
                            ]
                          },
                          "online": {
                            "type": "boolean"
                          },
                          "setpoint_kw": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "measured_kw": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "fail_safe": {
                            "description": "The fail-safe provisioned to this device model, or null if none.",
                            "oneOf": [
                              {
                                "$ref": "#/components/schemas/CommsFailSafeDeclaration"
                              },
                              {
                                "type": "null"
                              }
                            ]
                          },
                          "fail_safe_applied_at": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/comms/{site_id}/check": {
      "post": {
        "operationId": "runCommsCheck",
        "summary": "Run the watchdog check now",
        "description": "Evaluates the last heartbeat against the site's `heartbeat_timeout_s` (60 when\nunconfigured) and records any transition. If the gateway has been silent beyond\nthe timeout and no loss is open, a `loss` event is recorded and\n`transition` is `{kind: loss, id}`. If a heartbeat has arrived while a loss was\nopen, the loss is closed and a `restored` event is recorded. A site with no\nheartbeat row at all reports `link: lost` but records no loss.\n\nDuring a loss the cloud commands nothing: the fail-safe state is provisioned to\nthe devices beforehand and applied by them. In the demo realm the devices' local\nbehaviour is simulated on the device model (each goes offline and holds its\ndeclared fail-safe) and every such row says `local_behaviour_simulated: true`.\nFor a production organisation the event is recorded and nothing else happens.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Gateway link and buffering"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Link state derived from recorded gateway heartbeats against the configured timeout; device local behaviour is simulated only in the demo environment.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The watchdog result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommsCheckResult"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/comms/{site_id}/rehearse/drop": {
      "post": {
        "operationId": "rehearseCommsDrop",
        "summary": "Rehearsal: the gateway falls silent (demo realm only)",
        "description": "Simulates the gateway falling silent: the site's heartbeat is back-dated to one\nsecond beyond the timeout, a `rehearsal_start` event is recorded, and the\nwatchdog check runs, which records the `loss` event and simulates each device\nmodel applying its declared fail-safe. Returns the watchdog result.\n\nRefused with 403 unless the organisation is in the demo realm (`demo-<slug>`\nidentifier); an organisation in no known realm is refused as firmly as a\nproduction one.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Back-dates the heartbeat of a demo environment site to rehearse a link loss.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The watchdog result after the drop, normally with `transition.kind` `loss`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommsCheckResult"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role or tenant check failed, or the organisation is not in the demo realm.\n`detail` is the guard's sentence.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/comms/{site_id}/rehearse/restore": {
      "post": {
        "operationId": "rehearseCommsRestore",
        "summary": "Rehearsal: the gateway heartbeat resumes (demo realm only)",
        "description": "Simulates the heartbeat resuming: a fresh heartbeat is recorded, the watchdog\ncheck runs (closing the open loss with a `restored` event and marking the device\nmodels reachable again), and a `rehearsal_end` event is recorded. Returns the\nwatchdog result.\n\nRefused with 403 unless the organisation is in the demo realm.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Records a fresh heartbeat for a demo environment site to rehearse recovery.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The watchdog result after the restore, with `transition.kind` `restored` when a loss was open.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommsCheckResult"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role or tenant check failed, or the organisation is not in the demo realm.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/buffer/{site_id}/replay": {
      "post": {
        "operationId": "replayGatewayBuffer",
        "summary": "Replay a batch of readings the gateway buffered while offline",
        "description": "Accepts one batch of readings a gateway held while its link was down. Each\nreading carries a per-device sequence number that only ever goes up. The replay\ncontract:\n\n* **Dedupe on (device, sequence).** A reading whose `(device_id, seq)` is already\n  held, or appears twice in the batch, is counted in `duplicates` and not stored.\n* **Dedupe on the telemetry natural key.** A reading whose `(organisation,\n  device, ts)` already exists in telemetry (for example from the live poll before\n  the link fully dropped) is counted in `already_stored`, kept in the buffer log\n  with outcome `already_stored`, and not written to telemetry again.\n* **Store in sequence order** regardless of arrival order. `out_of_order` counts\n  the descending steps in the arrival order.\n* **Gap detection.** Each device carries a `last_contiguous_seq`, the highest\n  number such that every number up to it has been received. A batch that jumps\n  past a missing number leaves the cursor behind, reports the missing range in\n  `new_gaps` and records a `gap` event. When the missing readings arrive later the\n  cursor moves on and the range is reported in `closed_gaps` with a `gap_closed`\n  event. A gap partly filled is reported as closed and re-opened narrower.\n  Nothing is interpolated into a gap.\n* **Link accounting.** The replay posts a heartbeat and runs the watchdog. If a\n  loss was open it is closed and `comms_event_id` is the restored event. If no\n  loss was open and `link_lost_at` is given (and is before the replay), a loss and\n  a restore are recorded from the gateway's own account and `comms_event_id` is\n  that loss. `link_note` says which case applied.\n\nThe whole batch is accounted for within one transaction. `quality` values other\nthan `measured`, `estimated` or `interpolated` are stored as `measured`. The\n`raw` object of each reading is passed through to telemetry with `quality`,\n`source`, `gateway_id`, `buffer_seq`, `replay_id` and `buffered_at` added.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Gateway link and buffering"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BufferReplayRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The replay result, per device and in total.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BufferReplayResult"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token, or the tenant scope check inside\nthe replay found no usable organisation id.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/buffer/{site_id}/status": {
      "get": {
        "operationId": "getGatewayBufferStatus",
        "summary": "Buffered, replayed and gap counts per device, with the replay log",
        "description": "Returns the per-device replay cursors, the recent replay and gap events, totals\nacross the site, and which device model rows declare an edge runtime\n(`state.edge_runtime`). Local optimisation while offline runs only on a gateway\ncarrying the edge runtime; for the seeded demo devices none declares one and\n`edge_runtime.statement` says so rather than implying otherwise.\n\nIn `totals`, `buffered` is the sum of readings received over every replay,\n`replayed` the sum stored to telemetry, `gap_events` and `replays` are counts\nover the events returned (so bounded by `limit`), and `open_gaps` is the number\nof gap ranges still open across all cursors.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Gateway link and buffering"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 50
            },
            "description": "Maximum number of buffer events to return."
          }
        ],
        "responses": {
          "200": {
            "description": "Cursors, events, totals and the edge runtime summary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BufferStatus"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/buffer/{site_id}/rows": {
      "get": {
        "operationId": "listGatewayBufferRows",
        "summary": "Buffered readings held for the site, in device and sequence order",
        "description": "Returns the buffer log rows for the site, ordered by device then sequence,\noptionally filtered to one device or one replay. Every reading accepted by a\nreplay is here, including those not written to telemetry because a row with the\nsame natural key already existed (`outcome: already_stored`). Duplicates by\n(device, sequence) are never stored and so never appear.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Gateway link and buffering"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "device_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "replay_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 5000,
              "default": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Buffer rows.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "rows": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BufferRow"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/buffer/{site_id}/rehearse": {
      "post": {
        "operationId": "rehearseGatewayBufferReplay",
        "summary": "Rehearsal: the gateway replays a buffer built from the device model (demo realm only)",
        "description": "Builds a buffered batch from the synthetic device model's present operating\npoint (one reading per device per half hour for the last `half_hours` half\nhours, valued at the model's balance), re-sends the first three readings as\nduplicates, drops one sequence number from the meter so a gap is reported, marks\nevery reading `synthetic`, and replays it through the same path as\n`POST .../replay` with `link_lost_at` set to the start of the window. Sequence\nnumbers continue from the highest already held for the site's devices.\n\nRefused with 403 unless the organisation is in the demo realm. A site with no\nsynthetic device model is refused with 409.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Demo environment"
        ],
        "x-ampverve-status": "demo-realm",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Replays a buffer built from the demo environment device model, every reading marked synthetic.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "half_hours",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 48,
              "default": 4
            },
            "description": "Length of the rehearsed outage in half hour periods."
          }
        ],
        "responses": {
          "200": {
            "description": "The replay result for the rehearsed batch.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BufferReplayResult"
                }
              }
            }
          },
          "400": {
            "description": "Organisation context missing from the token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role or tenant check failed, or the organisation is not in the demo realm.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The site has no synthetic device model.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "site has no synthetic device model; run the control seed first"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/overrides": {
      "get": {
        "operationId": "listEmsOverrides",
        "summary": "Overrides for the organisation, active first",
        "description": "Lists human overrides, active ones first, each with who took control (token\nsubject and email), their roles, the authority the grant was made under, the\nreason, the scope (site or device), the expiry, and how many setpoint writes\nthe override has refused while active. Overrides whose expiry has passed are\nmarked `expired` as they are read.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "include_closed",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": true
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Override records with the action vocabulary and the enforcement statement.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": "string",
                      "nullable": true
                    },
                    "overrides": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsControlOverride"
                      }
                    },
                    "actions": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "hold",
                          "manual_setpoint",
                          "suspend_automation"
                        ]
                      }
                    },
                    "max_duration_hours": {
                      "type": "integer"
                    },
                    "enforcement": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createEmsOverride",
        "summary": "Take control from the loops with a recorded reason, authority and expiry",
        "description": "Records a human override and, for `manual_setpoint`, writes the target through\nthe verified setpoint path with the override as its source. While the override\nis active, `backend/core/ems_control/setpoint.py` refuses every setpoint write\nthat is not the override's own: `hold` and `manual_setpoint` refuse loops and\npeople alike, `suspend_automation` refuses the loops (import_limit,\nexport_limit, watchdog, tariff_schedule) and still lets a person write. A\nrefused write is recorded as `rejected` with the override id in its reason and\nthe override's `refusals` count goes up.\n\nWho may grant: a token with a write role, and in per-site access mode a user\nholding a site persona with the `control` permission (site_engineer,\noperations). The authority that allowed the grant is written on the row.\n\nA second override on a scope that is already held is refused with 409 until the\nfirst is released, so a hand-over is on the record. Expiry is mandatory and at\nmost 24 hours. An audit entry `ems.override.create` is chained in the same\ntransaction.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "x-ampverve-method": "Records the override as given; a manual setpoint acts on the demo environment device model only.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site_id",
                  "scope_kind",
                  "action",
                  "reason"
                ],
                "properties": {
                  "site_id": {
                    "type": "string"
                  },
                  "scope_kind": {
                    "type": "string",
                    "enum": [
                      "site",
                      "device"
                    ]
                  },
                  "device_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "hold",
                      "manual_setpoint",
                      "suspend_automation"
                    ]
                  },
                  "target_kw": {
                    "type": "number",
                    "nullable": true,
                    "description": "Required for manual_setpoint."
                  },
                  "reason": {
                    "type": "string",
                    "minLength": 10,
                    "maxLength": 2000
                  },
                  "expires_in_minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 1440,
                    "default": 60
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The override row and, for manual_setpoint, the verified command it wrote.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "override": {
                      "$ref": "#/components/schemas/EmsControlOverride"
                    },
                    "command": {
                      "nullable": true,
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/EmsControlSetpointCommand"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The named device is not on the named site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role check failed, or the site persona held does not carry the control permission.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Device not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Malformed override (reason, expiry, scope) or the scope is already held.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/overrides/{override_id}/revoke": {
      "post": {
        "operationId": "revokeEmsOverride",
        "summary": "Release an override, recording who and why",
        "description": "Marks the override `revoked` with the releasing user, the time and the reason\ngiven. The refusal count accumulated while it was active stays on the row. An\naudit entry `ems.override.revoke` is chained in the same transaction.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS control"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "override_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 2000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The released override.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "override": {
                      "$ref": "#/components/schemas/EmsControlOverride"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Override not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The override is already revoked or expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/decisions": {
      "get": {
        "operationId": "listEmsDecisions",
        "summary": "Dispatch decision records, newest first",
        "description": "One row per dispatch the EMS loops decided, written in the loop's own\ntransaction before anything is transmitted. Each carries the inputs as values\n(the stored load series, the meter reading, device state), the constraints in\nforce, the objective in words, `model_version` (the pure decision function's\nqualified name and a SHA-256 of its source, plus the solver build),\n`rule_version`, the rejected alternatives with their computed figures, the\nresult, and `could_intervene` (the users holding the control permission on the\nsite and any active override, with the basis stated).\n\nThe table is append-only by database trigger. Rows are filtered to the sites\nthe caller may read (per-site access mode, row G2).\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Decision log"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "loop",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "import_limit",
                "export_limit",
                "tariff_schedule",
                "override"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Decision records with the loop vocabulary and rule versions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": "string",
                      "nullable": true
                    },
                    "decisions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsDispatchDecision"
                      }
                    },
                    "loops": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "rule_versions": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "string"
                      }
                    },
                    "append_only": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/ems/decisions/{decision_id}": {
      "get": {
        "operationId": "getEmsDecision",
        "summary": "One decision with its inputs, constraints, objective, alternatives and result",
        "tags": [
          "Decision log"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "decision_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The decision record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "decision": {
                      "$ref": "#/components/schemas/EmsDispatchDecision"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Decision not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/decisions/{decision_id}/replay": {
      "post": {
        "operationId": "replayEmsDecision",
        "summary": "Re-run the pure decision on the stored inputs and compare",
        "description": "Loads the record, checks that its stored inputs still hash to `input_hash`,\ncalls the same pure decision function on them, and compares the result with\nthe recorded one. `status` is `reproduced`, `diverged` (with both results and\nwhether the decision code has changed since the record), `inputs_altered` (the\nrow was modified; nothing is replayed), `not_replayable` (a human decision) or\n`replay_failed`. Nothing is written to the decisions table and no device is\ntouched. The replay is recorded as user action `ems.decision.replay`.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Decision log"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "decision_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The replay comparison.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "replay": {
                      "type": "object",
                      "properties": {
                        "decision_id": {
                          "type": "string"
                        },
                        "loop": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string",
                          "enum": [
                            "reproduced",
                            "diverged",
                            "inputs_altered",
                            "not_replayable",
                            "replay_failed"
                          ]
                        },
                        "reason": {
                          "type": "string"
                        },
                        "recorded_model_version": {
                          "type": "string"
                        },
                        "current_model_version": {
                          "type": "string"
                        },
                        "model_changed": {
                          "type": "boolean"
                        },
                        "recorded": {
                          "type": "object",
                          "additionalProperties": true
                        },
                        "replayed": {
                          "type": "object",
                          "additionalProperties": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Decision not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/grid/sites/{site_id}/guardrails": {
      "get": {
        "operationId": "getEmsGridGuardrails",
        "summary": "Evaluate a site against its connection terms",
        "description": "The site's connection terms as executable constraints (the grid\ncontract runtime), resolved with the site's other limits in order of\nprecedence (the grid policy engine), and evaluated against the site's\nown stored data:\n\n- `resolution`: the import range allowed now, the obligation that set\n  each bound (`max_set_by`, `max_source`) and any obligation that\n  cannot be met with what overrode it. Tiers: SAFETY (the configured\n  `phase_limit_kw`), GRID (the connection terms and a windowed operator\n  limit), CUSTOMER (the site's own `import_limit_kw` and\n  `export_limit_kw`).\n- `measured`: the latest boundary meter half hour against the allowed\n  maximum, with the headroom.\n- `compliance`: every stored half hour in the window checked against the\n  terms, with the breaches listed. Readings are half hourly means of the\n  boundary meter, so an excursion shorter than a half hour can be\n  averaged away; `readings_basis` says so. Half hours without a reading\n  are counted and left out, never read as zero.\n- `compliance_suite`: the deterministic test suite the terms generate\n  (limits at their edges, timed blocks across clock changes,\n  curtailment, link loss, ramp, export), run against the guardrail.\n- `policy`: the terms as a versioned policy document with a SHA-256\n  `policy_hash`.\n\nThe terms come from the site's control configuration\n(`dno_import_limit_kw`, `dno_export_limit_kw`, and\n`temporary_dynamic_import_kw` in its window as an operator\ninstruction), each citing the configuration field it came from, and\n`contract.basis` is `site_configuration`. A site whose configuration has\nno `dno_import_limit_kw` returns `evaluated: false` with the reason; the\nsite's own ceiling is never promoted to a connection term.\n\nRead only and planning only: nothing is sent to a device. Needs the\n`ems` module and read permission on the site. Scoped to the organisation\nin your bearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR,\nANALYST, VIEWER.\n",
        "tags": [
          "EMS grid connection"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 168,
              "default": 24
            },
            "description": "Length of the readings window ending now, in hours."
          }
        ],
        "responses": {
          "200": {
            "description": "The evaluation, or `evaluated` false with the reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsGridGuardrails"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/grid/sites/{site_id}/guardrails/evaluate": {
      "post": {
        "operationId": "evaluateEmsGridGuardrails",
        "summary": "Evaluate a site against a policy document, and check a proposed change",
        "description": "As `GET /api/hardware/ems/grid/sites/{site_id}/guardrails`, with two\noptional inputs:\n\n- `policy`: a policy document from\n  `POST /api/hardware/ems/grid/agreements/confirm`. Its `policy_hash` is\n  recomputed and an unknown schema or a document that does not match its\n  hash is refused with 422. `contract.basis` is then `policy_document`.\n- `proposal`: a change any EMS, charge point system or battery optimiser\n  wants to make, as `changes` (each asset's current and proposed kW,\n  positive drawing from the site) over `horizon_s`. The import is\n  projected from `measured_import_kw`, or the latest boundary meter half\n  hour when it is not given. `check.decision` is `APPROVED`, or\n  `REJECTED` with each clause it would break and `suggested_changes`, a\n  compliant alternative. The target sits a margin inside each bound.\n\nA planning answer only: nothing is sent to any device. Needs the `ems`\nmodule and read permission on the site. Scoped to the organisation in\nyour bearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST,\nVIEWER.\n",
        "tags": [
          "EMS grid connection"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "hours": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 168,
                    "default": 24
                  },
                  "policy": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "additionalProperties": true,
                    "description": "A policy document with `schema`, `rules` and `policy_hash`, as returned by the confirm operation."
                  },
                  "proposal": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "required": [
                      "changes"
                    ],
                    "properties": {
                      "changes": {
                        "type": "array",
                        "minItems": 1,
                        "maxItems": 500,
                        "items": {
                          "type": "object",
                          "required": [
                            "asset",
                            "current_kw",
                            "proposed_kw"
                          ],
                          "properties": {
                            "asset": {
                              "type": "string"
                            },
                            "current_kw": {
                              "type": "number"
                            },
                            "proposed_kw": {
                              "type": "number"
                            }
                          }
                        }
                      },
                      "horizon_s": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 86400,
                        "default": 60
                      },
                      "measured_import_kw": {
                        "type": [
                          "number",
                          "null"
                        ]
                      }
                    }
                  }
                }
              },
              "example": {
                "proposal": {
                  "changes": [
                    {
                      "asset": "charger-bay-3",
                      "current_kw": 0,
                      "proposed_kw": 150
                    }
                  ],
                  "horizon_s": 300
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The evaluation, with `check` when a proposal was given.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsGridGuardrails"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "The policy document was refused (unknown schema, hash mismatch, a\nrule without a clause), or a proposal was given with no measured\nimport to project from, or the body failed validation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/grid/agreements/read": {
      "post": {
        "operationId": "readEmsGridAgreement",
        "summary": "Read a connection agreement and propose its terms",
        "description": "Turns a connection agreement or offer from the network operator into\nPROPOSED terms for a person to confirm. Give the document as `text` or\nas `pdf_base64` (a PDF with a text layer, at most 10 MB); not both.\n\nThe terms are extracted by the configured model deployment, or, when\n`proposed_terms` is given, are the terms a person typed, in the same\nshape: each with `value`, `unit`, `clause` and a verbatim `quote`.\nEither way every term is checked against the document: `supported`\nonly when the quote is in the document and its number equals the value\nafter unit conversion; `needs_confirmation` when a person must decide\n(a figure in kVA or MVA, which needs a power factor, or a number that\nmatches only after reformatting); `unsupported` when the quote is not in\nthe document (treated as invented) or the number does not match.\n\nWhen no extraction model is configured, `proposal.ok` is false and\n`proposal.error` says so; no terms are invented. The document text is\nsent to the configured model deployment only, and nothing is stored\n(`saved: false`). The response returns the document text and the\nextracted items so that the confirm operation can check them again.\n\nNeeds the `ems` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS grid connection"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "source_document"
                ],
                "properties": {
                  "source_document": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300,
                    "description": "The file name or a label for the document."
                  },
                  "text": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 400000
                  },
                  "pdf_base64": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The PDF's bytes in base64."
                  },
                  "proposed_terms": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "additionalProperties": true,
                    "description": "Terms typed by a person, keyed by term name, each `{value, unit, clause, quote}`."
                  }
                }
              },
              "example": {
                "source_document": "offer.txt",
                "text": "3.1 The Firm Import Capacity is 400 kW.",
                "proposed_terms": {
                  "firm_import_kw": {
                    "value": 400,
                    "unit": "kW",
                    "clause": "3.1",
                    "quote": "The Firm Import Capacity is 400 kW."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The proposal, with each term's status and reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsGridAgreementRead"
                }
              }
            }
          },
          "400": {
            "description": "Neither or both of `text` and `pdf_base64` were given, or `pdf_base64` is not base64.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "description": "The PDF or its text is too large.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "The PDF could not be read (no text layer, or the PDF library is missing), or the body failed validation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/grid/agreements/confirm": {
      "post": {
        "operationId": "confirmEmsGridAgreement",
        "summary": "Confirm proposed agreement terms and compile them (not saved)",
        "description": "A named person accepts, rejects or overrides each proposed term.\n`decisions` maps a term to `accept`, `reject` or\n`{\"value\": x, \"clause\": \"...\"}` (x in kW, kW/min, s or h; a list of\n`{start, end, import_kw}` blocks for `timed_import`). `contract_id` and\n`network_operator` are required for the terms to compile.\n\nThe proposal is rebuilt on the server from `document_text` and\n`extracted` (as returned by the read operation), so a status sent by a\nclient is never trusted: only a term the server finds `supported` can be\naccepted as proposed, and accepting anything else is a 422. The person\nconfirming is the user on the bearer token.\n\n**Not saved.** No table holds confirmed agreement terms yet, so the\nresponse carries `saved: false` and `not_saved_reason`. It returns the\ncompiled terms, the provenance record (who, when, and for each term\nwhether it came from a document quote or a human override), the policy\ndocument with its `policy_hash`, and the compliance suite the terms\ngenerate. Pass the policy to\n`POST /api/hardware/ems/grid/sites/{site_id}/guardrails/evaluate` to\nevaluate a site against it.\n\nNeeds the `ems` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "EMS grid connection"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "source_document",
                  "document_text",
                  "extracted",
                  "decisions"
                ],
                "properties": {
                  "source_document": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "document_text": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 400000
                  },
                  "extracted": {
                    "type": "object",
                    "additionalProperties": true
                  },
                  "decisions": {
                    "type": "object",
                    "minProperties": 1,
                    "additionalProperties": true
                  }
                }
              },
              "example": {
                "source_document": "offer.txt",
                "document_text": "3.1 The Firm Import Capacity is 400 kW.",
                "extracted": {
                  "firm_import_kw": {
                    "value": 400,
                    "unit": "kW",
                    "clause": "3.1",
                    "quote": "The Firm Import Capacity is 400 kW."
                  }
                },
                "decisions": {
                  "contract_id": {
                    "value": "NX-4471"
                  },
                  "network_operator": {
                    "value": "Example Networks plc"
                  },
                  "firm_import_kw": "accept"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The compiled terms, not saved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsGridAgreementConfirm"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role does not allow confirming terms, the token names no user, or the `ems` module is off.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "A decision was refused (an unsupported term accepted, an override\nwithout a clause, an unknown term) or the terms do not compile (for\nexample no `firm_import_kw`, or a fail-safe above full capacity).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/grid/sites/{site_id}/capacity": {
      "get": {
        "operationId": "getEmsGridCapacity",
        "summary": "Share the site's connection across its chargers and batteries",
        "description": "One decision of the capacity engine on the site's own data: a proposed\nsetpoint for every charger and battery, the planned import, the headroom\nleft, and whether each vehicle is on track.\n\nInputs: the effective import limit from the site operating envelope\n(the tightest of the site ceiling, the operator limit, a windowed\noperator limit and the phase limit); the latest boundary meter half hour\nin the last two hours; each battery with its stated capacity and power\nand its latest state of charge (the reserve is the configured\n`soc_floor_pct`, otherwise zero, and the response says which); and each\ncharger device with its stated rated power and latest power. A charger's\nenergy need and departure are not stored, so they are unknown and the\nengine gives that charger full power, shown per vehicle as\n`need_known: false`. An asset missing a figure is listed in\n`inputs.excluded` with the figure named; nothing is assumed for it. With\n`minimum_import_hours`, also the lowest import the site could hold for\nthat long with every known departure met, from a persistence base load.\n\n`available: false` with the reason when the site has no import limit\nconfigured or no recent meter reading. Proposed setpoints only: nothing\nis sent to a device. Needs the `ems` module and read permission on the\nsite. Scoped to the organisation in your bearer token. Roles:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "EMS grid connection"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "One capacity engine decision from the site's operating envelope, latest metered import and stored battery and charger state; proposed setpoints only, nothing is sent to a device.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tick_h",
            "in": "query",
            "schema": {
              "type": "number",
              "exclusiveMinimum": 0,
              "maximum": 24,
              "default": 0.5
            },
            "description": "The longest a setpoint may be held, in hours. A battery is only asked for power it can sustain for that long."
          },
          {
            "name": "minimum_import_hours",
            "in": "query",
            "schema": {
              "type": [
                "number",
                "null"
              ],
              "exclusiveMinimum": 0,
              "maximum": 48
            },
            "description": "Also compute the lowest import the site can hold for this many hours."
          }
        ],
        "responses": {
          "200": {
            "description": "The allocation, or `available` false with the reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsGridCapacity"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/ems/grid/sites/{site_id}/capacity/plan": {
      "post": {
        "operationId": "planEmsGridCapacity",
        "summary": "Plan the site's connection with given sessions and flexible loads",
        "description": "As `GET /api/hardware/ems/grid/sites/{site_id}/capacity`, with charging\nsessions (energy needed and hours to departure) and flexible loads\ngiven in the request: a what-if on the site's measured import, its\nconfigured limit and its batteries. When `sessions` is omitted the\nsite's charger devices are used as in the GET. A price above\n`cheap_below_p_per_kwh` leaves spare headroom unused.\n\nProposed setpoints only: nothing is sent to a device. Needs the `ems`\nmodule and read permission on the site. Scoped to the organisation in\nyour bearer token. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST,\nVIEWER.\n",
        "tags": [
          "EMS grid connection"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "The capacity engine run on the site's measured import and configured limit with the sessions and loads you supply; proposed setpoints only.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "sessions": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "maxItems": 500,
                    "items": {
                      "type": "object",
                      "required": [
                        "device_id",
                        "measured_kw",
                        "max_kw"
                      ],
                      "properties": {
                        "device_id": {
                          "type": "string"
                        },
                        "measured_kw": {
                          "type": "number",
                          "minimum": 0
                        },
                        "max_kw": {
                          "type": "number",
                          "minimum": 0
                        },
                        "min_kw": {
                          "type": "number",
                          "minimum": 0,
                          "default": 0
                        },
                        "energy_needed_kwh": {
                          "type": [
                            "number",
                            "null"
                          ],
                          "minimum": 0
                        },
                        "departs_in_h": {
                          "type": [
                            "number",
                            "null"
                          ],
                          "minimum": 0
                        },
                        "vehicle_ref": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    }
                  },
                  "loads": {
                    "type": "array",
                    "maxItems": 200,
                    "items": {
                      "type": "object",
                      "required": [
                        "device_id",
                        "measured_kw",
                        "min_kw"
                      ],
                      "properties": {
                        "device_id": {
                          "type": "string"
                        },
                        "measured_kw": {
                          "type": "number",
                          "minimum": 0
                        },
                        "min_kw": {
                          "type": "number",
                          "minimum": 0
                        },
                        "can_reduce_for_h": {
                          "type": "number",
                          "minimum": 0,
                          "default": 0
                        }
                      }
                    }
                  },
                  "tick_h": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 24,
                    "default": 0.5
                  },
                  "price_p_per_kwh": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "cheap_below_p_per_kwh": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "minimum_import_hours": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "exclusiveMinimum": 0,
                    "maximum": 48
                  }
                }
              },
              "example": {
                "sessions": [
                  {
                    "device_id": "bay-1",
                    "measured_kw": 0,
                    "max_kw": 150,
                    "energy_needed_kwh": 60,
                    "departs_in_h": 4
                  }
                ],
                "minimum_import_hours": 2
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The allocation, or `available` false with the reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmsGridCapacity"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/forecasting/sites": {
      "get": {
        "operationId": "listForecastSites",
        "summary": "Your sites with forecast freshness and validation status",
        "description": "One entry per site of your organisation (a workspace with at least one device)\nthat you hold read permission on. For each: the latest forecast the optimiser\nused (demand and solar method, whether each is validated by backtest), and\nfreshness (when the forecast was made, the weather status `fresh`, `cached`,\n`partial` or `missing`, its source and when it was fetched). `latest` is null\nfor a site with no stored forecast yet.\n\nThe ingress strips /api/optimiser; the router is mounted at /forecasting on the\noptimiser pod. Scoped to the organisation in your bearer token. Roles:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Sites with their latest forecast summary.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sites": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "site_id": {
                            "type": "string"
                          },
                          "latest": {
                            "$ref": "#/components/schemas/ForecastRunSummary"
                          },
                          "freshness": {
                            "$ref": "#/components/schemas/ForecastFreshness"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/optimiser/forecasting/sites/{site_id}/config": {
      "get": {
        "operationId": "getForecastSiteConfig",
        "summary": "Forecast configuration for a site and the values the model resolved",
        "description": "`configured` is the row set through PUT (null when none). `resolved` is what the\nforecast actually uses for each input, with its source: coordinates, PV kWp,\ntilt, azimuth, inverter AC rating, system efficiency and bank holiday division.\nA source of `default` means the value is a stated default (tilt 35 degrees,\nazimuth 180, system efficiency 0.86, England and Wales holidays); `missing`\nmeans there is no value and the dependent forecast is not made.\n\nA site that is not one of your organisation's sites is a 404. Roles:\nPLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The site (workspace) id. A site of another organisation is a 404."
          }
        ],
        "responses": {
          "200": {
            "description": "Configured and resolved inputs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "configured": {
                      "type": [
                        "object",
                        "null"
                      ]
                    },
                    "resolved": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "value": {},
                          "source": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "holiday_divisions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "operationId": "putForecastSiteConfig",
        "summary": "Set a site's coordinates, array geometry and holiday division",
        "description": "Creates or replaces the site's forecast configuration. Every field is optional;\na field sent as null (or omitted) is stored as not recorded, and the forecast\nthen falls back to the asset record or a stated default. Latitude and longitude\nmust be given together. The change is written to the audit trail as\n`forecast.site_config.update` with the before and after values. The new values\ntake effect at the next daily retrain or after POST .../backtest.\n\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, with configure permission on the site.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The site (workspace) id. A site of another organisation is a 404."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "latitude": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": -90,
                    "maximum": 90
                  },
                  "longitude": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": -180,
                    "maximum": 180
                  },
                  "pv_kwp": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "exclusiveMinimum": 0,
                    "maximum": 5000
                  },
                  "pv_tilt_deg": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 90
                  },
                  "pv_azimuth_deg": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 360,
                    "description": "Degrees from north",
                    "clockwise. 180 is due south.": null
                  },
                  "inverter_ac_kw": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "exclusiveMinimum": 0
                  },
                  "holiday_region": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "england-and-wales",
                      "scotland",
                      "northern-ireland",
                      null
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The configuration after the write.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "configured": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/forecasting/sites/{site_id}/forecast": {
      "get": {
        "operationId": "getSiteForecast",
        "summary": "The latest 48 hour forecast for a site",
        "description": "The most recent forecast the optimiser stored for the site (`source: stored`),\nor, with `refresh=true` or when none is stored, one computed now and stored\n(`source: computed`). Half-hourly `series` rows carry demand P10, P50 and P90\n(base load in kW, excluding EV charging and heat pump consumption, which the\noptimiser schedules itself), solar P10, P50 and P90 in kW, temperature and GHI.\nA value the platform does not have is null, never 0.\n\n`demand` and `solar` state the method (`gbm_quantile`, `seasonal_profile` or\n`none`; `pv_model_calibrated`, `pv_model_uncalibrated` or `none`), the history it\nwas trained on, the reason for any fallback, and the validation result.\n\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The site (workspace) id. A site of another organisation is a 404."
          },
          {
            "name": "refresh",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The forecast with its inputs and freshness.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "source": {
                      "type": "string",
                      "enum": [
                        "stored",
                        "computed"
                      ]
                    },
                    "demand": {
                      "type": "object"
                    },
                    "solar": {
                      "type": "object"
                    },
                    "weather": {
                      "type": "object"
                    },
                    "assumptions": {
                      "type": "object"
                    },
                    "freshness": {
                      "$ref": "#/components/schemas/ForecastFreshness"
                    },
                    "series": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ForecastSlot"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/optimiser/forecasting/sites/{site_id}/forecast-vs-actual": {
      "get": {
        "operationId": "getSiteForecastVsActual",
        "summary": "An earlier forecast against what the site measured",
        "description": "Takes the forecast issued at or before `now - hours_back` and returns, for each\nhalf hour from then until now, its demand and solar values beside the demand and\ngeneration the site measured (from telemetry_log, by the same derivation the\nmodel trains on; null where nothing was measured). `window_metrics` scores this\nwindow only; validation uses the stored backtests. `ahead` is the newest\nforecast's series from now on.\n\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The site (workspace) id. A site of another organisation is a 404."
          },
          {
            "name": "hours_back",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 168,
              "default": 24
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Forecast against measured, with window metrics and the current forecast.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "window_start": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "earlier_forecast": {
                      "$ref": "#/components/schemas/ForecastRunSummary"
                    },
                    "points": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "ts": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "demand_forecast_p10": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "demand_forecast_p50": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "demand_forecast_p90": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "demand_actual": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "solar_forecast_p50": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "solar_actual": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        }
                      }
                    },
                    "window_metrics": {
                      "type": "object"
                    },
                    "latest_forecast": {
                      "$ref": "#/components/schemas/ForecastRunSummary"
                    },
                    "ahead": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ForecastSlot"
                      }
                    },
                    "freshness": {
                      "$ref": "#/components/schemas/ForecastFreshness"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/optimiser/forecasting/sites/{site_id}/metrics": {
      "get": {
        "operationId": "getSiteForecastMetrics",
        "summary": "Backtest results for a site, newest first",
        "description": "Rolling-origin backtest results: for each test week the model is trained only on\ndata before it. Per target (demand, solar) and method: folds scored, MAE (kW),\nnMAE (MAE over mean actual), MAPE (only over half hours with an actual of at\nleast 0.2 kW, else null with the reason), pinball loss at P10 and P90, P10 to P90\ncoverage, the baseline and the skill against it, the threshold in force and\nwhether the method is `validated` for this site. `thresholds` are the values in\nforce now (FORECAST_DEMAND_NMAE_MAX, FORECAST_SOLAR_NMAE_MAX,\nFORECAST_MIN_BACKTEST_FOLDS, FORECAST_REQUIRE_BEATS_BASELINE).\n\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The site (workspace) id. A site of another organisation is a 404."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Backtest results.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "thresholds": {
                      "type": "object"
                    },
                    "validations": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "target": {
                            "type": "string",
                            "enum": [
                              "demand",
                              "solar"
                            ]
                          },
                          "method": {
                            "type": "string"
                          },
                          "evaluated_at": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "folds": {
                            "type": "integer"
                          },
                          "scored_points": {
                            "type": "integer"
                          },
                          "mae_kw": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "nmae": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "mape_pct": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "pinball_p10": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "pinball_p90": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "coverage_p10_p90": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "baseline_method": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "baseline_mae_kw": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "skill": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "threshold_nmae": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "validated": {
                            "type": "boolean"
                          },
                          "reason": {
                            "type": [
                              "string",
                              "null"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/optimiser/forecasting/sites/{site_id}/backtest": {
      "post": {
        "operationId": "runSiteForecastBacktest",
        "summary": "Retrain a site's forecasts and re-run the backtests now",
        "description": "Discards the site's in-memory models, reads its history again, refits the demand\nmodels and the solar calibration, re-runs the backtests (stored as new\nvalidation rows) and makes a fresh forecast. Recorded in the audit trail as\n`forecast.backtest.run`. Sends nothing to any device. Can take several seconds.\n\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, with configure permission on the site.\n",
        "tags": [
          "Forecasting"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The site (workspace) id. A site of another organisation is a 404."
          }
        ],
        "responses": {
          "200": {
            "description": "The new backtest results and the forecast inputs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "validations": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "forecast": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/optimiser/import-limit/{site_id}/config": {
      "get": {
        "operationId": "getImportLimitConfig",
        "summary": "Import threshold configured for a site",
        "description": "Returns the site's import threshold and import MPAN from the site control\nconfiguration, and whether a tariff library is available to price avoided\nenergy. `configured` is false when no `import_limit_kw` is set. `pricing` is the\nliteral `price_energy available` when a `price_energy` function resolves in this\nimage, otherwise `not priced: tariff library not configured`.\n\nThe public path repeats the optimiser segment: the ingress strips /api/optimiser\nand the router is mounted at /optimiser/import-limit.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The threshold configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "configured": {
                      "type": "boolean"
                    },
                    "import_limit_kw": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "import_mpan": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "pricing": {
                      "type": "string",
                      "enum": [
                        "price_energy available",
                        "not priced: tariff library not configured"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "putImportLimitConfig",
        "summary": "Set the import threshold for a site",
        "description": "Creates or updates the site's `import_limit_kw` and `import_mpan` in the site\ncontrol configuration. No check is made that the site exists in your\norganisation; the row is keyed by your organisation and the `site_id` given.\n\nOn an existing row, `import_mpan` is written as sent, so omitting it (or sending\nnull) clears a previously stored MPAN. On first creation a null `import_mpan` is\nsimply not written.\n\nThe public path repeats the optimiser segment: the ingress strips /api/optimiser\nand the router is mounted at /optimiser/import-limit.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "import_limit_kw"
                ],
                "properties": {
                  "import_limit_kw": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "description": "Import threshold in kW."
                  },
                  "import_mpan": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Import MPAN used when pricing avoided energy."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The threshold after the write.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "import_limit_kw": {
                      "type": "number"
                    },
                    "import_mpan": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/import-limit/{site_id}/series": {
      "get": {
        "operationId": "getImportLimitSeries",
        "summary": "The stored load series a run would optimise against",
        "description": "Returns the site's most recent stored building load, one point per half hour,\nfor up to `periods` periods. The series is read from telemetry rows whose tenant\nis your organisation and whose asset belongs to one of the site's devices.\nBuilding load is taken from `raw.load_kw` on the meter reading where present,\notherwise reconstructed as meter + solar - battery at the same timestamp.\n`load_basis` is `measured` when at least two points exist and `none` otherwise;\na run on a `none` series optimises and dispatches nothing.\n\nThe public path repeats the optimiser segment: the ingress strips /api/optimiser\nand the router is mounted at /optimiser/import-limit.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "periods",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 2,
              "maximum": 336,
              "default": 48
            },
            "description": "Number of half hour periods, newest last."
          }
        ],
        "responses": {
          "200": {
            "description": "The load series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "load_basis": {
                      "type": "string",
                      "enum": [
                        "measured",
                        "none"
                      ]
                    },
                    "periods": {
                      "type": "integer",
                      "description": "Number of points returned."
                    },
                    "series": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "ts": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "load_kw": {
                            "type": "number"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/import-limit/{site_id}/run": {
      "post": {
        "operationId": "runImportLimit",
        "summary": "Plan on the stored series, act on the device model, quantify avoided cost",
        "description": "One import limit run in two halves.\n\n**Plan.** A mixed integer programme over the site's stored half hourly load\nseries schedules the battery (rating and capacity from the device model, initial\nstate of charge from `state.soc` or 0.5) to hold import at or below\n`import_limit_kw`. It reports the energy above the threshold before and after,\nthe difference being `avoided_kwh`, and prices the per-period avoided energy\nthrough the tariff library when one is configured. `solver` is `milp_cbc` when\nCBC reached optimal, `greedy_fallback` when pulp is missing or the solve did not\nreach optimal (the greedy schedule respects the same limits but is not proven\noptimal), and `none` when the site has no battery model with a positive rating\nand capacity.\n\n**Act.** When `dispatch` is true and a battery model exists, one live iteration\nruns on the device model: read the boundary meter, and if import exceeds the\nthreshold command the battery to discharge the excess (plus a margin of the\nlarger of 0.5 kW and one per cent of the threshold) through the verified\nsetpoint write. The command and its read-back are in `dispatch.command`.\n\nResponse branches, by `status`:\n\n| status             | HTTP | What happened |\n| ------------------ | ---- | ------------- |\n| `not_configured`   | 409  | No `import_limit_kw` for the site. A run row with this status is still recorded before the 409 is raised. |\n| `no_stored_series` | 200  | Fewer than two stored load points. `load_basis` is `none`, `schedule` is empty, `dispatch` is `{}` and `reason` explains. Nothing optimised, nothing dispatched. |\n| `completed`        | 200  | Plan produced; `dispatch` reports whether an iteration was attempted and its result. |\n\n`pricing_status` is `priced` with an `avoided_cost_gbp` when a tariff library\npriced the avoided energy (or there was nothing above the threshold to price, in\nwhich case the amount is 0), and `not_priced` with a null amount and the reason\nin `pricing_detail` otherwise. A run is never priced at zero because a library\nwas missing.\n\n**Realm.** Planning works for any organisation with a stored load series\nand a battery model row. `dispatch=true` acts on the synthetic\ndevice model through the verified setpoint write, so the live iteration\nexecutes only in the demo realm; no physical device is commanded.\n\nThe public path repeats the optimiser segment: the ingress strips /api/optimiser\nand the router is mounted at /optimiser/import-limit.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-realm-note": "Planning works for any organisation with a stored load series and a battery model; dispatch=true executes only in the demo environment.",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Plan computed on the site's stored load series and battery model; dispatch acts on the demo environment device model only.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "periods",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 2,
              "maximum": 336,
              "default": 48
            },
            "description": "Number of half hour periods to plan over."
          },
          {
            "name": "dispatch",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": true
            },
            "description": "Whether to run the live iteration on the device model after planning."
          }
        ],
        "responses": {
          "200": {
            "description": "The run record. Fields present depend on `status`, see the description.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImportLimitRunResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "No import threshold is configured for this site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "No import threshold is configured for this site. Set one first."
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/import-limit/{site_id}/runs": {
      "get": {
        "operationId": "listImportLimitRuns",
        "summary": "Previous import limit runs for a site",
        "description": "Returns the site's recorded runs, newest first by `run_at`, including runs that\nended `not_configured` or `no_stored_series`. Each row is the stored run record;\nthe `battery` and `reason` fields returned by the run call itself are not stored\nand do not appear here.\n\nThe public path repeats the optimiser segment: the ingress strips /api/optimiser\nand the router is mounted at /optimiser/import-limit.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "x-ampverve-method": "Run records as written by the import limit run.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Run history.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "runs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ImportLimitRun"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/tariff-schedule/{site_id}/run": {
      "post": {
        "operationId": "runTariffSchedule",
        "summary": "Day-ahead battery schedule against the full charge stack",
        "description": "Prices every half hour of the next horizon through the C1 charge library\n(supply unit rate by DUoS band, DUoS unit rate, CCL, RO, BSUoS, AAHEDC, the\nCapacity Market supplier charge in its window, and the contracted export rate)\nfor the site's import and export MPANs, then solves a MILP for the battery:\nminimise import kWh x import rate minus export kWh x export rate plus throughput\nx cycling cost, subject to power balance, the rating, SoC bounds including the\nwarranty DoD floor, no simultaneous charge and discharge, no simultaneous import\nand export, the site import limit where configured, and the daily throughput cap\nfrom the warranty cycle budget (row F9). The objective is returned in words.\n\nThe load and PV basis is the site's own last stored half hours carried forward\none horizon, labelled `measured_persistence`. The saving is attributed per\ncharge component against the same site with the battery idle, with export\nrevenue and cycling cost as their own lines; the total is the sum of the lines.\n`tariff_version` is the library's version tag for the rates used.\n\nRefused honestly: no battery model (`no_battery`), no stored series\n(`no_stored_series`), no MPAN or no priced component (`not_priced`, with the\nlibrary's reason). A partially priced stack is optimised against the priced\ncomponents and names the rest in `unpriced_components`. A decision record\n(row F8) is written for every completed run and the run is audited as\n`ems.tariff_schedule.run`.\n\nScoped to the organisation in your bearer token, and to the `control`\npermission on the site in per-site access mode. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "Persistence forecast: the site's last stored half hours carried forward one horizon, priced through the charge library and optimised as a MILP.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "periods",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 2,
              "maximum": 96,
              "default": 48
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The run record and the objective statement.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "run": {
                      "$ref": "#/components/schemas/EmsTariffScheduleRun"
                    },
                    "objective": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/optimiser/tariff-schedule/{site_id}/runs": {
      "get": {
        "operationId": "listTariffScheduleRuns",
        "summary": "Recent tariff schedule runs for a site",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 5
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Runs, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "runs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsTariffScheduleRun"
                      }
                    },
                    "objective": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/optimiser/tariff-schedule/{site_id}/runs/{run_id}": {
      "get": {
        "operationId": "getTariffScheduleRun",
        "summary": "One tariff schedule run",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The run record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "run": {
                      "$ref": "#/components/schemas/EmsTariffScheduleRun"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Run not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/optimiser/tariff-schedule/{site_id}/warranty": {
      "get": {
        "operationId": "listBatteryWarranty",
        "summary": "Warranty models for the site's batteries",
        "description": "Each row with its derived figures: warranted throughput (stated, or cycles x\ncapacity x DoD), cycling cost per kWh (replacement cost over warranted\nthroughput), daily cycle budget (stated, or cycles over warranty days), the\ndaily throughput cap and the SoC floor. `method` states what the model is not:\nno rainflow counting, temperature or calendar ageing.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Warranty models and the site's battery device ids.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "batteries": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "warranties": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmsBatteryWarranty"
                      }
                    },
                    "method": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/optimiser/tariff-schedule/{site_id}/warranty/{device_id}": {
      "put": {
        "operationId": "putBatteryWarranty",
        "summary": "Set the warranty model for a battery",
        "description": "Creates or updates the warranty terms for one battery on the site. Only the\nfields given change. `capacity_kwh` is taken from the device model when the row\nis new and none is given. The change is audited as\n`ems.battery_warranty.update` with before and after.\n\nScoped to the organisation in your bearer token, and to the `configure`\npermission on the site in per-site access mode. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Optimisation"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "device_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "capacity_kwh": {
                    "type": "number",
                    "nullable": true
                  },
                  "warranty_years": {
                    "type": "number",
                    "nullable": true
                  },
                  "warranty_cycles": {
                    "type": "number",
                    "nullable": true
                  },
                  "warranty_throughput_kwh": {
                    "type": "number",
                    "nullable": true
                  },
                  "max_dod_pct": {
                    "type": "number",
                    "nullable": true,
                    "minimum": 0,
                    "maximum": 100
                  },
                  "max_cycles_per_day": {
                    "type": "number",
                    "nullable": true
                  },
                  "replacement_cost_gbp": {
                    "type": "number",
                    "nullable": true
                  },
                  "commissioned_on": {
                    "type": "string",
                    "format": "date",
                    "nullable": true
                  },
                  "source": {
                    "type": "string",
                    "enum": [
                      "vendor_datasheet",
                      "site_profile",
                      "contract",
                      "manual"
                    ],
                    "nullable": true
                  },
                  "source_note": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored row and its derived figures.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "warranty": {
                      "$ref": "#/components/schemas/EmsBatteryWarranty"
                    },
                    "derived": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Nothing to change, or a required field missing on a new row.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Battery not found on this site in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/vpp-signal/{site_id}/enrollment": {
      "get": {
        "operationId": "getVppEnrollment",
        "summary": "This site's enrollment in AmpVerve's own VPP",
        "description": "Whether the site is enrolled in AmpVerve's own VPP, when and by whom it was\nenrolled, and when it was last withdrawn. A site that has never been enrolled\nreturns `enrolled: false` with null timestamps rather than 404.\n\nEnrollment is consent to receive AmpVerve's own signal. It creates no record at\nany DNO, at NESO or at Elexon, and it places no market bid.\n\nScoped to the organisation in your bearer token, and the caller must hold the\n`read` permission on the site. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR,\nANALYST, VIEWER.\n",
        "tags": [
          "VPP enrolment"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The enrollment state and the consent version in force.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/VppEnrollment"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "consent_version": {
                          "type": "string",
                          "example": "v1"
                        }
                      },
                      "required": [
                        "consent_version"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role does not satisfy the route, or the caller holds no `read` permission on the site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "enrollSiteInVpp",
        "summary": "Enrol this site in AmpVerve's own VPP",
        "description": "Records the site as enrolled, with `enrolled_at` set to now, `enrolled_by` set\nto the `sub` claim on the token and any earlier `withdrawn_at` cleared. The\naction is written to the user action audit as `ems.vpp.enroll`.\n\n`consent` must be true; enrolling without consent is refused. Enrolling creates\nno record at any DNO, at NESO or at Elexon and places no market bid.\n\nScoped to the organisation in your bearer token, and the caller must hold the\n`configure` permission on the site. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "VPP enrolment"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "consent": {
                    "type": "boolean",
                    "description": "Must be true. Enrolling without consent is refused."
                  }
                },
                "required": [
                  "consent"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Enrolled.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/VppEnrollment"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "consent_version": {
                          "type": "string",
                          "example": "v1"
                        }
                      },
                      "required": [
                        "consent_version"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`consent` is false.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "consent must be true to enrol"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role does not satisfy the route, or the caller holds no `configure` permission on the site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/optimiser/vpp-signal/{site_id}/withdraw": {
      "post": {
        "operationId": "withdrawSiteFromVpp",
        "summary": "Withdraw this site from AmpVerve's own VPP",
        "description": "Records the site as not enrolled, with `withdrawn_at` set to now, and writes\n`ems.vpp.withdraw` to the user action audit. Withdrawing a site that was never\nenrolled records it as not enrolled; it is not an error. No request body.\n\nScoped to the organisation in your bearer token, and the caller must hold the\n`configure` permission on the site. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "VPP enrolment"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Withdrawn.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VppEnrollment"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role does not satisfy the route, or the caller holds no `configure` permission on the site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/optimiser/vpp-signal/{site_id}/signal": {
      "get": {
        "operationId": "getVppSignal",
        "summary": "The current signal from AmpVerve's own VPP, when the site is enrolled",
        "description": "For an enrolled site, the next recommended charge or discharge block and the\nstated saving, read from the site's latest tariff schedule run\n(`POST /api/optimiser/tariff-schedule/{site_id}/run`). Nothing is computed that\nthe schedule did not already produce, and there is no separate signal engine.\n\nThis is AmpVerve's own day ahead schedule priced against the site's retail\ntariff, not a live wholesale or DNO cleared market signal. Every response\ncarries `basis`, which says so.\n\nWhen the site is not enrolled, `enrolled` is false, `signal` is null and\n`reason` says why. When it is enrolled but there is no completed schedule run,\n`signal.available` is false and `signal.reason` says why. When the remaining\nhorizon schedules no charge or discharge, `signal.action` is `hold`.\n\nScoped to the organisation in your bearer token, and the caller must hold the\n`read` permission on the site. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR,\nANALYST, VIEWER.\n",
        "tags": [
          "VPP enrolment"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "Read from the site's latest tariff schedule run, which uses a persistence forecast of the site's own load and PV.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The signal, or the reason there is none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "enrolled": {
                      "type": "boolean"
                    },
                    "signal": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/VppSignal"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "reason": {
                      "type": "string",
                      "description": "Present only when the site is not enrolled."
                    },
                    "basis": {
                      "type": "string",
                      "description": "Present only when the site is not enrolled; an enrolled site's `signal` carries its own."
                    }
                  },
                  "required": [
                    "site_id",
                    "enrolled",
                    "signal"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role does not satisfy the route, or the caller holds no `read` permission on the site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/optimiser/v2x/ai-brain/awareness/catalogue": {
      "get": {
        "operationId": "getAwarenessCatalogue",
        "summary": "The twelve awareness layers and what live, fallback and missing mean",
        "tags": [
          "AI Brain"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "responses": {
          "200": {
            "description": "The layer catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "layers": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "states": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/optimiser/v2x/ai-brain/awareness/layers": {
      "get": {
        "operationId": "getAwarenessLayers",
        "summary": "Every awareness layer's status for one site, without solving",
        "description": "Gathers the six awareness layers (grid, carbon, comfort, travel, asset,\nsettlement) and reports the six other optimiser inputs (price, tariff, weather,\nsolar, demand, battery). Each layer is `live` (primary source answered,\nvalidated and fresh), `fallback` (a named, weaker substitute was used) or\n`missing` (no usable value; nothing is substituted). Stale data is `missing`.\n\nGrid: measured GB frequency and imbalance prices from Elexon BMRS, NESO DFS\nservice requirements from the NESO Data Portal, and the organisation's received\nSSEN Flexible Power dispatches and DSO tender windows. Carbon: the Carbon\nIntensity API 48 hour regional forecast, an average intensity and labelled as\nsuch, never marginal. Comfort: a first order thermal model fitted from the\nhome's own telemetry. Travel: OSRM road distance and measured efficiency.\nAsset: the fleet layer's capability, freshness and envelope. Settlement: off\nunless the organisation enabled it.\n\nScoped to the organisation in your bearer token. `site_id` defaults to the\ntoken's workspace, else the household site `home`.\n",
        "tags": [
          "AI Brain"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Twelve layers in a fixed order, with counts per state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": "string"
                    },
                    "generated_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "layers": {
                      "type": "array",
                      "minItems": 12,
                      "maxItems": 12,
                      "items": {
                        "type": "object",
                        "properties": {
                          "layer": {
                            "type": "string",
                            "enum": [
                              "price",
                              "tariff",
                              "weather",
                              "solar",
                              "demand",
                              "battery",
                              "grid",
                              "carbon",
                              "comfort",
                              "travel",
                              "asset",
                              "settlement"
                            ]
                          },
                          "title": {
                            "type": "string"
                          },
                          "owner": {
                            "type": "string",
                            "enum": [
                              "awareness",
                              "forecasting",
                              "existing"
                            ]
                          },
                          "state": {
                            "type": "string",
                            "enum": [
                              "live",
                              "fallback",
                              "missing"
                            ]
                          },
                          "source": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "method": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "An honest label for what the value is, for example \"regional average carbon intensity forecast, not marginal emissions\"."
                          },
                          "as_of": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "age_seconds": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "max_age_seconds": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "validated": {
                            "type": "boolean"
                          },
                          "checks": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "name": {
                                  "type": "string"
                                },
                                "passed": {
                                  "type": "boolean"
                                },
                                "detail": {
                                  "type": [
                                    "string",
                                    "null"
                                  ]
                                }
                              }
                            }
                          },
                          "reason": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "fallback": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "Name of the substitute used when state is fallback."
                          },
                          "components": {
                            "type": "object",
                            "description": "Sub-sources with their own status, for example the grid layer's frequency, system prices, DFS notices, DNO dispatches and DNO tenders."
                          },
                          "detail": {
                            "type": "object"
                          }
                        },
                        "required": [
                          "layer",
                          "state"
                        ]
                      }
                    },
                    "counts": {
                      "type": "object",
                      "properties": {
                        "live": {
                          "type": "integer"
                        },
                        "fallback": {
                          "type": "integer"
                        },
                        "missing": {
                          "type": "integer"
                        }
                      }
                    },
                    "notes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  },
                  "required": [
                    "organisation_id",
                    "site_id",
                    "layers",
                    "counts"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/optimiser/v2x/ai-brain/awareness/plan": {
      "post": {
        "operationId": "createAwarenessShadowPlan",
        "summary": "Build, solve and record one shadow plan using every awareness layer",
        "description": "Builds a 48 hour, half hourly plan for the site from the organisation's own\nregistered devices and solves it. Committed DNO dispatches are hard constraints;\nthe comfort band and the vehicle's required departure state of charge are hard\nconstraints; stale, unassessable or uncontrollable assets are left out. The\ncarbon term covers only slots the forecast covers, and `plan.plan_carbon`\nreports the plan's import carbon with its coverage and method. The settlement\nterm is present only when the organisation enabled it, and is reported\nseparately in `plan.objective_terms.settlement`; it creates no market\nobligation.\n\nThe plan is a shadow plan: nothing is sent to any device or market. Which\nlayers were live, fallback or missing is returned in `layers` and `inputs`, and\nrecorded append only in `ai_brain_plan_records` (`record_id`). When the record\ntable is not yet migrated, `record_id` is null and `record_error` says why.\n",
        "tags": [
          "AI Brain"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "A 48 hour half hourly shadow plan solved over the organisation's registered devices and the forecast awareness layers; nothing is sent to a device or market.",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The shadow plan, its layer statuses and its record id.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": "string"
                    },
                    "shadow": {
                      "type": "boolean",
                      "const": true
                    },
                    "generated_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "layers": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "counts": {
                      "type": "object"
                    },
                    "plan": {
                      "type": "object",
                      "properties": {
                        "status": {
                          "type": "string"
                        },
                        "summary": {
                          "type": "object"
                        },
                        "ev_report": {
                          "type": "object"
                        },
                        "schedule": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        },
                        "plan_carbon": {
                          "type": [
                            "object",
                            "null"
                          ],
                          "properties": {
                            "kg_co2": {
                              "type": "number"
                            },
                            "coverage": {
                              "type": "number"
                            },
                            "uncovered_import_kwh": {
                              "type": "number"
                            },
                            "average_gco2_per_kwh": {
                              "type": [
                                "number",
                                "null"
                              ]
                            },
                            "method": {
                              "type": [
                                "string",
                                "null"
                              ]
                            }
                          }
                        },
                        "objective_terms": {
                          "type": [
                            "object",
                            "null"
                          ],
                          "description": "Every term the objective held. `settlement.present` is false, with the reason, when the organisation has not enabled it."
                        },
                        "grid_constraints": {
                          "type": [
                            "object",
                            "null"
                          ]
                        },
                        "infeasible_reason": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    },
                    "inputs": {
                      "type": "object"
                    },
                    "record_id": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "record_error": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  },
                  "required": [
                    "organisation_id",
                    "site_id",
                    "shadow",
                    "layers",
                    "plan"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/optimiser/v2x/ai-brain/awareness/plan/latest": {
      "get": {
        "operationId": "getLatestAwarenessPlanRecord",
        "summary": "The organisation's latest recorded shadow plan for the site",
        "tags": [
          "AI Brain"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The record, or null with `error` when there is none or the table is not migrated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "site_id": {
                      "type": "string"
                    },
                    "record": {
                      "type": [
                        "object",
                        "null"
                      ]
                    },
                    "error": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/optimiser/v2x/ai-brain/awareness/settings": {
      "get": {
        "operationId": "getAwarenessSettings",
        "summary": "Whether the settlement term is enabled for the organisation (read only)",
        "description": "Read from the organisation's `ai_brain_org_settings` row. A missing row, or a\ntable that is not migrated, reads as disabled, which is the default. This API\ncannot change the setting and no market write is ever made from it.\n",
        "tags": [
          "AI Brain"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "responses": {
          "200": {
            "description": "The setting.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "settlement_term_enabled": {
                      "type": "boolean"
                    },
                    "updated_at": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "error": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "default": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/optimiser/v2x/ai-brain/awareness/grid": {
      "get": {
        "operationId": "getGridSnapshot",
        "summary": "Measured GB system frequency and the latest imbalance prices",
        "description": "Public data from Elexon BMRS: the latest `FREQ` measurement and the latest\npublished System Sell and System Buy Prices. Each has its own live or missing\nstatus; a reading older than its freshness limit is null, never carried\nforward. Imbalance prices are published after each settlement period and are\nnot wholesale or forecast prices.\n",
        "tags": [
          "AI Brain"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "responses": {
          "200": {
            "description": "The snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "frequency_hz": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "frequency_measured_at": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "system_price": {
                      "type": [
                        "object",
                        "null"
                      ]
                    },
                    "status": {
                      "type": "object"
                    },
                    "sources": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/hardware/ems/access/personas": {
      "get": {
        "operationId": "listSitePersonas",
        "summary": "The five site personas and what each may do",
        "description": "`landlord` (read, finance_read), `occupier` (read; the building tenant, named\noccupier because tenant means SaaS tenant elsewhere), `site_engineer` (read,\ncontrol, configure), `operations` (read, control, configure, alerts, export),\n`finance` (read, finance_read, export). Also the organisation role to permission\ntable that applies when an organisation holds no memberships, and the rule.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site access"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "Personas, permissions and the rule.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "personas": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SitePersona"
                      }
                    },
                    "permissions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "role_permissions": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      }
                    },
                    "rule": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/hardware/ems/access/memberships": {
      "get": {
        "operationId": "listSiteMemberships",
        "summary": "Site memberships of the organisation, with the access mode",
        "description": "Every persona held on every site, with the user's email and organisation role\nand the site name, plus `mode`: `organisation_global` when no membership exists,\n`site_scoped` once one does. Roles: PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "Site access"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Memberships, users, sites and personas.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "mode": {
                      "type": "string",
                      "enum": [
                        "organisation_global",
                        "site_scoped"
                      ]
                    },
                    "memberships": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SiteMembership"
                      }
                    },
                    "users": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    },
                    "sites": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "personas": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SitePersona"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "grantSiteMembership",
        "summary": "Grant or replace a user's persona on a site",
        "description": "One persona per user per site; a second grant replaces the first. Audited as\n`access.site_membership.grant` with the replaced persona. Roles: PLATFORM_ADMIN,\nORG_ADMIN.\n",
        "tags": [
          "Site access"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site_id",
                  "user_id",
                  "persona"
                ],
                "properties": {
                  "site_id": {
                    "type": "string"
                  },
                  "user_id": {
                    "type": "string"
                  },
                  "persona": {
                    "type": "string",
                    "enum": [
                      "landlord",
                      "occupier",
                      "site_engineer",
                      "operations",
                      "finance"
                    ]
                  },
                  "expires_at": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true
                  },
                  "note": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The membership row.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "membership": {
                      "$ref": "#/components/schemas/SiteMembership"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Site or user not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "operationId": "revokeSiteMembership",
        "summary": "Remove a user's persona from a site",
        "description": "Audited as `access.site_membership.revoke`. Roles PLATFORM_ADMIN, ORG_ADMIN.",
        "tags": [
          "Site access"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "user_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Rows removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "removed": {
                      "type": "integer"
                    },
                    "site_id": {
                      "type": "string"
                    },
                    "user_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No membership for that user on that site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/ems/access/me": {
      "get": {
        "operationId": "mySiteAccess",
        "summary": "What the signed-in user may do on each site",
        "description": "One entry per site with the permissions held, the authority (`role:<ROLE>`,\n`persona:<persona>` or `platform`) and the mode. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Site access"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "The caller's grants.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "user_id": {
                      "type": "string",
                      "nullable": true
                    },
                    "grants": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "site_id": {
                            "type": "string"
                          },
                          "site_name": {
                            "type": "string"
                          },
                          "permissions": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            }
                          },
                          "authority": {
                            "type": "string"
                          },
                          "persona": {
                            "type": "string",
                            "nullable": true
                          },
                          "mode": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/hardware/admin/audit-log": {
      "get": {
        "operationId": "listAuditLog",
        "summary": "A page of your organisation's audit trail",
        "description": "Reads one page of the audit table, newest first (`created_at` descending,\nwith `id` descending as the tiebreak so rows written in the same instant do\nnot move between pages). `total` is a count over the same filter, not the\nlength of the page.\n\nAuthority and scope come from the verified token and nothing else. An\n`ORG_ADMIN` reads their own organisation's rows. A platform administrator\nreads every organisation, and the response says so with\n`scope.cross_tenant: true`. `OPERATOR`, `ANALYST` and `VIEWER` are refused\nwith 403. A query parameter that names an organisation (`organisation_id`,\n`organization_id`, `org_id`, `tenant_id`) is refused with 400 rather than\nignored, and no request header changes the scope.\n\n`action`, `since` and `until` are the same filters the CSV export takes, so\na page and the export show the same rows. They narrow within the scope and\nnever widen it. `chain_verification` reports the tamper evident hash chain\nfor the organisation; for the cross tenant view it says that chains are per\norganisation and does not claim verification.\n\nThe handler returns a dict without a FastAPI response model; the schema\nbelow is read from `backend/core/audit_read.py` and is not enforced at\nruntime.\n",
        "tags": [
          "Audit log"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "action",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Action name prefix, for example `ems.override` or `settlement.`."
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            },
            "description": "Inclusive lower bound on `created_at`."
          },
          {
            "name": "until",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            },
            "description": "Exclusive upper bound on `created_at`."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50
            },
            "description": "Page size. Values are clamped to 1 to 200, not rejected; `limit` in the response is the value applied."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Rows to skip. A negative value is treated as 0."
          }
        ],
        "responses": {
          "200": {
            "description": "One page of audit rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditLogPage"
                }
              }
            }
          },
          "400": {
            "description": "A tenant-naming query parameter was supplied.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No verified token, or an organisation token with no resolvable\norganisation. There is no fallback organisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller holds no role permitted to read audit records: `ORG_ADMIN`\non an organisation token, or `PLATFORM_ADMIN` or `PLATFORM_OWNER` on a\nplatform token.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Audit records require the ORG_ADMIN role"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/admin/audit-log/actions": {
      "get": {
        "operationId": "listAuditActionCatalogue",
        "summary": "The catalogue of user actions the platform records",
        "description": "Every action name a route can write through\n`backend.core.user_action_audit.record_user_action`, with its meaning. The\nconsole and the CSV export render these names verbatim. Same authority as the\naudit read: ORG_ADMIN, or PLATFORM_ADMIN and PLATFORM_OWNER.\n",
        "tags": [
          "Audit log"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "The catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "actions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "action": {
                            "type": "string"
                          },
                          "meaning": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/admin/audit-log/export.csv": {
      "get": {
        "operationId": "exportAuditLogCsv",
        "summary": "Export the caller's audit trail as CSV",
        "description": "Streams the audit rows the caller may read, oldest first in chain order, as\nCSV with the columns `created_at, chain_seq, id, organisation_id, org_name,\nuser_id, actor_email, actor_roles, actor_type, action, meaning, resource,\ndetails, prev_hash, entry_hash`, followed by a trailer line stating the row\ncount, the scope, and whether the export hit the cap (50 000 rows) and is a\nprefix of the trail.\n\nSame scoping as the paginated read: an ORG_ADMIN exports their own\norganisation, a platform administrator exports every organisation and the\ntrailer says so. A tenant-naming query parameter is refused with 400. The\nexport is itself recorded on the chain as `audit.export`, committed before\nthe first row is streamed.\n",
        "tags": [
          "Audit log"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "action",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Action name prefix, for example `ems.override`."
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "until",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50000,
              "default": 50000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "CSV, `text/csv; charset=utf-8`, with a `Content-Disposition` attachment filename.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "A tenant-naming query parameter was supplied.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/data-integrity/assets": {
      "get": {
        "operationId": "listIntegrityAssets",
        "summary": "Registered assets grouped by site",
        "description": "Returns every device registered to your organisation, grouped by site, for\nthe console's site and asset pickers. Unlike the device health surface this\nincludes devices that are not live enabled, because a data fed asset (a seeded\nsite, or a customer's CSV) has readings without ever being polled.\n\n`quality_taxonomy` carries the four quality words the backend writes on a\nreading (`measured`, `interpolated`, `estimated`, `missing`) so the console\nrenders the same vocabulary rather than a copy that can drift.\n\n`site_name` on a site is taken from the first device that records one in its\ndetails; it is null when no device does.\n\nScoped to the organisation in your bearer token. Roles on this service default\nto `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Data integrity"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Assets grouped by site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntegrityAssetsResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/data-integrity/commissioning": {
      "get": {
        "operationId": "getCommissioningFindings",
        "summary": "Latest commissioning run for one site",
        "description": "Returns the most recent stored commissioning run for one site together with\nthe check catalogue and the site's nameplate context (coordinates, PV kWp,\ngrid supply kW) read from its device rows.\n\n`state` is `never_run` and `latest` is null when no run has been stored for\nthe site, so the console shows that rather than an empty list that reads as\nall clear. `latest` is also null when the results table is unreachable.\n\nThe six checks are `ct_polarity`, `ct_ratio`, `pv_at_night`, `power_factor`,\n`sentinel_values` and `timestamp_dst`. A finding's `status` is `fail`, `pass`\nor `skipped`; a skipped check carries its reason in `summary` so a check that\ncould not run is never mistaken for one that passed. `severity` is `critical`\nor `warning` on a fail (per check) and `info` otherwise.\n\nScoped to the organisation in your bearer token. A `site_id` belonging to\nanother organisation returns 404, indistinguishable from an unknown id. Roles\non this service default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Data integrity"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "workspaces.id of the site"
          }
        ],
        "responses": {
          "200": {
            "description": "Latest run, check catalogue and site context.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntegrityCommissioningResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No device of your organisation has that site id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "No site with that id in your organisation."
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/data-integrity/commissioning/run": {
      "post": {
        "operationId": "runCommissioningChecks",
        "summary": "Run the commissioning checks now",
        "description": "Runs every commissioning check over the site's readings for the last `hours`\nhours and stores the findings as a new run. Readings are merged from both\ntelemetry stores (`public.telemetry` and `telemetry_log`), bound to your\norganisation and to the asset ids of the site's own device rows.\n\n`readings` maps every asset id of the site to the number of samples found, so\na silent asset appears as zero rather than being absent. `stored` is false when\nthe results table could not be written; the findings are still returned.\n\n`kind` records why the run happened: `manual` for the console's run now button,\n`onboarding` for the first run after a site is registered.\n\nScoped to the organisation in your bearer token. Roles on this service default\nto `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Data integrity"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 744,
              "default": 48
            },
            "description": "Window length in hours, ending now."
          },
          {
            "name": "kind",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^(manual|onboarding)$",
              "default": "manual"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The run just performed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntegrityRunResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No device of your organisation has that site id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "No site with that id in your organisation."
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/sites/templates": {
      "get": {
        "operationId": "listSiteTemplates",
        "summary": "Site templates available to the organisation",
        "description": "Returns the platform default templates (scope `platform`) alongside the\ntemplates your organisation has added (scope `organisation`). A template is a\nlist of slots, each naming a device class and the telemetry points a device in\nthat slot should supply.\n\n`device_classes` is the vocabulary a slot's `device_class` is drawn from:\n`meter`, `solar`, `battery`, `charger`, `heatpump`.\n\nScoped to the organisation in your bearer token. Roles: `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Site onboarding"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "templates": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OnboardingTemplate"
                      }
                    },
                    "device_classes": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "meter",
                          "solar",
                          "battery",
                          "charger",
                          "heatpump"
                        ]
                      }
                    }
                  },
                  "required": [
                    "organisation_id",
                    "templates",
                    "device_classes"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createSiteTemplate",
        "summary": "Add an organisation template",
        "description": "Creates or replaces a template owned by your organisation. The template id is\nderived from your organisation id and a slug of `name`, so posting the same\nname again overwrites the earlier template. `source` is recorded as `operator`\nand `version` as 1.\n\nEach slot must carry a `slot` name (unique within the template) and a known\n`device_class`. `points` may be plain strings or objects with `point`,\n`required` (default true) and `unit`. `max_devices` defaults to 1; null means\nany number of devices may fill the slot.\n\nScoped to the organisation in your bearer token. Roles: `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`.\n",
        "tags": [
          "Site onboarding"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OnboardingTemplateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The stored template.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "template": {
                      "$ref": "#/components/schemas/OnboardingTemplate"
                    }
                  },
                  "required": [
                    "template"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The slots payload is malformed: an unknown `device_class`, a missing slot\nname, a duplicate slot name, a malformed point, or `max_devices` below 1.\n`detail` is the validation message.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role on the token does not satisfy the endpoint, or the request reached\nthe store without a usable organisation id.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/source-priority": {
      "get": {
        "operationId": "listSourcePriority",
        "summary": "List per-asset source priority settings",
        "description": "When one physical asset reaches the platform from two sources, the\ncustomer's Home Assistant connector and a direct cloud connector (for\nexample the read-only LuxPower or myVAILLANT connectors), every\nmeasurement of that asset is taken from exactly one source so nothing\nis counted twice. This lists the settings that decide which.\n\nAn asset with no row uses the default, returned in `default`: Home\nAssistant primary, the direct connector as fallback once the primary\nreading is older than `stale_after_seconds` (900).\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Limit to one site."
          }
        ],
        "responses": {
          "200": {
            "description": "The organisation's source priority rows and the default.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "policies": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SourcePriorityPolicy"
                      }
                    },
                    "default": {
                      "type": "object",
                      "properties": {
                        "primary_source": {
                          "type": "string",
                          "enum": [
                            "home_assistant",
                            "direct"
                          ]
                        },
                        "fallback_enabled": {
                          "type": "boolean"
                        },
                        "stale_after_seconds": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "putSourcePriority",
        "summary": "Set the source priority for one asset at one site",
        "description": "Creates or replaces the source priority row for (`site_id`,\n`asset_key`). `home_assistant_entities` maps each measurement name\n(for example `battery_soc_pct`, `battery_power_w`) to the Home\nAssistant entity id that reports it. `direct_device_id` must be a\nconnected device of your organisation, or the request is refused\nwith 404.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "asset_key"
                ],
                "properties": {
                  "site_id": {
                    "type": "string",
                    "default": "default"
                  },
                  "asset_key": {
                    "type": "string"
                  },
                  "primary_source": {
                    "type": "string",
                    "enum": [
                      "home_assistant",
                      "direct"
                    ],
                    "default": "home_assistant"
                  },
                  "fallback_enabled": {
                    "type": "boolean",
                    "default": true
                  },
                  "stale_after_seconds": {
                    "type": "integer",
                    "minimum": 30,
                    "maximum": 86400,
                    "default": 900
                  },
                  "direct_device_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "home_assistant_entities": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored row.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "policy": {
                      "$ref": "#/components/schemas/SourcePriorityPolicy"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/source-priority/resolve": {
      "get": {
        "operationId": "resolveSourcePriority",
        "summary": "Current telemetry for one asset, one source per measurement",
        "description": "Applies the asset's source priority to the latest Home Assistant\nreading per mapped entity and the direct connector's last good poll.\nEach measurement names the source it came from, when that value was\nmeasured and why that source was chosen. Values are never summed or\naveraged across sources. A measurement with no fresh value from any\npermitted source is returned with `available: false` and a `null`\nvalue: unknown, not zero.\n\n`direct_measurements_not_counted` lists the measurements taken from\nHome Assistant, which a consumer must not also take from the direct\ndevice.\n\nScoped to the organisation in your bearer token. Roles: PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "asset_key",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "default"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The resolved measurements.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "asset_key": {
                      "type": "string"
                    },
                    "policy": {
                      "$ref": "#/components/schemas/SourcePriorityPolicy"
                    },
                    "direct_device_found": {
                      "type": "boolean",
                      "nullable": true
                    },
                    "direct_measurements_not_counted": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "measurements": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "value": {
                            "nullable": true
                          },
                          "unit": {
                            "type": "string",
                            "nullable": true
                          },
                          "source": {
                            "type": "string",
                            "enum": [
                              "home_assistant",
                              "direct"
                            ],
                            "nullable": true
                          },
                          "observed_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "available": {
                            "type": "boolean"
                          },
                          "reason": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/sites/from-template": {
      "post": {
        "operationId": "createSiteFromTemplate",
        "summary": "Create a site from a template and propose its device mapping",
        "description": "Creates a site (a workspace of type `site`), discovers candidate devices, pairs\nthem with the template's slots and stores the result as an onboarding run with\nstatus `proposed`. The onboarding clock starts at `timing.started_at` and is\nstopped by `POST /api/hardware/sites/{site_id}/confirm-mapping`.\n\nDiscovery has four modes, reported in `discovery.mode`:\n\n| mode         | When |\n| ------------ | ---- |\n| `fixture`    | Your organisation is in the demo realm and a seeded discovery fixture exists. Credentials supplied are not used and each is reported with status `ignored`. |\n| `live`       | At least one credential set was supplied. Each is verified through the provider adapter and, where the adapter can enumerate, its devices are listed. |\n| `registered` | No credentials were supplied; devices registered with your organisation and not yet placed on a site are offered. |\n| `none`       | No credentials and no waiting registered device. Slots are shown empty. |\n\nRegistered devices not yet on a site are offered in the `live` mode too.\nCredentials are used for the scan only and are never echoed or stored; a\nprovider error message has credential values redacted before it is recorded\nin `credential_outcomes`.\n\nThe proposal is deterministic. Each slot gets a `confidence` from 0 to 1 with a\n`band` of `high` (0.85 and above), `medium` (0.60 and above), `low`, or `none`\nwhen no device of the slot's class was discovered, and a `reason` sentence\nexplaining the score. A slot with `max_devices` null receives every remaining\ndevice of its class as indexed sub slots (`ev_chargers[1]`, `ev_chargers[2]`).\n\nIn the demo environment the discovery is a seeded fixture rather than a live\nscan; `discovery.notice` says so in words.\n\nScoped to the organisation in your bearer token. Roles: `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`.\n",
        "tags": [
          "Site onboarding"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Deterministic matching of discovered devices to template slots, with a confidence score; in the demo environment discovery is a seeded fixture, reported as discovery.mode fixture.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OnboardingFromTemplateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Site created, discovery and proposal stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "$ref": "#/components/schemas/OnboardingSite"
                    },
                    "template": {
                      "$ref": "#/components/schemas/OnboardingTemplate"
                    },
                    "run": {
                      "$ref": "#/components/schemas/OnboardingRun"
                    },
                    "discovery": {
                      "$ref": "#/components/schemas/OnboardingDiscovery"
                    },
                    "proposal": {
                      "$ref": "#/components/schemas/OnboardingProposal"
                    },
                    "timing": {
                      "type": "object",
                      "description": "Clock at creation. `elapsed_ms` is null until the mapping is confirmed.",
                      "properties": {
                        "started_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "discovered_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "discovery_ms": {
                          "type": "integer",
                          "description": "Milliseconds from start to the end of discovery."
                        },
                        "elapsed_ms": {
                          "type": "null"
                        },
                        "note": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "started_at",
                        "discovered_at",
                        "discovery_ms",
                        "elapsed_ms",
                        "note"
                      ]
                    }
                  },
                  "required": [
                    "site",
                    "template",
                    "run",
                    "discovery",
                    "proposal",
                    "timing"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "A credential set names a `device_class` that is not one of the five known classes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The role on the token does not satisfy the endpoint, or the request reached\nthe store without a usable organisation id.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No template with that id is visible to your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "template not found for this organisation"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "The stored template's slots column is malformed and cannot be parsed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/sites/onboarding/runs": {
      "get": {
        "operationId": "listOnboardingRuns",
        "summary": "Onboarding runs of the organisation with their timings",
        "description": "Returns your organisation's onboarding runs, newest `started_at` first. The\nlist is kept light: the full `discovery`, `proposal` and `confirmed` objects\nare omitted and replaced by `device_count` (devices discovered) and\n`accepted_count` (slots accepted at confirmation, 0 while proposed). Read the\nfull run on `GET /api/hardware/sites/{site_id}/onboarding`.\n\n`elapsed_ms` is the time from `started_at` to `confirmed_at`, null while the\nrun is still `proposed`.\n\nScoped to the organisation in your bearer token. Roles: `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Site onboarding"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Restrict to one site."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Runs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "runs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OnboardingRunListItem"
                      }
                    }
                  },
                  "required": [
                    "organisation_id",
                    "runs"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/sites/{site_id}/onboarding": {
      "get": {
        "operationId": "getSiteOnboarding",
        "summary": "Latest onboarding run and confirmed mapping of a site",
        "description": "Returns the site, its most recent onboarding run in full (discovery, proposal\nand, once confirmed, the `confirmed` object with the accepted and rejected\ndecisions and the timing), and the slot mappings currently in force for the\nsite. `run` is null when the site has never been onboarded through a template\nand `mappings` is empty until a mapping is confirmed.\n\nScoped to the organisation in your bearer token. Roles: `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Site onboarding"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Site, latest run and mappings.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "$ref": "#/components/schemas/OnboardingSite"
                    },
                    "run": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/OnboardingRun"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "mappings": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OnboardingMapping"
                      }
                    }
                  },
                  "required": [
                    "site",
                    "run",
                    "mappings"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "site not found for this organisation"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/sites/{site_id}/confirm-mapping": {
      "post": {
        "operationId": "confirmSiteMapping",
        "summary": "Confirm the proposed mapping and stop the onboarding clock",
        "description": "Applies the operator's decisions to a `proposed` run. For each decision the\nslot must be in the run's proposal; `device_ref` defaults to the proposed\ndevice and must come from the same run's discovery and match the slot's class.\nA decision with `accept` false, or with no device, is recorded under\n`confirmed.rejected`.\n\nEach accepted device is given a row on the site, and `how` in the accepted\nentry says how: `placed_registered_device` (an existing registered device moved\nonto the site), `created_synthetic_from_fixture` (a demo realm fixture device,\ncreated with `synthetic: true` and not live enabled), `connected_live_device`\n(a live device written with the credentials supplied, live enabled, picked up\nby the poller) or `placed_without_credentials` (a live device recorded with\n`needs_credentials: true` and not live enabled).\n\nThe run moves to `confirmed`, its slot mappings replace any earlier mapping for\nthe site, and the clock stops: `timing.elapsed_ms` is start to confirmation,\n`discovery_ms` start to end of discovery, `review_ms` discovery to\nconfirmation. `confirmed.required_slots_unfilled` lists required slots that no\naccepted decision filled.\n\nIn the demo environment a fixture device becomes a synthetic device row.\n\nScoped to the organisation in your bearer token. Roles: `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`.\n",
        "tags": [
          "Site onboarding"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "x-ampverve-method": "Writes the mapping you confirm; a demo environment fixture device is created as synthetic and not live enabled.",
        "parameters": [
          {
            "name": "site_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OnboardingConfirmIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The confirmed run and the mappings now in force.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "$ref": "#/components/schemas/OnboardingSite"
                    },
                    "run": {
                      "$ref": "#/components/schemas/OnboardingRun"
                    },
                    "mappings": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OnboardingMapping"
                      }
                    },
                    "timing": {
                      "$ref": "#/components/schemas/OnboardingTiming"
                    }
                  },
                  "required": [
                    "site",
                    "run",
                    "mappings",
                    "timing"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The run was already confirmed, a decision names a slot not in the proposal,\na `device_ref` was not part of the run's discovery, the device's class does\nnot match the slot, or the store was reached without a usable organisation\nid. `detail` says which.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No site with that id in your organisation, no onboarding run with `run_id`\nfor this site, or a registered device to be placed was not found in your\norganisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/admin/alerts-history": {
      "get": {
        "operationId": "listAlertHistory",
        "summary": "Platform staff only: persisted alerts across every organisation",
        "description": "Returns a page of persisted admin alerts across every organisation, newest\n`created_at` first, each enriched with `org_name` (falls back to the\norganisation id when no organisation row matches). Filter by `resolved` and by\n`provider`.\n\nThis is a platform surface, not tenant scoped: the role on the token must be\n`PLATFORM_ADMIN` or `PLATFORM_OWNER`. It is not available to partner\norganisations; an organisation token receives 403. Read your organisation's\nalerts with `GET /api/hardware/ems/alerts`, and its queue statistics with\n`GET /api/hardware/admin/alerts-history/stats`.\n\nWhen the alerts table does not exist yet, or either query fails, the response\nis `{ \"total\": 0, \"alerts\": [] }` rather than an error. The list carries the\ncreation and resolution columns only; acknowledgement and assignment state\nwritten by the action routes below is not returned here.\n",
        "tags": [
          "Alerts"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-audience": "platform-staff",
        "parameters": [
          {
            "name": "resolved",
            "in": "query",
            "schema": {
              "type": [
                "boolean",
                "null"
              ]
            },
            "description": "Only resolved (true) or only open (false) alerts. Omit for both."
          },
          {
            "name": "provider",
            "in": "query",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Exact match on the alert's provider."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of alerts. `total` counts every row matching the filters, not the page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AlertHistoryList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token does not carry `PLATFORM_ADMIN` or `PLATFORM_OWNER`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Platform admin role required for admin overview"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/admin/alerts-history/{alert_id}/acknowledge": {
      "post": {
        "operationId": "acknowledgeAlert",
        "summary": "Record that a named person has seen the alert",
        "description": "Sets `acknowledged_at` and `acknowledged_by` on an open alert, keeping the\nfirst acknowledgement if one already exists. Acknowledgement is separate from\nresolution: someone is looking at it and it is dealt with are different facts.\n\nThe actor is the `sub` (else `user_id`, else `email`) claim on the token. A\ntoken with no identifiable user is refused, because the action is recorded\nagainst a person. Scoped to the organisation in your bearer token unless the\ntoken carries `PLATFORM_ADMIN` or `PLATFORM_OWNER`, in which case any\norganisation's alert may be acknowledged and `cross_tenant` is true.\n\nRoles on this service default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`.\n",
        "tags": [
          "Alerts"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "alert_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Acknowledged.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "alert_id": {
                      "type": "string"
                    },
                    "acknowledged_by": {
                      "type": "string",
                      "description": "The user claim the action was recorded against."
                    },
                    "cross_tenant": {
                      "type": "boolean",
                      "description": "True when the caller acted with a platform role rather than within one organisation."
                    }
                  },
                  "required": [
                    "ok",
                    "alert_id",
                    "acknowledged_by",
                    "cross_tenant"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "No credential, or the token carries no identifiable user (`sub`, `user_id`\nor `email`), or a non platform token carries no organisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No open alert with that id in your organisation. The row may not exist,\nmay belong to another organisation, or may already be resolved; the\nmessage does not distinguish these.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "No open alert with that id in your organisation."
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "The update statement failed. `detail` begins `Alert transition failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/admin/alerts-history/{alert_id}/assign": {
      "post": {
        "operationId": "assignAlert",
        "summary": "Give the alert a named owner",
        "description": "Sets `assigned_to` (trimmed) and `assigned_at` on an open alert. Reassigning\noverwrites the previous owner.\n\nActor resolution and tenant scoping are as for acknowledge: the actor is the\n`sub`, `user_id` or `email` claim; scoped to the organisation in your bearer\ntoken unless the token carries a platform role, in which case `cross_tenant`\nis true.\n\nRoles on this service default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`.\n",
        "tags": [
          "Alerts"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "alert_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AlertHistoryAssignIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Assigned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "alert_id": {
                      "type": "string"
                    },
                    "assigned_to": {
                      "type": "string"
                    },
                    "assigned_by": {
                      "type": "string",
                      "description": "The user claim of the caller."
                    },
                    "cross_tenant": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok",
                    "alert_id",
                    "assigned_to",
                    "assigned_by",
                    "cross_tenant"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`assigned_to` is missing or blank.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No credential, or the token carries no identifiable user, or a non\nplatform token carries no organisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No open alert with that id in your organisation (see acknowledge).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "The update statement failed. `detail` begins `Alert transition failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/admin/alerts-history/{alert_id}/resolve": {
      "post": {
        "operationId": "resolveAlert",
        "summary": "Close the alert, recording who and why",
        "description": "Sets `resolved` true, `resolved_at`, `resolved_by` and `resolution_note` on an\nopen alert. The body is optional; a blank `resolution_note` is stored as null.\n\nAlerts closed before `resolved_by` was written by this route carry a null\n`resolved_by`; that is not backfilled.\n\nActor resolution and tenant scoping are as for acknowledge. Roles on this\nservice default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Alerts"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "alert_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/AlertHistoryResolveIn"
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resolved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "alert_id": {
                      "type": "string"
                    },
                    "resolved_by": {
                      "type": "string"
                    },
                    "resolution_note": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "cross_tenant": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok",
                    "alert_id",
                    "resolved_by",
                    "resolution_note",
                    "cross_tenant"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "No credential, or the token carries no identifiable user, or a non\nplatform token carries no organisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No open alert with that id in your organisation (see acknowledge).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "The update statement failed. `detail` begins `Alert transition failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/admin/alerts-history/{alert_id}/comment": {
      "post": {
        "operationId": "commentOnAlert",
        "summary": "Append a comment to the alert, stamped with who and when",
        "description": "Appends `{ by, at, text }` to the alert's `context.comments` list. `by` is the\nactor, `at` is the server time in UTC, and `text` is the trimmed body text. The\nlist only ever grows; a comment cannot be edited or removed through the API.\n\nUnlike acknowledge, assign and resolve, a comment is accepted on a resolved\nalert as well as an open one, because a post incident note is the usual reason\nto comment after resolution.\n\nActor resolution and tenant scoping are as for acknowledge: the actor is the\n`sub`, `user_id` or `email` claim; scoped to the organisation in your bearer\ntoken unless the token carries `PLATFORM_ADMIN` or `PLATFORM_OWNER`, in which\ncase any organisation's alert may be commented on and `cross_tenant` is true.\n\nRoles on this service default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`.\n",
        "tags": [
          "Alerts"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "alert_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AlertHistoryCommentIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Comment appended.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "alert_id": {
                      "type": "string"
                    },
                    "comment": {
                      "type": "object",
                      "properties": {
                        "by": {
                          "type": "string",
                          "description": "The user claim the comment was recorded against."
                        },
                        "at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "text": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "by",
                        "at",
                        "text"
                      ]
                    },
                    "cross_tenant": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok",
                    "alert_id",
                    "comment",
                    "cross_tenant"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`text` is missing, blank after trimming, or longer than 2000 characters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No credential, or the token carries no identifiable user, or a non\nplatform token carries no organisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No alert with that id in your organisation. The message reads \"No open\nalert with that id in your organisation.\" for consistency with the other\nactions, although a resolved alert can be commented on.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "description": "The update statement failed. `detail` begins `Alert transition failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/admin/alerts-history/stats": {
      "get": {
        "operationId": "getAlertStats",
        "summary": "Alert queue volumes, response times and unowned or unexplained alerts",
        "description": "Counts over the alerts created in the last `days` days: raised, resolved,\nacknowledged, open with no owner (`open_unowned`), and resolved with no\nresolution note (`closed_without_note`). `actioned_pct` is the share of raised\nalerts a person acknowledged, null when none were raised. The two mean times are\nin hours from creation, null when no alert in the window reached that state.\n\nA closure with no stated reason is not evidence that the underlying fault was\nfixed, which is why `closed_without_note` is reported separately; `note` in the\nresponse says the same.\n\nScoped to the organisation in your bearer token unless the token carries\n`PLATFORM_ADMIN` or `PLATFORM_OWNER`, in which case the counts cover every\norganisation and `cross_tenant` is true. The token must carry an identifiable\nuser (`sub`, `user_id` or `email`), as for the alert actions.\n\nRoles on this service default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`.\n",
        "tags": [
          "Alerts"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 7
            },
            "description": "Length of the window in days. Values below 1 are treated as 1 and values above 90 as 90."
          }
        ],
        "responses": {
          "200": {
            "description": "Counts and mean times for the window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AlertHistoryStats"
                }
              }
            }
          },
          "401": {
            "description": "No credential, or the token carries no identifiable user, or a non\nplatform token carries no organisation.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/notifications/": {
      "get": {
        "operationId": "listNotifications",
        "summary": "The calling user's notifications, newest first",
        "description": "The notifications addressed to the calling user (the token's `sub`) in the\ntoken's organisation, newest first. There is no organisation wide view:\neach user sees their own list.\n\nRoles on this service default to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`; `VIEWER` is refused with 403. An organisation that is not\nactive is refused with 403.\n",
        "tags": [
          "Notifications"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "unread_only",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Notifications.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Notification"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/notifications/unread-count": {
      "get": {
        "operationId": "getUnreadNotificationCount",
        "summary": "Unread count for the calling user",
        "description": "The number of unread notifications addressed to the calling user, for a\nbadge. Same authorisation as the list.\n",
        "tags": [
          "Notifications"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "The count.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "unread_count"
                  ],
                  "properties": {
                    "unread_count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/notifications/{notification_id}/read": {
      "post": {
        "operationId": "markNotificationRead",
        "summary": "Mark one of your notifications read",
        "description": "Sets `is_read` on one notification addressed to the calling user and\nreturns it. A notification addressed to someone else is not found. Same\nauthorisation as the list.\n",
        "tags": [
          "Notifications"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "notification_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The notification after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Notification"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No notification with that id addressed to you.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Notification not found"
                }
              }
            }
          }
        }
      }
    },
    "/api/notifications/read-all": {
      "post": {
        "operationId": "markAllNotificationsRead",
        "summary": "Mark all of your notifications read",
        "description": "Marks every unread notification addressed to the calling user read and\nreturns how many changed. Same authorisation as the list.\n",
        "tags": [
          "Notifications"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The number marked read.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "marked_read"
                  ],
                  "properties": {
                    "marked_read": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/notifications/preferences": {
      "get": {
        "operationId": "getNotificationPreferences",
        "summary": "The calling user's notification preferences",
        "description": "The calling user's preference row. When none exists one is created with\nthe defaults (every topic and channel on, except `marketing` and\n`sms_enabled`) and returned. The flags are stored per user; EMS alert\nrouting does not consult them, and `sms_enabled` is not a delivery\nchannel. Same authorisation as the list.\n",
        "tags": [
          "Notifications"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The preferences.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationPreferences"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "updateNotificationPreferences",
        "summary": "Update the calling user's notification preferences",
        "description": "Changes only the fields you send. Creates the row first if none exists.\nSame authorisation as the list.\n",
        "tags": [
          "Notifications"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NotificationPreferencesUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The preferences after the update.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationPreferences"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/field/vocabulary": {
      "get": {
        "operationId": "getFieldVocabulary",
        "summary": "Work order statuses, allowed transitions, priorities and resolution codes",
        "description": "The lifecycle the gateway enforces: the seven statuses, which status may follow\nwhich through the transition action, the four priorities, the resolution codes a\nresolved order must carry, the sources an order can be raised from and the SLA\nrisk bands the dispatch list filters on.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "The vocabulary.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "statuses": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FieldWorkOrderStatus"
                      }
                    },
                    "active_statuses": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FieldWorkOrderStatus"
                      }
                    },
                    "transitions": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "$ref": "#/components/schemas/FieldWorkOrderStatus"
                        }
                      }
                    },
                    "priorities": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "priority": {
                            "type": "string"
                          },
                          "label": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "resolution_codes": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string"
                          },
                          "meaning": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "sources": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "manual",
                          "alert",
                          "stale_feed"
                        ]
                      }
                    },
                    "assignee_kinds": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "user",
                          "contractor"
                        ]
                      }
                    },
                    "sla_risks": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "clocks": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "elapsed",
                          "business_hours"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/field/assets": {
      "get": {
        "operationId": "listFieldAssets",
        "summary": "Registry devices with feed freshness and live work orders",
        "description": "Every device of the organisation's asset registry with its site, its feed\nfreshness band (healthy, stale, dark, never_worked, pending, judged against the\ndevice's own reporting cadence) and the work orders still live against it. The\nsites list is the organisation's workspaces. Used to raise a work order by hand\nagainst a device, or from a stale feed.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "Sites and devices.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "as_of": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "sites": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "site_id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "assets": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "device_id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string",
                            "nullable": true
                          },
                          "device_type": {
                            "type": "string",
                            "nullable": true
                          },
                          "asset_type": {
                            "type": "string",
                            "nullable": true
                          },
                          "provider": {
                            "type": "string",
                            "nullable": true
                          },
                          "site_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "site_name": {
                            "type": "string",
                            "nullable": true
                          },
                          "freshness": {
                            "type": "object",
                            "additionalProperties": true
                          },
                          "needs_attention": {
                            "type": "boolean"
                          },
                          "live_work_orders": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "additionalProperties": true
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/field/alerts": {
      "get": {
        "operationId": "listFieldOpenAlerts",
        "summary": "Open alerts with the work order raised from each",
        "description": "The organisation's open alerts, newest first, each with `work_order_id` (the\nalert side of the link) and `work_order` (reference and status of the order\nraised from it, or null).\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Open alerts with their work order link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "alerts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/field/sla-policies": {
      "get": {
        "operationId": "getFieldSlaPolicies",
        "summary": "SLA policy in force per priority, with the platform defaults",
        "description": "The policy each priority runs under for this organisation: the organisation's\nown row where it has one, the platform default otherwise. The platform defaults\nare data (table `field_sla_policy_defaults`), not code. A work order keeps the\npolicy it was raised under, so a change applies to orders raised or\nre-prioritised after it.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "Policies in force and the defaults.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FieldSlaPolicies"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/field/sla-policies/{priority}": {
      "put": {
        "operationId": "putFieldSlaPolicy",
        "summary": "Set the organisation's SLA policy for one priority",
        "description": "Response and resolution minutes on either the `elapsed` (24x7) clock or the\n`business_hours` clock, which counts only the working window in the policy's\nIANA timezone and so stays correct across a clock change. Resolution may not be\nshorter than response. An audit entry `field.sla_policy.update` with the before\nand after is chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "priority",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "P1",
                "P2",
                "P3",
                "P4"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "response_minutes",
                  "resolution_minutes"
                ],
                "properties": {
                  "response_minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 527040
                  },
                  "resolution_minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 527040
                  },
                  "clock": {
                    "type": "string",
                    "enum": [
                      "elapsed",
                      "business_hours"
                    ],
                    "default": "elapsed"
                  },
                  "timezone": {
                    "type": "string",
                    "default": "Europe/London"
                  },
                  "business_start": {
                    "type": "string",
                    "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
                    "default": "08:00"
                  },
                  "business_end": {
                    "type": "string",
                    "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
                    "default": "18:00"
                  },
                  "business_days": {
                    "type": "array",
                    "items": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 6
                    },
                    "default": [
                      0,
                      1,
                      2,
                      3,
                      4
                    ]
                  },
                  "pause_on_hold": {
                    "type": "boolean",
                    "default": true
                  },
                  "description": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The policies in force after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FieldSlaPolicies"
                }
              }
            }
          },
          "400": {
            "description": "The policy cannot be used (window, timezone, days or minutes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "operationId": "resetFieldSlaPolicy",
        "summary": "Remove the organisation's policy so the platform default applies",
        "description": "Deletes the organisation's row for the priority. An audit entry\n`field.sla_policy.reset` carrying the removed row is chained in the same\ntransaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "priority",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "P1",
                "P2",
                "P3",
                "P4"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The policies in force after the reset.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FieldSlaPolicies"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The organisation has no policy of its own for that priority.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders": {
      "get": {
        "operationId": "listFieldWorkOrders",
        "summary": "Dispatch list, filtered by status, priority, site, assignee and SLA risk",
        "description": "The organisation's work orders ordered breached first, then by the next due-by,\nthen by priority. `sla_risk` is evaluated at request time on each order's own\npolicy clock. `risk_counts` counts the filtered set by risk.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma separated statuses."
          },
          {
            "name": "priority",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma separated priorities, P1 to P4."
          },
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "device_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "assignee",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "assignee_ref or assignee_name."
          },
          {
            "name": "sla_risk",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "on_track",
                "due_soon",
                "breached",
                "met",
                "not_applicable"
              ]
            }
          },
          {
            "name": "source_kind",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "manual",
                "alert",
                "stale_feed"
              ]
            }
          },
          {
            "name": "alert_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Orders raised from this alert."
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Matches title or reference."
          },
          {
            "name": "due_soon_minutes",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 10080,
              "default": 120
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of work orders.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "total": {
                      "type": "integer"
                    },
                    "limit": {
                      "type": "integer"
                    },
                    "offset": {
                      "type": "integer"
                    },
                    "risk_counts": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      }
                    },
                    "scan_truncated": {
                      "type": "boolean"
                    },
                    "work_orders": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FieldWorkOrder"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "An unknown status, priority or SLA risk.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createFieldWorkOrder",
        "summary": "Raise a work order by hand against the asset registry",
        "description": "Creates an `open` order numbered `WO-nnnnnn` per organisation. A `device_id` must\nbe a device of this organisation and fixes the site to the device's workspace;\nnaming a different site is refused. The response and resolution due-bys come\nfrom the SLA policy in force for the priority, which is stored on the order. An\naudit entry `field.work_order.create` is chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title",
                  "priority"
                ],
                "properties": {
                  "title": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200
                  },
                  "description": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 4000
                  },
                  "priority": {
                    "type": "string",
                    "enum": [
                      "P1",
                      "P2",
                      "P3",
                      "P4"
                    ]
                  },
                  "device_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "site_id": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The new work order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "$ref": "#/components/schemas/FieldWorkOrder"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The device is not on the named site, or a field is malformed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Device or site not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "503": {
            "description": "The Field migration has not been applied to this database.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/due": {
      "get": {
        "operationId": "listFieldWorkOrdersDue",
        "summary": "Active work orders already breached or due inside the window",
        "description": "Orders in open, assigned, in_progress or on_hold whose response or resolution\nis already breached, or whose next due-by falls within `within_minutes`, breached\nfirst. An order on hold under a pausing policy has no running resolution due-by.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "within_minutes",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 10080,
              "default": 120
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Breached and due soon orders.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "as_of": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "within_minutes": {
                      "type": "integer"
                    },
                    "breached": {
                      "type": "integer"
                    },
                    "due_soon": {
                      "type": "integer"
                    },
                    "work_orders": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FieldWorkOrder"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/field/reports/sla": {
      "get": {
        "operationId": "getFieldSlaReport",
        "summary": "SLA report for the organisation over a period",
        "description": "Figures for the orders raised in the period, overall, by priority and by site:\ncounts by current status, median response and resolution times (elapsed minutes\nfrom creation), response and resolution breaches judged against each order's own\ndue-by as at `as_of`, orders breached and compliance. Cancelled orders are\ncounted but carry no SLA. The dates are local calendar dates in `tz` (resolved\nthrough zoneinfo, so a period spanning a clock change starts and ends at local\nmidnight); `end` is included.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "First local date; default the first of this month."
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Last local date, inclusive; default today."
          },
          {
            "name": "tz",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "Europe/London"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The report.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "period_start": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "period_end": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "as_of": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "timezone": {
                      "type": "string"
                    },
                    "basis": {
                      "type": "string"
                    },
                    "overall": {
                      "$ref": "#/components/schemas/FieldSlaFigures"
                    },
                    "by_priority": {
                      "type": "object",
                      "additionalProperties": {
                        "$ref": "#/components/schemas/FieldSlaFigures"
                      }
                    },
                    "by_site": {
                      "type": "object",
                      "additionalProperties": {
                        "$ref": "#/components/schemas/FieldSlaFigures"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Period or timezone invalid (end before start, more than 366 days, unknown timezone).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/field/work-orders/from-alert/{alert_id}": {
      "post": {
        "operationId": "createFieldWorkOrderFromAlert",
        "summary": "Raise a work order from an alert, linked both ways",
        "description": "Raises an order from an open alert of this organisation. The order carries\n`source_alert_id` and a snapshot of the alert; the alert's `work_order_id` is\nset to the order in the same transaction. The device and site come from the\nalert when they are in the registry. Without a `priority` the alert severity\nsets it (critical P1, warning P2, info P3, low P4). A second live order for the\nsame alert is refused with 409 until the first is closed or cancelled. An audit\nentry `field.work_order.create` naming the alert is chained in the same\ntransaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "alert_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FieldRaiseRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The new work order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "$ref": "#/components/schemas/FieldWorkOrder"
                    },
                    "alert_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Alert not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The alert is resolved or already has a live work order.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/from-stale-feed/{device_id}": {
      "post": {
        "operationId": "createFieldWorkOrderFromStaleFeed",
        "summary": "Raise a work order for a device whose feed has stopped",
        "description": "Raises an order for a registry device whose freshness band is stale, dark or\nnever_worked, judged by the same bands and per device cadence as the device\nhealth views. The verdict (band, last good reading, age, cadence) is stored as\nthe order's snapshot. A healthy feed is refused with 409, as is a second live\nstale feed order for the same device. An audit entry `field.work_order.create`\nis chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "device_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FieldRaiseRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The new work order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "$ref": "#/components/schemas/FieldWorkOrder"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Device not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The feed is healthy, or the device already has a live stale feed work order.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/{work_order_id}": {
      "get": {
        "operationId": "getFieldWorkOrder",
        "summary": "One work order with its timeline, notes, attachments and links",
        "description": "The order with its SLA position, every event in order, notes, attachment\nmetadata, and the registry device, site and source alert it links to.\n\nRequires the `field` module. Scoped to the organisation in your bearer token: an\norder of another organisation is 404, exactly as one that does not exist.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "work_order_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The work order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/FieldWorkOrder"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "events": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "additionalProperties": true
                              }
                            },
                            "notes": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "additionalProperties": true
                              }
                            },
                            "attachments": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "additionalProperties": true
                              }
                            },
                            "device": {
                              "type": "object",
                              "nullable": true,
                              "additionalProperties": true
                            },
                            "site": {
                              "type": "object",
                              "nullable": true,
                              "additionalProperties": true
                            },
                            "alert": {
                              "type": "object",
                              "nullable": true,
                              "additionalProperties": true
                            }
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Work order not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/{work_order_id}/assign": {
      "post": {
        "operationId": "assignFieldWorkOrder",
        "summary": "Assign to a named person or contractor",
        "description": "A `user` assignee must be an active user of this organisation, named by id or\nemail; a `contractor` is a name or reference with an optional contact. Assigning\nan open order moves it to `assigned`; reassigning keeps the status. A resolved,\nclosed or cancelled order cannot be reassigned. An audit entry\n`field.work_order.assign` is chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "work_order_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "assignee_kind",
                  "assignee_ref"
                ],
                "properties": {
                  "assignee_kind": {
                    "type": "string",
                    "enum": [
                      "user",
                      "contractor"
                    ]
                  },
                  "assignee_ref": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200
                  },
                  "assignee_name": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 200
                  },
                  "assignee_contact": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 200
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The work order after the assignment.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "$ref": "#/components/schemas/FieldWorkOrder"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Work order or user not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The order cannot be reassigned in its status, or the user is deactivated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/{work_order_id}/transition": {
      "post": {
        "operationId": "transitionFieldWorkOrder",
        "summary": "Move the work order along its lifecycle",
        "description": "Allowed moves: open to assigned or cancelled; assigned to open, in_progress,\non_hold or cancelled; in_progress to on_hold, resolved, assigned or cancelled;\non_hold to in_progress, assigned or cancelled; resolved to closed or back to\nin_progress (reopen). closed and cancelled are terminal. in_progress, on_hold\nand resolved need an assignee; resolved needs a `resolution_code`; cancelled and\nreopen need a `reason`. The first entry into in_progress is the response\ninstant. Leaving on_hold under a pausing policy adds the clock minutes on hold to\nthe resolution due-by. A breach once observed stays recorded. Anything else is\nrefused with 409 and nothing is written. An audit entry\n`field.work_order.transition` is chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "work_order_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "to_status"
                ],
                "properties": {
                  "to_status": {
                    "$ref": "#/components/schemas/FieldWorkOrderStatus"
                  },
                  "reason": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 4000
                  },
                  "resolution_code": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "fixed_on_site",
                      "fixed_remotely",
                      "part_replaced",
                      "configuration_changed",
                      "third_party_resolved",
                      "no_fault_found",
                      "duplicate",
                      "not_reproducible"
                    ]
                  },
                  "resolution_note": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 4000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The work order after the transition.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "$ref": "#/components/schemas/FieldWorkOrder"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Work order not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The lifecycle does not allow the move, or a required fact is missing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/{work_order_id}/priority": {
      "post": {
        "operationId": "changeFieldWorkOrderPriority",
        "summary": "Re-prioritise and recompute the due-bys",
        "description": "Recomputes both due-bys from the creation instant under the new priority's\npolicy, plus any clock minutes already spent on hold. A breach already recorded\nstays recorded. A closed or cancelled order cannot be re-prioritised. An audit\nentry `field.work_order.priority.change` is chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "work_order_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "priority",
                  "reason"
                ],
                "properties": {
                  "priority": {
                    "type": "string",
                    "enum": [
                      "P1",
                      "P2",
                      "P3",
                      "P4"
                    ]
                  },
                  "reason": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 4000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The work order after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "work_order": {
                      "$ref": "#/components/schemas/FieldWorkOrder"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Work order not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Same priority, or the order is closed or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/{work_order_id}/notes": {
      "post": {
        "operationId": "addFieldWorkOrderNote",
        "summary": "Append a note",
        "description": "Notes are never edited and may be added in any status. An audit entry\n`field.work_order.note.add` is chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "work_order_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 4000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The note.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "note": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Work order not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/field/work-orders/{work_order_id}/attachments": {
      "post": {
        "operationId": "addFieldWorkOrderAttachment",
        "summary": "Record an attachment's metadata (no file is stored)",
        "description": "Records the file name, content type, size, SHA-256 and a description. No file\nis uploaded or stored by this release; every row carries\n`storage_state: metadata_only`. An audit entry `field.work_order.attachment.add`\nis chained in the same transaction.\n\nRequires the `field` module. Scoped to the organisation in your bearer token.\nRoles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "Field"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "parameters": [
          {
            "name": "work_order_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "file_name"
                ],
                "properties": {
                  "file_name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 255
                  },
                  "content_type": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 200
                  },
                  "size_bytes": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 0
                  },
                  "sha256": {
                    "type": "string",
                    "nullable": true,
                    "pattern": "^[0-9a-fA-F]{64}$"
                  },
                  "description": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 1000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The attachment record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "attachment": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A path in the file name, a malformed digest or an impossible size.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Work order not found in this organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/devices/health": {
      "get": {
        "operationId": "listDeviceHealth",
        "summary": "Connectivity and freshness of every connected device",
        "description": "One row per live enabled device of your organisation, with the health\nblock the telemetry poller writes on every cycle, a freshness verdict, the\ndevice's capabilities and the latest live readings the poller stored. No\nadapter is called by this request, and it is not narrowed by workspace.\n\nFreshness is judged against each device's own adapter cadence, not a flat\nfigure: `healthy` under 3 missed cycles since the last successful read,\n`stale` from 3, `dark` from 12, `never_worked` when polling was attempted\nand never succeeded, `pending` when nothing has been attempted yet.\n`is_stale` is true for `stale`, `dark` and `never_worked`. `needs_relink`\nis true when the owner has to reconnect the integration (a revoked or\nexpired token, for example).\n\n`summary.healthy` is `total` minus `needs_relink` minus `stale`; a device\nthat both needs relinking and is stale is subtracted twice, so read the per\ndevice fields when exact counts matter.\n\n`last_reported_at` and `last_reported_source` say when each device last\nreported and how that is known, null when nothing is known. See\n`DeviceLastReportedSource`.\n\nPoll this read to find device level problems. The handler returns a dict\nwithout a FastAPI response model; the schema below is read from\n`routers/devices.py` and is not enforced at runtime.\n\nScoped to the organisation in your bearer token. Roles on this service\ndefault to `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "stored",
        "responses": {
          "200": {
            "description": "Device health, a summary and the thresholds applied.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeviceHealthList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/vehicles": {
      "get": {
        "operationId": "listVehicles",
        "summary": "Connected vehicles",
        "description": "Returns every vehicle connection stored for your organisation, one row per\naggregator link. A car linked through two aggregators appears twice here; the\ntelemetry route below collapses such rows. `connected_at` is the time the\nconnection row was created.\n\n`last_reported_at` is when the car last reported, from stored evidence\nonly: this listing makes no provider call. A car linked through Enode\nhas it (Enode's own last seen time, or the poller's stored reading or\nsync); a car linked only through Smartcar or Tesla has no stored\nevidence and is null here. Read `GET /api/hardware/vehicles/telemetry/all`\nfor the provider's reading time on a live read.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles: `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Must match the organisation in the token if supplied."
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Narrow the listing to one workspace."
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Platform tokens only. Read another organisation's data read only."
          }
        ],
        "responses": {
          "200": {
            "description": "Vehicles.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DeviceVehicle"
                  }
                }
              }
            }
          },
          "400": {
            "description": "The token carries no organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Organisation missing in token"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/vehicles/telemetry/all": {
      "get": {
        "operationId": "getAllVehicleTelemetry",
        "summary": "Live telemetry from every connected vehicle",
        "description": "Reads telemetry from each connected vehicle through its provider adapter\n(`smartcar`, `enode` or `tesla`), refreshing provider tokens where the adapter\nrotated them. Connection rows for the same physical car are collapsed to one\nentry first, matched on VIN, else on make and model where neither VIN is\nknown and the providers differ; a collapsed entry carries `duplicate_of`\ndescribing the match.\n\n`telemetry` is the adapter's own dict and is passed through as returned. An\nadapter error puts `error` in `telemetry`, sets `status` to `offline` and\n`online` to false. An unsupported provider yields `telemetry.error`. An\nexception while reading a vehicle yields a reduced entry of `vehicle_id`,\n`error` and the two last reported fields.\n\n`last_reported_at` is the provider's own reading time from this read\n(`telemetry.reading_at`) when it gave one, else the stored evidence the\nvehicle listing uses, else null. `telemetry.timestamp` and\n`telemetry.fetched_at` are the time of the request, not a reading time,\nand are never used for it.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles: `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Telemetry per vehicle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeviceVehicleTelemetryAll"
                }
              }
            }
          },
          "400": {
            "description": "The token carries no organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Organisation missing in token"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/hardware/smartmeter": {
      "get": {
        "operationId": "listSmartMeters",
        "summary": "Connected smart meters with current readings",
        "description": "Returns your organisation's connected devices of asset type `smart_meter`,\neach enriched with whatever reading path succeeds:\n\n1. Where the stored credentials carry an Octopus Energy API key and MPAN, the\n   Octopus consumption API is read directly. `telemetry_kind` is\n   `delayed_settlement` (or `live_home_mini` when a Home Mini is present),\n   `power_kw` is null, and the half hourly import and export totals, latest\n   readings and tariff fields are filled. A failure sets `telemetry_error`.\n2. Otherwise the device's adapter is read. `telemetry_kind` defaults to\n   `live_instantaneous` and `power_kw`, `electricity_today_kwh` and\n   `electricity_24h_kwh` are filled. Failures are swallowed and leave the\n   device without readings.\n\nIn both cases the poller's stored `live_telemetry` fills gaps: tariff code and\nname, the Home Mini `live_*` wattage fields (also promoted to the top level as\n`live_demand_w`, `live_import_kw`, `live_export_kw`), and the rate fields.\n\n`live_telemetry` is the adapter or Octopus dict passed through as returned;\nits shape is not fixed. Credentials inside `details` are redacted to `***`.\n\n`last_reported_at` is the Home Mini's own reading time when one is paired,\nelse the newest stored reading or the poller's last sync with the\nsupplier. `last_seen_at` is the connect time and does not move.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles on this service default to `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Smart meters.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "devices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DeviceSmartMeter"
                      }
                    }
                  },
                  "required": [
                    "devices"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/chargers": {
      "get": {
        "operationId": "listChargers",
        "summary": "Connected chargers with current readings",
        "description": "Returns your organisation's connected devices of asset type `charger`. Each\nrow carries its adapter's declared capabilities merged with the stored ones,\n`v2x_capable`, `needs_relink` and `last_ok_at` from the poller's health block,\nand any paired vehicle identity. A charger reached through a cloud adapter is\nread live on this request: its power, mode, plug state and session energy\nare added, or `telemetry_error` when the read failed.\n\nEvery device carries `last_reported_at` (ISO 8601 UTC, `Z` suffix) and\n`last_reported_source`, both null when nothing is known. See the schema\n`DeviceLastReportedSource` for the sources and the order they are\npreferred in. Neither is ever the time of the request, the connect time\nor `last_seen_at`.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles on this service default to `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`. The handler returns a dict without a\nFastAPI response model; the schema is read from `routers/devices.py`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Chargers.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "chargers": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DeviceConnected"
                      }
                    }
                  },
                  "required": [
                    "chargers"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/solar": {
      "get": {
        "operationId": "listSolar",
        "summary": "Connected solar inverters with current readings",
        "description": "Returns your organisation's connected devices of asset type `solar`. A device\nreached through a cloud adapter is read live on this request: `generating_kw`\n(never negative), `today_kwh` and the plant's battery and grid figures are\nadded, or `telemetry_error` when the read failed.\n\nEvery device carries `last_reported_at` (ISO 8601 UTC, `Z` suffix) and\n`last_reported_source`, both null when nothing is known. See the schema\n`DeviceLastReportedSource` for the sources and the order they are\npreferred in. Neither is ever the time of the request, the connect time\nor `last_seen_at`.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles on this service default to `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`. The handler returns a dict without a\nFastAPI response model; the schema is read from `routers/devices.py`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Solar inverters.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "devices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DeviceConnected"
                      }
                    }
                  },
                  "required": [
                    "devices"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/battery": {
      "get": {
        "operationId": "listBatteries",
        "summary": "Connected batteries",
        "description": "Returns your organisation's connected devices of asset type `battery`, as\nstored. When there are none, a Huawei FusionSolar plant's linked battery is\nlisted instead, with the plant's battery figures and the plant's\n`last_reported_at`, since it reports through the plant connection.\n\nEvery device carries `last_reported_at` (ISO 8601 UTC, `Z` suffix) and\n`last_reported_source`, both null when nothing is known. See the schema\n`DeviceLastReportedSource` for the sources and the order they are\npreferred in. Neither is ever the time of the request, the connect time\nor `last_seen_at`.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles on this service default to `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`. The handler returns a dict without a\nFastAPI response model; the schema is read from `routers/devices.py`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Batteries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "devices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DeviceConnected"
                      }
                    }
                  },
                  "required": [
                    "devices"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/heatpump": {
      "get": {
        "operationId": "listHeatPumps",
        "summary": "Connected heat pumps with current readings",
        "description": "Returns your organisation's connected devices of asset type `heatpump`. A\ndevice with an adapter is read live on this request: room, outdoor, leaving\nwater, target and tank temperatures, mode and electricity counters are added,\nor `telemetry_error` when the read failed.\n\nEvery device carries `last_reported_at` (ISO 8601 UTC, `Z` suffix) and\n`last_reported_source`, both null when nothing is known. See the schema\n`DeviceLastReportedSource` for the sources and the order they are\npreferred in. Neither is ever the time of the request, the connect time\nor `last_seen_at`.\n\nScoped to the organisation in your bearer token, and to the workspace on the\ntoken when one is present. Roles on this service default to `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`, `ANALYST`. The handler returns a dict without a\nFastAPI response model; the schema is read from `routers/devices.py`.\n",
        "tags": [
          "Devices"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Heat pumps.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "devices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DeviceConnected"
                      }
                    }
                  },
                  "required": [
                    "devices"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/hardware/fleet/telematics/vehicles": {
      "get": {
        "operationId": "listFleetTelematicsVehicles",
        "summary": "Latest measured position and ignition for each vehicle",
        "description": "One row per vehicle of your organisation: the most recent record the vendor\ntelematics feed delivered in the last 7 days. Every value is the vendor's\nmeasurement as received; nothing is estimated or substituted.\n\n`state` is derived only from those values: `moving` (ignition on and speed\nabove 3 km/h), `idling` (ignition on, stationary), `parked` (ignition off),\n`stale` (no record for six hours) or `unknown` (ignition not reported).\n`counts` gives the number of vehicles in each state. The feed carries no\ntraction state of charge and no charging status; `capability` states what\nthe feed can and cannot answer. A read failure of the telemetry store is\nanswered as an empty list, not as an error.\n\nScoped to the organisation in your bearer token. Requires the `fleet`\nmodule. Roles: PLATFORM_ADMIN, ORG_ADMIN, OPERATOR, ANALYST, VIEWER.\n",
        "tags": [
          "Fleet telematics"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "responses": {
          "200": {
            "description": "The latest record for each vehicle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetTelematicsVehicles"
                }
              }
            }
          },
          "400": {
            "description": "The token carries no organisation context.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/hardware/usage/carbon": {
      "get": {
        "operationId": "getHouseholdCarbonFootprint",
        "summary": "Carbon footprint of the household's own electricity import, by day, week or month",
        "description": "kgCO2 for the electricity your own Octopus smart meter imported, per London\ncalendar day, Monday week or calendar month, ending with the current one.\nEach half hour's kWh is multiplied by the National Grid ESO (NESO) Carbon\nIntensity API's regional intensity for the same half hour in your region,\nin grams per kWh. NESO publishes regional intensity as an estimate for each\nhalf hour (there is no regional outturn), so `x-ampverve-data-basis` is\n`calculated`: the kWh are measured, the intensity is NESO's estimate. Location based,\nnot the supplier's fuel mix.\n\nA day is counted only when the meter reported every half hour of it (46,\n48 or 50 on clock change days) and the feed published an intensity for\nevery one. `kgco2` on a period is set only when every finished day in it\nwas counted; otherwise `covered_kgco2` and `covered_days` give the counted\npart, to be shown as \"for N of M days\". Today is never counted.\n\nGreat Britain only. The region is the GSP group of the import MPAN\n(distributor ID 10 to 23) or, failing that, the region letter of the\nimport tariff on the Octopus account. A home that cannot be placed in one\nof the fourteen GB regions is answered `available: false` with the reason;\nno national figure is substituted.\n\nScoped to the organisation in your bearer token. The Octopus API key is\nread from the organisation's own smart meter record, used only for the\nOctopus calls and never returned. Cached for 15 minutes per organisation\nand query.\n",
        "tags": [
          "Carbon and emissions"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Measured import energy multiplied by NESO's published regional half hourly carbon intensity, which NESO publishes as an estimate.",
        "parameters": [
          {
            "name": "granularity",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "day",
                "week",
                "month"
              ],
              "default": "month"
            }
          },
          {
            "name": "count",
            "in": "query",
            "required": false,
            "description": "Periods ending with the current one. Day up to 31, week up to 13, month up to 12. Defaults 7, 8 and 12.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The footprint per period, or `available` false with the reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HouseholdCarbon"
                }
              }
            }
          },
          "400": {
            "description": "Unknown granularity, or a count outside the range for it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/fleet/readiness": {
      "get": {
        "operationId": "getFleetReadinessBoard",
        "summary": "Departure readiness board for one site",
        "description": "Every vehicle registered to the site with its `DepartureAssessment`: the\ncurrent state of charge, the required departure state of charge, the\npredicted state of charge at departure, the departure time, the energy still\nneeded, the charger and rate the plan assumes, the planned start and expected\ncompletion, whether the vehicle is flexibility eligible (only when a reserve\nremains after the requirement), the status and the reason codes, and the\nevidence for every input (source, timestamp, freshness band).\n\nStatus is one of `READY`, `CHARGING`, `AT_RISK`, `NOT_CONNECTED` or\n`DATA_UNAVAILABLE`. `DATA_UNAVAILABLE` is answered whenever the state of\ncharge or the departure is unknown or the reading is stale; no default is ever\nsubstituted. `stats` counts each status; `readiness_pct` is the share of\nassessed vehicles on track and is null when nothing was assessed.\n\nEach call appends one snapshot row per vehicle to\n`fleet_departure_assessments` (append only, forced RLS). Requires the\n`fleet/readiness` module. Roles `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`, `VIEWER`.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One of the organisation's sites (workspaces of type site). Omitted, the\norganisation's first site is used and every response lists the sites the\ncaller may choose from. A site of another organisation is a 404.\n"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The board.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetReadinessBoard"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/fleet/readiness/electrification": {
      "get": {
        "operationId": "getFleetElectrificationClass",
        "summary": "Fleet electrification class from the evidence present",
        "description": "`ELECTRIFY_NOW`, `ELECTRIFY_WITH_INFRASTRUCTURE_CHANGE`, `REVIEW_LATER` or\n`INSUFFICIENT_EVIDENCE`, computed only from evidence the platform holds for\nthe site: daily energy per vehicle (learned travel patterns), dwell hours\n(learned places), the site's charger power, the battery capacity and the\nsite operating envelope. Every input is listed with its source and whether\nit was available; `INSUFFICIENT_EVIDENCE` names what is missing and is a\nvalid outcome.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One of the organisation's sites (workspaces of type site). Omitted, the\norganisation's first site is used and every response lists the sites the\ncaller may choose from. A site of another organisation is a 404.\n"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The classification with its evidence.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetElectrificationAssessment"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/fleet/depot": {
      "get": {
        "operationId": "getFleetDepot",
        "summary": "The depot's operating envelope, chargers and live charging load",
        "description": "The site operating envelope resolved from the site's control configuration\n(import and export limits, DNO limits, per asset caps, with the reason code\nfor each bound), the chargers registered to the site, and the sum of live\nvehicle charge power. `live_ev_charging_kw` is null when no vehicle has a\ncurrent reading, never zero for \"unknown\".\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One of the organisation's sites (workspaces of type site). Omitted, the\norganisation's first site is used and every response lists the sites the\ncaller may choose from. A site of another organisation is a 404.\n"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The depot.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetDepot"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/fleet/depot/constraints": {
      "get": {
        "operationId": "getFleetDepotConstraints",
        "summary": "Flexibility envelopes per vehicle and for the site",
        "description": "For each vehicle, the flexibility envelope from `build_asset_envelope`\n(posture, consent, mobility verdict from the departure requirement, plug\nstate, state of charge, physical limits, strictest first), and the site\nenvelope from `build_site_envelope`, which counts and names unassessable\nvehicles instead of summing them as zero. The site operating envelope\n(`constraints_applied`) is the hard cap the optimiser applies.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "measured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One of the organisation's sites (workspaces of type site). Omitted, the\norganisation's first site is used and every response lists the sites the\ncaller may choose from. A site of another organisation is a 404.\n"
          },
          {
            "name": "window_hours",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "default": 4,
              "minimum": 0.5,
              "maximum": 48
            }
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The envelopes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetDepotConstraints"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "put": {
        "operationId": "putFleetDepotConstraints",
        "summary": "Set the site import or export limit and the mobility reserve",
        "description": "Writes `import_limit_kw`, `export_limit_kw` and `mobility_reserve_pct` on\nthe site's control configuration (`ems_site_control_config`), the same row\nthe EMS control loops and the optimiser read. Answers the depot as\n`GET /api/fleet/depot` would after the change. Roles `PLATFORM_ADMIN`,\n`ORG_ADMIN`, `OPERATOR`.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site_id"
                ],
                "properties": {
                  "site_id": {
                    "type": "string"
                  },
                  "import_limit_kw": {
                    "type": "number",
                    "nullable": true,
                    "exclusiveMinimum": 0
                  },
                  "export_limit_kw": {
                    "type": "number",
                    "nullable": true,
                    "exclusiveMinimum": 0
                  },
                  "mobility_reserve_pct": {
                    "type": "number",
                    "nullable": true,
                    "minimum": 0,
                    "maximum": 100
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The depot after the change, with `updated`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetDepot"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/fleet/shift-schedule": {
      "get": {
        "operationId": "getFleetShiftSchedule",
        "summary": "Departure requirements at the site",
        "description": "Every `fleet_departure_requirements` row at the site, all sources\n(`csv_import`, `shift_schedule`, `telematics`, `operator`), ordered by\ndeparture.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One of the organisation's sites (workspaces of type site). Omitted, the\norganisation's first site is used and every response lists the sites the\ncaller may choose from. A site of another organisation is a 404.\n"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The requirements.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetShiftSchedule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "put": {
        "operationId": "putFleetShiftSchedule",
        "summary": "Replace the shift schedule requirements at the site",
        "description": "Replaces every requirement of source `shift_schedule` at the site with the\nbody; requirements from other sources are kept. Every row is validated before\nanything is written. `departure_at` is ISO 8601 or a bare `HH:MM` meaning\nthe next such time. Roles `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "site_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "requirements": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/FleetDepartureRequirementIn"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The requirements at the site after the write.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetShiftSchedule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/fleet/requirements/import": {
      "post": {
        "operationId": "importFleetDepartureRequirements",
        "summary": "Import departure requirements from CSV",
        "description": "Body is the CSV as `text/csv`, or JSON `{\"site_id\", \"csv\"}`. Header row\nrequired, column order free. Columns: `vehicle_id` (required; a devices id,\nev_assets id, VIN, provider vehicle id or the vehicle name on the board),\n`departure_at` (required; ISO 8601 or `HH:MM` for the next such time),\n`required_soc_pct` (required; 1 to 100), `recurrence_rule` (optional RRULE),\n`priority_class` (optional: critical, high, normal, low), `label` (optional).\nReplaces every `csv_import` requirement at the site. A bad row rejects the\nwhole file with its row number and leaves the stored schedule as it was.\nAt most 5000 rows and 2 MB. Roles `PLATFORM_ADMIN`, `ORG_ADMIN`, `OPERATOR`.\n",
        "tags": [
          "Fleet readiness"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "parameters": [
          {
            "name": "site_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One of the organisation's sites (workspaces of type site). Omitted, the\norganisation's first site is used and every response lists the sites the\ncaller may choose from. A site of another organisation is a 404.\n"
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-workspace-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "x-impersonate-org",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "text/csv": {
              "schema": {
                "type": "string"
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "csv"
                ],
                "properties": {
                  "site_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "csv": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Imported.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FleetShiftSchedule"
                }
              }
            }
          },
          "413": {
            "description": "The CSV is larger than 2 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The organisation does not have the `fleet/readiness` module, or the role may not perform this action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The named site is not one of the organisation's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/csms/api/hub/sites": {
      "get": {
        "operationId": "listHubSites",
        "summary": "Public charging sites with live price, availability and reliability",
        "description": "The organisation's Hub sites. Each carries the price in force now from the\npublished schedule (`live_price_p_per_kwh`, null when none covers now), every\ncharge point with its latest OCPI EVSE status from the last 24 hours of OCPP\nStatusNotification records, and 30 day reliability (PCPR 2023 regulation 7\nstatus semantics). Scoped to the organisation in the bearer token. Roles\n`TENANT_ADMIN`, `ORG_ADMIN`, `OPERATOR`, `PLATFORM_ADMIN`, `DEVELOPER`, `VIEWER`.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Sites.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "organisation_id": {
                      "type": "string"
                    },
                    "sites": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/HubSite"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "503": {
            "description": "The Hub tables are missing; the detail names the migration to apply.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createHubSite",
        "summary": "Create a public charging site",
        "description": "Records the site's import limit, load sharing settings (`prioritise_rapid`,\n`min_current_a`, at least 6 A) and the facts the PCPR checks read: operator\nmicro business status, helpline, open data publication and price display.\nAudited as `hub.site.created`. Roles `TENANT_ADMIN`, `ORG_ADMIN`, `OPERATOR`,\n`PLATFORM_ADMIN`.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HubSiteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubSite"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getHubSite",
        "summary": "One site with its charge points and schedule versions",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The site, its charge points, the published schedule and every version.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubSite"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "operationId": "updateHubSite",
        "summary": "Replace a site's settings",
        "description": "Audited as `hub.site.updated` with the before and after values.",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HubSiteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubSite"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/charge-points": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "attachHubChargePoint",
        "summary": "Place a registered CSMS charger at a site",
        "description": "`device_id` is a `csms_devices` id or serial number of the caller's\norganisation; another organisation's device is a 404. Placing a charger\nalready at another of the organisation's sites moves it. Audited as\n`hub.charge_point.attached`.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "configured",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "device_id",
                  "max_power_kw"
                ],
                "properties": {
                  "device_id": {
                    "type": "string"
                  },
                  "max_power_kw": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 1000
                  },
                  "evse_count": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 16,
                    "default": 1
                  },
                  "current_type": {
                    "type": "string",
                    "enum": [
                      "AC",
                      "DC"
                    ],
                    "default": "AC"
                  },
                  "phases": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 3,
                    "default": 3
                  },
                  "voltage": {
                    "type": "number",
                    "default": 230
                  },
                  "contactless": {
                    "type": [
                      "boolean",
                      "null"
                    ]
                  },
                  "commissioned_on": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "became_public_on": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "helpline_displayed": {
                    "type": [
                      "boolean",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The charge point.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubChargePoint"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/price-schedules": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "listHubPriceSchedules",
        "summary": "Every version of the site's price schedule",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Versions, oldest first, with their half hourly periods and merged blocks.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site_id": {
                      "type": "string"
                    },
                    "versions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/HubPriceSchedule"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "post": {
        "operationId": "createHubPriceSchedule",
        "summary": "Compute a new draft price schedule version",
        "description": "Each interval's candidate price is energy cost times pass through, plus the\nmargin and the congestion, carbon and utilisation adders, plus VAT, clamped to\nthe published floor and ceiling and rounded to `price_step_p`. The published\nprice then moves no sooner than `min_change_interval_minutes` after the last\nchange, at most `max_changes_per_day` times per UTC day, and not for a move\nunder `min_change_p`; a held period names why. The schedule carries over the\nprice showing from the published version so a new version cannot dodge the\ninterval limit. Returns the PCPR 2023 check and any regulation 11 publish\nblockers. Audited as `hub.price_schedule.created`.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Each interval priced from energy cost, pass through, margin and the stated adders, then clamped and rate limited by the site's published rules.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "policy",
                  "intervals"
                ],
                "properties": {
                  "policy": {
                    "$ref": "#/components/schemas/HubTariffPolicy"
                  },
                  "intervals": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 2976,
                    "items": {
                      "type": "object",
                      "required": [
                        "start",
                        "energy_cost_p_per_kwh"
                      ],
                      "properties": {
                        "start": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "end": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time"
                        },
                        "energy_cost_p_per_kwh": {
                          "type": "number"
                        },
                        "congestion": {
                          "type": [
                            "number",
                            "null"
                          ],
                          "minimum": 0,
                          "maximum": 1
                        },
                        "carbon_g_per_kwh": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "utilisation": {
                          "type": [
                            "number",
                            "null"
                          ],
                          "minimum": 0,
                          "maximum": 1
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The draft, with `pcpr` and `publish_blockers`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubPriceSchedule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "A policy value or interval that cannot produce a lawful, stable price; `detail.field` names it.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/price-schedules/{version}/publish": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "version",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer"
          }
        }
      ],
      "post": {
        "operationId": "publishHubPriceSchedule",
        "summary": "Publish a draft version to drivers",
        "description": "Supersedes the published version. Refused with 409 while a PCPR 2023\nregulation 11 check fails (price not in pence per kWh before charging, or the\nsession price lock is off). Audited as `hub.price_schedule.published`. Roles\n`TENANT_ADMIN`, `ORG_ADMIN`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The published version.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubPriceSchedule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Not a draft, or a regulation 11 check fails (`detail.blockers`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/quote": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "charge_point_id",
          "in": "query",
          "required": false,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "connector_id",
          "in": "query",
          "required": false,
          "schema": {
            "type": "integer",
            "minimum": 0
          }
        }
      ],
      "get": {
        "operationId": "quoteHubPrice",
        "summary": "The price a driver sees now at a connector",
        "description": "From the published schedule: the price per kWh inc VAT, its components, how long\nit holds, and the idle and connection fees. Authenticated; there is no\nunauthenticated price read in this service.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "The published price schedule applied to the connector and the time of the request.",
        "responses": {
          "200": {
            "description": "The quote.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "price_p_per_kwh": {
                      "type": "string"
                    },
                    "display": {
                      "type": "string",
                      "example": "62p/kWh"
                    },
                    "period_start": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "price_holds_until": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "candidate": {
                      "type": "object"
                    },
                    "schedule_version": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The published schedule does not cover the current time.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/transactions": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "limit",
          "in": "query",
          "required": false,
          "schema": {
            "type": "integer",
            "minimum": 1,
            "maximum": 500,
            "default": 50
          }
        }
      ],
      "get": {
        "operationId": "listHubTransactions",
        "summary": "Recent OCPP transactions at the site",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Transactions, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/csms/api/hub/transactions/{transaction_id}/cost": {
      "parameters": [
        {
          "name": "transaction_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "costHubTransaction",
        "summary": "Itemised cost of a completed OCPP transaction",
        "description": "Costed from the stored Energy.Active.Import.Register readings under the schedule\nversion published at or before the session start. Energy between readings is\nspread evenly over time and split at price changes; a period priced above the\nstart price is billed at the start price (PCPR 2023 regulation 11(2)). Idle\ntime runs from the end of the last interval in which energy flowed. Each line\nis rounded half up to the penny and the total is the sum of the lines. A falling\nregister or energy with no published price is refused with 422 and a code.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The itemised charge.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HubSessionCost"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Still in progress, not at a Hub site, or no schedule was published before it started.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`detail.error` is register_decreased, insufficient_readings, no_price_in_force or unsupported_unit.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/reliability": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "start",
          "in": "query",
          "required": false,
          "schema": {
            "type": "string",
            "format": "date-time"
          }
        },
        {
          "name": "end",
          "in": "query",
          "required": false,
          "schema": {
            "type": "string",
            "format": "date-time"
          }
        }
      ],
      "get": {
        "operationId": "getHubReliability",
        "summary": "Reliability and utilisation per charger and for the site",
        "description": "From stored OCPP StatusNotification records, mapped to OCPI EVSE statuses and\ncounted under PCPR 2023 regulation 7 (UNKNOWN counts as reliable, so\n`unknown_pct` is reported beside it). Default window the last 30 days, at most\n400 days.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The report.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/compliance": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "as_of",
          "in": "query",
          "required": false,
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "getHubCompliance",
        "summary": "PCPR 2023 check for the site",
        "description": "Each checked rule of The Public Charge Point Regulations 2023 (SI 2023/1168)\nwith its citation, legislation.gov.uk URL, whether it was verified, and a status\nof pass, fail, not_applicable, not_yet_due, insufficient_data or review.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The check.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/csms/api/hub/sites/{site_id}/load-sharing": {
      "parameters": [
        {
          "name": "site_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "shareHubLoad",
        "summary": "Share the site import limit across active sessions",
        "description": "Budget is the import limit less the EMS margin (`ems_control.balance.margin_kw`)\nand the base load. Sessions are admitted at their minimum current (paused at 0\nwhen it does not fit) and the rest is water filled, rapid first when the site\nprioritises rapid. Returns OCPP 1.6 and 2.0.1 SetChargingProfile payloads as\ndata. With `dispatch: true` profiles are sent through the existing OCPP\ncharging profile path ONLY for a demo realm organisation's synthetic devices\n(`ems_control.realm_guard`); for a production organisation nothing is sent and\n`dispatch.mode` is `computed_only` with the reason. A dispatch request is\naudited as `hub.load_sharing.dispatch`. Dispatch roles `TENANT_ADMIN`,\n`OPERATOR`, `PLATFORM_ADMIN`.\n",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "calculated",
        "x-ampverve-method": "Site import limit shared across active sessions; profiles are sent only to demo environment devices.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dispatch": {
                    "type": "boolean",
                    "default": false
                  },
                  "base_load_kw": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0
                  },
                  "requests": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "required": [
                        "transaction_id",
                        "requested_kw"
                      ],
                      "properties": {
                        "transaction_id": {
                          "type": "string"
                        },
                        "requested_kw": {
                          "type": "number",
                          "minimum": 0
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Allocations, profiles and the dispatch outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The site has no import limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/csms/api/hub/regulations": {
      "get": {
        "operationId": "listHubRegulations",
        "summary": "The PCPR 2023 rules as data",
        "description": "Each rule with its regulation, citation, URL, date and verification flags.",
        "tags": [
          "Hub"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "Rules.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/me": {
      "get": {
        "operationId": "getMe",
        "summary": "Identity context for the bearer token",
        "description": "Returns who the token belongs to and which organisation it is scoped to,\nwith the organisation's console branding and module entitlements.\n\n`user.id` is the token's `sub` claim. `user.email` is read from the user row\nwhen it can be loaded, else from the token's `email` claim. `user.roles` is the\ntoken's `roles` claim. `user.is_platform` is true when the token carries\n`is_platform_user` or `user_kind` of `PLATFORM`.\n\n`tenant.id` is the token's `tenant_id` claim, else `org_id`. `tenant.type` and\n`tenant.status` come from the organisation row and are null when it cannot be\nloaded. The remaining `tenant` fields are branding: organisations with a\nbranding entry receive it; every other organisation receives a generic shell\nwhose `audience` is mapped from the organisation type (`residential` to `b2c`,\n`fleet` to `b2b`, `energy_partner` to `b2b2c`, anything else `b2c`) and whose\n`display_name` is the organisation's name when the row loaded, else a title\ncased form of its id. `allowed_modules` is `core_modules` plus\n`custom_modules`.\n\nScoped to the organisation in your bearer token.\n",
        "tags": [
          "Authentication"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Must match the organisation in the token if supplied."
          }
        ],
        "responses": {
          "200": {
            "description": "Identity context.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthMe"
                }
              }
            }
          },
          "400": {
            "description": "A client token with no organisation claim.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Client token missing organisation_id"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`X-Tenant-ID` disagrees with the token, a platform token carries an\norganisation id, or the token carries no roles.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/notifications/studies/types": {
      "get": {
        "operationId": "listPlanStudyTypes",
        "summary": "Study types, their inputs and parameters",
        "description": "The three AmpVerve Plan study types (DepotTwin, Power Passport, Plan),\neach with its site fields, engine parameters (with ranges and the\nengine defaults), and its inputs: format (CSV or JSON), whether\nrequired, and for CSV inputs every column. Also the largest input\naccepted and the current engine version of each type.\n\nRequires role `ORG_ADMIN`, `ANALYST`, `OPERATOR`, `VIEWER` or\n`PLATFORM_ADMIN`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "responses": {
          "200": {
            "description": "The catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "study_types": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "max_input_bytes": {
                      "type": "integer"
                    },
                    "engine_versions": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/notifications/studies/templates/{study_type}/{kind}": {
      "get": {
        "operationId": "downloadPlanStudyTemplate",
        "summary": "Download the template for one input",
        "description": "A header and two example rows (CSV) or a minimal object (JSON) for one\ninput of one study type, as an attachment. Read roles as\n`listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_type",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "depottwin",
                "power_passport",
                "storage_plan"
              ]
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The template file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/studies/samples/{study_type}/{kind}": {
      "get": {
        "operationId": "downloadPlanStudySampleInput",
        "summary": "Download one input of the illustrative sample study",
        "description": "One input of the bundled ILLUSTRATIVE sample for a study type. Synthetic\nand seeded, not any customer's site; every cost and rate in it carries a\nsource saying it is illustrative. Read roles as `listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "simulated",
        "x-ampverve-method": "Illustrative sample input, synthetic and seeded, not any customer's site.",
        "parameters": [
          {
            "name": "study_type",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "depottwin",
                "power_passport",
                "storage_plan"
              ]
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The sample file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/studies/samples/{study_type}": {
      "post": {
        "operationId": "createPlanSampleStudy",
        "summary": "Create the illustrative sample study",
        "description": "Creates a complete study of the type from the bundled illustrative\nsample: site, parameters and every input, each stored with\n`source: sample`. A run of it is flagged `is_sample` and its report\ncarries the \"Sample study\" banner. Writes `plan_study.created` to the\naudit chain.\n\nRequires role `ORG_ADMIN`, `ANALYST` or `PLATFORM_ADMIN`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_type",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "depottwin",
                "power_passport",
                "storage_plan"
              ]
            }
          }
        ],
        "responses": {
          "201": {
            "description": "The sample study.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudy"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/studies": {
      "get": {
        "operationId": "listPlanStudies",
        "summary": "List the organisation's studies",
        "description": "The organisation's studies, newest first, each with its latest run.\nDeleted studies are not listed. Scoped to the organisation in your\nbearer token; no parameter selects another. Read roles as\n`listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "depottwin",
                "power_passport",
                "storage_plan"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "x-tenant-id",
            "in": "header",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Optional. If supplied it must match the organisation in your bearer token, or the request is rejected with 403."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of studies.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "total": {
                      "type": "integer"
                    },
                    "limit": {
                      "type": "integer"
                    },
                    "offset": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createPlanStudy",
        "summary": "Create a study",
        "description": "Creates a DepotTwin, Power Passport or Plan study with its site details\nand engine parameters. Every field is validated; a refusal is 422 with\none entry per field (`detail.errors[]` with `field` and `message`).\nUnknown parameters are refused rather than ignored. Writes\n`plan_study.created` to the audit chain.\n\nRequires role `ORG_ADMIN`, `ANALYST` or `PLATFORM_ADMIN`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "study_type",
                  "name"
                ],
                "properties": {
                  "study_type": {
                    "type": "string",
                    "enum": [
                      "depottwin",
                      "power_passport",
                      "storage_plan"
                    ]
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "site": {
                    "type": "object",
                    "description": "site_name, region, network_operator, prepared_for, data_basis"
                  },
                  "params": {
                    "type": "object",
                    "description": "The study type's parameters; see listPlanStudyTypes."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The study.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudy"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "Per-field validation errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudyInputErrors"
                }
              }
            }
          }
        }
      }
    },
    "/api/notifications/studies/{study_id}": {
      "get": {
        "operationId": "getPlanStudy",
        "summary": "One study with its inputs and runs",
        "description": "The study, each input with its validation summary (rows, period,\nsource), the required inputs still missing, and its last 20 runs. A\nstudy of another organisation is 404. Read roles as\n`listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The study.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudy"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updatePlanStudy",
        "summary": "Change a study's name, site details or parameters",
        "description": "Merges the given site and parameter fields into the study and\nvalidates the result. Refused with 409 while a run is queued or\nrunning. Writes `plan_study.updated`. Write roles as `createPlanStudy`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "site": {
                    "type": "object"
                  },
                  "params": {
                    "type": "object"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The study.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudy"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "A run is queued or running."
          },
          "422": {
            "description": "Per-field validation errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudyInputErrors"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deletePlanStudy",
        "summary": "Delete a study",
        "description": "Soft delete: the study disappears from every read, the rows are kept,\nand `plan_study.deleted` is written to the audit chain with who and\nwhen. Write roles as `createPlanStudy`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "deleted": {
                      "type": "boolean"
                    },
                    "deleted_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/studies/{study_id}/inputs/{kind}": {
      "put": {
        "operationId": "uploadPlanStudyInput",
        "summary": "Upload or replace one input",
        "description": "The input is parsed and validated before anything is stored. A gap (a\nmissing, duplicated or blank half hour) is refused with its row and the\nhalf hour expected, never filled. Every cost needs its source. A\ntariff needs `meta.source`. At most 5 MB of text per input; the content\nis stored in the database. Replacing an input supersedes the old one,\nwhich is kept so a past run's input hash still names it. Refused with\n409 while a run is queued or running. Writes\n`plan_study.input.uploaded`. Write roles as `createPlanStudy`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "content"
                ],
                "properties": {
                  "content": {
                    "type": "string",
                    "description": "The CSV text",
                    "or the JSON text of a JSON input.": null
                  },
                  "filename": {
                    "type": "string"
                  },
                  "meta": {
                    "type": "object",
                    "description": "For a tariff: {source}."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored, with what validation found.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "kind": {
                      "type": "string"
                    },
                    "content_sha256": {
                      "type": "string"
                    },
                    "size_bytes": {
                      "type": "integer"
                    },
                    "summary": {
                      "type": "object"
                    },
                    "meta": {
                      "type": "object"
                    },
                    "source": {
                      "type": "string",
                      "enum": [
                        "upload",
                        "sample"
                      ]
                    },
                    "uploaded_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "A run is queued or running."
          },
          "413": {
            "description": "The body is larger than one input may be."
          },
          "422": {
            "description": "Per-field, per-row validation errors. Nothing was stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudyInputErrors"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "downloadPlanStudyInput",
        "summary": "Download an input as uploaded",
        "description": "The current content of one input, exactly as stored. Read roles as `listPlanStudyTypes`.",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The input file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "removePlanStudyInput",
        "summary": "Remove an input",
        "description": "Supersedes the current input of that kind. Writes `plan_study.input.removed`. Write roles as `createPlanStudy`.",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Removed."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "A run is queued or running."
          }
        }
      }
    },
    "/api/notifications/studies/{study_id}/runs": {
      "post": {
        "operationId": "requestPlanStudyRun",
        "summary": "Run the study's engine as a background job",
        "description": "Every input is parsed again and the set checked for completeness and\nconsistency; a study that cannot run is refused with 422 and per-field\nerrors before anything is queued. Otherwise a run is recorded as\n`queued` with the engine version and the input hash, and 202 is\nreturned. Poll `getPlanStudyRun` for `running`, then `succeeded` or\n`failed` with `error_kind` (for example `ceiling_breach` when a Plan\nsaving exceeds its physical ceiling and the result is withheld,\n`invalid_input`, `inputs_changed`, `interrupted`). One active run per\nstudy (409 otherwise). Writes `plan_study.run.requested`; the job\nwrites `plan_study.run.succeeded` or `plan_study.run.failed`.\n\nRequires role `ORG_ADMIN`, `ANALYST` or `PLATFORM_ADMIN`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "A projection from the study engine run on the study's inputs (which may be the illustrative sample), labelled Projection in the response.",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Queued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudyRun"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "A run is already queued or running."
          },
          "422": {
            "description": "The study cannot run; per-field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudyInputErrors"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listPlanStudyRuns",
        "summary": "The study's runs, newest first",
        "description": "Read roles as `listPlanStudyTypes`.",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The runs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlanStudyRun"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/studies/{study_id}/runs/{run_id}": {
      "get": {
        "operationId": "getPlanStudyRun",
        "summary": "One run with its summary and result",
        "description": "Status, timings, engine version, input hash, the headline summary and,\nonce succeeded, the full engine result in an envelope with\n`label: \"MODELLED\"` (a machine value, kept for existing clients) and\n`display_label: \"Projection\"` (the text to show), the engine name,\nversion, input hash and whether the inputs were the illustrative\nsample. Read roles as\n`listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "A projection from the study engine run on the study's inputs (which may be the illustrative sample), labelled Projection in the response.",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanStudyRun"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/studies/{study_id}/runs/{run_id}/report": {
      "get": {
        "operationId": "getPlanStudyReport",
        "summary": "The run's HTML report",
        "description": "The self-contained HTML report (inline CSS and SVG, no external\nrequests, A4 print styles; print to PDF from the browser), served only\nfrom the owning organisation's run. Sent with a restrictive\nContent-Security-Policy, `X-AmpVerve-Engine-Version` and\n`X-AmpVerve-Input-Hash`. 409 when the run has no report. Each download\nwrites `plan_study.report.downloaded`. Read roles as\n`listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "A projection from the study engine run on the study's inputs (which may be the illustrative sample), labelled Projection in the response.",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "download",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "true sends it as an attachment."
          }
        ],
        "responses": {
          "200": {
            "description": "The report.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The run has not succeeded",
            "so there is no report.": null
          }
        }
      }
    },
    "/api/notifications/studies/{study_id}/runs/{run_id}/proposal-pack": {
      "get": {
        "operationId": "getPlanStudyProposalPack",
        "summary": "The run's proposal pack, a ZIP to attach to your own email",
        "description": "One ZIP for a completed run: the report exactly as stored (its\nSHA-256 matches the run's `report_sha256`), a plain text covering\nletter, and `manifest.json` with the study, run, engine version, input\nhash and the SHA-256 of each file. The letter quotes only figures the\nrun produced, calls them projections, and says so when the run used\nthe illustrative sample. Nothing is sent: the pack is for a person to\nattach to an email from their own mailbox. Served only from the\nowning organisation's run; 409 when the run has no report. Each\ndownload writes `plan_study.proposal_pack.downloaded`. Read roles as\n`listPlanStudyTypes`.\n",
        "tags": [
          "Plan studies"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "The stored report of a completed run with a covering letter that quotes only the run's own figures, as projections.",
        "parameters": [
          {
            "name": "study_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The proposal pack.",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The run has not succeeded",
            "so there is no report.": null
          }
        }
      }
    },
    "/api/notifications/crm/settings": {
      "get": {
        "operationId": "getCrmSettings",
        "summary": "The organisation's CRM settings",
        "description": "Stage probabilities used by the forecast and the prospect retention period, as\norganisation data. The first read creates the row from the defaults (lead 5%,\ncontacted 10%, call booked 25%, study proposed 50%, won 100%, lost 0%; 365 days).\nEvery CRM operation needs the `crm` module enabled for the organisation\n(403 `module_not_enabled` otherwise). Roles: any organisation role.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "The settings row.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmSettings"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "operationId": "putCrmSettings",
        "summary": "Change stage probabilities or the retention period",
        "description": "Probabilities are needed for every stage, each between 0 and 1. Retention is\n30 to 3650 days. Recorded in the organisation's audit chain as\n`crm.settings.update`. Roles: PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "stage_probabilities": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 1
                    }
                  },
                  "prospect_retention_days": {
                    "type": "integer",
                    "minimum": 30,
                    "maximum": 3650
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated settings.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmSettings"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/notifications/crm/forecast": {
      "get": {
        "operationId": "getCrmForecast",
        "summary": "Pipeline value by stage and expected close month, probability weighted",
        "description": "Weighted by the organisation's own stage probabilities, which the response\nrepeats. Lost deals appear in the stage table and are left out of the month\ntable. Deals with no value are counted in `totals.deals_without_value`.\nRoles: any organisation role.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "x-ampverve-data-basis": "forecast",
        "x-ampverve-method": "Open deal value weighted by stage probability, by expected close month.",
        "parameters": [
          {
            "name": "owner",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only deals owned by this user id."
          }
        ],
        "responses": {
          "200": {
            "description": "The forecast.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmForecast"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/notifications/crm/accounts": {
      "get": {
        "operationId": "listCrmAccounts",
        "summary": "Accounts (organisations being sold to)",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Name contains."
          }
        ],
        "responses": {
          "200": {
            "description": "Accounts of the caller's organisation, by name.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "accounts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmAccount"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "createCrmAccount",
        "summary": "Create an account",
        "description": "Roles PLATFORM_ADMIN, ORG_ADMIN, OPERATOR. A linked lead must be an opportunity of the same organisation.",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CrmAccountInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmAccount"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/accounts/{account_id}": {
      "parameters": [
        {
          "name": "account_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getCrmAccount",
        "summary": "Account with its contacts, deals, tasks and activity timeline",
        "description": "The timeline merges logged activities with outreach emails sent through the\napprove-then-send outreach flow to the account's contacts. A record of another\norganisation is 404, exactly as one that does not exist.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "The account detail.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "account": {
                      "$ref": "#/components/schemas/CrmAccount"
                    },
                    "contacts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmContact"
                      }
                    },
                    "deals": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmDeal"
                      }
                    },
                    "tasks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmTask"
                      }
                    },
                    "timeline": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmActivity"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updateCrmAccount",
        "summary": "Change an account",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CrmAccountInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmAccount"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteCrmAccount",
        "summary": "Delete an account with its deals, activities and tasks",
        "description": "Refused with 409 while the account has contacts: contacts are removed through\nthe audited erase operation. Audited as `crm.account.delete`. Roles:\nPLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "Counts of what was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The account still has contacts."
          }
        }
      }
    },
    "/api/notifications/crm/contacts": {
      "get": {
        "operationId": "listCrmContacts",
        "summary": "Contacts, each with its suppression state",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "account_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contacts of the caller's organisation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "contacts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmContact"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCrmContact",
        "summary": "Create a contact",
        "description": "A contact needs a name or a role and a lawful basis note. An email must be a\nbusiness address: consumer mailboxes are refused with 422, because PECR needs\nconsent to email an individual subscriber. A LinkedIn URL must be a\nlinkedin.com profile or company URL. Roles PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CrmContactInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The contact.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmContact"
                }
              }
            }
          },
          "409": {
            "description": "A contact with this email already exists in the organisation."
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/notifications/crm/contacts/{contact_id}": {
      "parameters": [
        {
          "name": "contact_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getCrmContact",
        "summary": "A contact and its timeline",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "The contact.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "contact": {
                      "$ref": "#/components/schemas/CrmContact"
                    },
                    "timeline": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmActivity"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updateCrmContact",
        "summary": "Change a contact",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CrmContactInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The contact.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmContact"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/contacts/{contact_id}/opt-out": {
      "parameters": [
        {
          "name": "contact_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "optOutCrmContact",
        "summary": "Record an opt out and suppress the address",
        "description": "Adds the address to the outreach suppression list, which also stops any unsent\noutreach to it. Audited as `crm.contact.opt_out`. Roles PLATFORM_ADMIN,\nORG_ADMIN, OPERATOR.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The contact, now suppressed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmContact"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/contacts/{contact_id}/erase": {
      "parameters": [
        {
          "name": "contact_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "eraseCrmContact",
        "summary": "Delete a contact on request (UK GDPR Article 17)",
        "description": "Removes the contact, the activities and tasks logged against it, and the\noutreach messages sent to its address; deals stay with the contact link\nremoved. By default the address stays on the suppression list so the person is\nnot contacted again. Audited as `crm.contact.erase` with the contact id, the\ncounts and the request reference, never the name or address. Cannot be undone.\nRoles: PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "request_reference": {
                    "type": "string",
                    "maxLength": 120
                  },
                  "keep_suppression": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What was removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "contact_id": {
                      "type": "string"
                    },
                    "erased": {
                      "type": "boolean"
                    },
                    "activities": {
                      "type": "integer"
                    },
                    "tasks": {
                      "type": "integer"
                    },
                    "outreach_messages": {
                      "type": "integer"
                    },
                    "deals_unlinked": {
                      "type": "integer"
                    },
                    "suppression_kept": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/contacts/{contact_id}/outreach-draft": {
      "parameters": [
        {
          "name": "contact_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "draftCrmContactOutreach",
        "summary": "Draft an outreach email to a contact for approval",
        "description": "Creates a draft in the approve-then-send outreach flow; a person must still\napprove it before it can be sent. Refused with 409 when the contact has opted\nout, its address or domain is suppressed, or it has no business email.\nRoles: PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "deal_id": {
                    "type": "string"
                  },
                  "signal_summary": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The draft.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "const": "draft"
                    },
                    "subject": {
                      "type": "string"
                    },
                    "contact_email": {
                      "type": "string"
                    },
                    "thread_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The contact cannot be messaged; `detail` says why."
          }
        }
      }
    },
    "/api/notifications/crm/deals": {
      "get": {
        "operationId": "listCrmDeals",
        "summary": "Deals, for the pipeline board",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "stage",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/CrmStage"
            }
          },
          {
            "name": "owner",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "product",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/CrmProduct"
            }
          },
          {
            "name": "account_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deals of the caller's organisation, most recently changed first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deals": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmDeal"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCrmDeal",
        "summary": "Create a deal",
        "description": "The contact must belong to the deal's account; a linked lead must be an\nopportunity of the same organisation; `linked_study_id` is a plain reference.\nCreating at `lost` needs `lost_reason`. Roles PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CrmDealInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The deal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmDeal"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/notifications/crm/deals/{deal_id}": {
      "parameters": [
        {
          "name": "deal_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getCrmDeal",
        "summary": "Deal with its account, contact, stage history, tasks and timeline",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "The deal detail.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deal": {
                      "$ref": "#/components/schemas/CrmDeal"
                    },
                    "account": {
                      "$ref": "#/components/schemas/CrmAccount"
                    },
                    "contact": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/CrmContact"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "stage_history": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "from_stage": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "to_stage": {
                            "type": "string"
                          },
                          "reason": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "changed_by": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "changed_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    },
                    "tasks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmTask"
                      }
                    },
                    "timeline": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmActivity"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updateCrmDeal",
        "summary": "Change a deal's details (not its stage)",
        "description": "The stage changes only through the stage operation, which records the move.",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CrmDealInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The deal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmDeal"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteCrmDeal",
        "summary": "Delete a deal",
        "description": "Activities and tasks linked to it are kept and unlinked. Roles PLATFORM_ADMIN, ORG_ADMIN.",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/deals/{deal_id}/stage": {
      "parameters": [
        {
          "name": "deal_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "moveCrmDealStage",
        "summary": "Move a deal to another stage",
        "description": "Any open stage may move to any stage, forwards or back. Moving to `lost` needs\n`lost_reason` (422 without). Reopening a `won` or `lost` deal needs\nPLATFORM_ADMIN or ORG_ADMIN (403 otherwise). Every move is recorded with the\nuser and time. Roles PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "stage"
                ],
                "properties": {
                  "stage": {
                    "$ref": "#/components/schemas/CrmStage"
                  },
                  "lost_reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The deal at its new stage.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmDeal"
                }
              }
            }
          },
          "403": {
            "description": "Reopening a closed deal without an administrator role."
          },
          "409": {
            "description": "The deal is already at that stage."
          },
          "422": {
            "description": "Unknown stage, or lost without a reason."
          }
        }
      }
    },
    "/api/notifications/crm/activities": {
      "get": {
        "operationId": "listCrmActivities",
        "summary": "Timeline for one account, contact or deal",
        "description": "Logged activities merged with sent outreach emails, newest first. Give exactly one of the three ids.",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "account_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "contact_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "deal_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The timeline.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "activities": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmActivity"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "logCrmActivity",
        "summary": "Log a call, meeting, email or note",
        "description": "Logged against an account, contact or deal (at least one); links must belong to\nthe same account. The author is the caller. Roles PLATFORM_ADMIN, ORG_ADMIN,\nOPERATOR.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "kind",
                  "subject"
                ],
                "properties": {
                  "kind": {
                    "type": "string",
                    "enum": [
                      "call",
                      "meeting",
                      "email",
                      "note"
                    ]
                  },
                  "subject": {
                    "type": "string"
                  },
                  "body": {
                    "type": "string"
                  },
                  "occurred_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "account_id": {
                    "type": "string"
                  },
                  "contact_id": {
                    "type": "string"
                  },
                  "deal_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The activity.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmActivity"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/activities/{activity_id}": {
      "parameters": [
        {
          "name": "activity_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "operationId": "deleteCrmActivity",
        "summary": "Delete a logged activity",
        "description": "Roles PLATFORM_ADMIN, ORG_ADMIN.",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/tasks": {
      "get": {
        "operationId": "listCrmTasks",
        "summary": "Tasks, the caller's or everyone's",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "mine",
                "all"
              ],
              "default": "mine"
            }
          },
          {
            "name": "done",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tasks by due date.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tasks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmTask"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCrmTask",
        "summary": "Create a task",
        "description": "The assignee defaults to the caller. Roles PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title",
                  "due_date"
                ],
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string"
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "assignee_user_id": {
                    "type": "string"
                  },
                  "account_id": {
                    "type": "string"
                  },
                  "contact_id": {
                    "type": "string"
                  },
                  "deal_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The task.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmTask"
                }
              }
            }
          }
        }
      }
    },
    "/api/notifications/crm/tasks/due": {
      "get": {
        "operationId": "listCrmTasksDue",
        "summary": "Open tasks that are overdue or due today (UK date)",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "parameters": [
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "mine",
                "all"
              ],
              "default": "mine"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The two lists.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "today": {
                      "type": "string",
                      "format": "date"
                    },
                    "overdue": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmTask"
                      }
                    },
                    "due_today": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CrmTask"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/notifications/crm/tasks/{task_id}": {
      "parameters": [
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "operationId": "updateCrmTask",
        "summary": "Change a task or mark it done",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string"
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "assignee_user_id": {
                    "type": "string"
                  },
                  "done": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The task.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmTask"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/notifications/crm/import/tracker": {
      "post": {
        "operationId": "importCrmTracker",
        "summary": "Import the outreach tracker CSV",
        "description": "Columns Organisation, Segment, Contact role, Channel, Message sent date, Reply,\nCall date, Outcome, Next step (optional Contact name, Contact email, LinkedIn\nURL, Product, Value GBP, Notes). Creates or matches accounts, contacts and one\ndeal per account and product; a stage is read from the outcome or the dates and\nan existing deal only moves forward. A reply or outcome asking to stop records\nan opt out. Idempotent. Bad rows are reported by line and skipped. One audit\nentry, `crm.import.tracker`. Roles PLATFORM_ADMIN, ORG_ADMIN, OPERATOR.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The tracker as a .csv file",
                    "at most 1 MB and 2000 rows.": null
                  },
                  "lawful_basis_note": {
                    "type": "string",
                    "description": "Recorded on every contact the import creates."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Counts and the rows that were skipped.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "rows": {
                      "type": "integer"
                    },
                    "accounts_created": {
                      "type": "integer"
                    },
                    "contacts_created": {
                      "type": "integer"
                    },
                    "deals_created": {
                      "type": "integer"
                    },
                    "deals_moved": {
                      "type": "integer"
                    },
                    "activities_created": {
                      "type": "integer"
                    },
                    "tasks_created": {
                      "type": "integer"
                    },
                    "contacts_opted_out": {
                      "type": "integer"
                    },
                    "rows_skipped": {
                      "type": "integer"
                    },
                    "errors": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "line": {
                            "type": "integer"
                          },
                          "error": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Not a CSV, no header row, or no Organisation column."
          }
        }
      }
    },
    "/api/notifications/crm/retention/preview": {
      "get": {
        "operationId": "previewCrmRetention",
        "summary": "Contacts the next retention sweep would erase",
        "description": "Prospects with no engagement (no reply, no logged call or meeting, not on a deal\nat call booked or later) older than the organisation's retention period. Ids\nonly. Roles PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "The candidates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "retention_days": {
                      "type": "integer"
                    },
                    "contacts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "account_id": {
                            "type": "string"
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/notifications/crm/retention/run": {
      "post": {
        "operationId": "runCrmRetention",
        "summary": "Erase the prospects past the retention period now",
        "description": "The same sweep the service runs daily for every organisation. Audited as\n`crm.retention.erase`. Roles PLATFORM_ADMIN, ORG_ADMIN.\n",
        "tags": [
          "CRM"
        ],
        "x-ampverve-status": "awaiting-migration",
        "responses": {
          "200": {
            "description": "How many contacts were erased.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "erased": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/notifications/intake/capacity-check": {
      "post": {
        "operationId": "requestCapacityCheckStudy",
        "summary": "Request a DepotTwin study from the Depot Capacity Check (no credential)",
        "description": "The request form under https://app.ampverve.com/capacity-check. Public: no\ncredential and no organisation. The lead is written into the one organisation\nnamed by the deployment's `CAPACITY_CHECK_LEAD_ORG_ID`, never one the request\nnames; when that is unset, or that organisation does not have the `crm`\nmodule, the answer is 503 and nothing is written. One request records an\naccount, a contact (with the consent wording and time as its lawful basis), a\n`depottwin_study` deal at `lead` (an open one is reused), a note with the\nvisitor's inputs and the page's planning estimates, and a task for a person to\nreply. No email is sent automatically.\n\nThe contact, note and task are personal data under the CRM retention rule: a\ncontact with no engagement is erased with them after the organisation's\n`prospect_retention_days`. No IP address or browser data is stored.\n\nControls: `Content-Type: application/json` only (415); 8 KiB cap (413);\nunknown fields refused (422); `consent_contact` must be `true`; a UK depot\npostcode; a work email (consumer and disposable mailboxes are refused, 422);\na hidden honeypot field `website` (a filled one is answered 202 and nothing is\nwritten); `form_started_ms` at least 5 seconds before the request (429); rate\nlimits per IP (5 an hour, 20 a day), per email (3 a day) and in total (30 an\nhour, 200 a day), each 429. The same request from the same address within 10\nminutes is recorded once. The 202 answer is the same whether or not the address\nwas already known.\n",
        "tags": [
          "Lead intake"
        ],
        "x-ampverve-status": "awaiting-migration",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CapacityCheckRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Received. A person replies by email.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status",
                    "message"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "received"
                      ]
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "Larger than 8 KiB."
          },
          "415": {
            "description": "Not application/json."
          },
          "422": {
            "description": "A field failed validation, or the mailbox is personal or disposable.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "detail": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Sent too fast, or a rate limit was reached.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "detail": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string",
                      "enum": [
                        "too_fast",
                        "rate_ip_hour",
                        "rate_ip_day",
                        "rate_email_day",
                        "rate_global_hour",
                        "rate_global_day"
                      ]
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Requests are not being taken on this deployment (no organisation configured, or it lacks the `crm` module)."
          }
        }
      }
    }
  },
  "webhooks": {
    "alert.raised": {
      "post": {
        "operationId": "alertRaisedWebhook",
        "summary": "An alert was raised in your organisation",
        "description": "**Trigger.** An alert is raised in your organisation (a device health,\nEMS or settlement operations alert) after the endpoint was registered.\nThe dispatcher reads new alerts about every 5 seconds and looks back at\nmost 24 hours, so history before registration is never sent. One event\nper alert, delivered to every active endpoint of your organisation that\nsubscribes to `alert.raised`, never to another organisation's. `data`\ncarries identifiers, type, severity, title and times; the alert's\nmessage and context are never sent. A sandbox key can send a sample with\n`POST /api/devtools/partner/v1/sandbox/webhooks/test`; its `data.sandbox`\nis `true`.\n\n**Delivery.** At least once, not ordered. A `2xx` within 10 seconds is\nsuccess. A non `2xx` answer, a timeout, a redirect (never followed) or a\ntarget that fails the public address check is a failed attempt, retried\nafter 30 seconds, doubling each time, plus up to 10 percent jitter, for at\nmost 10 attempts; the tenth is 4 hours 15 minutes after the first, or up\nto 10 percent later with jitter. After 20 consecutive failed attempts across its deliveries the\nendpoint is disabled and its pending deliveries are abandoned; an open\nalert of type `partner_webhook_disabled` is raised for your\norganisation (in the console, in `GET /api/devtools/partner/v1/alerts`,\nand as `alert.raised` to your other endpoints), and an organisation\nadministrator must re-enable the endpoint, which resolves the alert. The schedule is on the\nWebhooks page of the published documentation.\n\nHeaders: `AmpVerve-Signature`, `AmpVerve-Webhook-Id` (the event `id`,\nstable across retries), `AmpVerve-Delivery-Id`, `AmpVerve-Event-Type`,\n`Content-Type: application/json`, `User-Agent: AmpVerve-Webhooks/1.0`. The\nbody is serialised with sorted keys; verify the signature over the raw\nbytes before parsing.\n",
        "tags": [
          "Webhook events"
        ],
        "x-ampverve-status": "awaiting-migration",
        "x-ampverve-trigger": "An alert is raised in your organisation after the endpoint was registered. The dispatcher reads new alerts about every 5 seconds, looking back at most 24 hours.",
        "security": [],
        "parameters": [
          {
            "name": "AmpVerve-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "t=1759737600,v1=<64 lowercase hex characters>",
            "description": "`t=<unix seconds>,v1=<hex>[,v1=<hex>]`. Each `v1` is hex HMAC-SHA256, keyed with the endpoint secret (the whole `whsec_...` string), over `<t>.` followed by the raw body bytes. Accept when any `v1` matches. Replay tolerance: reject the request when `|now - t|` is more than 300 seconds. A second `v1` is present during a secret rotation's grace period."
          },
          {
            "name": "AmpVerve-Webhook-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "evt_5c0f3a9e2b7d4c1f8e6a0b9d2c4f7a13",
            "description": "The event id, equal to `id` in the body and stable across every retry of the event. Delivery is at least once, so deduplicate on this value: store it when you accept an event and acknowledge a repeat with `2xx` without processing it again."
          },
          {
            "name": "AmpVerve-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          },
          {
            "name": "AmpVerve-Event-Type",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "alert.raised"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerWebhookEvent"
              },
              "examples": {
                "raised": {
                  "summary": "A charger stopped reporting",
                  "value": {
                    "api_version": "2026-10-03",
                    "data": {
                      "alert": {
                        "alert_type": "comms_lost",
                        "created_at": "2026-10-06T07:00:00Z",
                        "device_id": "dev-0c1d2e3f",
                        "id": "alr-7a8b9c0d",
                        "provider": "ocpp",
                        "resolved": false,
                        "resolved_at": null,
                        "severity": "warning",
                        "title": "Depot charger 2 stopped reporting"
                      }
                    },
                    "id": "evt_5c0f3a9e2b7d4c1f8e6a0b9d2c4f7a13",
                    "occurred_at": "2026-10-06T07:00:00Z",
                    "organisation_id": "org-example-0001",
                    "type": "alert.raised"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Accepted. Return within 10 seconds and do the work asynchronously."
          },
          "default": {
            "description": "Any other response",
            "a timeout or a redirect is a failed attempt and is retried.": null
          }
        }
      }
    },
    "alert.resolved": {
      "post": {
        "operationId": "alertResolvedWebhook",
        "summary": "An alert in your organisation was resolved",
        "description": "**Trigger.** An alert in your organisation is resolved after the\nendpoint was registered. `occurred_at` is the resolution time. The\ndispatcher reads resolutions about every 5 seconds and looks back at\nmost 24 hours. One event per alert, delivered to every active endpoint\nof your organisation that subscribes to `alert.resolved`, never to\nanother organisation's. A sandbox key can send a sample.\n\n**Delivery.** At least once, not ordered. A `2xx` within 10 seconds is\nsuccess. A non `2xx` answer, a timeout, a redirect (never followed) or a\ntarget that fails the public address check is a failed attempt, retried\nafter 30 seconds, doubling each time, plus up to 10 percent jitter, for at\nmost 10 attempts; the tenth is 4 hours 15 minutes after the first, or up\nto 10 percent later with jitter. After 20 consecutive failed attempts across its deliveries the\nendpoint is disabled and its pending deliveries are abandoned; an open\nalert of type `partner_webhook_disabled` is raised for your\norganisation (in the console, in `GET /api/devtools/partner/v1/alerts`,\nand as `alert.raised` to your other endpoints), and an organisation\nadministrator must re-enable the endpoint, which resolves the alert. The schedule is on the\nWebhooks page of the published documentation.\n\nHeaders: `AmpVerve-Signature`, `AmpVerve-Webhook-Id` (the event `id`,\nstable across retries), `AmpVerve-Delivery-Id`, `AmpVerve-Event-Type`,\n`Content-Type: application/json`, `User-Agent: AmpVerve-Webhooks/1.0`. The\nbody is serialised with sorted keys; verify the signature over the raw\nbytes before parsing.\n",
        "tags": [
          "Webhook events"
        ],
        "x-ampverve-status": "awaiting-migration",
        "x-ampverve-trigger": "An alert in your organisation is resolved after the endpoint was registered. The dispatcher reads resolutions about every 5 seconds, looking back at most 24 hours.",
        "security": [],
        "parameters": [
          {
            "name": "AmpVerve-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "t=1759737600,v1=<64 lowercase hex characters>",
            "description": "`t=<unix seconds>,v1=<hex>[,v1=<hex>]`. Each `v1` is hex HMAC-SHA256, keyed with the endpoint secret (the whole `whsec_...` string), over `<t>.` followed by the raw body bytes. Accept when any `v1` matches. Replay tolerance: reject the request when `|now - t|` is more than 300 seconds. A second `v1` is present during a secret rotation's grace period."
          },
          {
            "name": "AmpVerve-Webhook-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "evt_5c0f3a9e2b7d4c1f8e6a0b9d2c4f7a13",
            "description": "The event id, equal to `id` in the body and stable across every retry of the event. Delivery is at least once, so deduplicate on this value: store it when you accept an event and acknowledge a repeat with `2xx` without processing it again."
          },
          {
            "name": "AmpVerve-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          },
          {
            "name": "AmpVerve-Event-Type",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "alert.resolved"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerWebhookEvent"
              },
              "examples": {
                "resolved": {
                  "summary": "The charger reports again",
                  "value": {
                    "api_version": "2026-10-03",
                    "data": {
                      "alert": {
                        "alert_type": "comms_lost",
                        "created_at": "2026-10-06T07:00:00Z",
                        "device_id": "dev-0c1d2e3f",
                        "id": "alr-7a8b9c0d",
                        "provider": "ocpp",
                        "resolved": true,
                        "resolved_at": "2026-10-06T09:12:40Z",
                        "severity": "warning",
                        "title": "Depot charger 2 stopped reporting"
                      }
                    },
                    "id": "evt_9e1d7c3b5a2f4e6d8c0b1a3f5e7d9c2b",
                    "occurred_at": "2026-10-06T09:12:40Z",
                    "organisation_id": "org-example-0001",
                    "type": "alert.resolved"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Accepted. Return within 10 seconds and do the work asynchronously."
          },
          "default": {
            "description": "Any other response",
            "a timeout or a redirect is a failed attempt and is retried.": null
          }
        }
      }
    },
    "device.offline": {
      "post": {
        "operationId": "deviceOfflineWebhook",
        "summary": "A device in your organisation stopped reporting",
        "description": "**Trigger.** An alert is raised in your organisation whose type is an\noffline type and which names a device. The offline types are, by\ndefault, `stale_device`, `stale_telemetry`, `comms_gap`, `comms_lost`,\n`device_offline` and `dead_device`. The same alert also produces an\n`alert.raised` event, each with its own `id`. `since` is when the alert\nwas raised. There is no matching online event: an `alert.resolved` for\nthe same alert `id` is the signal that the device reports again. A\nsandbox key can send a sample.\n\n**Delivery.** At least once, not ordered. A `2xx` within 10 seconds is\nsuccess. A non `2xx` answer, a timeout, a redirect (never followed) or a\ntarget that fails the public address check is a failed attempt, retried\nafter 30 seconds, doubling each time, plus up to 10 percent jitter, for at\nmost 10 attempts; the tenth is 4 hours 15 minutes after the first, or up\nto 10 percent later with jitter. After 20 consecutive failed attempts across its deliveries the\nendpoint is disabled and its pending deliveries are abandoned; an open\nalert of type `partner_webhook_disabled` is raised for your\norganisation (in the console, in `GET /api/devtools/partner/v1/alerts`,\nand as `alert.raised` to your other endpoints), and an organisation\nadministrator must re-enable the endpoint, which resolves the alert. The schedule is on the\nWebhooks page of the published documentation.\n\nHeaders: `AmpVerve-Signature`, `AmpVerve-Webhook-Id` (the event `id`,\nstable across retries), `AmpVerve-Delivery-Id`, `AmpVerve-Event-Type`,\n`Content-Type: application/json`, `User-Agent: AmpVerve-Webhooks/1.0`. The\nbody is serialised with sorted keys; verify the signature over the raw\nbytes before parsing.\n",
        "tags": [
          "Webhook events"
        ],
        "x-ampverve-status": "awaiting-migration",
        "x-ampverve-trigger": "An alert of an offline type (by default stale_device, stale_telemetry, comms_gap, comms_lost, device_offline or dead_device) that names a device is raised in your organisation. Sent beside its alert.raised event.",
        "security": [],
        "parameters": [
          {
            "name": "AmpVerve-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "t=1759737600,v1=<64 lowercase hex characters>",
            "description": "`t=<unix seconds>,v1=<hex>[,v1=<hex>]`. Each `v1` is hex HMAC-SHA256, keyed with the endpoint secret (the whole `whsec_...` string), over `<t>.` followed by the raw body bytes. Accept when any `v1` matches. Replay tolerance: reject the request when `|now - t|` is more than 300 seconds. A second `v1` is present during a secret rotation's grace period."
          },
          {
            "name": "AmpVerve-Webhook-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "evt_5c0f3a9e2b7d4c1f8e6a0b9d2c4f7a13",
            "description": "The event id, equal to `id` in the body and stable across every retry of the event. Delivery is at least once, so deduplicate on this value: store it when you accept an event and acknowledge a repeat with `2xx` without processing it again."
          },
          {
            "name": "AmpVerve-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          },
          {
            "name": "AmpVerve-Event-Type",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "device.offline"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerWebhookEvent"
              },
              "examples": {
                "offline": {
                  "summary": "A charger stopped reporting",
                  "value": {
                    "api_version": "2026-10-03",
                    "data": {
                      "alert": {
                        "alert_type": "comms_lost",
                        "created_at": "2026-10-06T07:00:00Z",
                        "device_id": "dev-0c1d2e3f",
                        "id": "alr-7a8b9c0d",
                        "provider": "ocpp",
                        "resolved": false,
                        "resolved_at": null,
                        "severity": "warning",
                        "title": "Depot charger 2 stopped reporting"
                      },
                      "device_id": "dev-0c1d2e3f",
                      "provider": "ocpp",
                      "since": "2026-10-06T07:00:00Z"
                    },
                    "id": "evt_2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c",
                    "occurred_at": "2026-10-06T07:00:00Z",
                    "organisation_id": "org-example-0001",
                    "type": "device.offline"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Accepted. Return within 10 seconds and do the work asynchronously."
          },
          "default": {
            "description": "Any other response",
            "a timeout or a redirect is a failed attempt and is retried.": null
          }
        }
      }
    },
    "webhook.test": {
      "post": {
        "operationId": "webhookTestWebhook",
        "summary": "A test delivery you asked for",
        "description": "**Trigger.** Only on request: an organisation administrator calls\n`POST /api/devtools/webhooks/{endpoint_id}/test` (one endpoint), or a\nsandbox key calls `POST /api/devtools/partner/v1/sandbox/webhooks/test`\n(every active sandbox endpoint, or the one named). It is delivered to an\nendpoint whatever event types the endpoint subscribes to, and is\nretried and signed like every other event. You cannot subscribe to it.\n\n**Delivery.** At least once, not ordered. A `2xx` within 10 seconds is\nsuccess. A non `2xx` answer, a timeout, a redirect (never followed) or a\ntarget that fails the public address check is a failed attempt, retried\nafter 30 seconds, doubling each time, plus up to 10 percent jitter, for at\nmost 10 attempts; the tenth is 4 hours 15 minutes after the first, or up\nto 10 percent later with jitter. After 20 consecutive failed attempts across its deliveries the\nendpoint is disabled and its pending deliveries are abandoned; an open\nalert of type `partner_webhook_disabled` is raised for your\norganisation (in the console, in `GET /api/devtools/partner/v1/alerts`,\nand as `alert.raised` to your other endpoints), and an organisation\nadministrator must re-enable the endpoint, which resolves the alert. The schedule is on the\nWebhooks page of the published documentation.\n\nHeaders: `AmpVerve-Signature`, `AmpVerve-Webhook-Id` (the event `id`,\nstable across retries), `AmpVerve-Delivery-Id`, `AmpVerve-Event-Type`,\n`Content-Type: application/json`, `User-Agent: AmpVerve-Webhooks/1.0`. The\nbody is serialised with sorted keys; verify the signature over the raw\nbytes before parsing.\n",
        "tags": [
          "Webhook events"
        ],
        "x-ampverve-status": "live",
        "x-ampverve-trigger": "You call POST /api/devtools/webhooks/{endpoint_id}/test as an organisation administrator, or POST /api/devtools/partner/v1/sandbox/webhooks/test with a sandbox key. Never sent otherwise.",
        "security": [],
        "parameters": [
          {
            "name": "AmpVerve-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "t=1759737600,v1=<64 lowercase hex characters>",
            "description": "`t=<unix seconds>,v1=<hex>[,v1=<hex>]`. Each `v1` is hex HMAC-SHA256, keyed with the endpoint secret (the whole `whsec_...` string), over `<t>.` followed by the raw body bytes. Accept when any `v1` matches. Replay tolerance: reject the request when `|now - t|` is more than 300 seconds. A second `v1` is present during a secret rotation's grace period."
          },
          {
            "name": "AmpVerve-Webhook-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "evt_5c0f3a9e2b7d4c1f8e6a0b9d2c4f7a13",
            "description": "The event id, equal to `id` in the body and stable across every retry of the event. Delivery is at least once, so deduplicate on this value: store it when you accept an event and acknowledge a repeat with `2xx` without processing it again."
          },
          {
            "name": "AmpVerve-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          },
          {
            "name": "AmpVerve-Event-Type",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "webhook.test"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerWebhookEvent"
              },
              "examples": {
                "administrator": {
                  "summary": "From an administrator",
                  "value": {
                    "api_version": "2026-10-03",
                    "data": {
                      "endpoint_id": "7f3a9c1e5b2d4f6a8c0e2b4d6f8a1c3e",
                      "message": "Test delivery requested by an organisation administrator"
                    },
                    "id": "evt_6b8d0f2a4c6e8b1d3f5a7c9e2b4d6f8a",
                    "occurred_at": "2026-10-06T10:30:00Z",
                    "organisation_id": "org-example-0001",
                    "type": "webhook.test"
                  }
                },
                "sandbox": {
                  "summary": "From the sandbox",
                  "value": {
                    "api_version": "2026-10-03",
                    "data": {
                      "message": "Sandbox test delivery",
                      "sandbox": true
                    },
                    "id": "evt_1a3c5e7b9d2f4a6c8e0b2d4f6a8c1e3b",
                    "occurred_at": "2026-10-06T10:30:00Z",
                    "organisation_id": "demo-sandbox-a3086976a3fc5003",
                    "type": "webhook.test"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Accepted. Return within 10 seconds and do the work asynchronously."
          },
          "default": {
            "description": "Any other response",
            "a timeout or a redirect is a failed attempt and is retried.": null
          }
        }
      }
    },
    "emsAlertRouted": {
      "post": {
        "operationId": "receiveEmsAlertWebhook",
        "summary": "EMS alert delivered to a routing rule's webhook channel",
        "description": "AmpVerve POSTs this body to the `https://` target of an EMS routing rule\nwhose channel is `webhook`, when an alert is routed under that rule\n(`kind: alert`) or escalated to it (`kind: escalation`).\n\n**When it fires.** Only when routing is called: newly raised alerts by\n`POST /api/hardware/ems/alerts/evaluate`, open alerts by\n`POST /api/hardware/ems/routing/dispatch`, escalations by\n`POST /api/hardware/ems/routing/escalations/check`. There is no scheduler\nthat calls these yet.\n\n**Delivery.** A single attempt with a 10 second timeout and no retry. A\n2xx answer is recorded as `sent`; anything else, or a transport error, as\n`failed`, with the reason in the delivery log. Each rule delivers an alert\nonce unless dispatch is forced, so deduplicate on `alert_id` and `kind`.\n\n**Signature.** When AmpVerve has provisioned a signing secret for the rule,\n`X-AmpVerve-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the\nraw body under that secret. The body is serialised with sorted keys.\nVerify against the raw bytes before parsing. The signature carries no\ntimestamp, so it does not by itself protect against replay. Partners\ncannot set the secret through the API.\n\nRules whose `kinds` include `weekly_digest` also receive the weekly\ndigest (`X-AmpVerve-Kind: weekly_digest`) with a different body, which is\nnot documented here.\n",
        "tags": [
          "Webhook events"
        ],
        "x-ampverve-status": "live",
        "parameters": [
          {
            "name": "X-AmpVerve-Kind",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "alert",
                "escalation"
              ]
            }
          },
          {
            "name": "X-AmpVerve-Signature",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^sha256=[0-9a-f]{64}$"
            },
            "description": "Present only when a signing secret is provisioned for the rule."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmsAlertWebhookPayload"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Any 2xx marks the delivery `sent`. The response body is ignored."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "The credential for every operation except the `Partner API` ones, which take\n`apiKeyAuth` instead.\n\nObtain an access token from `POST /api/auth/login`. Send it on every request as\n`Authorization: Bearer <access_token>`.\n\nThe token is an HS256 JWT carrying `sub`, `tenant_id`, `org_id`, `roles`,\n`iat` and `exp`. Lifetime is 60 minutes. Refresh with\n`POST /api/auth/token/refresh`, which rotates both tokens.\n\nEvery request is scoped to the `tenant_id` in the token. There is no way to\nread another organisation's data with your token, and supplying an\n`X-Tenant-ID` header that disagrees with the token is rejected with 403.\n\nThere is no scope model. Authorisation is by role: `ORG_ADMIN`, `OPERATOR`,\n`ANALYST`, `RESEARCHER`, `VIEWER`. Ingestion requires `ORG_ADMIN`, `OPERATOR`\nor `ANALYST`.\n"
      },
      "apiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "AmpVerve API key (avk_...)",
        "description": "An organisation scoped API key, issued once by an organisation\nadministrator at `POST /api/devtools/apikeys` and sent as\n`Authorization: Bearer avk_<12 hex>_<43 base64url>`.\n\nAccepted only by the `Partner API` operations under\n`/api/devtools/partner/v1` and the `Home Assistant connector` operations under\n`/api/telemetry/connectors/home-assistant/v1`, and those accept nothing else: a\nJWT there is 401, and a key on any other operation is 401.\n\nThe organisation is the one the key was issued to. Headers such as\n`X-Tenant-ID` are ignored. Each operation requires a scope (`alerts:read`,\n`devices:read`, `telemetry:read`, or `telemetry:write` for the Home Assistant\nconnector, which pushes readings and grants no control); a missing scope is 403\nwith code `insufficient_scope` and `required_scope` naming the scope (and, in the\ndefault `application/json` form, the earlier\n`{\"detail\": {\"error\": \"insufficient_scope\", \"required_scope\": ...}}`).\nRevoked, expired and unknown keys are 401. Each key is limited to 600\nrequests a minute (429 with `Retry-After`). On both sets of operations every\nresponse also carries `RateLimit-Limit`, `RateLimit-Remaining`,\n`RateLimit-Reset`, `RateLimit-Policy` and `X-Request-Id`, and every error is\nproblem details (`ProblemDetails`, or `ErrorEnvelope` without\n`Accept: application/problem+json`).\n\nKeys are stored as HMAC-SHA256 under a server secret with a public prefix;\nAmpVerve cannot show a key again after creation.\n\nA sandbox key (issued with `environment=sandbox`) has the same format and rules.\nIts organisation is your sandbox organisation, and the Partner API answers it\nwith simulated data; see the `Sandbox` tag.\n"
      }
    },
    "parameters": {
      "EmsExportSiteId": {
        "name": "site_id",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "EmsExportWindowStart": {
        "name": "window_start",
        "in": "query",
        "schema": {
          "type": [
            "string",
            "null"
          ]
        },
        "description": "ISO 8601 start of the window. Must be given with `window_end`."
      },
      "EmsExportWindowEnd": {
        "name": "window_end",
        "in": "query",
        "schema": {
          "type": [
            "string",
            "null"
          ]
        },
        "description": "ISO 8601 end of the window, exclusive. Must be given with `window_start`."
      },
      "EmsExportDate": {
        "name": "date",
        "in": "query",
        "schema": {
          "type": [
            "string",
            "null"
          ]
        },
        "description": "First day of the window as `YYYY-MM-DD`. Ignored when `window_start` is given."
      },
      "EmsExportDays": {
        "name": "days",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 31,
          "default": 7
        },
        "description": "Length of the window in whole days when `window_start` is not given."
      },
      "EmsExportCorrections": {
        "name": "corrections",
        "in": "query",
        "schema": {
          "type": "string",
          "enum": [
            "applied",
            "stored"
          ],
          "default": "applied"
        },
        "description": "`applied`: B7 corrections overlaid (the default); `stored`: the readings as stored."
      },
      "Environment": {
        "name": "environment",
        "in": "query",
        "required": false,
        "description": "`sandbox` makes the operation act on your organisation's sandbox organisation\n(`demo-sandbox-<16 hex>`, derived from your organisation) instead of your\norganisation. Keys and endpoints of the two are separate: a live key or endpoint\nanswers 404 in the sandbox, and the reverse. The audit row is written to your\nown organisation. Any other value is 422.\n",
        "schema": {
          "type": "string",
          "enum": [
            "live",
            "sandbox"
          ],
          "default": "live"
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque. The `next_cursor` of the previous page, sent unchanged; omit it for the\nfirst page. Send the same filters with every page. A cursor that cannot be\nread, that belongs to another operation, or that comes with different filters\nis `400` `invalid_cursor`. A cursor names no organisation and grants nothing:\nevery page is read for your own organisation. See the Pagination page.\n",
        "schema": {
          "type": "string",
          "maxLength": 512
        }
      }
    },
    "headers": {
      "XRequestId": {
        "description": "Identifies this request. Kept from the request when you send a safe\n`X-Request-Id` (8 to 128 of `A-Z a-z 0-9 . _ : -`), otherwise `req_<32 hex>`.\nQuote it to support. Equal to `request_id` (and `error.request_id`) on an error.\n",
        "schema": {
          "type": "string",
          "example": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
        }
      },
      "RateLimitLimit": {
        "description": "Requests this API key may make in the window.",
        "schema": {
          "type": "integer",
          "example": 600
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window after this one.",
        "schema": {
          "type": "integer",
          "example": 599
        }
      },
      "RateLimitReset": {
        "description": "Seconds until the window frees capacity.",
        "schema": {
          "type": "integer",
          "example": 42
        }
      },
      "RateLimitPolicy": {
        "description": "The limit and window, as `<limit>;w=<seconds>`.",
        "schema": {
          "type": "string",
          "example": "600;w=60"
        }
      },
      "RetryAfter": {
        "description": "On a 429, seconds to wait before retrying.",
        "schema": {
          "type": "integer",
          "example": 42
        }
      },
      "KeyExpiresAt": {
        "description": "When the API key that authenticated this request expires (RFC 3339, UTC).",
        "schema": {
          "type": "string",
          "format": "date-time",
          "example": "2027-01-01T00:00:00Z"
        }
      },
      "KeyExpiryWarning": {
        "description": "Present only from 14 days before the key expires. Names the days left and\nthe rotate operation for this key. Alert your own operators on it: rotation\nissues a second active key and keeps this one valid for a grace period, so\nthe key can be replaced without downtime.\n",
        "schema": {
          "type": "string",
          "example": "this API key expires in 9 day(s); rotate it with POST /api/devtools/apikeys/9b1e.../rotate"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "No credential, a malformed `Authorization` header, an expired or invalid\ntoken, or a token whose roles are malformed or are not roles its principal\ncan hold (a platform role on an organisation token, for example). An\nauthentication failure is always 401, never 500.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "detail": "Authorization header missing"
                }
              },
              "malformed": {
                "value": {
                  "detail": "Invalid authorization header"
                }
              },
              "expired": {
                "value": {
                  "detail": "Invalid or expired token"
                }
              },
              "roles": {
                "value": {
                  "detail": "Invalid roles: ['PLATFORM_ADMIN']"
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated, but not permitted. Either the role on the token does not satisfy\nthe endpoint, or an `X-Tenant-ID` header disagrees with the token.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "roles": {
                "value": {
                  "detail": "Insufficient permissions"
                }
              },
              "tenant": {
                "value": {
                  "detail": "Tenant mismatch"
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The resource does not exist, or is not visible to your organisation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "detail": "Not Found"
            }
          }
        }
      },
      "TwinForbidden": {
        "description": "Either the `twin` module is not enabled for your organisation (`detail.error`\nis `module_not_enabled`; an administrator enables it per organisation), or your\nrole cannot perform the write (`detail` is `Insufficient permissions`). Reads\nare open to every organisation role; writes need ORG_ADMIN or OPERATOR.\n",
        "content": {
          "application/json": {
            "examples": {
              "module": {
                "value": {
                  "detail": {
                    "error": "module_not_enabled",
                    "module": "twin",
                    "organisation_id": "your-organisation",
                    "entitlement_source": "table",
                    "message": "The twin module is not enabled for this organisation. A platform administrator can enable it under Organisations, Modules."
                  }
                }
              },
              "roles": {
                "value": {
                  "detail": "Insufficient permissions"
                }
              }
            }
          }
        }
      },
      "TwinConflict": {
        "description": "The write breaks a registry rule. `detail` names the rule: a class the parent\ndoes not allow, a parent on another site, a cycle, the depth cap, a device on\nanother site or already registered, or a delete of a record that feeds others.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "site": {
                "value": {
                  "detail": "parent 4b1f... is on site site-0413, not site-0412; an asset belongs to exactly one site and its parent must be on the same site"
                }
              },
              "adjacency": {
                "value": {
                  "detail": "a charger may not sit under a charger; allowed parents: (none), board, meter"
                }
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "Request body or parameters failed schema validation. `detail` is an **array**\nof field level errors, not a string. Parse accordingly.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ValidationError"
            },
            "example": {
              "detail": [
                {
                  "type": "missing",
                  "loc": [
                    "body",
                    "email"
                  ],
                  "msg": "Field required",
                  "input": {}
                }
              ]
            }
          }
        }
      },
      "ApiKeyUnauthorized": {
        "description": "No key, a malformed key, an unknown key, a revoked or expired key, or a credential\nthat is not an API key. Branch on `code`.\n",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/XRequestId"
          },
          "WWW-Authenticate": {
            "description": "`Bearer realm=\"ampverve-partner-api\"`",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "missing": {
                "value": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-api-key-missing",
                  "title": "Unauthorized",
                  "status": 401,
                  "detail": "API key missing",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "api_key_missing",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "error": {
                    "code": "api_key_missing",
                    "message": "API key missing",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              },
              "invalid": {
                "value": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-api-key-invalid",
                  "title": "Unauthorized",
                  "status": 401,
                  "detail": "Invalid API key",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "api_key_invalid",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "error": {
                    "code": "api_key_invalid",
                    "message": "Invalid API key",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              },
              "revoked": {
                "value": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-api-key-revoked",
                  "title": "Unauthorized",
                  "status": 401,
                  "detail": "API key revoked",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "api_key_revoked",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "error": {
                    "code": "api_key_revoked",
                    "message": "API key revoked",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              },
              "expired": {
                "value": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-api-key-expired",
                  "title": "Unauthorized",
                  "status": 401,
                  "detail": "API key expired",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "api_key_expired",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "error": {
                    "code": "api_key_expired",
                    "message": "API key expired",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              },
              "jwt": {
                "value": {
                  "type": "https://www.ampverve.com/api-docs/#error-code-api-key-required",
                  "title": "Unauthorized",
                  "status": 401,
                  "detail": "This route accepts only an AmpVerve API key (Bearer avk_...)",
                  "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "code": "api_key_required",
                  "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
                  "error": {
                    "code": "api_key_required",
                    "message": "This route accepts only an AmpVerve API key (Bearer avk_...)",
                    "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
                  }
                }
              }
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ProblemDetails"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-api-key-expired",
              "title": "Unauthorized",
              "status": 401,
              "detail": "API key expired",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "api_key_expired",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "api_key_expired",
                "message": "API key expired",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          }
        }
      },
      "InvalidCursor": {
        "description": "The `cursor` cannot be used: it could not be read, it belongs to another list\noperation, or the filters differ from the first page's. Start again without a\ncursor.\n",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/XRequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-invalid-cursor",
              "title": "Bad Request",
              "status": 400,
              "detail": {
                "error": "invalid_cursor",
                "message": "The cursor was issued for different filters; repeat the filters of the first page"
              },
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "invalid_cursor",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "invalid_cursor",
                "message": "The cursor was issued for different filters; repeat the filters of the first page",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ProblemDetails"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-invalid-cursor",
              "title": "Bad Request",
              "status": 400,
              "detail": "The cursor was issued for different filters; repeat the filters of the first page",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "invalid_cursor",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "invalid_cursor",
                "message": "The cursor was issued for different filters; repeat the filters of the first page",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          }
        }
      },
      "InsufficientScope": {
        "description": "The key is valid but does not hold the scope this operation requires.\n`required_scope` names it. The body names no organisation.\n",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/XRequestId"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-insufficient-scope",
              "title": "Forbidden",
              "status": 403,
              "detail": {
                "error": "insufficient_scope",
                "required_scope": "devices:read"
              },
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "insufficient_scope",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "required_scope": "devices:read",
              "error": {
                "code": "insufficient_scope",
                "message": "This API key does not hold the scope devices:read",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ProblemDetails"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-insufficient-scope",
              "title": "Forbidden",
              "status": 403,
              "detail": "This API key does not hold the scope devices:read",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "insufficient_scope",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "required_scope": "devices:read",
              "error": {
                "code": "insufficient_scope",
                "message": "This API key does not hold the scope devices:read",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          }
        }
      },
      "ApiKeyRateLimited": {
        "description": "This API key exceeded its limit (600 requests a minute by default). Wait\n`Retry-After` seconds, then back off exponentially with jitter.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          },
          "X-Request-Id": {
            "$ref": "#/components/headers/XRequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-rate-limited",
              "title": "Too Many Requests",
              "status": 429,
              "detail": "Rate limit exceeded for this API key",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "rate_limited",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "rate_limited",
                "message": "Rate limit exceeded for this API key",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ProblemDetails"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-rate-limited",
              "title": "Too Many Requests",
              "status": 429,
              "detail": "Rate limit exceeded for this API key",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "rate_limited",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "rate_limited",
                "message": "Rate limit exceeded for this API key",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          }
        }
      },
      "PartnerValidationError": {
        "description": "A query parameter failed validation (`validation_error`), for example a `limit`\nout of range. `errors` names each field; the default `application/json` form\nalso keeps the earlier `detail` array.\n",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/XRequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ProblemDetails"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-validation-error",
              "title": "Unprocessable Content",
              "status": 422,
              "detail": "Request validation failed: limit: Input should be greater than or equal to 1",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "validation_error",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "errors": [
                {
                  "type": "greater_than_equal",
                  "loc": [
                    "query",
                    "limit"
                  ],
                  "msg": "Input should be greater than or equal to 1"
                }
              ],
              "error": {
                "code": "validation_error",
                "message": "Request validation failed: limit: Input should be greater than or equal to 1",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          }
        }
      },
      "SandboxOnly": {
        "description": "The key is a live key; this operation answers sandbox keys only.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/XRequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-sandbox-only",
              "title": "Forbidden",
              "status": 403,
              "detail": {
                "error": "sandbox_only",
                "message": "This operation is available to sandbox API keys only"
              },
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "sandbox_only",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "sandbox_only",
                "message": "This operation is available to sandbox API keys only",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/ProblemDetails"
            },
            "example": {
              "type": "https://www.ampverve.com/api-docs/#error-code-sandbox-only",
              "title": "Forbidden",
              "status": 403,
              "detail": "This operation is available to sandbox API keys only",
              "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "code": "sandbox_only",
              "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
              "error": {
                "code": "sandbox_only",
                "message": "This operation is available to sandbox API keys only",
                "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limit exceeded. Honour the `Retry-After` header. Back off exponentially.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "detail": "Rate limit exceeded"
            }
          }
        },
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait before retrying."
          }
        }
      },
      "ServerError": {
        "description": "An unhandled error. Safe to retry with backoff for idempotent reads. For\ningestion, a 5xx leaves the outcome of the write ambiguous; see the\ndeduplication guidance on the ingest endpoint.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "CapacityCheckRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "company",
          "name",
          "email",
          "depot_postcode",
          "consent_contact",
          "form_started_ms",
          "inputs",
          "estimates"
        ],
        "properties": {
          "company": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 254,
            "description": "A work email."
          },
          "depot_postcode": {
            "type": "string",
            "maxLength": 10,
            "example": "ZZ99 3CZ",
            "description": "A UK postcode."
          },
          "consent_contact": {
            "type": "boolean",
            "const": true,
            "description": "The visitor ticked the consent box."
          },
          "form_started_ms": {
            "type": "integer",
            "minimum": 0,
            "description": "When the form was opened",
            "Unix milliseconds.": null
          },
          "website": {
            "type": "string",
            "maxLength": 200,
            "description": "Honeypot. Leave empty."
          },
          "inputs": {
            "type": "object",
            "additionalProperties": false,
            "description": "The visitor's figures.",
            "required": [
              "kva",
              "day_load_kw",
              "night_load_kw",
              "vans",
              "arrive",
              "depart",
              "miles_per_day",
              "miles_per_kwh",
              "charger_kw",
              "day_rate_pence",
              "night_rate_pence",
              "operating_days",
              "power_factor"
            ],
            "properties": {
              "kva": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 100000
              },
              "day_load_kw": {
                "type": "number",
                "minimum": 0,
                "maximum": 100000
              },
              "night_load_kw": {
                "type": "number",
                "minimum": 0,
                "maximum": 100000
              },
              "vans": {
                "type": "integer",
                "minimum": 1,
                "maximum": 5000
              },
              "arrive": {
                "type": "string",
                "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
              },
              "depart": {
                "type": "string",
                "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
              },
              "miles_per_day": {
                "type": "number",
                "minimum": 0,
                "maximum": 2000
              },
              "miles_per_kwh": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 20
              },
              "charger_kw": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 400
              },
              "day_rate_pence": {
                "type": "number",
                "minimum": 0,
                "maximum": 500
              },
              "night_rate_pence": {
                "type": "number",
                "minimum": 0,
                "maximum": 500
              },
              "operating_days": {
                "type": "integer",
                "minimum": 1,
                "maximum": 366
              },
              "power_factor": {
                "type": "number",
                "minimum": 0.5,
                "maximum": 1
              }
            }
          },
          "estimates": {
            "type": "object",
            "additionalProperties": false,
            "description": "What the page showed, computed in the visitor's browser. Planning estimates, stored as received.",
            "required": [
              "connection_kw",
              "vans_fit_on_arrival",
              "vans_fit_smart",
              "peak_on_arrival_kw",
              "peak_smart_kw"
            ],
            "properties": {
              "connection_kw": {
                "type": "number",
                "minimum": 0
              },
              "vans_fit_on_arrival": {
                "type": "integer",
                "minimum": 0
              },
              "vans_fit_smart": {
                "type": "integer",
                "minimum": 0
              },
              "peak_on_arrival_kw": {
                "type": "number",
                "minimum": 0
              },
              "peak_smart_kw": {
                "type": "number",
                "minimum": 0
              },
              "annual_saving_gbp": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "timed_peak_kw": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0
              },
              "too_short": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "RetailTariffFieldError": {
        "type": "object",
        "required": [
          "field",
          "message"
        ],
        "properties": {
          "field": {
            "type": "string",
            "description": "The field refused, e.g. import_rates.bands or capacity.power_factor."
          },
          "message": {
            "type": "string"
          }
        }
      },
      "RetailTariffFieldErrors": {
        "type": "object",
        "properties": {
          "detail": {
            "type": "object",
            "properties": {
              "error": {
                "type": "string",
                "enum": [
                  "invalid"
                ]
              },
              "errors": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/RetailTariffFieldError"
                }
              }
            }
          }
        }
      },
      "RetailTariffDefinition": {
        "type": "object",
        "description": "A retail tariff definition. Decimal values are strings so they are carried\nexactly. Band windows are local clock times (Europe/London), start inclusive,\nend exclusive, on the half hour; a window may wrap past midnight. days are\n0 Monday to 6 Sunday, months 1 to 12, both default to all. A date in\nholidays is matched as a Sunday. All rates are pence exclusive of VAT.\n",
        "required": [
          "tariff_id",
          "name",
          "basis",
          "valid_from",
          "standing_charge_p_per_day",
          "import_rates",
          "vat_rates_pct"
        ],
        "properties": {
          "tariff_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "supplier": {
            "type": [
              "string",
              "null"
            ]
          },
          "basis": {
            "type": "string",
            "description": "Free text from the tariff's author: where the rates were read from, or how they were derived."
          },
          "valid_from": {
            "type": "string",
            "format": "date"
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Exclusive."
          },
          "standing_charge_p_per_day": {
            "type": "string"
          },
          "import_rates": {
            "$ref": "#/components/schemas/RetailRateSchedule"
          },
          "export_rates": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/RetailRateSchedule"
              },
              {
                "type": "null"
              }
            ]
          },
          "duos": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/RetailRateSchedule"
              },
              {
                "type": "null"
              }
            ]
          },
          "capacity": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "agreed_capacity_kva": {
                "type": "string"
              },
              "capacity_rate_p_per_kva_day": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "excess_rate_p_per_kva_day": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "power_factor": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Required with an excess rate; never assumed."
              }
            }
          },
          "levies": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "name",
                "basis",
                "rate",
                "source",
                "vatable"
              ],
              "properties": {
                "name": {
                  "type": "string"
                },
                "basis": {
                  "type": "string",
                  "enum": [
                    "per_kwh_import",
                    "per_day",
                    "percent_of_energy"
                  ]
                },
                "rate": {
                  "type": "string"
                },
                "source": {
                  "type": "string"
                },
                "vatable": {
                  "type": "boolean"
                }
              }
            }
          },
          "vat_rates_pct": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Customer class to VAT percent, e.g. domestic 5, business 20, as inputs."
          },
          "holidays": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date"
            }
          }
        }
      },
      "RetailRateSchedule": {
        "type": "object",
        "description": "Exactly one of bands or dynamic.",
        "properties": {
          "bands": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "name",
                "start",
                "end",
                "rate_p_per_kwh"
              ],
              "properties": {
                "name": {
                  "type": "string"
                },
                "start": {
                  "type": "string",
                  "example": "16:00"
                },
                "end": {
                  "type": "string",
                  "example": "19:00"
                },
                "rate_p_per_kwh": {
                  "type": "string"
                },
                "days": {
                  "type": "array",
                  "items": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 6
                  }
                },
                "months": {
                  "type": "array",
                  "items": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 12
                  }
                }
              }
            }
          },
          "dynamic": {
            "type": "object",
            "properties": {
              "source": {
                "type": "string"
              },
              "rates": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "start_utc": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "rate_p_per_kwh": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "RetailTariffWrite": {
        "type": "object",
        "required": [
          "definition"
        ],
        "properties": {
          "definition": {
            "$ref": "#/components/schemas/RetailTariffDefinition"
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 1000
          }
        }
      },
      "RetailTariffVersion": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "tariff_id": {
            "type": "string"
          },
          "version": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "basis": {
            "type": "string"
          },
          "valid_from": {
            "type": "string",
            "format": "date"
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "definition": {
            "$ref": "#/components/schemas/RetailTariffDefinition"
          },
          "definition_sha256": {
            "type": "string"
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RetailTariffList": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "tariffs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RetailTariffVersion"
            }
          },
          "rule": {
            "type": "string"
          }
        }
      },
      "RetailUsageSource": {
        "type": "object",
        "required": [
          "period_start",
          "period_end",
          "customer_class"
        ],
        "description": "Exactly one of site_id, usage_csv or usage.",
        "properties": {
          "period_start": {
            "type": "string",
            "format": "date"
          },
          "period_end": {
            "type": "string",
            "format": "date",
            "description": "Exclusive. At most 400 days after period_start."
          },
          "customer_class": {
            "type": "string"
          },
          "rounding": {
            "type": "string",
            "enum": [
              "pence_2dp",
              "pounds_2dp"
            ],
            "default": "pence_2dp"
          },
          "site_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "usage_csv": {
            "type": [
              "string",
              "null"
            ]
          },
          "usage": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "object",
              "properties": {
                "start_utc": {
                  "type": "string",
                  "format": "date-time"
                },
                "import_kwh": {
                  "type": [
                    "string",
                    "number"
                  ]
                },
                "export_kwh": {
                  "type": [
                    "string",
                    "number",
                    "null"
                  ]
                }
              }
            }
          }
        }
      },
      "RetailBillRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/RetailUsageSource"
          },
          {
            "type": "object",
            "required": [
              "tariff_id"
            ],
            "properties": {
              "tariff_id": {
                "type": "string"
              },
              "version": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 1
              }
            }
          }
        ]
      },
      "RetailCompareRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/RetailUsageSource"
          },
          {
            "type": "object",
            "required": [
              "tariffs"
            ],
            "properties": {
              "tariffs": {
                "type": "array",
                "minItems": 2,
                "maxItems": 20,
                "items": {
                  "type": "object",
                  "required": [
                    "tariff_id"
                  ],
                  "properties": {
                    "tariff_id": {
                      "type": "string"
                    },
                    "version": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "minimum": 1
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "RetailBill": {
        "type": "object",
        "properties": {
          "tariff_id": {
            "type": "string"
          },
          "tariff_version": {
            "type": "integer"
          },
          "definition_sha256": {
            "type": "string"
          },
          "customer_class": {
            "type": "string"
          },
          "period_start": {
            "type": "string",
            "format": "date"
          },
          "period_end": {
            "type": "string",
            "format": "date"
          },
          "half_hours": {
            "type": "integer"
          },
          "import_kwh": {
            "type": "string"
          },
          "export_kwh": {
            "type": "string"
          },
          "rounding_rule": {
            "type": "string"
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "category": {
                  "type": "string",
                  "enum": [
                    "standing",
                    "energy",
                    "duos",
                    "capacity",
                    "excess_capacity",
                    "levy",
                    "vat",
                    "export"
                  ]
                },
                "label": {
                  "type": "string"
                },
                "quantity": {
                  "type": "string"
                },
                "unit": {
                  "type": "string"
                },
                "rate": {
                  "type": "string"
                },
                "rate_unit": {
                  "type": "string"
                },
                "amount_exact_p": {
                  "type": "string"
                },
                "amount_p": {
                  "type": "string"
                },
                "amount_gbp": {
                  "type": "string"
                },
                "vatable": {
                  "type": "boolean"
                },
                "basis": {
                  "type": "string"
                }
              }
            }
          },
          "total_p": {
            "type": "string"
          },
          "total_gbp": {
            "type": "string"
          },
          "reconciles": {
            "type": "boolean"
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "label": {
            "type": "string"
          },
          "usage_source": {
            "type": "string"
          }
        }
      },
      "HubSiteInput": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "postcode": {
            "type": [
              "string",
              "null"
            ]
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "import_limit_kw": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0
          },
          "base_load_kw": {
            "type": "number",
            "minimum": 0,
            "default": 0
          },
          "prioritise_rapid": {
            "type": "boolean",
            "default": false
          },
          "min_current_a": {
            "type": "number",
            "minimum": 6,
            "maximum": 80,
            "default": 6
          },
          "published": {
            "type": "boolean",
            "default": false
          },
          "operator_is_micro_business": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "helpline": {
            "type": [
              "object",
              "null"
            ],
            "description": "`number`, `free_of_charge`, `staffed`, `hours_24_7` (PCPR regulation 9)."
          },
          "open_data": {
            "type": [
              "object",
              "null"
            ],
            "description": "`published`, `free_of_charge`, `machine_readable`, `status_latency_s`, `fields` (PCPR regulation 10)."
          },
          "price_display": {
            "type": [
              "object",
              "null"
            ],
            "description": "`unit` (p/kWh), `shown_before_charging`, `requires_contract` (PCPR regulation 11)."
          },
          "access_hours": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "object"
            }
          }
        }
      },
      "HubSite": {
        "allOf": [
          {
            "$ref": "#/components/schemas/HubSiteInput"
          },
          {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "live_price_p_per_kwh": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "schedule_version": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "charge_points": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/HubChargePoint"
                }
              }
            }
          }
        ]
      },
      "HubChargePoint": {
        "type": "object",
        "properties": {
          "charge_point_id": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "max_power_kw": {
            "type": "number"
          },
          "rapid": {
            "type": "boolean"
          },
          "current_type": {
            "type": "string",
            "enum": [
              "AC",
              "DC"
            ]
          },
          "evse_count": {
            "type": "integer"
          },
          "contactless": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "ocpp_version": {
            "type": [
              "string",
              "null"
            ]
          },
          "evse_status": {
            "type": "object",
            "description": "Latest OCPI EVSE status by EVSE id."
          },
          "reliability_30d_pct": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "HubTariffPolicy": {
        "type": "object",
        "required": [
          "floor_p_per_kwh",
          "ceiling_p_per_kwh"
        ],
        "description": "Pence per kWh inc VAT unless named ex VAT. Floor and ceiling must be multiples of `price_step_p`.",
        "properties": {
          "floor_p_per_kwh": {
            "type": "number",
            "exclusiveMinimum": 0
          },
          "ceiling_p_per_kwh": {
            "type": "number"
          },
          "margin_p_per_kwh": {
            "type": "number",
            "minimum": 0,
            "description": "ex VAT"
          },
          "energy_cost_pass_through": {
            "type": "number",
            "minimum": 0,
            "default": 1
          },
          "congestion_max_adder_p": {
            "type": "number",
            "minimum": 0,
            "description": "ex VAT",
            "at congestion 1": null
          },
          "carbon_max_adder_p": {
            "type": "number",
            "minimum": 0,
            "description": "ex VAT",
            "at carbon_high_g": null
          },
          "carbon_low_g": {
            "type": "number",
            "default": 50
          },
          "carbon_high_g": {
            "type": "number",
            "default": 300
          },
          "utilisation_max_adder_p": {
            "type": "number",
            "minimum": 0,
            "description": "ex VAT",
            "at full utilisation": null
          },
          "utilisation_threshold": {
            "type": "number",
            "default": 0.6
          },
          "vat_rate": {
            "type": "number",
            "default": 0.2
          },
          "price_step_p": {
            "type": "number",
            "default": 1
          },
          "min_change_interval_minutes": {
            "type": "integer",
            "default": 60
          },
          "max_changes_per_day": {
            "type": "integer",
            "default": 8
          },
          "min_change_p": {
            "type": "number",
            "default": 1
          },
          "idle_fee_p_per_minute": {
            "type": "number",
            "default": 0
          },
          "idle_grace_minutes": {
            "type": "integer",
            "default": 0
          },
          "idle_fee_cap_p": {
            "type": [
              "number",
              "null"
            ]
          },
          "connection_fee_p": {
            "type": "number",
            "default": 0
          },
          "price_lock_during_session": {
            "type": "boolean",
            "default": true
          },
          "currency": {
            "type": "string",
            "default": "GBP"
          }
        }
      },
      "HubPriceSchedule": {
        "type": "object",
        "properties": {
          "version": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "published",
              "superseded"
            ]
          },
          "policy": {
            "$ref": "#/components/schemas/HubTariffPolicy"
          },
          "valid_from": {
            "type": "string",
            "format": "date-time"
          },
          "valid_to": {
            "type": "string",
            "format": "date-time"
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "periods": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "start": {
                  "type": "string",
                  "format": "date-time"
                },
                "end": {
                  "type": "string",
                  "format": "date-time"
                },
                "price_p_per_kwh": {
                  "type": "string"
                },
                "candidate": {
                  "type": "object",
                  "description": "The computed price, its components, bound and notes."
                },
                "held_reason": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "enum": [
                    "below_min_change",
                    "min_change_interval",
                    "max_changes_per_day",
                    null
                  ]
                },
                "changed": {
                  "type": "boolean"
                }
              }
            }
          },
          "blocks": {
            "type": "array",
            "items": {
              "type": "object"
            }
          }
        }
      },
      "HubSessionCost": {
        "type": "object",
        "properties": {
          "transaction_id": {
            "type": "string"
          },
          "schedule_version": {
            "type": "integer"
          },
          "energy_kwh": {
            "type": "string"
          },
          "start_price_p_per_kwh": {
            "type": "string"
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "kind": {
                  "type": "string",
                  "enum": [
                    "connection_fee",
                    "energy",
                    "idle_fee"
                  ]
                },
                "description": {
                  "type": "string"
                },
                "kwh": {
                  "type": "string"
                },
                "scheduled_p_per_kwh": {
                  "type": "string"
                },
                "applied_p_per_kwh": {
                  "type": "string"
                },
                "price_lock_applied": {
                  "type": "boolean"
                },
                "amount_p": {
                  "type": "string"
                }
              }
            }
          },
          "total_p": {
            "type": "string"
          },
          "total_gbp": {
            "type": "string"
          },
          "vat_included_p": {
            "type": "string"
          },
          "unrounded_total_p": {
            "type": "string"
          },
          "rounding_difference_p": {
            "type": "string"
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "SourcePriorityPolicy": {
        "type": "object",
        "description": "One asset's source priority. `is_default` is true when no row is stored and the default applies.",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "asset_key": {
            "type": "string"
          },
          "primary_source": {
            "type": "string",
            "enum": [
              "home_assistant",
              "direct"
            ]
          },
          "fallback_enabled": {
            "type": "boolean"
          },
          "stale_after_seconds": {
            "type": "integer"
          },
          "direct_device_id": {
            "type": "string",
            "nullable": true
          },
          "home_assistant_entities": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "is_default": {
            "type": "boolean"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_by": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ForecastFreshness": {
        "type": "object",
        "properties": {
          "forecast_generated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "forecast_age_seconds": {
            "type": [
              "number",
              "null"
            ]
          },
          "weather_status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "fresh",
              "cached",
              "partial",
              "missing",
              null
            ]
          },
          "weather_source": {
            "type": [
              "string",
              "null"
            ],
            "description": "open-meteo or met-office-datahub"
          },
          "weather_fetched_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "weather_age_seconds": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "ForecastRunSummary": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "horizon_start": {
            "type": "string",
            "format": "date-time"
          },
          "demand_method": {
            "type": "string",
            "enum": [
              "gbm_quantile",
              "seasonal_profile",
              "none"
            ]
          },
          "demand_validated": {
            "type": "boolean"
          },
          "solar_method": {
            "type": "string",
            "enum": [
              "pv_model_calibrated",
              "pv_model_uncalibrated",
              "none"
            ]
          },
          "solar_validated": {
            "type": "boolean"
          },
          "demand": {
            "type": "object",
            "description": "Method",
            "model version": null,
            "history": null,
            "features": null,
            "reason and validation result.": null
          },
          "solar": {
            "type": "object",
            "description": "Method",
            "calibration factor and band": null,
            "reason and validation result.": null
          },
          "weather": {
            "type": "object",
            "description": "Source",
            "status": null,
            "fetch time": null,
            "age and coordinates of the weather used.": null
          },
          "assumptions": {
            "type": "object",
            "description": "Every input value used",
            "with its source.": null
          }
        }
      },
      "ForecastSlot": {
        "type": "object",
        "properties": {
          "ts": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the half hour",
            "UTC.": null
          },
          "demand_p10": {
            "type": [
              "number",
              "null"
            ]
          },
          "demand_p50": {
            "type": [
              "number",
              "null"
            ]
          },
          "demand_p90": {
            "type": [
              "number",
              "null"
            ]
          },
          "solar_p10": {
            "type": [
              "number",
              "null"
            ]
          },
          "solar_p50": {
            "type": [
              "number",
              "null"
            ]
          },
          "solar_p90": {
            "type": [
              "number",
              "null"
            ]
          },
          "temperature_c": {
            "type": [
              "number",
              "null"
            ]
          },
          "ghi_wm2": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "PlanStudyInputErrors": {
        "type": "object",
        "description": "Why an input, a study or a run request was refused. One entry per field\n(and per row for a file), so every problem can be fixed in one pass.\n",
        "properties": {
          "detail": {
            "type": "object",
            "properties": {
              "message": {
                "type": "string"
              },
              "errors": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "field",
                    "message"
                  ],
                  "properties": {
                    "field": {
                      "type": "string",
                      "example": "site_load.timestamp"
                    },
                    "row": {
                      "type": "integer",
                      "description": "Line number in the file, the header being line 1."
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "PlanStudyRun": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "study_id": {
            "type": "string"
          },
          "study_type": {
            "type": "string",
            "enum": [
              "depottwin",
              "power_passport",
              "storage_plan"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "succeeded",
              "failed"
            ]
          },
          "engine_version": {
            "type": "string",
            "description": "Study type and a hash of the engine, report and adapter source."
          },
          "input_hash": {
            "type": "string",
            "description": "SHA-256 of the canonical input manifest."
          },
          "is_sample": {
            "type": "boolean"
          },
          "requested_at": {
            "type": "string",
            "format": "date-time"
          },
          "requested_by_user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "duration_seconds": {
            "type": [
              "number",
              "null"
            ]
          },
          "error_kind": {
            "type": [
              "string",
              "null"
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "summary": {
            "type": [
              "object",
              "null"
            ],
            "description": "Headline figures. Carries `label` (machine value `MODELLED`, unchanged) and `display_label` (`Projection`, the text to show); a savings audit inside it has `display_label` `Calculated from your charging data`."
          },
          "report_available": {
            "type": "boolean"
          },
          "report_sha256": {
            "type": [
              "string",
              "null"
            ]
          },
          "input_manifest": {
            "type": "object",
            "description": "getPlanStudyRun only."
          },
          "result": {
            "type": [
              "object",
              "null"
            ],
            "description": "getPlanStudyRun only: the engine result envelope, with `label` (`MODELLED`) and `display_label` (`Projection`)."
          }
        }
      },
      "PlanStudy": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "study_type": {
            "type": "string",
            "enum": [
              "depottwin",
              "power_passport",
              "storage_plan"
            ]
          },
          "study_type_title": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "site": {
            "type": "object"
          },
          "params": {
            "type": "object"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by_user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_sample": {
            "type": "boolean"
          },
          "inputs": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "kind": {
                  "type": "string"
                },
                "title": {
                  "type": "string"
                },
                "format": {
                  "type": "string",
                  "enum": [
                    "csv",
                    "json"
                  ]
                },
                "required": {
                  "type": "boolean"
                },
                "uploaded": {
                  "type": "boolean"
                },
                "summary": {
                  "type": "object"
                },
                "source": {
                  "type": "string",
                  "enum": [
                    "upload",
                    "sample"
                  ]
                }
              }
            }
          },
          "missing_required_inputs": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "ready_to_run": {
            "type": "boolean"
          },
          "engine_version": {
            "type": "string"
          },
          "runs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlanStudyRun"
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "The standard AmpVerve error body. `detail` is a human readable string on most\nerrors, an array on 422, and an object on the plan limit error.\n\nThe partner endpoints (`/api/devtools/...`, `POST /api/telemetry/ingest` and the\nHome Assistant connector's API key operations) answer every error as RFC 9457\nproblem details with a stable `code`; see `ProblemDetails` and `ErrorEnvelope`.\nElsewhere there is no machine readable code yet: branch on the HTTP status, and\ntreat `detail` as diagnostic text rather than as a stable machine contract.\n",
        "properties": {
          "detail": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "object"
                }
              },
              {
                "type": "object"
              }
            ]
          },
          "error": {
            "$ref": "#/components/schemas/ErrorObject"
          }
        },
        "required": [
          "detail"
        ],
        "example": {
          "detail": "Authorization header missing"
        }
      },
      "ErrorObject": {
        "type": "object",
        "description": "Present on every error from the partner endpoints, beside the problem details\nmembers it repeats (`code`, `request_id`). Kept for clients written before\nthose members; new clients should read the top level `code`. Branch on `code`,\nshow `message` to people, and quote `request_id` (also the `X-Request-Id`\nheader) when you contact support. New codes can be added; treat an unknown\ncode by its HTTP status.\n",
        "required": [
          "code",
          "message",
          "request_id"
        ],
        "properties": {
          "code": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9_]{1,63}$",
            "description": "Stable, snake case. Specific codes: `api_key_missing`, `api_key_invalid`,\n`api_key_revoked`, `api_key_expired`, `api_key_required` (a credential that\nis not an API key on a Partner API operation), `api_key_auth_unavailable`,\n`insufficient_scope`, `sandbox_only`, `invalid_cursor` (400, a list `cursor`\nthat cannot be used), `rate_limited`, `authentication_required`,\n`token_invalid`, `insufficient_role`, `tenant_mismatch`. Otherwise the code\nof the status: `bad_request` (400), `unauthorized` (401), `forbidden` (403),\n`not_found` (404), `method_not_allowed` (405), `conflict` (409),\n`payload_too_large` (413), `unsupported_media_type` (415),\n`validation_error` (422), `locked` (423), `rate_limited` (429),\n`internal_error` (500), `upstream_error` (502), `service_unavailable` (503),\n`upstream_timeout` (504), and `client_error` or `server_error` for any\nother status. Where `detail` is an object with an `error` code, that code\nis used. The Error code reference on the Errors page of the published\ndocumentation gives the meaning of each code and what to do about it; a\ntest keeps that table, this list and the service code in step.\n\nTelemetry ingestion and the Home Assistant connector add:\n`all_records_rejected` (422, every record of an ingestion batch was\nrejected; see `results`), `cross_tenant_record` (403, a record or the body\nnames an organisation), `no_verified_organisation` (403),\n`writer_unavailable`, `writer_error` and `partial_write` (503, the store did\nnot persist the batch; retry it).\n",
            "example": "api_key_expired"
          },
          "message": {
            "type": "string",
            "description": "Human readable. Its wording can change; do not parse it.",
            "example": "API key expired"
          },
          "request_id": {
            "type": "string",
            "description": "The same value as the `X-Request-Id` response header.",
            "example": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "description": "The `application/json` error body of the partner endpoints, sent when the\nrequest does not ask for `application/problem+json`: the members of\n`ProblemDetails`, except that `detail` keeps the shape it had before\n2026-10-15 (a string on most errors, an object on a scope, sandbox or cursor\nrefusal, an array on field validation), plus `error`. When `detail` is a\nstring this body is itself a valid problem details document.\n",
        "required": [
          "type",
          "title",
          "status",
          "detail",
          "instance",
          "code",
          "request_id",
          "error"
        ],
        "additionalProperties": true,
        "properties": {
          "type": {
            "type": "string",
            "format": "uri"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "integer"
          },
          "detail": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "object"
                }
              },
              {
                "type": "object"
              }
            ]
          },
          "instance": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9_]{1,63}$"
          },
          "request_id": {
            "type": "string"
          },
          "error": {
            "$ref": "#/components/schemas/ErrorObject"
          }
        },
        "example": {
          "type": "https://www.ampverve.com/api-docs/#error-code-api-key-expired",
          "title": "Unauthorized",
          "status": 401,
          "detail": "API key expired",
          "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
          "code": "api_key_expired",
          "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
          "error": {
            "code": "api_key_expired",
            "message": "API key expired",
            "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          }
        }
      },
      "ProblemDetails": {
        "type": "object",
        "description": "RFC 9457 problem details, the `application/problem+json` error body of the\npartner endpoints (`/api/devtools/...`, `POST /api/telemetry/ingest`, the Home\nAssistant connector's API key operations), sent when the request carries\n`Accept: application/problem+json`. Without that header the same members come\nas `application/json`; see `ErrorEnvelope`.\n\nBranch on `code`. `detail` is for people. Other members are extensions named\nby the error: `errors` (field validation: `loc`, `msg`, `type`; the input is\nnot echoed), `required_scope` (a missing scope), and the counts and\n`results` of an ingestion batch. An error body never names an organisation\nother than the caller's own.\n",
        "required": [
          "type",
          "title",
          "status",
          "detail",
          "instance",
          "code",
          "request_id"
        ],
        "additionalProperties": true,
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "The code's entry on the Errors page, `https://www.ampverve.com/api-docs/#error-code-<code with hyphens>`."
          },
          "title": {
            "type": "string",
            "description": "The HTTP status phrase."
          },
          "status": {
            "type": "integer",
            "description": "The HTTP status code of this response."
          },
          "detail": {
            "type": "string",
            "description": "Human readable explanation of this occurrence. Its wording can change; do not parse it."
          },
          "instance": {
            "type": "string",
            "description": "`urn:ampverve:request:<request_id>`, naming this occurrence."
          },
          "code": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9_]{1,63}$",
            "description": "Stable machine readable code. The codes are listed under `ErrorObject.code`."
          },
          "request_id": {
            "type": "string",
            "description": "The same value as the `X-Request-Id` response header. Quote it to support."
          },
          "errors": {
            "type": "array",
            "description": "Field validation errors, on 422 `validation_error`.",
            "items": {
              "type": "object",
              "properties": {
                "loc": {
                  "type": "array",
                  "items": {
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "integer"
                      }
                    ]
                  }
                },
                "msg": {
                  "type": "string"
                },
                "type": {
                  "type": "string"
                }
              }
            }
          },
          "required_scope": {
            "type": "string",
            "description": "The scope the API key lacks, on 403 `insufficient_scope`."
          },
          "error": {
            "$ref": "#/components/schemas/ErrorObject"
          }
        },
        "example": {
          "type": "https://www.ampverve.com/api-docs/#error-code-insufficient-scope",
          "title": "Forbidden",
          "status": 403,
          "detail": "This API key does not hold the scope devices:read",
          "instance": "urn:ampverve:request:req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
          "code": "insufficient_scope",
          "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c",
          "required_scope": "devices:read",
          "error": {
            "code": "insufficient_scope",
            "message": "This API key does not hold the scope devices:read",
            "request_id": "req_4be1c0f35a2d4e7f9b8c6d5e4f3a2b1c"
          }
        }
      },
      "ValidationError": {
        "type": "object",
        "properties": {
          "detail": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string"
                },
                "loc": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "msg": {
                  "type": "string"
                },
                "input": {}
              }
            }
          }
        },
        "required": [
          "detail"
        ]
      },
      "PlanLimitError": {
        "type": "object",
        "properties": {
          "detail": {
            "type": "object",
            "properties": {
              "error": {
                "type": "string",
                "const": "plan_limit_exceeded"
              },
              "metric": {
                "type": "string"
              },
              "limit": {
                "type": "integer"
              },
              "upgrade_url": {
                "type": "string"
              }
            }
          }
        }
      },
      "TokenResponse": {
        "type": "object",
        "required": [
          "access_token",
          "refresh_token",
          "token_type"
        ],
        "properties": {
          "access_token": {
            "type": "string",
            "description": "HS256 JWT. 60 minute lifetime."
          },
          "refresh_token": {
            "type": "string",
            "description": "7 day lifetime. Single use, rotated on refresh."
          },
          "token_type": {
            "type": "string",
            "const": "bearer"
          }
        },
        "example": {
          "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
          "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
          "token_type": "bearer"
        }
      },
      "Readiness": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ready",
              "degraded"
            ]
          },
          "kafka_bootstrap": {
            "type": "string"
          },
          "topics": {
            "type": "object",
            "properties": {
              "raw": {
                "type": "string"
              },
              "normalized": {
                "type": "string"
              },
              "anomalies": {
                "type": "string"
              }
            }
          },
          "timeseries_table": {
            "type": "string"
          },
          "live": {
            "type": "object",
            "properties": {
              "kafka_producer": {
                "type": "boolean"
              },
              "kafka_consumer": {
                "type": "boolean"
              },
              "engine": {
                "type": "boolean"
              },
              "redis_stream": {
                "type": "boolean"
              }
            }
          },
          "checks": {
            "type": "object",
            "properties": {
              "kafka": {
                "type": "boolean"
              },
              "redis": {
                "type": "boolean"
              },
              "database": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "TelemetryRecord": {
        "type": "object",
        "description": "A single telemetry sample. Additional properties are permitted and are\npreserved verbatim in the `raw` column. A record without `asset_id` (or\n`device_id`) or `ts`, with an unreadable `ts`, with a measurement that is not a\nnumber, or with a `status` that is not a string is rejected and not stored;\nsee `IngestRecordResult`.\n",
        "additionalProperties": true,
        "properties": {
          "asset_id": {
            "type": "string",
            "description": "Stable identifier for the asset. `device_id` is accepted as a synonym.\nRequired: a record without either is rejected (`missing_asset_id`).\n"
          },
          "device_id": {
            "type": "string",
            "description": "Synonym for `asset_id`."
          },
          "tenant_id": {
            "type": "string",
            "description": "Your organisation identifier. Falls back to the `X-Tenant-ID` header, and\nmust agree with your bearer token.\n"
          },
          "ts": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 with an explicit UTC offset. Send `2026-07-31T14:05:00Z`.\nRequired: a record without one is rejected (`missing_ts`), and one that\ncannot be read as ISO 8601 is rejected (`invalid_ts`).\n"
          },
          "power_kw": {
            "type": "number",
            "description": "Kilowatts, signed. Positive is import or charge. Negative is export,\ngeneration or discharge.\n"
          },
          "soc_pct": {
            "type": "number",
            "minimum": 0,
            "maximum": 100,
            "description": "State of charge, percent. Values outside 0 to 100 are flagged."
          },
          "voltage": {
            "type": "number",
            "description": "Volts."
          },
          "current": {
            "type": "number",
            "description": "Amperes."
          },
          "temp_c": {
            "type": "number",
            "description": "Degrees Celsius."
          },
          "status": {
            "type": "string",
            "description": "Device status. Send one of `charging`, `discharging`, `producing`,\n`importing`, `exporting`, `idle`, `online`, `offline`, `fault` or\n`unknown`. Any other string is accepted and stored as sent; the\nPartner API returns it as sent in `status` and mapped onto that set in\n`status_canonical`. Defaults to `unknown`.\n"
          },
          "run_id": {
            "type": "string",
            "description": "Optional correlation identifier."
          },
          "intent_id": {
            "type": "string",
            "description": "Optional correlation identifier."
          }
        }
      },
      "IngestResult": {
        "type": "object",
        "description": "The body of `200` (every record stored) and `207` (every record that could be\nstored was stored, and at least one was rejected or was a duplicate; `results`\nnames each record's outcome).\n",
        "required": [
          "count",
          "accepted",
          "stored",
          "rejected",
          "duplicates",
          "streamed",
          "anomalies"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "description": "Records received."
          },
          "accepted": {
            "type": "integer",
            "description": "Records handed to the store, after rejections and duplicates."
          },
          "stored": {
            "type": "integer",
            "description": "Records durably written to the time series store. On a 2xx it always\nequals `accepted`; on `200` it also equals `count`.\n"
          },
          "rejected": {
            "type": "integer",
            "description": "Records rejected before the write. Each is named in `results`."
          },
          "duplicates": {
            "type": "integer",
            "description": "Exact copies of an earlier record in the same batch, stored once."
          },
          "streamed": {
            "type": "integer",
            "description": "Records published to the live event stream."
          },
          "anomalies": {
            "type": "array",
            "description": "One entry per stored record that failed a quality rule. Flagged records are\nstill stored.\n",
            "items": {
              "type": "object",
              "properties": {
                "asset_id": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "reasons": {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "enum": [
                      "soc_out_of_range",
                      "power_out_of_range",
                      "voltage_out_of_range"
                    ]
                  }
                },
                "record": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "results": {
            "type": "array",
            "description": "On `207` only. One entry per record, in the order sent.",
            "items": {
              "$ref": "#/components/schemas/IngestRecordResult"
            }
          }
        }
      },
      "IngestRecordResult": {
        "type": "object",
        "description": "The outcome of one record of an ingestion batch.",
        "required": [
          "index",
          "outcome"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "description": "The record's position in the batch, from 0."
          },
          "outcome": {
            "type": "string",
            "enum": [
              "stored",
              "rejected",
              "duplicate",
              "not_stored"
            ],
            "description": "`stored`. `rejected`: the store cannot take the record as sent, and\nresending it unchanged fails again; fix it. `duplicate`: an exact copy of\nthe record at `duplicate_of`, stored once. `not_stored`: the record was\nvalid, and the store did not persist the batch (503); resend it.\n"
          },
          "reason": {
            "type": "string",
            "enum": [
              "missing_asset_id",
              "invalid_asset_id",
              "missing_ts",
              "invalid_ts",
              "non_numeric_value",
              "invalid_status"
            ],
            "description": "On `rejected`."
          },
          "field": {
            "type": "string",
            "description": "On `rejected`, the field at fault."
          },
          "duplicate_of": {
            "type": "integer",
            "description": "On `duplicate`, the index of the record it copies."
          }
        }
      },
      "LiveEvent": {
        "type": "object",
        "properties": {
          "ts": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "type": {
            "type": "string",
            "const": "telemetry"
          },
          "payload": {
            "type": "object",
            "properties": {
              "asset_id": {
                "type": "string"
              },
              "power_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "soc_pct": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "voltage": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "current": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "temp_c": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "status": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      },
      "AssetHistory": {
        "type": "object",
        "properties": {
          "asset_id": {
            "type": "string"
          },
          "asset_type": {
            "type": "string"
          },
          "metric": {
            "type": "string"
          },
          "range": {
            "type": "string",
            "enum": [
              "live",
              "hourly",
              "daily",
              "weekly",
              "historical"
            ]
          },
          "unit": {
            "type": "string",
            "enum": [
              "kW",
              "%",
              "kWh",
              "degC",
              "GBP"
            ]
          },
          "series": {
            "type": "array",
            "description": "Sparse. Buckets with no samples are omitted. `t` is the bucket start,\n`v` is the mean of the samples in the bucket.\n",
            "items": {
              "type": "object",
              "properties": {
                "t": {
                  "type": "string",
                  "format": "date-time"
                },
                "v": {
                  "type": "number"
                }
              }
            }
          },
          "summary": {
            "type": "object",
            "properties": {
              "count": {
                "type": "integer"
              },
              "min": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "max": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "avg": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "latest": {
                "type": [
                  "number",
                  "null"
                ]
              }
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "timeseries",
              "timeseries_unavailable"
            ],
            "description": "`timeseries_unavailable` means the store was unreachable and `series` is\nempty for that reason, not because there is no data.\n"
          }
        }
      },
      "Asset": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "type": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "details": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          }
        }
      },
      "TwinAssetClass": {
        "type": "string",
        "enum": [
          "connection_point",
          "meter",
          "board",
          "inverter",
          "battery",
          "solar_pv",
          "charger",
          "heat_pump",
          "hvac",
          "other"
        ]
      },
      "TwinAssetWrite": {
        "type": "object",
        "description": "`site_id`, `asset_class` and `name` are required on create. On update every\nfield is optional and a present null clears it.\n",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "asset_class": {
            "$ref": "#/components/schemas/TwinAssetClass"
          },
          "name": {
            "type": "string",
            "maxLength": 200
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "make": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200
          },
          "model": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200
          },
          "serial_number": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200
          },
          "rated_power_kw": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 1000000
          },
          "rated_energy_kwh": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 1000000
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The device this asset is integrated through. Must be on the same site."
          },
          "integration_adapter": {
            "type": [
              "string",
              "null"
            ],
            "description": "For an asset with no device row only."
          },
          "integration_provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "For an asset with no device row only."
          },
          "external_ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "MPAN, board reference or asset tag."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "TwinAsset": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "asset_class": {
            "$ref": "#/components/schemas/TwinAssetClass"
          },
          "name": {
            "type": "string"
          },
          "make": {
            "type": [
              "string",
              "null"
            ]
          },
          "model": {
            "type": [
              "string",
              "null"
            ]
          },
          "serial_number": {
            "type": [
              "string",
              "null"
            ]
          },
          "ratings": {
            "type": "object",
            "properties": {
              "power_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "energy_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              }
            }
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "integration": {
            "type": "object",
            "properties": {
              "provider": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "adapter": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "source": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "device",
                  "registry",
                  null
                ]
              },
              "device_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "device_type": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "freshness": {
            "type": "object",
            "properties": {
              "band": {
                "type": "string",
                "enum": [
                  "healthy",
                  "stale",
                  "dark",
                  "never_reported",
                  "not_linked"
                ]
              },
              "last_seen_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "age_seconds": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "cadence_seconds": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "missed_cycles": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "basis": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "poller_health or telemetry: which reading was newest."
              }
            }
          },
          "control_posture": {
            "type": "object",
            "properties": {
              "posture": {
                "type": "string",
                "description": "From the device's evaluated capability profile (for example\nCHARGER_CONTROL_ONLY, OBSERVATION_ONLY, OFFLINE_OR_STALE), or\nNOT_EVALUATED, NOT_APPLICABLE or UNAVAILABLE.\n"
              },
              "reasons": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "evaluated_at": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "source": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "external_ref": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "TwinSiteTopology": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "type": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "hierarchy_path": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "kind": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          },
          "roots": {
            "type": "array",
            "description": "TwinAsset records with `depth` and nested `children`.",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "max_depth": {
            "type": "integer"
          },
          "findings": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "severity": {
                  "type": "string",
                  "enum": [
                    "error",
                    "warning",
                    "info"
                  ]
                },
                "code": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                },
                "asset_id": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          },
          "unregistered_devices": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "device_type": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "asset_type": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "proposed_class": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          },
          "counts": {
            "type": "object",
            "properties": {
              "assets": {
                "type": "integer"
              },
              "by_class": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "by_freshness": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "AgilePrices": {
        "type": "object",
        "properties": {
          "region": {
            "type": "string"
          },
          "region_name": {
            "type": "string"
          },
          "product": {
            "type": "string"
          },
          "tariff_code": {
            "type": "string"
          },
          "count": {
            "type": "integer"
          },
          "prices": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "valid_from": {
                  "type": "string",
                  "format": "date-time"
                },
                "valid_to": {
                  "type": "string",
                  "format": "date-time"
                },
                "price_pence_per_kwh": {
                  "type": "number",
                  "description": "Pence per kWh, VAT inclusive."
                },
                "price_exc_vat": {
                  "type": "number",
                  "description": "Pence per kWh, VAT exclusive."
                }
              }
            }
          }
        }
      },
      "Tender": {
        "type": "object",
        "properties": {
          "dno": {
            "type": "string",
            "enum": [
              "UKPN",
              "SSEN"
            ]
          },
          "platform_id": {
            "type": "string"
          },
          "substation_or_zone": {
            "type": "string"
          },
          "product": {
            "type": "string"
          },
          "direction": {
            "type": "string"
          },
          "delivery_start": {
            "type": "string",
            "format": "date-time"
          },
          "delivery_end": {
            "type": "string",
            "format": "date-time"
          },
          "required_capacity_mw": {
            "type": "number"
          },
          "reserve_price_gbp_per_mwh": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "SiteQuality": {
        "type": "string",
        "enum": [
          "measured",
          "unmarked",
          "interpolated",
          "estimated",
          "missing"
        ],
        "description": "Measurement quality, worst wins across a bucket. `measured` only when every\ninput sample said so; `unmarked` when the source asserted nothing (silence\nis never promoted to measured); `missing` when there were no samples.\n"
      },
      "SiteProvenance": {
        "type": "string",
        "enum": [
          "device",
          "unknown",
          "synthetic"
        ],
        "description": "Where the samples came from, worst wins across a bucket. One synthetic\n(seeded demo) row makes the bucket synthetic. A separate axis from quality.\n"
      },
      "Site": {
        "type": "object",
        "description": "One workspace of the organisation and the assets it carries, from the device records.",
        "required": [
          "site_id",
          "name",
          "assets",
          "pv_kwp",
          "pv_kwp_source",
          "battery_nameplate_kwh",
          "battery_nameplate_source",
          "profile",
          "profile_readable"
        ],
        "properties": {
          "site_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "assets": {
            "type": "object",
            "description": "Device ids per role. A role with no device is an empty list.",
            "required": [
              "solar",
              "meter",
              "battery"
            ],
            "properties": {
              "solar": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "meter": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "battery": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "pv_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Installed PV capacity in kWp. Null when neither the site profile nor a solar device record carries one."
          },
          "pv_kwp_source": {
            "type": "string",
            "enum": [
              "site_profile",
              "device_record",
              "unavailable"
            ]
          },
          "battery_nameplate_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Battery nameplate capacity in kWh. Sum of the battery device records unless the site profile sets it."
          },
          "battery_nameplate_source": {
            "type": "string",
            "enum": [
              "site_profile",
              "device_record",
              "unavailable"
            ]
          },
          "profile": {
            "type": [
              "object",
              "null"
            ],
            "description": "The `site_profiles` row, or null when the site has none.",
            "properties": {
              "floor_area_m2": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "opening_hours_per_week": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "source": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "profile_readable": {
            "type": "boolean",
            "description": "False when the `site_profiles` table could not be read (migration 20260912_site_profiles not applied)."
          }
        }
      },
      "SiteList": {
        "type": "object",
        "required": [
          "organisation_id",
          "sites",
          "source"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Site"
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "device_records",
              "timeseries_unavailable"
            ]
          }
        }
      },
      "SelfConsumptionInput": {
        "type": "object",
        "description": "Bookkeeping for one input role (solar, meter or battery) over a bucket.",
        "required": [
          "quality",
          "provenance",
          "samples",
          "slots_present",
          "slots_missing"
        ],
        "properties": {
          "quality": {
            "$ref": "#/components/schemas/SiteQuality"
          },
          "provenance": {
            "$ref": "#/components/schemas/SiteProvenance"
          },
          "samples": {
            "type": "integer",
            "description": "Stored samples for this role in the bucket."
          },
          "slots_present": {
            "type": "integer",
            "description": "Half hour slots in which this role was heard from."
          },
          "slots_missing": {
            "type": "integer",
            "description": "Expected slots minus slots present",
            "floored at zero.": null
          }
        }
      },
      "SelfConsumptionTotal": {
        "type": "object",
        "description": "The split over one bucket or over the whole window. Energy figures are kWh\nsummed over complete half hours (both inverter and meter heard from) and\nare null when the bucket has no complete slot. Battery figures are null\nwhen the site has no battery.\n",
        "required": [
          "generation_kwh",
          "self_consumed_kwh",
          "exported_kwh",
          "self_consumed_pct",
          "exported_pct",
          "grid_import_kwh",
          "grid_export_kwh",
          "export_beyond_generation_kwh",
          "battery_charge_kwh",
          "battery_discharge_kwh",
          "quality",
          "provenance",
          "complete",
          "coverage_pct",
          "slots_expected",
          "slots_complete",
          "inputs"
        ],
        "properties": {
          "generation_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "PV generation",
            "kWh.": null
          },
          "self_consumed_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Generation minus exported",
            "kWh. Includes energy that charged an on site battery.": null
          },
          "exported_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "min(generation",
            "grid export) per half hour": null,
            "summed": null,
            "kWh.": null
          },
          "self_consumed_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Self-consumed share of generation",
            "per cent. Null when generation is zero.": null
          },
          "exported_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Exported share of generation",
            "per cent. Null when generation is zero.": null
          },
          "grid_import_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Energy imported at the grid meter",
            "kWh.": null
          },
          "grid_export_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Energy exported at the grid meter",
            "kWh": null,
            "uncapped.": null
          },
          "export_beyond_generation_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Grid export above generation in the same half hour",
            "kWh. Battery or other export": null,
            "not PV.": null
          },
          "battery_charge_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "battery_discharge_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "quality": {
            "$ref": "#/components/schemas/SiteQuality"
          },
          "provenance": {
            "$ref": "#/components/schemas/SiteProvenance"
          },
          "complete": {
            "type": "boolean",
            "description": "True when every expected slot was complete."
          },
          "coverage_pct": {
            "type": "number",
            "description": "Complete slots as a share of expected slots",
            "per cent": null,
            "one decimal.": null
          },
          "slots_expected": {
            "type": "integer",
            "description": "Half hour slots the window and the clock allow the bucket to contain."
          },
          "slots_complete": {
            "type": "integer"
          },
          "inputs": {
            "type": "object",
            "description": "Per role bookkeeping. `battery` is present only when the site has a battery.",
            "required": [
              "solar",
              "meter"
            ],
            "properties": {
              "solar": {
                "$ref": "#/components/schemas/SelfConsumptionInput"
              },
              "meter": {
                "$ref": "#/components/schemas/SelfConsumptionInput"
              },
              "battery": {
                "$ref": "#/components/schemas/SelfConsumptionInput"
              }
            }
          }
        }
      },
      "SelfConsumptionBucket": {
        "allOf": [
          {
            "$ref": "#/components/schemas/SelfConsumptionTotal"
          },
          {
            "type": "object",
            "required": [
              "key",
              "period_start",
              "period_end"
            ],
            "properties": {
              "key": {
                "type": "string",
                "description": "Bucket label. ISO 8601 UTC slot start for `hh`, `YYYY-MM-DD` local day for `daily`, `YYYY-MM` for `monthly`."
              },
              "period_start": {
                "type": "string",
                "format": "date-time",
                "description": "UTC instant the bucket starts."
              },
              "period_end": {
                "type": "string",
                "format": "date-time",
                "description": "UTC instant the bucket ends",
                "exclusive.": null
              },
              "settlement_day": {
                "type": "string",
                "format": "date",
                "description": "Only for `hh`. GB settlement day of the slot."
              },
              "settlement_period": {
                "type": "integer",
                "minimum": 1,
                "maximum": 50,
                "description": "Only for `hh`. Settlement period counted from local midnight, 1 to 46, 48 or 50 depending on the day."
              }
            }
          }
        ]
      },
      "SelfConsumption": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "site",
          "resolution",
          "from",
          "to",
          "calendar",
          "settlement_aligned",
          "bucket_seconds",
          "method",
          "quality_scale",
          "provenance_scale",
          "buckets",
          "total",
          "source",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Site"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null on the `timeseries_unavailable` branch."
          },
          "resolution": {
            "type": "string",
            "enum": [
              "hh",
              "daily",
              "monthly"
            ]
          },
          "from": {
            "type": "string",
            "format": "date-time",
            "description": "Window start after snapping to the bucket boundary",
            "UTC.": null
          },
          "to": {
            "type": "string",
            "format": "date-time",
            "description": "Window end",
            "exclusive": null,
            "UTC.": null
          },
          "calendar": {
            "type": "string",
            "description": "`Europe/London`, or `UTC (zone database unavailable)` when the host has no zone database and local days degrade to UTC days."
          },
          "settlement_aligned": {
            "type": "boolean",
            "description": "True only for `hh`."
          },
          "bucket_seconds": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1800 for `hh`",
            "null otherwise.": null
          },
          "method": {
            "type": "string",
            "description": "The split rule",
            "stated in prose.": null
          },
          "quality_scale": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The quality values in order from best to worst."
          },
          "provenance_scale": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The provenance values in order from best to worst."
          },
          "buckets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SelfConsumptionBucket"
            },
            "description": "Every bucket the window touches, in order, including empty ones. Empty on the unavailable branches."
          },
          "total": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/SelfConsumptionTotal"
              },
              {
                "type": "null"
              }
            ],
            "description": "The split over the whole window. Null on the unavailable branches."
          },
          "source": {
            "type": "string",
            "enum": [
              "timeseries",
              "device_records",
              "timeseries_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "BatteryStateCapacityDiagnostics": {
        "type": "object",
        "description": "The segment analysis behind the computed state of health, returned even when it yielded no figure.",
        "required": [
          "segments_used",
          "charge_segments",
          "discharge_segments",
          "capacity_kwh_median",
          "capacity_kwh_min",
          "capacity_kwh_max",
          "pairs_examined",
          "inconsistent_pairs",
          "min_swing_pct"
        ],
        "properties": {
          "segments_used": {
            "type": "integer",
            "description": "Charge or discharge segments whose SoC swing reached the minimum."
          },
          "charge_segments": {
            "type": "integer"
          },
          "discharge_segments": {
            "type": "integer"
          },
          "capacity_kwh_median": {
            "type": [
              "number",
              "null"
            ],
            "description": "Median measured capacity across segments",
            "kWh.": null
          },
          "capacity_kwh_min": {
            "type": [
              "number",
              "null"
            ]
          },
          "capacity_kwh_max": {
            "type": [
              "number",
              "null"
            ]
          },
          "pairs_examined": {
            "type": "integer",
            "description": "Consecutive sample pairs no more than an hour apart with SoC and power on both."
          },
          "inconsistent_pairs": {
            "type": "integer",
            "description": "Pairs where power flowed but the state of charge did not move the same way."
          },
          "min_swing_pct": {
            "type": "number",
            "description": "Minimum SoC swing for a segment to count",
            "SoC points.": null
          }
        }
      },
      "BatteryStateReport": {
        "type": "object",
        "description": "Everything shown for one battery, with sources.",
        "required": [
          "asset_id",
          "nameplate_kwh",
          "nameplate_source",
          "samples",
          "quality",
          "latest",
          "temperature",
          "throughput",
          "soh"
        ],
        "properties": {
          "asset_id": {
            "type": "string"
          },
          "nameplate_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Nameplate capacity used for cycles and state of health",
            "kWh.": null
          },
          "nameplate_source": {
            "type": "string",
            "enum": [
              "site_profile",
              "device_record",
              "unavailable",
              "site_total_not_split_per_battery"
            ]
          },
          "samples": {
            "type": "integer",
            "description": "Stored samples for this battery in the window."
          },
          "quality": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/SiteQuality"
              },
              {
                "type": "null"
              }
            ],
            "description": "Worst quality across the samples. Null when there are none."
          },
          "latest": {
            "type": [
              "object",
              "null"
            ],
            "description": "The most recent sample. Null when there are none.",
            "required": [
              "ts",
              "age_seconds",
              "stale",
              "soc_pct",
              "power_kw",
              "direction",
              "quality"
            ],
            "properties": {
              "ts": {
                "type": "string",
                "format": "date-time"
              },
              "age_seconds": {
                "type": "integer"
              },
              "stale": {
                "type": "boolean",
                "description": "True when the sample is more than an hour old."
              },
              "soc_pct": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "State of charge",
                "per cent.": null
              },
              "power_kw": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Positive charging",
                "negative discharging.": null
              },
              "direction": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "charging",
                  "discharging",
                  "idle",
                  null
                ],
                "description": "From the sign of `power_kw` with a 0.05 kW idle band. Null when power is absent."
              },
              "quality": {
                "$ref": "#/components/schemas/SiteQuality"
              }
            }
          },
          "temperature": {
            "type": "object",
            "description": "Cell or module temperature spread from the latest sample. `kind` says\nwhat the adapter reported: `cell_spread` or `module_spread` carry\n`min_c`, `max_c`, `spread_c`, `sensor_count` (null when only a min and\nmax were reported) and `as_of`; `single_scalar` carries `value_c`,\n`spread_c` (null) and `as_of`; `unavailable` carries only `statement`.\n",
            "required": [
              "kind",
              "statement"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "cell_spread",
                  "module_spread",
                  "single_scalar",
                  "unavailable"
                ]
              },
              "statement": {
                "type": "string"
              },
              "min_c": {
                "type": "number"
              },
              "max_c": {
                "type": "number"
              },
              "spread_c": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "sensor_count": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "value_c": {
                "type": "number"
              },
              "as_of": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "throughput": {
            "type": "object",
            "required": [
              "vendor",
              "computed"
            ],
            "properties": {
              "vendor": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Lifetime figures passed through from the adapter's stored row. Null when the row carries none.",
                "required": [
                  "cycle_count",
                  "cycle_count_field",
                  "throughput_kwh",
                  "throughput_field",
                  "as_of",
                  "statement"
                ],
                "properties": {
                  "cycle_count": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "cycle_count_field": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The stored field the count was read from."
                  },
                  "throughput_kwh": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "throughput_field": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "as_of": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "statement": {
                    "type": "string"
                  }
                }
              },
              "computed": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Integrated by AmpVerve from the signed power series over the window. Null when there are no samples.",
                "required": [
                  "window_from",
                  "window_to",
                  "charge_kwh",
                  "discharge_kwh",
                  "equivalent_full_cycles",
                  "method"
                ],
                "properties": {
                  "window_from": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "window_to": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "charge_kwh": {
                    "type": "number"
                  },
                  "discharge_kwh": {
                    "type": "number"
                  },
                  "equivalent_full_cycles": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Discharge kWh over nameplate kWh. Null without a nameplate."
                  },
                  "method": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "soh": {
            "type": "object",
            "required": [
              "shown",
              "vendor",
              "computed",
              "computed_unavailable_reason",
              "computed_diagnostics",
              "statement"
            ],
            "properties": {
              "shown": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The headline state of health and which basis it is. Null when neither basis is available.",
                "required": [
                  "value_pct",
                  "basis"
                ],
                "properties": {
                  "value_pct": {
                    "type": "number",
                    "description": "Per cent."
                  },
                  "basis": {
                    "type": "string",
                    "enum": [
                      "vendor_bms",
                      "computed"
                    ]
                  }
                }
              },
              "vendor": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The vendor BMS value, unverified by AmpVerve. Null when the adapter wrote none.",
                "required": [
                  "value_pct",
                  "field",
                  "as_of",
                  "statement"
                ],
                "properties": {
                  "value_pct": {
                    "type": "number"
                  },
                  "field": {
                    "type": "string",
                    "description": "The stored field it was read from."
                  },
                  "as_of": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "statement": {
                    "type": "string"
                  }
                }
              },
              "computed": {
                "description": "AmpVerve's computed figure with the segments, measured capacity and nameplate behind it. Null when withheld.",
                "oneOf": [
                  {
                    "type": "null"
                  },
                  {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/BatteryStateCapacityDiagnostics"
                      },
                      {
                        "type": "object",
                        "required": [
                          "value_pct",
                          "nameplate_kwh",
                          "nameplate_source",
                          "method"
                        ],
                        "properties": {
                          "value_pct": {
                            "type": "number",
                            "description": "Median measured capacity over nameplate",
                            "per cent.": null
                          },
                          "nameplate_kwh": {
                            "type": "number"
                          },
                          "nameplate_source": {
                            "type": "string"
                          },
                          "method": {
                            "type": "string"
                          }
                        }
                      }
                    ]
                  }
                ]
              },
              "computed_unavailable_reason": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Why no computed figure is shown. Null when one is."
              },
              "computed_diagnostics": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/BatteryStateCapacityDiagnostics"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Null when the battery has no samples."
              },
              "statement": {
                "type": "string",
                "description": "Which figure is shown and why",
                "in prose.": null
              }
            }
          }
        }
      },
      "BatteryState": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "window",
          "soh_bases",
          "computed_soh_method",
          "site",
          "batteries",
          "source",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "window": {
            "type": "object",
            "required": [
              "from",
              "to",
              "days"
            ],
            "properties": {
              "from": {
                "type": "string",
                "format": "date-time"
              },
              "to": {
                "type": "string",
                "format": "date-time"
              },
              "days": {
                "type": "integer"
              }
            }
          },
          "soh_bases": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The two bases a shown figure can have, `vendor_bms` and `computed`."
          },
          "computed_soh_method": {
            "type": "string"
          },
          "site": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Site"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null on the `timeseries_unavailable` branch."
          },
          "batteries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatteryStateReport"
            },
            "description": "One report per battery device on the site. Empty on the unavailable branches."
          },
          "source": {
            "type": "string",
            "enum": [
              "timeseries",
              "device_records",
              "timeseries_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "PortfolioLeagueRow": {
        "type": "object",
        "description": "One site in the league table, ranked or not, with its normalisation record.",
        "required": [
          "site_id",
          "name",
          "assets",
          "pv_kwp",
          "pv_kwp_source",
          "floor_area_m2",
          "opening_hours_per_week",
          "generation_kwh",
          "self_consumed_kwh",
          "self_consumed_pct",
          "exported_kwh",
          "grid_import_kwh",
          "specific_yield_kwh_per_kwp",
          "generation_kwh_per_m2",
          "import_kwh_per_opening_hour",
          "normalised_for",
          "not_normalised_for",
          "coverage_pct",
          "slots_complete",
          "slots_expected",
          "quality",
          "provenance",
          "caveats",
          "unranked_reason",
          "rank"
        ],
        "properties": {
          "site_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "assets": {
            "type": "object",
            "description": "Device count per role.",
            "required": [
              "solar",
              "meter",
              "battery"
            ],
            "properties": {
              "solar": {
                "type": "integer"
              },
              "meter": {
                "type": "integer"
              },
              "battery": {
                "type": "integer"
              }
            }
          },
          "pv_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Installed PV capacity",
            "kWp.": null
          },
          "pv_kwp_source": {
            "type": "string",
            "enum": [
              "site_profile",
              "device_record",
              "unavailable"
            ]
          },
          "floor_area_m2": {
            "type": [
              "number",
              "null"
            ]
          },
          "opening_hours_per_week": {
            "type": [
              "number",
              "null"
            ]
          },
          "generation_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Over the window",
            "complete half hours only. Null for an unranked site with no split.": null
          },
          "self_consumed_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "self_consumed_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Per cent of generation."
          },
          "exported_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "grid_import_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "specific_yield_kwh_per_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "The ranking basis. Generation kWh per installed kWp",
            "two decimals.": null
          },
          "generation_kwh_per_m2": {
            "type": [
              "number",
              "null"
            ],
            "description": "Only when the site profile carries a floor area."
          },
          "import_kwh_per_opening_hour": {
            "type": [
              "number",
              "null"
            ],
            "description": "Grid import per opening hour in the window. Only when the site profile carries opening hours."
          },
          "normalised_for": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What this row was normalised for, each with the figure and where it came from."
          },
          "not_normalised_for": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What this row was not normalised for and why. Always includes weather."
          },
          "coverage_pct": {
            "type": "number",
            "description": "Complete half hours as a share of expected",
            "per cent. 0.0 when there is no split.": null
          },
          "slots_complete": {
            "type": "integer"
          },
          "slots_expected": {
            "type": "integer"
          },
          "quality": {
            "$ref": "#/components/schemas/SiteQuality"
          },
          "provenance": {
            "$ref": "#/components/schemas/SiteProvenance"
          },
          "caveats": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Low coverage (below 80 per cent), estimated inputs, or synthetic demo inputs."
          },
          "unranked_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the site is not ranked. Null for a ranked site."
          },
          "rank": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Position from 1 among ranked sites. Null when unranked."
          }
        }
      },
      "PortfolioLeague": {
        "type": "object",
        "required": [
          "organisation_id",
          "window",
          "calendar",
          "basis",
          "site_count",
          "ranked",
          "unranked",
          "source",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "window": {
            "type": "object",
            "required": [
              "from",
              "to",
              "days"
            ],
            "properties": {
              "from": {
                "type": "string",
                "format": "date-time",
                "description": "Local midnight of the first day",
                "UTC.": null
              },
              "to": {
                "type": "string",
                "format": "date-time"
              },
              "days": {
                "type": "integer"
              }
            }
          },
          "calendar": {
            "type": "string",
            "description": "`Europe/London`, or `UTC (zone database unavailable)`."
          },
          "basis": {
            "type": "object",
            "required": [
              "metric",
              "statement",
              "normalised_for",
              "not_normalised_for",
              "low_coverage_caveat_below_pct"
            ],
            "properties": {
              "metric": {
                "type": "string",
                "const": "specific_yield_kwh_per_kwp"
              },
              "statement": {
                "type": "string"
              },
              "normalised_for": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "not_normalised_for": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "low_coverage_caveat_below_pct": {
                "type": "number",
                "description": "Coverage below this carries a caveat",
                "per cent.": null
              }
            }
          },
          "site_count": {
            "type": "integer"
          },
          "ranked": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PortfolioLeagueRow"
            },
            "description": "Ordered by specific yield descending, then generation, then name."
          },
          "unranked": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PortfolioLeagueRow"
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "timeseries",
              "device_records",
              "timeseries_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "PpaContract": {
        "type": "object",
        "description": "One `ppa_contracts` row. Rates are GBP per kWh as stored.",
        "required": [
          "id",
          "mpan",
          "contract_ref",
          "counterparty",
          "consumed_rate_gbp_per_kwh",
          "export_rate_gbp_per_kwh",
          "metering_boundary",
          "valid_from",
          "valid_to",
          "version",
          "source",
          "note"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "contract_ref": {
            "type": "string",
            "description": "Falls back to the row id when the row has no reference."
          },
          "counterparty": {
            "type": [
              "string",
              "null"
            ]
          },
          "consumed_rate_gbp_per_kwh": {
            "type": "number",
            "description": "Rate for PV consumed on site",
            "GBP per kWh.": null
          },
          "export_rate_gbp_per_kwh": {
            "type": "number",
            "description": "Rate for PV exported",
            "GBP per kWh.": null
          },
          "metering_boundary": {
            "type": "string",
            "description": "Where consumed and exported are measured",
            "in prose.": null
          },
          "valid_from": {
            "type": "string",
            "format": "date",
            "description": "First settlement day covered",
            "inclusive.": null
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "First settlement day no longer covered",
            "exclusive. Null for an open contract.": null
          },
          "version": {
            "type": "integer",
            "description": "Highest version covering a day wins."
          },
          "source": {
            "type": "string"
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "PpaReadingRef": {
        "type": "object",
        "description": "A stored reading whose held interval touches the half hour.",
        "required": [
          "reading_id",
          "asset_id",
          "ts"
        ],
        "properties": {
          "reading_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "sha256(organisation_id|asset_id|ts_utc_iso)[:32] as the lineage module derives it. Null when the lineage module is not importable in this process."
          },
          "asset_id": {
            "type": "string"
          },
          "ts": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PpaLine": {
        "type": "object",
        "description": "One GB settlement period of the month.",
        "required": [
          "settlement_day",
          "settlement_period",
          "period_start",
          "period_end",
          "consumed_kwh",
          "exported_kwh",
          "export_beyond_generation_kwh",
          "consumed_rate_gbp_per_kwh",
          "export_rate_gbp_per_kwh",
          "contract_id",
          "contract_ref",
          "contract_version",
          "consumed_gbp",
          "exported_gbp",
          "line_gbp",
          "status",
          "reason",
          "quality",
          "provenance",
          "readings",
          "trace"
        ],
        "properties": {
          "settlement_day": {
            "type": "string",
            "format": "date"
          },
          "settlement_period": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50
          },
          "period_start": {
            "type": "string",
            "format": "date-time",
            "description": "UTC."
          },
          "period_end": {
            "type": "string",
            "format": "date-time",
            "description": "UTC",
            "exclusive.": null
          },
          "consumed_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "PV consumed on site",
            "kWh": null,
            "six decimals. Null on a missing line.": null
          },
          "exported_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "PV exported",
            "capped at generation": null,
            "kWh. Null on a missing line.": null
          },
          "export_beyond_generation_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Export above generation",
            "not PV": null,
            "not priced. Null on a missing line.": null
          },
          "consumed_rate_gbp_per_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Null when no contract covers the day."
          },
          "export_rate_gbp_per_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Null when no contract covers the day."
          },
          "contract_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "contract_ref": {
            "type": [
              "string",
              "null"
            ]
          },
          "contract_version": {
            "type": [
              "integer",
              "null"
            ]
          },
          "consumed_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "consumed_kwh times the consumed rate",
            "GBP": null,
            "six decimals. Only on a priced line.": null
          },
          "exported_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "exported_kwh times the export rate",
            "GBP": null,
            "six decimals. Only on a priced line.": null
          },
          "line_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "consumed_gbp plus exported_gbp. Only on a priced line."
          },
          "status": {
            "type": "string",
            "enum": [
              "priced",
              "missing",
              "no_contract"
            ],
            "description": "`missing`: an input reading is absent, kWh and pounds absent. `no_contract`: kWh shown, pounds absent. `priced`: both."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the line is missing or unpriced. Null on a priced line."
          },
          "quality": {
            "type": "object",
            "required": [
              "solar",
              "meter",
              "line"
            ],
            "properties": {
              "solar": {
                "$ref": "#/components/schemas/SiteQuality"
              },
              "meter": {
                "$ref": "#/components/schemas/SiteQuality"
              },
              "line": {
                "$ref": "#/components/schemas/SiteQuality"
              }
            }
          },
          "provenance": {
            "$ref": "#/components/schemas/SiteProvenance"
          },
          "readings": {
            "type": "object",
            "required": [
              "solar",
              "meter"
            ],
            "properties": {
              "solar": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PpaReadingRef"
                }
              },
              "meter": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PpaReadingRef"
                }
              }
            }
          },
          "trace": {
            "type": "object",
            "description": "Identifiers that open the lineage tree for the same half hour.",
            "required": [
              "consumed_figure_id",
              "exported_figure_id",
              "term"
            ],
            "properties": {
              "consumed_figure_id": {
                "type": "string",
                "description": "`self_consumed_pv_kwh|{site_id}|{utc day start}|{utc day end}`."
              },
              "exported_figure_id": {
                "type": "string",
                "description": "`grid_export_kwh|{site_id}|{utc day start}|{utc day end}`."
              },
              "term": {
                "type": "string",
                "format": "date-time",
                "description": "The line's `period_start`."
              }
            }
          }
        }
      },
      "PpaTotals": {
        "type": "object",
        "required": [
          "lines",
          "lines_priced",
          "lines_missing",
          "lines_no_contract",
          "consumed_kwh",
          "exported_kwh",
          "consumed_gbp",
          "exported_gbp",
          "total_gbp",
          "complete",
          "quality"
        ],
        "properties": {
          "lines": {
            "type": "integer",
            "description": "Settlement periods in the month."
          },
          "lines_priced": {
            "type": "integer"
          },
          "lines_missing": {
            "type": "integer"
          },
          "lines_no_contract": {
            "type": "integer"
          },
          "consumed_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Sum over priced and no_contract lines",
            "kWh. Null when there are none.": null
          },
          "exported_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "consumed_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Sum of the unrounded priced lines",
            "rounded to the penny. Null when no line is priced.": null
          },
          "exported_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "total_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "complete": {
            "type": "boolean",
            "description": "True when every line is priced."
          },
          "quality": {
            "$ref": "#/components/schemas/SiteQuality"
          }
        }
      },
      "PpaStatement": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "site",
          "period",
          "period_start",
          "period_end",
          "calendar",
          "metering_boundary",
          "metering_boundaries_differ",
          "contracts",
          "contracts_applied",
          "method",
          "rounding",
          "trace_note",
          "reading_id_derivation",
          "reading_ids_available",
          "quality_scale",
          "provenance_scale",
          "lines",
          "totals",
          "source",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Site"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null on the `timeseries_unavailable` branch."
          },
          "period": {
            "type": "string",
            "description": "The requested month",
            "YYYY-MM.": null
          },
          "period_start": {
            "type": "string",
            "format": "date-time",
            "description": "Local midnight of the first day",
            "UTC.": null
          },
          "period_end": {
            "type": "string",
            "format": "date-time",
            "description": "Local midnight of the first day of the next month",
            "UTC": null,
            "exclusive.": null
          },
          "calendar": {
            "type": "string",
            "description": "`Europe/London`, or `UTC (zone database unavailable)`."
          },
          "metering_boundary": {
            "type": [
              "string",
              "null"
            ],
            "description": "The boundary of the contracts applied (or of all contracts when none applied), joined with `; ` when they differ. Null without a contract."
          },
          "metering_boundaries_differ": {
            "type": "boolean"
          },
          "contracts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PpaContract"
            },
            "description": "Every contract row for the site."
          },
          "contracts_applied": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PpaContract"
            },
            "description": "The rows that priced at least one line."
          },
          "method": {
            "type": "string"
          },
          "rounding": {
            "type": "string"
          },
          "trace_note": {
            "type": "string"
          },
          "reading_id_derivation": {
            "type": [
              "string",
              "null"
            ],
            "description": "How reading ids are derived. Null when the lineage module is not importable."
          },
          "reading_ids_available": {
            "type": "boolean"
          },
          "quality_scale": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "provenance_scale": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PpaLine"
            },
            "description": "One per settlement period of the month, in order. Empty on the unavailable branches."
          },
          "totals": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/PpaTotals"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null on the unavailable branches."
          },
          "source": {
            "type": "string",
            "enum": [
              "timeseries",
              "device_records",
              "timeseries_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "PpaContracts": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "contracts",
          "source",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "contracts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PpaContract"
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "ppa_contracts",
              "timeseries_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when the `ppa_contracts` table could not be read."
          }
        }
      },
      "CapacityPriced": {
        "type": "object",
        "description": "Pounds for a kVA times days quantity at a p/kVA/day rate, or an honest refusal naming the publication to confirm from.",
        "required": [
          "priced",
          "gbp",
          "rate",
          "unit",
          "status",
          "source",
          "version",
          "row_id",
          "reason"
        ],
        "properties": {
          "priced": {
            "type": "boolean"
          },
          "gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "GBP",
            "two decimals. Null when not priced. Negative on a recommendation change means a saving.": null
          },
          "rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Pence per kVA per day."
          },
          "unit": {
            "type": "string",
            "description": "The rate unit",
            "normally `p/kVA/day`.": null
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "The rate row's status (`confirmed`, `derived`, `to_be_confirmed`, `contracted`, `synthetic`), or `to_be_confirmed` when no row exists."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "The publication the rate came from",
            "or must be confirmed from.": null
          },
          "version": {
            "type": [
              "string",
              "null"
            ],
            "description": "The rate row's library version."
          },
          "row_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why it is not priced. Null when priced."
          },
          "basis": {
            "type": "string",
            "description": "The quantity priced",
            "in prose. Present on the annual availability charge and its change.": null
          }
        }
      },
      "CapacityMonth": {
        "type": "object",
        "description": "One calendar month of the window and its excess capacity charge.",
        "required": [
          "month",
          "max_demand",
          "max_demand_at",
          "excess",
          "days_charged",
          "gbp",
          "priced",
          "reason"
        ],
        "properties": {
          "month": {
            "type": "string",
            "description": "YYYY-MM in the stated calendar."
          },
          "max_demand": {
            "type": "number",
            "description": "Highest half hour demand in the month",
            "in the review's unit (kVA or kW)": null,
            "one decimal.": null
          },
          "max_demand_at": {
            "type": "string",
            "format": "date-time",
            "description": "UTC start of that half hour."
          },
          "excess": {
            "type": "number",
            "description": "max_demand minus agreed capacity",
            "floored at zero.": null
          },
          "days_charged": {
            "type": "integer",
            "description": "Every day of the month when breached",
            "else 0.": null
          },
          "gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "0.0 when there was no breach. Null when breached but not priced."
          },
          "priced": {
            "type": "boolean"
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Only when breached. Pence per kVA per day."
          },
          "unit": {
            "type": "string",
            "description": "Only when breached."
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only when breached. The rate row's status."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only when breached."
          },
          "version": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only when breached."
          },
          "row_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only when breached."
          }
        }
      },
      "CapacityBandFlag": {
        "type": "object",
        "required": [
          "code",
          "severity",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "band_table_unavailable",
              "assigned_band_missing",
              "assigned_band_unknown",
              "assigned_differs_from_agreed",
              "measured_below_assigned_range",
              "measured_above_assigned_range",
              "compared_in_kw"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "info",
              "warn"
            ]
          },
          "message": {
            "type": "string"
          }
        }
      },
      "CapacityReview": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "site",
          "window",
          "method",
          "mpan",
          "agreed_capacity",
          "demand",
          "demand_reason",
          "utilisation",
          "excess_charge",
          "recommendation",
          "band",
          "source",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Site"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null on the `timeseries_unavailable` branch."
          },
          "window": {
            "type": "object",
            "required": [
              "days",
              "from",
              "to",
              "calendar"
            ],
            "properties": {
              "days": {
                "type": "integer"
              },
              "from": {
                "type": "string",
                "format": "date-time",
                "description": "UTC."
              },
              "to": {
                "type": "string",
                "format": "date-time",
                "description": "The last complete half hour boundary before now",
                "UTC.": null
              },
              "calendar": {
                "type": "string",
                "description": "`Europe/London`, or `UTC (zone database unavailable)`."
              }
            }
          },
          "method": {
            "type": "string",
            "description": "The whole method",
            "in prose.": null
          },
          "mpan": {
            "type": [
              "object",
              "null"
            ],
            "description": "The `mpan_charge_config` row in force. Null when none is linked to the site or the charge library tables are not readable.",
            "required": [
              "mpan",
              "config_row_id",
              "dno_region_id",
              "dno",
              "voltage_level",
              "duos_tariff_code",
              "power_factor",
              "effective_from",
              "source",
              "note"
            ],
            "properties": {
              "mpan": {
                "type": "string",
                "description": "13 digit core MPAN."
              },
              "config_row_id": {
                "type": "string"
              },
              "dno_region_id": {
                "type": "integer",
                "description": "Distributor id",
                "10 to 23.": null
              },
              "dno": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "DNO name from the charge library. Null when the library is not loaded."
              },
              "voltage_level": {
                "type": "string",
                "description": "LV",
                "HV or EHV.": null
              },
              "duos_tariff_code": {
                "type": "string"
              },
              "power_factor": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Used to express kW as kVA. Null means the review compares in kW."
              },
              "effective_from": {
                "type": "string",
                "format": "date"
              },
              "source": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "note": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "agreed_capacity": {
            "type": [
              "object",
              "null"
            ],
            "description": "Null only on the `timeseries_unavailable` branch.",
            "required": [
              "kva",
              "source",
              "reason"
            ],
            "properties": {
              "kva": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Agreed supply capacity",
                "kVA. Null without an MPAN row or when the row carries none.": null
              },
              "source": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The row's `asc_source`",
                "else its `source`.": null
              },
              "config_row_id": {
                "type": "string",
                "description": "Only when an agreed capacity exists."
              },
              "reason": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Why there is no agreed capacity. Null when there is one."
              },
              "availability_charge_per_year": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/CapacityPriced"
                  }
                ],
                "description": "Only when an agreed capacity exists. Agreed kVA times 365 days at the DUoS capacity rate."
              }
            }
          },
          "demand": {
            "type": [
              "object",
              "null"
            ],
            "description": "Measured maximum demand. Null without a grid meter or a complete half hour; see `demand_reason`.",
            "required": [
              "unit",
              "basis",
              "max_demand",
              "max_demand_kw",
              "at",
              "at_local",
              "settlement_day",
              "settlement_period",
              "quality",
              "provenance",
              "slots_used",
              "slots_expected",
              "coverage_pct",
              "slots_excluded_low_coverage",
              "slots_incomplete_meters",
              "min_slot_coverage",
              "peak_sample"
            ],
            "properties": {
              "unit": {
                "type": "string",
                "enum": [
                  "kVA",
                  "kW"
                ],
                "description": "kVA when the MPAN row carries a power factor",
                "else kW.": null
              },
              "basis": {
                "type": "string"
              },
              "max_demand": {
                "type": "number",
                "description": "Highest half hour average import in the window",
                "in `unit`": null,
                "one decimal.": null
              },
              "max_demand_kw": {
                "type": "number",
                "description": "The same half hour in kW."
              },
              "at": {
                "type": "string",
                "format": "date-time",
                "description": "UTC start of the peak half hour."
              },
              "at_local": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "The same instant in Europe/London. Null without a zone database."
              },
              "settlement_day": {
                "type": "string",
                "format": "date"
              },
              "settlement_period": {
                "type": "integer",
                "minimum": 1,
                "maximum": 50
              },
              "quality": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/SiteQuality"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Quality of the peak half hour."
              },
              "provenance": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/SiteProvenance"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "slots_used": {
                "type": "integer",
                "description": "Half hours with every meter heard from for at least half the slot."
              },
              "slots_expected": {
                "type": "integer",
                "description": "Half hours in the window."
              },
              "coverage_pct": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "slots_used over slots_expected",
                "per cent": null,
                "one decimal.": null
              },
              "slots_excluded_low_coverage": {
                "type": "integer",
                "description": "Half hours dropped because a meter was heard for less than half the slot."
              },
              "slots_incomplete_meters": {
                "type": "integer",
                "description": "Half hours dropped because a meter was silent."
              },
              "min_slot_coverage": {
                "type": "number",
                "description": "The share of a half hour a meter must be heard for",
                "0.5.": null
              },
              "peak_sample": {
                "type": "object",
                "description": "The highest single import sample, for information only.",
                "required": [
                  "kw",
                  "at",
                  "note"
                ],
                "properties": {
                  "kw": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time"
                  },
                  "note": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "demand_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why `demand` is null. Null when it is filled."
          },
          "utilisation": {
            "type": [
              "object",
              "null"
            ],
            "description": "Null unless both an agreed capacity and a maximum demand exist.",
            "required": [
              "pct",
              "headroom",
              "excess",
              "unit",
              "status"
            ],
            "properties": {
              "pct": {
                "type": "number",
                "description": "Maximum demand over agreed capacity",
                "per cent.": null
              },
              "headroom": {
                "type": "number",
                "description": "Agreed capacity minus maximum demand",
                "floored at zero": null,
                "in `unit`.": null
              },
              "excess": {
                "type": "number",
                "description": "Maximum demand minus agreed capacity",
                "floored at zero": null,
                "in `unit`.": null
              },
              "unit": {
                "type": "string",
                "enum": [
                  "kVA",
                  "kW"
                ]
              },
              "status": {
                "type": "string",
                "enum": [
                  "over_capacity",
                  "under_utilised",
                  "well_sized"
                ],
                "description": "`over_capacity` when maximum demand exceeds the agreed capacity, `under_utilised` when the recommended capacity is below the agreed capacity, `well_sized` otherwise."
              }
            }
          },
          "excess_charge": {
            "type": [
              "object",
              "null"
            ],
            "description": "Excess capacity charge per calendar month and the window total. Null unless both an agreed capacity and a maximum demand exist.",
            "required": [
              "gbp",
              "priced",
              "any_breach",
              "reason",
              "rule",
              "months"
            ],
            "properties": {
              "gbp": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Window total",
                "GBP. 0.0 when no month breached. Null when a breached month could not be priced.": null
              },
              "priced": {
                "type": "boolean",
                "description": "True when every breached month was priced."
              },
              "any_breach": {
                "type": "boolean"
              },
              "reason": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The first unpriced month's reason. Null when all priced."
              },
              "rule": {
                "type": "string"
              },
              "months": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/CapacityMonth"
                }
              }
            }
          },
          "recommendation": {
            "type": [
              "object",
              "null"
            ],
            "description": "Null unless both an agreed capacity and a maximum demand exist.",
            "required": [
              "recommended_kva",
              "recommended_value",
              "unit",
              "buffer_pct",
              "rounding_kva",
              "basis",
              "change",
              "availability_charge_change_per_year",
              "note"
            ],
            "properties": {
              "recommended_kva": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "The recommended agreed capacity in kVA. Null when the review is in kW."
              },
              "recommended_value": {
                "type": "number",
                "description": "The recommendation in `unit`. Maximum demand times 1.15",
                "rounded up to the next 5.": null
              },
              "unit": {
                "type": "string",
                "enum": [
                  "kVA",
                  "kW"
                ]
              },
              "buffer_pct": {
                "type": "number",
                "description": 15.0
              },
              "rounding_kva": {
                "type": "number",
                "description": 5.0
              },
              "basis": {
                "type": "string"
              },
              "change": {
                "type": "number",
                "description": "Recommended minus agreed",
                "in `unit`. Negative is a reduction.": null
              },
              "availability_charge_change_per_year": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/CapacityPriced"
                  }
                ],
                "description": "The change times 365 days at the DUoS capacity rate. Negative GBP is a saving."
              },
              "note": {
                "type": "string"
              }
            }
          },
          "band": {
            "type": [
              "object",
              "null"
            ],
            "description": "The assigned residual band against the bands the agreed capacity and measured demand imply. Null without an MPAN row.",
            "required": [
              "assigned",
              "assigned_range",
              "implied_by_agreed_capacity",
              "implied_by_measured_demand",
              "voltage_level",
              "publication",
              "table_available",
              "flags",
              "looks_wrong"
            ],
            "properties": {
              "assigned": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The band on the MPAN row",
                "for example `band_2`.": null
              },
              "assigned_range": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The kVA range of the assigned band at this voltage level. Null when not assigned or not in the table.",
                "required": [
                  "min_kva",
                  "max_kva"
                ],
                "properties": {
                  "min_kva": {
                    "type": "number"
                  },
                  "max_kva": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Null for the open top band."
                  }
                }
              },
              "implied_by_agreed_capacity": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "implied_by_measured_demand": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "voltage_level": {
                "type": "string"
              },
              "publication": {
                "type": "string",
                "description": "The banding table's publication."
              },
              "table_available": {
                "type": "boolean",
                "description": "False when the banding table could not be loaded in this process."
              },
              "flags": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/CapacityBandFlag"
                }
              },
              "looks_wrong": {
                "type": "boolean",
                "description": "True when any flag has severity `warn`."
              }
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "timeseries",
              "timeseries_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "The MPAN reason first",
            "else the demand reason.": null
          }
        }
      },
      "CapacityShaveWhatIf": {
        "type": "object",
        "description": "One cap tested against the measured half hours. Nothing is priced.",
        "required": [
          "label",
          "cap_kw",
          "half_hours_above",
          "hours_above",
          "share_of_measured_pct",
          "energy_above_cap_kwh",
          "max_kw_above_cap",
          "days_above",
          "events",
          "longest_event_half_hours",
          "largest_event_kwh",
          "worst_day",
          "battery_lower_bound",
          "largest_events"
        ],
        "properties": {
          "label": {
            "type": "string",
            "description": "How the cap was chosen."
          },
          "cap_kw": {
            "type": "number"
          },
          "half_hours_above": {
            "type": "integer"
          },
          "hours_above": {
            "type": "number"
          },
          "share_of_measured_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Share of measured half hours",
            "not of the window.": null
          },
          "energy_above_cap_kwh": {
            "type": "number",
            "description": "Sum of (demand - cap) x 0.5 h over the half hours above the cap."
          },
          "max_kw_above_cap": {
            "type": "number"
          },
          "days_above": {
            "type": "integer",
            "description": "Local days with at least one half hour above the cap."
          },
          "events": {
            "type": "integer",
            "description": "Runs of consecutive measured half hours above the cap. A missing half hour ends a run."
          },
          "longest_event_half_hours": {
            "type": "integer"
          },
          "largest_event_kwh": {
            "type": "number"
          },
          "worst_day": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "date": {
                "type": "string",
                "format": "date"
              },
              "kwh": {
                "type": "number"
              }
            }
          },
          "battery_lower_bound": {
            "type": "object",
            "required": [
              "power_kw",
              "energy_kwh",
              "basis"
            ],
            "properties": {
              "power_kw": {
                "type": "number"
              },
              "energy_kwh": {
                "type": "number"
              },
              "basis": {
                "type": "string"
              }
            }
          },
          "largest_events": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "type": "object",
              "required": [
                "start",
                "end",
                "start_local",
                "half_hours",
                "kwh_above_cap",
                "peak_kw"
              ],
              "properties": {
                "start": {
                  "type": "string",
                  "format": "date-time"
                },
                "end": {
                  "type": "string",
                  "format": "date-time"
                },
                "start_local": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "half_hours": {
                  "type": "integer"
                },
                "kwh_above_cap": {
                  "type": "number"
                },
                "peak_kw": {
                  "type": "number"
                }
              }
            }
          }
        }
      },
      "CapacityPeaks": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "site",
          "window",
          "unit",
          "method",
          "coverage",
          "peak_days",
          "duration_curve",
          "peak_hour_histogram",
          "statistics",
          "what_if",
          "source",
          "ledger",
          "unavailable_reason"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Site"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null on the `timeseries_unavailable` branch."
          },
          "window": {
            "type": "object",
            "required": [
              "days",
              "from",
              "to",
              "calendar"
            ],
            "properties": {
              "days": {
                "type": "integer"
              },
              "from": {
                "type": "string",
                "format": "date-time"
              },
              "to": {
                "type": "string",
                "format": "date-time"
              },
              "calendar": {
                "type": "string"
              }
            }
          },
          "unit": {
            "type": "string",
            "enum": [
              "kW"
            ]
          },
          "method": {
            "type": "string",
            "description": "The whole method",
            "in prose.": null
          },
          "coverage": {
            "type": "object",
            "required": [
              "slots_used",
              "slots_expected",
              "coverage_pct",
              "slots_excluded_low_coverage",
              "slots_incomplete_meters",
              "min_slot_coverage"
            ],
            "properties": {
              "slots_used": {
                "type": "integer"
              },
              "slots_expected": {
                "type": "integer"
              },
              "coverage_pct": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "slots_excluded_low_coverage": {
                "type": "integer"
              },
              "slots_incomplete_meters": {
                "type": "integer"
              },
              "min_slot_coverage": {
                "type": "number"
              }
            }
          },
          "peak_days": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "rank",
                "date",
                "weekday",
                "kw",
                "at",
                "at_local",
                "local_time",
                "settlement_day",
                "settlement_period",
                "quality",
                "provenance"
              ],
              "properties": {
                "rank": {
                  "type": "integer"
                },
                "date": {
                  "type": "string",
                  "format": "date",
                  "description": "Local day."
                },
                "weekday": {
                  "type": "string"
                },
                "kw": {
                  "type": "number",
                  "description": "The day's highest half hour average import."
                },
                "at": {
                  "type": "string",
                  "format": "date-time",
                  "description": "Half hour start",
                  "UTC.": null
                },
                "at_local": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "local_time": {
                  "type": "string",
                  "description": "HH:MM local."
                },
                "settlement_day": {
                  "type": "string",
                  "format": "date"
                },
                "settlement_period": {
                  "type": "integer"
                },
                "quality": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "provenance": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          },
          "duration_curve": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "pct_of_time",
                "kw"
              ],
              "properties": {
                "pct_of_time": {
                  "type": "number"
                },
                "kw": {
                  "type": "number"
                }
              }
            }
          },
          "peak_hour_histogram": {
            "type": "array",
            "description": "24 entries, one per local hour.",
            "items": {
              "type": "object",
              "required": [
                "hour",
                "days"
              ],
              "properties": {
                "hour": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 23
                },
                "days": {
                  "type": "integer"
                }
              }
            }
          },
          "statistics": {
            "type": [
              "object",
              "null"
            ],
            "required": [
              "max_kw",
              "mean_kw",
              "min_kw",
              "load_factor_pct",
              "days_measured"
            ],
            "properties": {
              "max_kw": {
                "type": "number"
              },
              "mean_kw": {
                "type": "number"
              },
              "min_kw": {
                "type": "number"
              },
              "load_factor_pct": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "days_measured": {
                "type": "integer"
              }
            }
          },
          "what_if": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CapacityShaveWhatIf"
            }
          },
          "source": {
            "type": "string",
            "description": "`timeseries_unavailable`, `computed`, or the ledger source of the demand series."
          },
          "ledger": {
            "type": [
              "object",
              "null"
            ],
            "description": "Ledger provenance of the series",
            "as on the capacity review.": null
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "EnergyLedgerRow": {
        "type": "object",
        "description": "One stored ledger row. Energy figures are null, never zero, when the input was not heard from.",
        "required": [
          "id",
          "organisation_id",
          "site_id",
          "period_start",
          "period_end",
          "settlement_date",
          "settlement_period",
          "consumption_basis",
          "balance_closed",
          "balance_tolerance_kwh",
          "pricing_status",
          "quality",
          "provenance",
          "quality_by_input",
          "source",
          "corrections_applied",
          "basis",
          "calculation_version",
          "stale",
          "reading_ids",
          "balance_identity"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "sha256(organisation_id|site_id|period_start)[:32]."
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "period_end": {
            "type": "string",
            "format": "date-time"
          },
          "settlement_date": {
            "type": "string",
            "format": "date",
            "description": "GB settlement day (Europe/London)."
          },
          "settlement_period": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50
          },
          "pv_generation_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "grid_import_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "grid_export_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "battery_charge_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Zero by construction for a site with no battery."
          },
          "battery_discharge_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "site_consumption_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "consumption_basis": {
            "type": "string",
            "enum": [
              "measured",
              "derived_from_balance",
              "unavailable"
            ]
          },
          "pv_self_consumed_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "generation less min(generation, export), the A3 rule."
          },
          "residual_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "pv_generation + grid_import + battery_discharge - (site_consumption + grid_export + battery_charge). Written down, never absorbed."
          },
          "balance_closed": {
            "type": "boolean",
            "description": "True when |residual_kwh| is within balance_tolerance_kwh."
          },
          "balance_tolerance_kwh": {
            "type": "number",
            "description": "max(0.05 kWh, 1 percent of the supply side)."
          },
          "balance_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "cost_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Net pounds",
            "import less export credit. Null unless priced.": null
          },
          "cost_import_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "cost_export_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "pricing_status": {
            "type": "string",
            "enum": [
              "priced",
              "not_priced",
              "no_energy"
            ],
            "description": "A partially priced stack is not_priced with the reason; a lower bound is never shown as a price."
          },
          "pricing_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "tariff_version": {
            "type": [
              "string",
              "null"
            ]
          },
          "mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "carbon_kg_location": {
            "type": [
              "number",
              "null"
            ],
            "description": "Import times the stored regional intensity for the same half hour."
          },
          "carbon_kg_market": {
            "type": [
              "number",
              "null"
            ],
            "description": "Import times the contractual factor recorded for the MPAN."
          },
          "factor_set_location": {
            "type": [
              "string",
              "null"
            ]
          },
          "factor_row_location": {
            "type": [
              "string",
              "null"
            ]
          },
          "factor_version_location": {
            "type": [
              "string",
              "null"
            ]
          },
          "factor_set_market": {
            "type": [
              "string",
              "null"
            ]
          },
          "factor_row_market": {
            "type": [
              "string",
              "null"
            ]
          },
          "carbon_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "quality": {
            "type": "string",
            "enum": [
              "measured",
              "unmarked",
              "interpolated",
              "estimated",
              "invalid",
              "missing"
            ],
            "description": "Worst quality of the required inputs."
          },
          "provenance": {
            "type": "string",
            "enum": [
              "device",
              "unknown",
              "synthetic"
            ]
          },
          "quality_by_input": {
            "type": "object",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "quality": {
                  "type": "string"
                },
                "provenance": {
                  "type": "string"
                },
                "samples": {
                  "type": "integer"
                },
                "invalid_samples": {
                  "type": "integer"
                },
                "covered_hours": {
                  "type": "number"
                },
                "assets_present": {
                  "type": "integer"
                },
                "assets_expected": {
                  "type": "integer"
                }
              }
            }
          },
          "source": {
            "type": "object",
            "description": "Per input, the readings the row was built from.",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "reading_id": {
                    "type": "string"
                  },
                  "asset_id": {
                    "type": "string"
                  },
                  "ts": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "power_kw": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "quality": {
                    "type": "string"
                  },
                  "stored_power_kw": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Present where a correction changed power_kw."
                  },
                  "correction_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "corrections_applied": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "basis": {
            "type": "object",
            "additionalProperties": true,
            "description": "The pricing lines per direction",
            "the factor rows": null,
            "the MPAN source": null,
            "the battery note.": null
          },
          "calculation_version": {
            "type": "string"
          },
          "computed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "stale": {
            "type": "boolean"
          },
          "stale_reason": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "reading",
              "correction",
              "tariff",
              "factor",
              null
            ]
          },
          "stale_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "reading_ids": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          "balance_identity": {
            "type": "string"
          }
        }
      },
      "EnergyLedgerBucket": {
        "type": "object",
        "description": "A day, a month or the window total over fresh rows. A stale row is counted in periods_stale and contributes nothing.",
        "required": [
          "key",
          "period_start",
          "period_end",
          "periods_expected",
          "periods_in_ledger",
          "periods_stale",
          "periods_missing",
          "complete",
          "balance_closed_periods",
          "balance_periods",
          "balance_closed",
          "priced_periods",
          "pricing_status",
          "tariff_versions",
          "factor_sets",
          "quality",
          "provenance",
          "corrections_applied"
        ],
        "properties": {
          "key": {
            "type": "string"
          },
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "period_end": {
            "type": "string",
            "format": "date-time"
          },
          "periods_expected": {
            "type": "integer"
          },
          "periods_in_ledger": {
            "type": "integer",
            "description": "Fresh rows."
          },
          "periods_stale": {
            "type": "integer"
          },
          "periods_missing": {
            "type": "integer"
          },
          "complete": {
            "type": "boolean"
          },
          "pv_generation_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "grid_import_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "grid_export_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "battery_charge_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "battery_discharge_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "site_consumption_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "pv_self_consumed_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "residual_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "pv_generation_kwh_periods": {
            "type": "integer",
            "description": "Rows carrying a figure for the column; one such count per energy column."
          },
          "balance_closed_periods": {
            "type": "integer"
          },
          "balance_periods": {
            "type": "integer"
          },
          "balance_closed": {
            "type": "boolean"
          },
          "cost_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "cost_import_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "cost_export_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "priced_periods": {
            "type": "integer"
          },
          "pricing_status": {
            "type": "string",
            "enum": [
              "priced",
              "not_priced",
              "no_energy"
            ]
          },
          "tariff_versions": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "carbon_kg_location": {
            "type": [
              "number",
              "null"
            ]
          },
          "carbon_kg_location_periods": {
            "type": "integer"
          },
          "carbon_kg_market": {
            "type": [
              "number",
              "null"
            ]
          },
          "carbon_kg_market_periods": {
            "type": "integer"
          },
          "factor_sets": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "quality": {
            "type": "string"
          },
          "provenance": {
            "type": "string"
          },
          "corrections_applied": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "EnergyLedger": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "site",
          "resolution",
          "from",
          "to",
          "calendar",
          "calculation_version",
          "balance_identity",
          "quality_scale",
          "source",
          "unavailable_reason",
          "rows",
          "buckets",
          "total"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Site"
              },
              {
                "type": "null"
              }
            ]
          },
          "resolution": {
            "type": "string",
            "enum": [
              "hh",
              "daily",
              "monthly"
            ]
          },
          "from": {
            "type": "string",
            "format": "date-time"
          },
          "to": {
            "type": "string",
            "format": "date-time"
          },
          "calendar": {
            "type": "string"
          },
          "calculation_version": {
            "type": "string"
          },
          "balance_identity": {
            "type": "string"
          },
          "quality_scale": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "ledger",
              "timeseries_unavailable",
              "ledger_unavailable"
            ]
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "rows": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EnergyLedgerRow"
            },
            "description": "Filled at hh resolution."
          },
          "buckets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EnergyLedgerBucket"
            },
            "description": "Filled at daily and monthly resolution."
          },
          "total": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/EnergyLedgerBucket"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "EnergyLedgerPeriod": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "row",
          "tree",
          "source"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "row": {
            "$ref": "#/components/schemas/EnergyLedgerRow"
          },
          "tree": {
            "type": "object",
            "additionalProperties": true,
            "description": "The source tree in the report lineage figure shape (figure_id",
            "formula": null,
            "terms with inputs and readings": null,
            "factor_rows).": null
          },
          "source": {
            "type": "string",
            "enum": [
              "ledger"
            ]
          }
        }
      },
      "EnergyLedgerRebuild": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_id",
          "window_start",
          "window_end",
          "rows_written",
          "balance_closed",
          "priced",
          "calculation_version",
          "requested_by",
          "requested_at",
          "reason",
          "rebuild_id",
          "audit_entry_id"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "rows_written": {
            "type": "integer"
          },
          "balance_closed": {
            "type": "integer"
          },
          "priced": {
            "type": "integer"
          },
          "calculation_version": {
            "type": "string"
          },
          "requested_by": {
            "type": "string"
          },
          "requested_at": {
            "type": "string",
            "format": "date-time"
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "rebuild_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The energy_ledger_rebuilds row; null when that table is not present."
          },
          "audit_entry_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The audit chain entry; null when the platform audit store is not reachable from the telemetry service."
          }
        }
      },
      "EmsSiteSettings": {
        "type": "object",
        "description": "The site's EMS configuration row as stored. Used by the report, Scope 2 and\nunderperformance routes. Null or absent columns are returned as null.\n",
        "required": [
          "id",
          "organisation_id",
          "site_id",
          "latitude",
          "longitude",
          "pv_kwp",
          "performance_ratio",
          "neso_region_id",
          "carbon_factor_set_id",
          "import_mpan",
          "export_mpan",
          "underperformance_window_days",
          "underperformance_ratio",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ],
            "description": "Decimal degrees. Used by the clear sky model."
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ],
            "description": "Decimal degrees. Used by the clear sky model."
          },
          "pv_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Installed PV capacity in kWp. Takes precedence over the PV device details."
          },
          "performance_ratio": {
            "type": [
              "number",
              "null"
            ],
            "description": "Fraction of clear sky yield the array delivers (tilt, orientation, temperature, soiling, inverter losses). Platform default 0.75 when null."
          },
          "neso_region_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "NESO carbon intensity region for the location based method. No location based figure without it."
          },
          "carbon_factor_set_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Factor set for the location based method. Default `neso-ci-api:regional` when null."
          },
          "import_mpan": {
            "type": [
              "string",
              "null"
            ],
            "description": "MPAN the market based factor and the underperformance pricing are looked up on."
          },
          "export_mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "underperformance_window_days": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Consecutive short days that make an episode. Platform default 3 when null."
          },
          "underperformance_ratio": {
            "type": [
              "number",
              "null"
            ],
            "description": "Actual over expected below which a day is short. Platform default 0.90 when null."
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "EmsSiteSettingsInput": {
        "type": "object",
        "description": "Keys the write honours. Any other key is ignored, not written. Omit a key to\nleave it unchanged on an existing row; send null to clear it.\n",
        "additionalProperties": true,
        "properties": {
          "latitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "pv_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "kWp."
          },
          "performance_ratio": {
            "type": [
              "number",
              "null"
            ]
          },
          "neso_region_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "carbon_factor_set_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "import_mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "export_mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "underperformance_window_days": {
            "type": [
              "integer",
              "null"
            ]
          },
          "underperformance_ratio": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "EmsSiteList": {
        "type": "object",
        "required": [
          "organisation_id",
          "sites"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "sites": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "site_id",
                "name",
                "assets",
                "settings"
              ],
              "properties": {
                "site_id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "assets": {
                  "type": "object",
                  "description": "Count of classified assets at the site, keyed by asset type. A type with no assets is absent.",
                  "properties": {
                    "meter": {
                      "type": "integer"
                    },
                    "solar": {
                      "type": "integer"
                    },
                    "battery": {
                      "type": "integer"
                    }
                  }
                },
                "settings": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/EmsSiteSettings"
                    },
                    {
                      "type": "null"
                    }
                  ]
                }
              }
            }
          }
        }
      },
      "EmsCoverage": {
        "type": "object",
        "description": "Number of half hours in the window carrying each quality word (vocabulary `AV-EMS-QUALITY-4`).",
        "required": [
          "measured",
          "estimated",
          "interpolated",
          "missing"
        ],
        "properties": {
          "measured": {
            "type": "integer"
          },
          "estimated": {
            "type": "integer"
          },
          "interpolated": {
            "type": "integer"
          },
          "missing": {
            "type": "integer"
          }
        }
      },
      "EmsFigureSummary": {
        "type": "object",
        "description": "One report figure without its per half hour terms.",
        "required": [
          "figure_id",
          "key",
          "label",
          "value",
          "unit",
          "status",
          "coverage",
          "periods_total",
          "periods_used",
          "notes",
          "basis",
          "policy_id",
          "quality_vocabulary"
        ],
        "properties": {
          "figure_id": {
            "type": "string",
            "description": "`key|site_id|window_start|window_end`. Pass to `GET /api/hardware/ems/lineage`, URL encoded."
          },
          "key": {
            "type": "string",
            "enum": [
              "pv_generation_kwh",
              "grid_import_kwh",
              "grid_export_kwh",
              "battery_charge_kwh",
              "battery_discharge_kwh",
              "site_load_kwh",
              "self_consumed_pv_kwh",
              "self_consumption_pct",
              "scope2_location_kgco2e",
              "scope2_market_kgco2e",
              "avoided_self_consumed_kgco2e",
              "avoided_export_kgco2e"
            ]
          },
          "label": {
            "type": "string"
          },
          "value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Sum over the half hours that contributed, rounded to 3 decimals (2 for the percentage). Null when status is `no_data` or `not_configured`."
          },
          "unit": {
            "type": "string",
            "enum": [
              "kWh",
              "%",
              "kgCO2e"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "complete",
              "partial",
              "no_data",
              "not_configured"
            ],
            "description": "`partial` when any half hour was missing a required input or, for the\nlocation based carbon figure, had no stored factor. `no_data` when no half\nhour contributed. `not_configured` on the carbon figures when no region or\nno contractual factor is configured.\n"
          },
          "coverage": {
            "$ref": "#/components/schemas/EmsCoverage"
          },
          "periods_total": {
            "type": "integer",
            "description": "Half hours in the window."
          },
          "periods_used": {
            "type": "integer",
            "description": "Half hours that contributed a value."
          },
          "notes": {
            "type": "array",
            "description": "Every place a gap or estimation policy applied, citing the policy clause.",
            "items": {
              "type": "string"
            }
          },
          "basis": {
            "type": "object",
            "additionalProperties": true,
            "description": "Varies by figure. Energy figures: `required_inputs` (asset types) and\n`site_has_battery`. Self consumption ratio: `numerator_kwh` and\n`denominator_kwh`. Location based carbon: `method`, `region`\n(`region_id`, `factor_set_id`, or null), `factor_unavailable_periods`\nand `factor_sets`. Market based carbon: `method`, `mpan` and\n`factor_sets`. `factor_sets` entries are `EmsFactorSet` objects.\n"
          },
          "policy_id": {
            "type": "string",
            "description": "Always `AV-EMS-LIN-1`."
          },
          "quality_vocabulary": {
            "type": "string",
            "description": "Always `AV-EMS-QUALITY-4`."
          }
        }
      },
      "EmsFactorSet": {
        "type": "object",
        "required": [
          "factor_set_id",
          "factor_vintage",
          "publisher"
        ],
        "properties": {
          "factor_set_id": {
            "type": "string"
          },
          "factor_vintage": {
            "type": "string"
          },
          "publisher": {
            "type": "string"
          }
        }
      },
      "EmsReport": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "periods",
          "readings_total",
          "policy_id",
          "quality_vocabulary",
          "figures",
          "settings"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "required": [
              "site_id",
              "name",
              "assets",
              "pv_kwp"
            ],
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "assets": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "asset_id",
                    "asset_type",
                    "name"
                  ],
                  "properties": {
                    "asset_id": {
                      "type": "string"
                    },
                    "asset_type": {
                      "type": "string",
                      "enum": [
                        "meter",
                        "solar",
                        "battery"
                      ]
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              },
              "pv_kwp": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "kWp summed from the solar device details."
              }
            }
          },
          "window_start": {
            "type": "string",
            "format": "date-time",
            "description": "Snapped to the UTC half hour."
          },
          "window_end": {
            "type": "string",
            "format": "date-time",
            "description": "Exclusive. Snapped to the UTC half hour."
          },
          "periods": {
            "type": "integer",
            "description": "Half hours in the window."
          },
          "readings_total": {
            "type": "integer",
            "description": "Telemetry rows loaded for the site's assets over the window."
          },
          "policy_id": {
            "type": "string",
            "description": "Always `AV-EMS-LIN-1`."
          },
          "quality_vocabulary": {
            "type": "string",
            "description": "Always `AV-EMS-QUALITY-4`."
          },
          "figures": {
            "type": "array",
            "description": "In fixed order, PV generation through Scope 2 market based.",
            "items": {
              "$ref": "#/components/schemas/EmsFigureSummary"
            }
          },
          "settings": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsSiteSettings"
              }
            ],
            "description": "The site's settings row, or an empty object when none exists."
          }
        }
      },
      "EmsExportCoverage": {
        "type": "object",
        "description": "The coverage statement every export carries.",
        "required": [
          "policy_id",
          "half_hours",
          "assets",
          "meter_quality",
          "pv_quality",
          "battery_quality",
          "half_hours_load_unknown",
          "corrections",
          "statement"
        ],
        "properties": {
          "policy_id": {
            "type": "string",
            "enum": [
              "AV-EMS-EXPORT-1"
            ]
          },
          "half_hours": {
            "type": "integer"
          },
          "assets": {
            "type": "object",
            "properties": {
              "meters": {
                "type": "integer"
              },
              "pv": {
                "type": "integer"
              },
              "batteries": {
                "type": "integer"
              }
            }
          },
          "meter_quality": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Half hours per quality word."
          },
          "pv_quality": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Half hours per quality word, or `no_asset`."
          },
          "battery_quality": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Half hours per quality word, or `no_asset`."
          },
          "half_hours_load_unknown": {
            "type": "integer"
          },
          "corrections": {
            "type": "string",
            "enum": [
              "applied",
              "stored"
            ]
          },
          "statement": {
            "type": "string"
          }
        }
      },
      "EmsIntervalExportInfo": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "columns",
          "definitions",
          "coverage"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "columns": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "definitions": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "coverage": {
            "$ref": "#/components/schemas/EmsExportCoverage"
          }
        }
      },
      "EmsEnergySummary": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "month",
          "window_start",
          "window_end",
          "calendar",
          "coverage",
          "definitions",
          "days",
          "totals"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "month": {
            "type": "string"
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "calendar": {
            "type": "string"
          },
          "coverage": {
            "$ref": "#/components/schemas/EmsExportCoverage"
          },
          "definitions": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "days": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "grid_import_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "grid_export_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "pv_generation_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "site_load_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "battery_charge_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "battery_discharge_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "peak_import_kw": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "peak_import_local_time": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "half_hours": {
                  "type": "integer"
                },
                "half_hours_meter_missing": {
                  "type": "integer"
                },
                "half_hours_load_unknown": {
                  "type": "integer"
                }
              }
            }
          },
          "totals": {
            "type": "object",
            "properties": {
              "grid_import_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "grid_export_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "pv_generation_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "site_load_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "battery_charge_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "battery_discharge_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "peak_import_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "peak_import_at_local": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      },
      "EmsReading": {
        "type": "object",
        "description": "One stored telemetry row with its derived id.",
        "required": [
          "reading_id",
          "asset_id",
          "asset_type",
          "ts",
          "power_kw",
          "soc_pct",
          "quality",
          "status",
          "source",
          "provenance",
          "estimation_method",
          "extra"
        ],
        "properties": {
          "reading_id": {
            "type": "string",
            "description": "`sha256(organisation_id|asset_id|ts_utc_iso)[:32]`."
          },
          "asset_id": {
            "type": "string"
          },
          "asset_type": {
            "type": "string",
            "description": "`meter`, `solar` or `battery`; `unknown` when the device can no longer be classified."
          },
          "ts": {
            "type": "string",
            "format": "date-time"
          },
          "power_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Grid meter positive on import and negative on export; battery positive charging and negative discharging; PV never negative."
          },
          "soc_pct": {
            "type": [
              "number",
              "null"
            ]
          },
          "quality": {
            "type": "string",
            "enum": [
              "measured",
              "estimated",
              "interpolated"
            ],
            "description": "A row that exists is never `missing`."
          },
          "status": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "string",
              "null"
            ]
          },
          "provenance": {
            "type": [
              "string",
              "null"
            ]
          },
          "estimation_method": {
            "type": [
              "string",
              "null"
            ]
          },
          "extra": {
            "type": "object",
            "additionalProperties": true,
            "description": "Only `load_kw`, `settlement_day`, `settlement_period` and `synthetic` from the writer's raw payload, when present. Reported, never used in arithmetic."
          }
        }
      },
      "EmsFactorRow": {
        "type": "object",
        "description": "One half hour of stored regional grid intensity, as used by the location based method.",
        "required": [
          "row_id",
          "period_start",
          "intensity_g_per_kwh",
          "quality_flag",
          "estimation_method",
          "factor_set_id",
          "factor_vintage",
          "source",
          "region_id",
          "region_shortname",
          "carried_from_period_start"
        ],
        "properties": {
          "row_id": {
            "type": "string"
          },
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "intensity_g_per_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "quality_flag": {
            "type": "string",
            "description": "BSC convention. `E` means estimated and marks a measured import half hour as estimated."
          },
          "estimation_method": {
            "type": "string"
          },
          "factor_set_id": {
            "type": "string"
          },
          "factor_vintage": {
            "type": "string"
          },
          "source": {
            "type": "string",
            "description": "Publisher of the series."
          },
          "region_id": {
            "type": "integer"
          },
          "region_shortname": {
            "type": "string"
          },
          "carried_from_period_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Set when the stored value was carried forward from an earlier half hour."
          }
        }
      },
      "EmsIntensitySeries": {
        "type": "object",
        "description": "The stored regional grid intensity for one site and window, with its coverage.",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ]
              }
            },
            "required": [
              "site_id",
              "name"
            ]
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time",
            "description": "Exclusive."
          },
          "region": {
            "description": "Null when the site has no `neso_region_id` configured.",
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "region_id": {
                    "type": "integer"
                  },
                  "factor_set_id": {
                    "type": "string"
                  },
                  "shortname": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "From the first stored row; null when no row is stored."
                  }
                },
                "required": [
                  "region_id",
                  "factor_set_id",
                  "shortname"
                ]
              },
              {
                "type": "null"
              }
            ]
          },
          "rows": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmsFactorRow"
            }
          },
          "unavailable_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "summary": {
            "type": "object",
            "properties": {
              "periods_expected": {
                "type": "integer",
                "description": "Half hours in the window."
              },
              "periods_stored": {
                "type": "integer"
              },
              "periods_missing": {
                "type": "integer",
                "description": "Half hours with no stored row."
              },
              "periods_carried": {
                "type": "integer",
                "description": "Stored rows carried forward from an earlier half hour."
              },
              "periods_unavailable": {
                "type": "integer",
                "description": "Stored rows with no intensity value."
              },
              "min": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "gCO2/kWh over rows with a value."
              },
              "max": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "mean": {
                "type": [
                  "number",
                  "null"
                ]
              }
            },
            "required": [
              "periods_expected",
              "periods_stored",
              "periods_missing",
              "periods_carried",
              "periods_unavailable",
              "min",
              "max",
              "mean"
            ]
          }
        },
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "region",
          "rows",
          "unavailable_reason",
          "summary"
        ]
      },
      "VppEnrollment": {
        "type": "object",
        "description": "One site's enrollment in AmpVerve's own VPP. Enrollment is consent to receive AmpVerve's signal, not a market registration.",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "enrolled": {
            "type": "boolean"
          },
          "enrolled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "enrolled_by": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `sub` claim of the user who enrolled the site."
          },
          "withdrawn_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "site_id",
          "enrolled",
          "enrolled_at",
          "enrolled_by",
          "withdrawn_at"
        ]
      },
      "VppSignal": {
        "type": "object",
        "description": "Derived from the site's latest tariff schedule run. When `available` is false\nonly `available`, `reason`, `basis` and, for a run that did not complete,\n`run_at` are present.\n",
        "properties": {
          "available": {
            "type": "boolean"
          },
          "action": {
            "type": "string",
            "enum": [
              "charge",
              "discharge",
              "hold"
            ]
          },
          "window_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Start of the next contiguous charge or discharge block. Null for `hold`."
          },
          "window_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "End of that block, exclusive. Null for `hold`."
          },
          "average_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Mean absolute battery power over the block, one decimal place."
          },
          "duos_band": {
            "type": [
              "string",
              "null"
            ]
          },
          "saving_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "The saving the schedule run stated for its horizon."
          },
          "tariff_version": {
            "type": [
              "string",
              "null"
            ]
          },
          "run_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "run_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "basis": {
            "type": "string"
          }
        },
        "required": [
          "available",
          "basis"
        ]
      },
      "EmsMarketFactor": {
        "type": "object",
        "description": "The contractual instrument in force for the window, as used by the market based method.",
        "required": [
          "row_id",
          "mpan",
          "instrument_type",
          "factor_g_per_kwh",
          "factor_set_name",
          "publisher",
          "vintage",
          "valid_from",
          "valid_to",
          "evidence_ref"
        ],
        "properties": {
          "row_id": {
            "type": "string"
          },
          "mpan": {
            "type": "string"
          },
          "instrument_type": {
            "type": "string",
            "enum": [
              "rego",
              "supplier_residual_mix",
              "supplier_specific",
              "ppa"
            ]
          },
          "factor_g_per_kwh": {
            "type": "number"
          },
          "factor_set_name": {
            "type": "string"
          },
          "publisher": {
            "type": "string"
          },
          "vintage": {
            "type": "string"
          },
          "valid_from": {
            "type": "string",
            "format": "date"
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "evidence_ref": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "EmsLineageInput": {
        "type": "object",
        "description": "One input to a half hour term. Energy and carbon figures carry `kw`, the\nmean power over the half hour summed across assets of the type, with the\nreadings behind it. The self consumption ratio carries `kwh` from the two\nfigures it depends on, without the readings.\n",
        "required": [
          "quality",
          "reading_ids"
        ],
        "properties": {
          "kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "quality": {
            "type": "string",
            "enum": [
              "measured",
              "estimated",
              "interpolated",
              "missing"
            ]
          },
          "reading_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "readings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmsReading"
            }
          },
          "note": {
            "type": "string",
            "description": "Present on the zero battery input of a site with no battery asset."
          }
        }
      },
      "EmsLineageTerm": {
        "type": "object",
        "required": [
          "period_start",
          "period_end",
          "inputs",
          "quality",
          "contribution",
          "factor",
          "note"
        ],
        "properties": {
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "period_end": {
            "type": "string",
            "format": "date-time"
          },
          "inputs": {
            "type": "object",
            "description": "Keyed by asset type, or by figure key for the self consumption ratio.",
            "additionalProperties": {
              "$ref": "#/components/schemas/EmsLineageInput"
            }
          },
          "quality": {
            "type": "string",
            "enum": [
              "measured",
              "estimated",
              "interpolated",
              "missing"
            ],
            "description": "The worst quality word across the inputs."
          },
          "contribution": {
            "type": [
              "number",
              "null"
            ],
            "description": "What the half hour added to the figure, in the figure's unit. Null when it contributed nothing."
          },
          "factor": {
            "description": "The factor row used by a carbon term. Null on energy figures and on half hours with no factor.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/EmsFactorRow"
              },
              {
                "$ref": "#/components/schemas/EmsMarketFactor"
              },
              {
                "type": "null"
              }
            ]
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "EmsLineageTree": {
        "allOf": [
          {
            "$ref": "#/components/schemas/EmsFigureSummary"
          },
          {
            "type": "object",
            "required": [
              "formula",
              "depends_on",
              "window_start",
              "window_end",
              "readings_total",
              "factor_rows",
              "terms"
            ],
            "properties": {
              "formula": {
                "type": "string",
                "description": "The formula as text, applied once as code."
              },
              "depends_on": {
                "type": "array",
                "description": "Keys of the figures this one is derived from.",
                "items": {
                  "type": "string"
                }
              },
              "window_start": {
                "type": "string",
                "format": "date-time"
              },
              "window_end": {
                "type": "string",
                "format": "date-time"
              },
              "readings_total": {
                "type": "integer",
                "description": "Reading ids across every term input."
              },
              "factor_rows": {
                "type": "array",
                "description": "The distinct factor rows used. Empty on energy figures.",
                "items": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/EmsFactorRow"
                    },
                    {
                      "$ref": "#/components/schemas/EmsMarketFactor"
                    }
                  ]
                }
              },
              "terms": {
                "type": "array",
                "description": "One per half hour in the window.",
                "items": {
                  "$ref": "#/components/schemas/EmsLineageTerm"
                }
              }
            }
          }
        ]
      },
      "EmsLineage": {
        "type": "object",
        "required": [
          "organisation_id",
          "site_name",
          "figure"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_name": {
            "type": "string"
          },
          "figure": {
            "$ref": "#/components/schemas/EmsLineageTree"
          }
        }
      },
      "EmsScope2Day": {
        "type": "object",
        "required": [
          "date",
          "import_kwh",
          "import_periods",
          "periods",
          "location_kgco2e",
          "location_periods",
          "location_vintages",
          "market_kgco2e",
          "market_periods",
          "market_vintages"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "UTC calendar day."
          },
          "import_kwh": {
            "type": "number",
            "description": "Grid import over the half hours the meter reported."
          },
          "import_periods": {
            "type": "integer",
            "description": "Half hours with a meter reading."
          },
          "periods": {
            "type": "integer",
            "description": "Half hours in the day."
          },
          "location_kgco2e": {
            "type": [
              "number",
              "null"
            ],
            "description": "Null when the location based method covered no half hour of the day."
          },
          "location_periods": {
            "type": "integer"
          },
          "location_vintages": {
            "type": "array",
            "description": "`<factor_set_id> <factor_vintage>` strings used on the day.",
            "items": {
              "type": "string"
            }
          },
          "market_kgco2e": {
            "type": [
              "number",
              "null"
            ],
            "description": "Null when the market based method covered no half hour of the day."
          },
          "market_periods": {
            "type": "integer"
          },
          "market_vintages": {
            "type": "array",
            "description": "`<factor_set_name> <vintage>` strings used on the day.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "EmsScope2": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "settings",
          "grid_import",
          "location_based",
          "market_based",
          "daily",
          "method_notes"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "required": [
              "site_id",
              "name"
            ],
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "settings": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsSiteSettings"
              }
            ],
            "description": "The site's settings row, or an empty object when none exists."
          },
          "grid_import": {
            "$ref": "#/components/schemas/EmsFigureSummary"
          },
          "location_based": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsFigureSummary"
              },
              {
                "type": "object",
                "required": [
                  "factor_rows_used",
                  "factor_sets",
                  "region"
                ],
                "properties": {
                  "factor_rows_used": {
                    "type": "integer",
                    "description": "Distinct intensity rows applied."
                  },
                  "factor_sets": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/EmsFactorSet"
                    }
                  },
                  "region": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "description": "Null when the site has no `neso_region_id`.",
                    "properties": {
                      "region_id": {
                        "type": "integer"
                      },
                      "factor_set_id": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            ]
          },
          "market_based": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsFigureSummary"
              },
              {
                "type": "object",
                "required": [
                  "factor_rows",
                  "factor_sets",
                  "mpan"
                ],
                "properties": {
                  "factor_rows": {
                    "type": "array",
                    "description": "The one instrument in force, or empty when none is recorded.",
                    "items": {
                      "$ref": "#/components/schemas/EmsMarketFactor"
                    }
                  },
                  "factor_sets": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/EmsFactorSet"
                    }
                  },
                  "mpan": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The site's `import_mpan`."
                  }
                }
              }
            ]
          },
          "daily": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmsScope2Day"
            }
          },
          "method_notes": {
            "type": "object",
            "required": [
              "location_based",
              "market_based"
            ],
            "properties": {
              "location_based": {
                "type": "string"
              },
              "market_based": {
                "type": "string"
              }
            }
          }
        }
      },
      "EmsMarketFactorRecord": {
        "type": "object",
        "description": "A stored contractual instrument row.",
        "required": [
          "id",
          "organisation_id",
          "mpan",
          "instrument_type",
          "factor_g_per_kwh",
          "factor_set_name",
          "publisher",
          "vintage",
          "valid_from",
          "valid_to",
          "evidence_ref",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "mpan": {
            "type": "string"
          },
          "instrument_type": {
            "type": "string",
            "enum": [
              "rego",
              "supplier_residual_mix",
              "supplier_specific",
              "ppa"
            ]
          },
          "factor_g_per_kwh": {
            "type": "number",
            "description": "Grams of CO2e per kWh imported."
          },
          "factor_set_name": {
            "type": "string"
          },
          "publisher": {
            "type": "string"
          },
          "vintage": {
            "type": "string"
          },
          "valid_from": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "evidence_ref": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "EmsMarketFactorInput": {
        "type": "object",
        "additionalProperties": true,
        "required": [
          "mpan",
          "instrument_type",
          "factor_g_per_kwh",
          "factor_set_name",
          "publisher",
          "vintage",
          "valid_from"
        ],
        "properties": {
          "mpan": {
            "type": "string"
          },
          "instrument_type": {
            "type": "string",
            "enum": [
              "rego",
              "supplier_residual_mix",
              "supplier_specific",
              "ppa"
            ]
          },
          "factor_g_per_kwh": {
            "type": "number",
            "minimum": 0,
            "description": "Grams of CO2e per kWh imported. Zero for a fully REGO backed tariff."
          },
          "factor_set_name": {
            "type": "string"
          },
          "publisher": {
            "type": "string"
          },
          "vintage": {
            "type": "string"
          },
          "valid_from": {
            "type": "string",
            "format": "date",
            "description": "`YYYY-MM-DD`."
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "`YYYY-MM-DD`. Open ended when omitted or null."
          },
          "evidence_ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "Reference to the contract, certificate or statement the factor comes from."
          }
        }
      },
      "EmsPricedLoss": {
        "type": "object",
        "description": "A quantity of energy that a tariff could price. `gbp` is the pounds for\n`kwh`. When `partial` is true, `gbp` is a lower bound: some components of the\ncharge stack were priced and `unpriced_components` names the rest, and\n`reason` starts `lower bound:`.\n",
        "required": [
          "priced",
          "gbp",
          "unit_rate_p_per_kwh",
          "source",
          "reason",
          "mpan",
          "period_start",
          "kwh",
          "direction",
          "partial",
          "unpriced_components"
        ],
        "properties": {
          "priced": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "gbp": {
            "type": "number",
            "description": "GBP."
          },
          "unit_rate_p_per_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Pence per kWh, when the provider reports one."
          },
          "source": {
            "type": "string",
            "description": "Which provider priced it, for example `GB charge library (<tariff version>)`."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null when fully priced; the lower bound note when `partial`."
          },
          "mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "period_start": {
            "type": "string",
            "format": "date-time",
            "description": "The half hour whose unit rate was used."
          },
          "kwh": {
            "type": "number"
          },
          "direction": {
            "type": "string",
            "enum": [
              "import",
              "export"
            ]
          },
          "partial": {
            "type": "boolean"
          },
          "unpriced_components": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "EmsNotPricedLoss": {
        "type": "object",
        "description": "A quantity of energy that no tariff could price. `gbp` and the unit rate are\nnull, `source` is `none`, and `reason` says why, starting `not priced:` (for\nexample no tariff library configured, no MPAN on the site, or the library\nraised). Never a substituted number.\n",
        "required": [
          "priced",
          "gbp",
          "unit_rate_p_per_kwh",
          "source",
          "reason",
          "mpan",
          "period_start",
          "kwh",
          "direction",
          "partial",
          "unpriced_components"
        ],
        "properties": {
          "priced": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "gbp": {
            "type": "null"
          },
          "unit_rate_p_per_kwh": {
            "type": "null"
          },
          "source": {
            "type": "string",
            "enum": [
              "none"
            ]
          },
          "reason": {
            "type": "string"
          },
          "mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "kwh": {
            "type": "number"
          },
          "direction": {
            "type": "string",
            "enum": [
              "import",
              "export"
            ]
          },
          "partial": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "unpriced_components": {
            "type": "array",
            "maxItems": 0,
            "items": {
              "type": "string"
            }
          }
        }
      },
      "EmsEpisodeMonetary": {
        "description": "The pound value of an episode's shortfall, in one of two shapes selected by\n`priced`, plus `basis` stating how the kWh was framed for pricing.\n",
        "allOf": [
          {
            "oneOf": [
              {
                "$ref": "#/components/schemas/EmsPricedLoss"
              },
              {
                "$ref": "#/components/schemas/EmsNotPricedLoss"
              }
            ]
          },
          {
            "type": "object",
            "required": [
              "basis"
            ],
            "properties": {
              "basis": {
                "type": "string",
                "description": "Always `shortfall kWh priced as import that would otherwise have been offset`."
              }
            }
          }
        ]
      },
      "EmsUnderperformanceHalfHour": {
        "type": "object",
        "required": [
          "period_start",
          "actual_kw",
          "actual_quality",
          "reading_ids",
          "ghi_w_m2",
          "ghi_quality",
          "expected_kw"
        ],
        "properties": {
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "actual_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Mean PV power over the half hour. Null when no reading."
          },
          "actual_quality": {
            "type": "string",
            "enum": [
              "measured",
              "estimated",
              "interpolated",
              "missing"
            ]
          },
          "reading_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "ghi_w_m2": {
            "type": [
              "number",
              "null"
            ],
            "description": "Global horizontal irradiance in W/m2, rounded to 1 decimal."
          },
          "ghi_quality": {
            "type": "string",
            "description": "The stored series' quality word, or `estimated` on the clear sky basis, or `missing` when the series has no value for the half hour."
          },
          "expected_kw": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "EmsUnderperformanceDay": {
        "type": "object",
        "required": [
          "date",
          "actual_kwh",
          "expected_kwh",
          "ratio",
          "short",
          "flag",
          "episode_key",
          "periods_reported",
          "periods_total",
          "periods_without_expected",
          "coverage"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "UTC calendar day."
          },
          "actual_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Over the half hours with both an actual reading and an expected value. Null when there were none."
          },
          "expected_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "ratio": {
            "type": [
              "number",
              "null"
            ],
            "description": "Actual over expected, 4 decimals. Null when expected is zero or no half hour was usable."
          },
          "short": {
            "type": "boolean",
            "description": "Ratio below the site's threshold."
          },
          "flag": {
            "type": "string",
            "enum": [
              "not_flagged",
              "single_day",
              "below_window",
              "sustained",
              "no_data"
            ]
          },
          "episode_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set on days inside a sustained episode."
          },
          "periods_reported": {
            "type": "integer",
            "description": "Half hours with a PV reading."
          },
          "periods_total": {
            "type": "integer"
          },
          "periods_without_expected": {
            "type": "integer",
            "description": "Reported half hours that had no irradiance value and were excluded from both sums."
          },
          "coverage": {
            "$ref": "#/components/schemas/EmsCoverage"
          },
          "half_hours": {
            "type": "array",
            "description": "Present only when `half_hours=true` was requested.",
            "items": {
              "$ref": "#/components/schemas/EmsUnderperformanceHalfHour"
            }
          }
        }
      },
      "EmsUnderperformanceEpisode": {
        "type": "object",
        "required": [
          "episode_key",
          "start",
          "end",
          "days",
          "actual_kwh",
          "expected_kwh",
          "shortfall_kwh",
          "mean_ratio",
          "monetary"
        ],
        "properties": {
          "episode_key": {
            "type": "string",
            "description": "16 hex characters derived from the site, start and end."
          },
          "start": {
            "type": "string",
            "format": "date"
          },
          "end": {
            "type": "string",
            "format": "date",
            "description": "Inclusive."
          },
          "days": {
            "type": "integer"
          },
          "actual_kwh": {
            "type": "number"
          },
          "expected_kwh": {
            "type": "number"
          },
          "shortfall_kwh": {
            "type": "number",
            "description": "Expected minus actual, floored at zero."
          },
          "mean_ratio": {
            "type": "number",
            "description": "Actual over expected across the episode."
          },
          "monetary": {
            "$ref": "#/components/schemas/EmsEpisodeMonetary"
          }
        }
      },
      "SsoProvider": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "provider": {
            "type": "string",
            "enum": [
              "entra_external_id",
              "entra_workforce"
            ]
          },
          "entra_tenant_id": {
            "type": "string"
          },
          "authority": {
            "type": "string"
          },
          "client_id": {
            "type": "string"
          },
          "enabled": {
            "type": "boolean"
          }
        }
      },
      "SsoGroupRoleMapping": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "group_id": {
            "type": "string"
          },
          "role": {
            "type": "string"
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "PlatformIncident": {
        "type": "object",
        "required": [
          "id",
          "scope",
          "title",
          "severity",
          "status",
          "affected_services",
          "started_at",
          "summary",
          "updates"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "null for a platform wide incident"
          },
          "scope": {
            "type": "string",
            "enum": [
              "platform",
              "organisation"
            ]
          },
          "title": {
            "type": "string"
          },
          "severity": {
            "type": "string",
            "enum": [
              "minor",
              "major",
              "critical"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "investigating",
              "identified",
              "monitoring",
              "resolved"
            ]
          },
          "affected_services": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "duration_minutes": {
            "type": [
              "number",
              "null"
            ]
          },
          "summary": {
            "type": "string"
          },
          "postmortem": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "updates": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "posted_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "status": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                },
                "posted_by": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          }
        }
      },
      "EmsGridContract": {
        "type": "object",
        "properties": {
          "basis": {
            "type": "string",
            "enum": [
              "site_configuration",
              "policy_document",
              "confirmed_agreement"
            ]
          },
          "contract_id": {
            "type": "string"
          },
          "network_operator": {
            "type": "string"
          },
          "source_document": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          },
          "firm_import_kw": {
            "type": "number"
          },
          "conditional_import_kw": {
            "type": "number"
          },
          "full_import_kw": {
            "type": "number"
          },
          "export_limit_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "timed_import": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "start": {
                  "type": "string"
                },
                "end": {
                  "type": "string"
                },
                "import_kw": {
                  "type": "number"
                }
              }
            }
          },
          "ramp_kw_per_min": {
            "type": [
              "number",
              "null"
            ]
          },
          "response_sla_s": {
            "type": [
              "integer",
              "null"
            ]
          },
          "curtailment_allowance_h_per_year": {
            "type": [
              "number",
              "null"
            ]
          },
          "fail_safe_import_kw": {
            "type": "number"
          },
          "telemetry_interval_s": {
            "type": [
              "integer",
              "null"
            ]
          },
          "market_reserve_kw": {
            "type": "number"
          },
          "clauses": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Where each term came from."
          }
        }
      },
      "EmsGridPolicy": {
        "type": "object",
        "required": [
          "schema",
          "contract_id",
          "rules",
          "precedence",
          "policy_hash"
        ],
        "properties": {
          "schema": {
            "type": "string",
            "enum": [
              "ampverve.grid-policy/0.1"
            ]
          },
          "contract_id": {
            "type": "string"
          },
          "network_operator": {
            "type": "string"
          },
          "source_document": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          },
          "rules": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "kind": {
                  "type": "string"
                },
                "clause": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "params": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "precedence": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "policy_hash": {
            "type": "string",
            "description": "SHA-256 of the canonical document without this field."
          }
        }
      },
      "EmsGridComplianceSuite": {
        "type": "object",
        "properties": {
          "contract_id": {
            "type": "string"
          },
          "tests": {
            "type": "integer"
          },
          "passed": {
            "type": "integer"
          },
          "day": {
            "type": "string",
            "format": "date"
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "test_id": {
                  "type": "string"
                },
                "clause": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "description": {
                  "type": "string"
                },
                "expected": {
                  "type": "string",
                  "enum": [
                    "APPROVED",
                    "REJECTED"
                  ]
                },
                "got": {
                  "type": "string"
                },
                "passed": {
                  "type": "boolean"
                }
              }
            }
          }
        }
      },
      "EmsGridGuardrails": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "configuration",
          "evaluated"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "configuration": {
            "type": "object",
            "properties": {
              "present": {
                "type": "boolean"
              },
              "updated_at": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "source": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "evaluated": {
            "type": "boolean"
          },
          "reason": {
            "type": "string",
            "description": "Why the site was not evaluated",
            "when `evaluated` is false.": null
          },
          "contract": {
            "$ref": "#/components/schemas/EmsGridContract"
          },
          "policy": {
            "$ref": "#/components/schemas/EmsGridPolicy"
          },
          "at": {
            "type": "string",
            "format": "date-time"
          },
          "envelope": {
            "type": "object",
            "additionalProperties": true,
            "description": "The import limit in force now",
            "why": null,
            "and what may be offered to markets.": null
          },
          "obligations": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "instructions": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "resolution": {
            "type": "object",
            "properties": {
              "import_min_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "import_max_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "max_set_by": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "max_source": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "min_set_by": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "min_source": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "applied": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "conflicts": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "measured": {
            "type": "object",
            "properties": {
              "available": {
                "type": "boolean"
              },
              "import_kw": {
                "type": "number"
              },
              "half_hour_start": {
                "type": "string",
                "format": "date-time"
              },
              "quality": {
                "type": "string"
              },
              "allowed_max_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "headroom_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "within_limit": {
                "type": [
                  "boolean",
                  "null"
                ]
              },
              "note": {
                "type": "string"
              },
              "reason": {
                "type": "string"
              }
            }
          },
          "compliance": {
            "type": "object",
            "properties": {
              "compliant": {
                "type": [
                  "boolean",
                  "null"
                ]
              },
              "readings": {
                "type": "integer"
              },
              "half_hours": {
                "type": "integer"
              },
              "half_hours_without_meter_reading": {
                "type": "integer"
              },
              "breaches": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "import_kw": {
                      "type": "number"
                    },
                    "limit_kw": {
                      "type": "number"
                    },
                    "excess_kw": {
                      "type": "number"
                    }
                  }
                }
              },
              "responses": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              },
              "ramp_violations": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              },
              "curtailment_hours_used": {
                "type": "number"
              },
              "curtailment_allowance_h": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "window_start": {
                "type": "string",
                "format": "date-time"
              },
              "window_end": {
                "type": "string",
                "format": "date-time"
              },
              "readings_basis": {
                "type": "string"
              }
            }
          },
          "compliance_suite": {
            "$ref": "#/components/schemas/EmsGridComplianceSuite"
          },
          "check": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "proposal_id": {
                "type": "string"
              },
              "decision": {
                "type": "string",
                "enum": [
                  "APPROVED",
                  "REJECTED"
                ]
              },
              "projected_import_kw": {
                "type": "number"
              },
              "allowed_min_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "allowed_max_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "reasons": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "rule": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "source": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              },
              "suggested_changes": {
                "type": [
                  "object",
                  "null"
                ],
                "additionalProperties": true
              },
              "measured_import_kw": {
                "type": "number"
              },
              "measured_import_source": {
                "type": "string"
              },
              "note": {
                "type": "string"
              }
            }
          }
        }
      },
      "EmsGridAgreementTerm": {
        "type": "object",
        "properties": {
          "term": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "supported",
              "needs_confirmation",
              "unsupported"
            ]
          },
          "reason": {
            "type": "string"
          },
          "clause": {
            "type": [
              "string",
              "null"
            ]
          },
          "quote": {
            "type": [
              "string",
              "null"
            ]
          },
          "value": {
            "description": "The value in the term's canonical unit",
            "or null when a person must give it.": null
          },
          "canonical_unit": {
            "type": "string"
          },
          "stated_value": {
            "description": "The value as the document states it."
          },
          "stated_unit": {
            "type": [
              "string",
              "null"
            ]
          },
          "blocks": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          }
        }
      },
      "EmsGridAgreementRead": {
        "type": "object",
        "required": [
          "organisation_id",
          "origin",
          "model",
          "saved",
          "document_text",
          "extracted",
          "proposal"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "origin": {
            "type": "string",
            "enum": [
              "extraction model",
              "typed by a person"
            ]
          },
          "model": {
            "type": "object",
            "properties": {
              "configured": {
                "type": "boolean"
              },
              "deployment": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "saved": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "document_text": {
            "type": "string"
          },
          "extracted": {
            "type": "object",
            "additionalProperties": true
          },
          "proposal": {
            "type": "object",
            "properties": {
              "ok": {
                "type": "boolean"
              },
              "error": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "source_document": {
                "type": "string"
              },
              "document_sha256": {
                "type": "string"
              },
              "terms": {
                "type": "object",
                "additionalProperties": {
                  "$ref": "#/components/schemas/EmsGridAgreementTerm"
                }
              },
              "not_stated": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "ignored": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "EmsGridAgreementConfirm": {
        "type": "object",
        "required": [
          "organisation_id",
          "saved",
          "not_saved_reason",
          "terms",
          "provenance",
          "contract",
          "policy",
          "compliance_suite"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "saved": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "not_saved_reason": {
            "type": "string"
          },
          "terms": {
            "type": "object",
            "additionalProperties": true
          },
          "provenance": {
            "type": "object",
            "properties": {
              "confirmed_by": {
                "type": "string"
              },
              "confirmed_at": {
                "type": "string",
                "format": "date-time"
              },
              "source_document": {
                "type": "string"
              },
              "document_sha256": {
                "type": "string"
              },
              "contract_id": {
                "type": "string"
              },
              "terms": {
                "type": "object",
                "additionalProperties": true
              },
              "rejected": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "undecided": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "contract": {
            "$ref": "#/components/schemas/EmsGridContract"
          },
          "policy": {
            "$ref": "#/components/schemas/EmsGridPolicy"
          },
          "compliance_suite": {
            "$ref": "#/components/schemas/EmsGridComplianceSuite"
          },
          "proposal": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "EmsGridCapacity": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "at",
          "available"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "at": {
            "type": "string",
            "format": "date-time"
          },
          "available": {
            "type": "boolean"
          },
          "reason": {
            "type": "string"
          },
          "envelope": {
            "type": "object",
            "additionalProperties": true
          },
          "inputs": {
            "type": "object",
            "properties": {
              "measured_import": {
                "type": "object",
                "additionalProperties": true
              },
              "import_limit_kw": {
                "type": "number"
              },
              "import_limit_reason": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "import_reason_code": {
                "type": "string"
              },
              "export_limit_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "sessions_source": {
                "type": "string"
              },
              "loads_source": {
                "type": "string"
              },
              "assets": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              },
              "excluded": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "device_id": {
                      "type": "string"
                    },
                    "kind": {
                      "type": "string"
                    },
                    "name": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "reason": {
                      "type": "string"
                    }
                  }
                }
              },
              "readings_note": {
                "type": "string"
              }
            }
          },
          "allocation": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "ok",
                  "constrained",
                  "breach_unavoidable"
                ]
              },
              "import_limit_kw": {
                "type": "number"
              },
              "target_import_kw": {
                "type": "number"
              },
              "measured_import_kw": {
                "type": "number"
              },
              "planned_import_kw": {
                "type": "number"
              },
              "headroom_kw": {
                "type": "number"
              },
              "breach_kw": {
                "type": "number"
              },
              "pv_curtail_kw": {
                "type": "number"
              },
              "setpoints": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "device_id": {
                      "type": "string"
                    },
                    "kind": {
                      "type": "string",
                      "enum": [
                        "charger",
                        "battery",
                        "flexible_load"
                      ]
                    },
                    "target_kw": {
                      "type": "number"
                    }
                  }
                }
              },
              "vehicles": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "device_id": {
                      "type": "string"
                    },
                    "vehicle_ref": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "target_kw": {
                      "type": "number"
                    },
                    "required_kw": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "need_known": {
                      "type": "boolean"
                    },
                    "on_track": {
                      "type": "boolean"
                    },
                    "paused": {
                      "type": "boolean"
                    },
                    "shortfall_kwh_if_held": {
                      "type": "number"
                    }
                  }
                }
              },
              "summary": {
                "type": "object",
                "additionalProperties": true
              },
              "notes": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "minimum_import": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "duration_h": {
                "type": "number"
              },
              "minimum_import_kw": {
                "type": "number"
              },
              "exports_at_minimum": {
                "type": "boolean"
              },
              "site_base_kw": {
                "type": "number"
              },
              "site_base_basis": {
                "type": "string"
              },
              "assets": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              },
              "note": {
                "type": "string"
              }
            }
          }
        }
      },
      "EmsOutOfHours": {
        "type": "object",
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "periods",
          "policy_id",
          "schedule",
          "totals",
          "baseload",
          "highest_out_of_hours",
          "days",
          "circuits",
          "notes",
          "priced",
          "pricing_note"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "sub_meters": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "asset_id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "circuit_label": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "periods": {
            "type": "integer",
            "description": "Half hours in the window."
          },
          "policy_id": {
            "type": "string",
            "enum": [
              "AV-EMS-OOH-1"
            ]
          },
          "schedule": {
            "type": "object",
            "required": [
              "open",
              "close",
              "days",
              "timezone",
              "source"
            ],
            "properties": {
              "open": {
                "type": "string"
              },
              "close": {
                "type": "string"
              },
              "days": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "timezone": {
                "type": "string"
              },
              "source": {
                "type": "string",
                "enum": [
                  "request",
                  "default"
                ]
              }
            }
          },
          "totals": {
            "type": "object",
            "properties": {
              "in_hours_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "out_of_hours_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "out_of_hours_share": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Of known site load",
                "0 to 1.": null
              },
              "above_baseload_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "half_hours_in": {
                "type": "integer"
              },
              "half_hours_out": {
                "type": "integer"
              },
              "half_hours_unknown_in": {
                "type": "integer"
              },
              "half_hours_unknown_out": {
                "type": "integer"
              }
            }
          },
          "baseload": {
            "type": "object",
            "properties": {
              "kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "percentile": {
                "type": "number"
              },
              "basis": {
                "type": "string"
              },
              "median_out_of_hours_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "median_in_hours_kw": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "out_to_in_ratio": {
                "type": [
                  "number",
                  "null"
                ]
              }
            }
          },
          "highest_out_of_hours": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "type": "object",
              "properties": {
                "start": {
                  "type": "string",
                  "format": "date-time"
                },
                "start_local": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "kw": {
                  "type": "number"
                }
              }
            }
          },
          "days": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date",
                  "description": "Local day."
                },
                "open_day": {
                  "type": "boolean"
                },
                "in_hours_kwh": {
                  "type": "number"
                },
                "out_of_hours_kwh": {
                  "type": "number"
                },
                "periods_unknown": {
                  "type": "integer"
                }
              }
            }
          },
          "circuits": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "asset_id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "circuit_label": {
                  "type": "string"
                },
                "in_hours_kwh": {
                  "type": "number"
                },
                "out_of_hours_kwh": {
                  "type": "number"
                },
                "out_of_hours_share": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "periods_missing": {
                  "type": "integer"
                }
              }
            }
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "priced": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "pricing_note": {
            "type": "string"
          }
        }
      },
      "EmsUnderperformance": {
        "type": "object",
        "description": "Two branches on `status`. `evaluated` carries `policy_id`, `basis`,\n`basis_statement`, `config`, `days`, `days_reported`, `days_short` and\n`episodes`. `not_configured` carries `reason` instead and none of those.\nBoth carry the envelope fields and `settings`.\n",
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "status",
          "settings"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "required": [
              "site_id",
              "name"
            ],
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "enum": [
              "evaluated",
              "not_configured"
            ]
          },
          "reason": {
            "type": "string",
            "description": "Only on `not_configured`. Why the evaluation could not run."
          },
          "policy_id": {
            "type": "string",
            "description": "Always `AV-EMS-UPF-1`."
          },
          "basis": {
            "type": "string",
            "enum": [
              "irradiance_series",
              "clear_sky_model"
            ]
          },
          "basis_statement": {
            "type": "string",
            "description": "One sentence stating the basis, its sources or coordinates, and what a shortfall against it means."
          },
          "config": {
            "type": "object",
            "required": [
              "pv_kwp",
              "pv_kwp_source",
              "performance_ratio",
              "performance_ratio_source",
              "window_days",
              "ratio_threshold",
              "import_mpan"
            ],
            "properties": {
              "pv_kwp": {
                "type": "number"
              },
              "pv_kwp_source": {
                "type": "string",
                "enum": [
                  "site settings",
                  "PV device details"
                ]
              },
              "performance_ratio": {
                "type": "number"
              },
              "performance_ratio_source": {
                "type": "string",
                "description": "`site settings` or `platform default 0.75`."
              },
              "window_days": {
                "type": "integer"
              },
              "ratio_threshold": {
                "type": "number"
              },
              "import_mpan": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The MPAN episodes were priced on."
              }
            }
          },
          "days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmsUnderperformanceDay"
            }
          },
          "days_reported": {
            "type": "integer",
            "description": "Days with a ratio."
          },
          "days_short": {
            "type": "integer",
            "description": "Reported days that were short."
          },
          "episodes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmsUnderperformanceEpisode"
            }
          },
          "settings": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsSiteSettings"
              }
            ],
            "description": "The site's settings row, or an empty object when none exists."
          }
        }
      },
      "EmsAlertThresholds": {
        "type": "object",
        "description": "Pounds per day at or above which each band applies.",
        "required": [
          "info_gbp_per_day",
          "warning_gbp_per_day",
          "critical_gbp_per_day",
          "source",
          "updated_at"
        ],
        "properties": {
          "info_gbp_per_day": {
            "type": "number"
          },
          "warning_gbp_per_day": {
            "type": "number"
          },
          "critical_gbp_per_day": {
            "type": "number"
          },
          "source": {
            "type": "string",
            "enum": [
              "organisation policy",
              "platform default"
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null for the platform defaults."
          }
        }
      },
      "EmsAlertPolicy": {
        "type": "object",
        "required": [
          "organisation_id",
          "policy_id",
          "unit",
          "thresholds",
          "defaults",
          "bands",
          "rate_rule"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "policy_id": {
            "type": "string",
            "description": "Always `AV-EMS-SEV-1`."
          },
          "unit": {
            "type": "string",
            "description": "Always `GBP per day`."
          },
          "thresholds": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsAlertThresholds"
              }
            ],
            "description": "The thresholds in force."
          },
          "defaults": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsAlertThresholds"
              }
            ],
            "description": "The platform defaults, info 5.00, warning 25.00, critical 100.00."
          },
          "bands": {
            "type": "array",
            "description": "The five bands in rank order with the rule for each.",
            "items": {
              "type": "object",
              "required": [
                "severity",
                "rule"
              ],
              "properties": {
                "severity": {
                  "type": "string",
                  "enum": [
                    "critical",
                    "warning",
                    "info",
                    "low",
                    "unpriced"
                  ]
                },
                "rule": {
                  "type": "string"
                }
              }
            }
          },
          "rate_rule": {
            "type": "string",
            "description": "How `impact_gbp_per_day` is computed from the window."
          }
        }
      },
      "EmsAlertPolicyInput": {
        "type": "object",
        "additionalProperties": true,
        "required": [
          "info_gbp_per_day",
          "warning_gbp_per_day",
          "critical_gbp_per_day"
        ],
        "properties": {
          "info_gbp_per_day": {
            "type": "number",
            "minimum": 0,
            "description": "GBP per day."
          },
          "warning_gbp_per_day": {
            "type": "number",
            "minimum": 0,
            "description": "GBP per day. At least `info_gbp_per_day`."
          },
          "critical_gbp_per_day": {
            "type": "number",
            "minimum": 0,
            "description": "GBP per day. At least `warning_gbp_per_day`."
          }
        }
      },
      "EmsMonetaryBasis": {
        "type": "object",
        "description": "How an alert's pounds were arrived at, stored with the alert.",
        "required": [
          "policy_id",
          "basis",
          "kwh",
          "direction",
          "window_start",
          "window_end",
          "window_days",
          "window_days_for_rate",
          "mpan",
          "price_source",
          "price_reason",
          "unit_rate_p_per_kwh",
          "partial",
          "unpriced_components",
          "thresholds"
        ],
        "properties": {
          "policy_id": {
            "type": "string",
            "description": "Always `AV-EMS-SEV-1`."
          },
          "basis": {
            "type": "string",
            "description": "How the kWh was framed, which half hour's unit rate was used and where the MPAN came from."
          },
          "kwh": {
            "type": "number"
          },
          "direction": {
            "type": "string",
            "enum": [
              "import",
              "export"
            ]
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "window_days": {
            "type": "number",
            "description": "Window length in days, 4 decimals."
          },
          "window_days_for_rate": {
            "type": "number",
            "description": "`max(window_days, 1)`: the divisor used for the per day figure."
          },
          "mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "price_source": {
            "type": "string"
          },
          "price_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "unit_rate_p_per_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "partial": {
            "type": "boolean",
            "description": "True when the pounds are a lower bound."
          },
          "unpriced_components": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "thresholds": {
            "$ref": "#/components/schemas/EmsAlertThresholds"
          }
        }
      },
      "EmsAlert": {
        "type": "object",
        "description": "One alert row as ranked. EMS alerts carry the monetary fields; rows written\nby other providers carry null monetary fields, `legacy: true` and a fixed\n`band_reason` explaining that their severity is operational.\n",
        "required": [
          "id",
          "organisation_id",
          "user_id",
          "device_id",
          "provider",
          "alert_type",
          "severity",
          "title",
          "message",
          "context",
          "resolved",
          "resolved_at",
          "resolved_by",
          "resolution_note",
          "acknowledged_at",
          "acknowledged_by",
          "assigned_to",
          "assigned_at",
          "created_at",
          "site_id",
          "monetary_impact_gbp",
          "monetary_basis",
          "impact_gbp_per_day",
          "band_reason",
          "alert_key",
          "legacy",
          "source_label"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when the finding concerns exactly one asset."
          },
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "`ems` for alerts raised here."
          },
          "alert_type": {
            "type": "string",
            "description": "For EMS alerts one of `underperformance`, `stopped_asset`, `comms_gap`, `asset_silence`, `commissioning_failure`, `export_limit_breach`. Other providers use their own values."
          },
          "severity": {
            "type": "string",
            "description": "For EMS alerts one of `critical`, `warning`, `info`, `low`, `unpriced`. Legacy rows carry the raising provider's severity."
          },
          "title": {
            "type": "string"
          },
          "message": {
            "type": [
              "string",
              "null"
            ]
          },
          "context": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Source specific detail (the episode, day, gap, check or event) plus `source`, `site_name` and `last_evaluated_at`. Shape varies by source and is passed through."
          },
          "resolved": {
            "type": "boolean"
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "resolved_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "resolution_note": {
            "type": [
              "string",
              "null"
            ]
          },
          "acknowledged_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "acknowledged_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ]
          },
          "assigned_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "site_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "monetary_impact_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Total GBP over the alert window. Null when unpriced."
          },
          "monetary_basis": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/EmsMonetaryBasis"
              },
              {
                "type": "null"
              }
            ]
          },
          "impact_gbp_per_day": {
            "type": [
              "number",
              "null"
            ],
            "description": "GBP per day, the ranking figure. Null when unpriced."
          },
          "band_reason": {
            "type": "string",
            "description": "The arithmetic and threshold behind the band, or why the kWh could not be priced."
          },
          "alert_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "`<source>|<site_id>|<condition>`. Null on legacy rows."
          },
          "legacy": {
            "type": "boolean",
            "description": "True for rows without monetary fields, written before or outside the EMS evaluation."
          },
          "source_label": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human label for `alert_type`; the raw `alert_type` for non EMS rows."
          }
        }
      },
      "EmsAlertQueue": {
        "allOf": [
          {
            "$ref": "#/components/schemas/EmsAlertPolicy"
          },
          {
            "type": "object",
            "required": [
              "sources",
              "count",
              "priced",
              "unpriced",
              "alerts"
            ],
            "properties": {
              "sources": {
                "type": "array",
                "description": "The six EMS alert sources with their labels.",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "label"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "enum": [
                        "underperformance",
                        "stopped_asset",
                        "comms_gap",
                        "asset_silence",
                        "commissioning_failure",
                        "export_limit_breach"
                      ]
                    },
                    "label": {
                      "type": "string"
                    }
                  }
                }
              },
              "count": {
                "type": "integer"
              },
              "priced": {
                "type": "integer",
                "description": "Alerts with a non null `impact_gbp_per_day`."
              },
              "unpriced": {
                "type": "integer"
              },
              "alerts": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/EmsAlert"
                }
              }
            }
          }
        ]
      },
      "EmsAlertEvaluateInput": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "site_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Evaluate one site only. Every site when omitted."
          },
          "days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 31,
            "default": 14,
            "description": "Window length in whole days when `window_start` is not given."
          },
          "window_start": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 8601. Must be given with `window_end`."
          },
          "window_end": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 8601, exclusive. Must be given with `window_start`."
          },
          "date": {
            "type": [
              "string",
              "null"
            ],
            "description": "First day of the window as `YYYY-MM-DD`."
          }
        }
      },
      "EmsAlertAction": {
        "type": "object",
        "description": "One alert raised or updated by an evaluation.",
        "required": [
          "id",
          "action",
          "alert_key",
          "source",
          "site_id",
          "title",
          "severity",
          "impact_gbp_per_day",
          "monetary_impact_gbp",
          "band_reason"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "raised",
              "updated"
            ]
          },
          "alert_key": {
            "type": "string"
          },
          "source": {
            "type": "string",
            "enum": [
              "underperformance",
              "stopped_asset",
              "comms_gap",
              "asset_silence",
              "commissioning_failure",
              "export_limit_breach"
            ]
          },
          "site_id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning",
              "info",
              "low",
              "unpriced"
            ]
          },
          "impact_gbp_per_day": {
            "type": [
              "number",
              "null"
            ]
          },
          "monetary_impact_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "band_reason": {
            "type": "string"
          }
        }
      },
      "EmsSourceError": {
        "type": "object",
        "required": [
          "source",
          "error"
        ],
        "properties": {
          "source": {
            "type": "string",
            "enum": [
              "underperformance",
              "stopped_asset",
              "comms_gap",
              "asset_silence",
              "commissioning_failure",
              "export_limit_breach"
            ]
          },
          "error": {
            "type": "string",
            "description": "The exception, as `repr`."
          }
        }
      },
      "EmsAlertEvaluation": {
        "type": "object",
        "required": [
          "organisation_id",
          "window_start",
          "window_end",
          "policy_id",
          "thresholds",
          "sources",
          "sites",
          "raised",
          "updated",
          "errors"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "policy_id": {
            "type": "string",
            "description": "Always `AV-EMS-SEV-1`."
          },
          "thresholds": {
            "$ref": "#/components/schemas/EmsAlertThresholds"
          },
          "sources": {
            "type": "array",
            "description": "The sources that were run.",
            "items": {
              "type": "string",
              "enum": [
                "underperformance",
                "stopped_asset",
                "comms_gap",
                "asset_silence",
                "commissioning_failure",
                "export_limit_breach"
              ]
            }
          },
          "sites": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "site_id",
                "site_name",
                "mpan",
                "mpan_source",
                "candidates",
                "raised",
                "updated",
                "alerts",
                "notes",
                "errors"
              ],
              "properties": {
                "site_id": {
                  "type": "string"
                },
                "site_name": {
                  "type": "string"
                },
                "mpan": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The MPAN findings were priced on."
                },
                "mpan_source": {
                  "type": "string",
                  "enum": [
                    "charge library MPAN configuration for this site",
                    "site settings import MPAN",
                    "no MPAN configured for this site"
                  ]
                },
                "candidates": {
                  "type": "integer",
                  "description": "Findings across all sources before pricing."
                },
                "raised": {
                  "type": "integer"
                },
                "updated": {
                  "type": "integer"
                },
                "alerts": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/EmsAlertAction"
                  }
                },
                "notes": {
                  "type": "array",
                  "description": "For example why the underperformance sources were not evaluated for this site.",
                  "items": {
                    "type": "string"
                  }
                },
                "errors": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/EmsSourceError"
                  }
                }
              }
            }
          },
          "raised": {
            "type": "integer"
          },
          "updated": {
            "type": "integer"
          },
          "errors": {
            "type": "array",
            "description": "Every site's source errors, flattened.",
            "items": {
              "$ref": "#/components/schemas/EmsSourceError"
            }
          },
          "routing": {
            "type": "object",
            "description": "How the newly raised alerts were routed under the organisation's EMS routing rules.",
            "properties": {
              "routed": {
                "type": "integer",
                "description": "Alerts routed."
              },
              "deliveries": {
                "type": "integer",
                "description": "Delivery rows written."
              },
              "by_status": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "error": {
                "type": "string",
                "description": "Present only when routing raised."
              },
              "policy_id": {
                "type": "string",
                "const": "AV-EMS-RTE-1"
              }
            }
          }
        }
      },
      "ChargeLibrary": {
        "type": "object",
        "description": "The library as one organisation may see it. Reference lists (`regions`,\n`tnuos_zones`, `duos_tariffs`, `components`, `capacity_bands`) are constants\nof the library release named by `library_version`; `rates` and `time_bands`\nare the stored rows.\n",
        "properties": {
          "library_version": {
            "type": "string",
            "description": "Library release tag carried by every published row, for example `2026-09-12.1`."
          },
          "organisation_id": {
            "type": "string"
          },
          "regions": {
            "type": "array",
            "description": "The 14 GB distribution licence areas.",
            "items": {
              "type": "object",
              "properties": {
                "region_id": {
                  "type": "integer",
                  "description": "MPAN distributor id, the first two digits of the core MPAN. 10 to 23."
                },
                "name": {
                  "type": "string"
                },
                "licensee": {
                  "type": "string"
                },
                "group": {
                  "type": "string",
                  "description": "The DNO group the licensee belongs to."
                },
                "duos_publication": {
                  "type": "string",
                  "description": "The schedule of charges each DUoS rate for this area must be confirmed from."
                },
                "tnuos_zone": {
                  "type": "integer",
                  "description": "TNUoS demand zone the licence area sits in, NESO numbering, 1 to 14."
                }
              },
              "required": [
                "region_id",
                "name",
                "licensee",
                "group",
                "duos_publication",
                "tnuos_zone"
              ]
            }
          },
          "tnuos_zones": {
            "type": "array",
            "description": "TNUoS demand zones, NESO numbering.",
            "items": {
              "type": "object",
              "properties": {
                "zone": {
                  "type": "integer"
                },
                "name": {
                  "type": "string"
                }
              },
              "required": [
                "zone",
                "name"
              ]
            }
          },
          "duos_tariffs": {
            "type": "array",
            "description": "DUoS tariff codes the library knows.",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "description": "`LV_HH` or `HV_HH`."
                },
                "label": {
                  "type": "string"
                }
              },
              "required": [
                "code",
                "label"
              ]
            }
          },
          "components": {
            "type": "object",
            "description": "Every charge component the stack knows, keyed by component code (for\nexample `DUOS_UNIT_RED`, `CCL`, `BSUOS`, `SUPPLY_UNIT`).\n",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "label": {
                  "type": "string"
                },
                "basis": {
                  "type": "string",
                  "enum": [
                    "kwh",
                    "kva_day",
                    "site_day",
                    "kvarh",
                    "kw_year",
                    "mwh",
                    "roc_mwh",
                    "gbp_roc",
                    "kw_yr_auction"
                  ],
                  "description": "What the rate is multiplied by: `kwh` per unit consumed, `kva_day` per\nkVA of agreed capacity per day, `site_day` per MPAN per day, `kvarh`\nreactive units, `kw_year` per kW of chargeable demand per year, `mwh`\nper MWh, `roc_mwh` and `gbp_roc` the two inputs to the Renewables\nObligation, `kw_yr_auction` a Capacity Market clearing price\n(informational).\n"
                },
                "unit": {
                  "type": "string",
                  "description": "The unit the rate is published in, for example `p/kWh`, `p/kVA/day`, `GBP/MWh`, `GBP/site/day`."
                },
                "scope": {
                  "type": "string",
                  "enum": [
                    "dno",
                    "national",
                    "tnuos_zone",
                    "tenant"
                  ],
                  "description": "Who sets the rate. `tenant` components are contracted rates only your organisation can hold."
                }
              },
              "required": [
                "label",
                "basis",
                "unit",
                "scope"
              ]
            }
          },
          "capacity_bands": {
            "type": "object",
            "description": "TCR site specific band thresholds by agreed capacity. EHV thresholds are deliberately absent, not guessed.",
            "properties": {
              "publication": {
                "type": "string"
              },
              "bands": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "voltage_level": {
                      "type": "string",
                      "enum": [
                        "LV",
                        "HV"
                      ]
                    },
                    "band": {
                      "type": "string",
                      "enum": [
                        "band_1",
                        "band_2",
                        "band_3",
                        "band_4"
                      ]
                    },
                    "min_kva": {
                      "type": "number",
                      "description": "Inclusive lower bound, kVA."
                    },
                    "max_kva": {
                      "type": [
                        "number",
                        "null"
                      ],
                      "description": "Exclusive upper bound, kVA. Null for the top band."
                    }
                  },
                  "required": [
                    "voltage_level",
                    "band",
                    "min_kva",
                    "max_kva"
                  ]
                }
              }
            },
            "required": [
              "publication",
              "bands"
            ]
          },
          "summary": {
            "type": "object",
            "description": "Rate row counts by status, so a screen can say how complete the library is.",
            "properties": {
              "published": {
                "type": "object",
                "description": "Counts of published rows keyed by status.",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "own": {
                "type": "object",
                "description": "Counts of your organisation's own rows keyed by status.",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "published_rows": {
                "type": "integer"
              },
              "own_rows": {
                "type": "integer"
              }
            },
            "required": [
              "published",
              "own",
              "published_rows",
              "own_rows"
            ]
          },
          "rates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChargeRateRow"
            }
          },
          "time_bands": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChargeTimeBandRow"
            }
          },
          "maintenance": {
            "type": "object",
            "description": "The append only rule, the republication endpoint and the precedence rule, as text.",
            "properties": {
              "rule": {
                "type": "string"
              },
              "republish_endpoint": {
                "type": "string"
              },
              "precedence": {
                "type": "string"
              }
            },
            "required": [
              "rule",
              "republish_endpoint",
              "precedence"
            ]
          }
        },
        "required": [
          "library_version",
          "organisation_id",
          "regions",
          "tnuos_zones",
          "duos_tariffs",
          "components",
          "capacity_bands",
          "summary",
          "rates",
          "time_bands",
          "maintenance"
        ]
      },
      "ChargeRateRow": {
        "type": "object",
        "description": "One effective dated rate row. `value` null means the rate is to be\nconfirmed from `publication`. It never means zero.\n",
        "properties": {
          "id": {
            "type": "string",
            "description": "SHA-256 of the natural key, so a re-run collides rather than duplicating."
          },
          "organisation_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null for a published reference row. Set for your organisation's own row."
          },
          "component": {
            "type": "string",
            "description": "Component code, a key of `components` on the library."
          },
          "dno_region_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "10 to 23 for a DNO scoped component, null for national ones."
          },
          "tnuos_zone": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1 to 14 for a zonal TNUoS component, else null."
          },
          "tariff_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "`LV_HH` or `HV_HH` for DUoS, null for national."
          },
          "band": {
            "type": [
              "string",
              "null"
            ],
            "description": "`red`, `amber`, `green` for a DUoS unit rate, `band_1` to `band_4` for a TCR banded charge, else null."
          },
          "voltage_level": {
            "type": [
              "string",
              "null"
            ]
          },
          "unit": {
            "type": "string",
            "description": "The unit `value` is in, for example `p/kWh`."
          },
          "value": {
            "type": [
              "number",
              "null"
            ],
            "description": "The rate in `unit`. Null when to be confirmed."
          },
          "charge_year": {
            "type": "string",
            "description": "GB charging year, 1 April to 31 March, for example `2026/27`."
          },
          "effective_from": {
            "type": "string",
            "format": "date"
          },
          "effective_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Exclusive. Null means open ended."
          },
          "publication": {
            "type": "string",
            "description": "The document the rate was, or must be, confirmed from."
          },
          "publication_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "publication_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status": {
            "type": "string",
            "enum": [
              "confirmed",
              "derived",
              "to_be_confirmed",
              "contracted",
              "synthetic"
            ],
            "description": "`confirmed` checked against the publication by a person; `derived` computed\nfrom confirmed rows; `to_be_confirmed` no value yet; `contracted` a\ntenant's contracted rate; `synthetic` a demo realm row, always tenant scoped.\n"
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "version": {
            "type": "string",
            "description": "Version tag. A republication carries a new one."
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "organisation_id",
          "component",
          "dno_region_id",
          "tnuos_zone",
          "tariff_code",
          "band",
          "voltage_level",
          "unit",
          "value",
          "charge_year",
          "effective_from",
          "effective_to",
          "publication",
          "publication_url",
          "publication_date",
          "status",
          "note",
          "version"
        ]
      },
      "ChargeRateRowIn": {
        "type": "object",
        "description": "One rate row to append. `unit` is set by the server from the component.",
        "properties": {
          "component": {
            "type": "string",
            "description": "Component code. Must be one of the library's components."
          },
          "charge_year": {
            "type": "string",
            "description": "GB charging year, for example `2026/27`."
          },
          "effective_from": {
            "type": "string",
            "format": "date"
          },
          "effective_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "publication": {
            "type": "string"
          },
          "publication_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "publication_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status": {
            "type": "string",
            "description": "`confirmed`, `derived`, `to_be_confirmed`, `contracted` or `synthetic`. See the value rules on the operation."
          },
          "value": {
            "type": [
              "number",
              "null"
            ],
            "description": "The rate in the component's unit. Must be null for `to_be_confirmed`, set otherwise."
          },
          "version": {
            "type": "string",
            "minLength": 3,
            "maxLength": 32
          },
          "dno_region_id": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 10,
            "maximum": 23
          },
          "tnuos_zone": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 14
          },
          "tariff_code": {
            "type": [
              "string",
              "null"
            ]
          },
          "band": {
            "type": [
              "string",
              "null"
            ]
          },
          "voltage_level": {
            "type": [
              "string",
              "null"
            ]
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "scope": {
            "type": "string",
            "default": "own",
            "description": "`own` writes a row scoped to your organisation. `published` writes a public reference row and needs a platform token."
          }
        },
        "required": [
          "component",
          "charge_year",
          "effective_from",
          "publication",
          "status",
          "version"
        ]
      },
      "ChargeTimeBandRow": {
        "type": "object",
        "description": "One DUoS time band window. A `to_be_confirmed` row carries no window and is never used for pricing.",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null for a published row."
          },
          "dno_region_id": {
            "type": "integer"
          },
          "tariff_code": {
            "type": "string"
          },
          "band": {
            "type": "string",
            "description": "`red`, `amber` or `green`."
          },
          "day_kind": {
            "type": "string",
            "enum": [
              "weekday",
              "weekend",
              "all"
            ]
          },
          "start_local": {
            "type": [
              "string",
              "null"
            ],
            "description": "`HH:MM` Europe/London local time. Null when the window is to be confirmed."
          },
          "end_local": {
            "type": [
              "string",
              "null"
            ],
            "description": "`HH:MM` local time. A window whose end is at or before its start wraps midnight."
          },
          "charge_year": {
            "type": "string"
          },
          "effective_from": {
            "type": "string",
            "format": "date"
          },
          "effective_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "publication": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "confirmed",
              "to_be_confirmed",
              "contracted",
              "synthetic"
            ]
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "version": {
            "type": "string"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "organisation_id",
          "dno_region_id",
          "tariff_code",
          "band",
          "day_kind",
          "start_local",
          "end_local",
          "charge_year",
          "effective_from",
          "effective_to",
          "publication",
          "status",
          "note",
          "version"
        ]
      },
      "ChargeTimeBandIn": {
        "type": "object",
        "properties": {
          "dno_region_id": {
            "type": "integer",
            "minimum": 10,
            "maximum": 23
          },
          "tariff_code": {
            "type": "string"
          },
          "band": {
            "type": "string",
            "description": "`red`, `amber` or `green`."
          },
          "day_kind": {
            "type": "string",
            "default": "weekday",
            "description": "`weekday`, `weekend` or `all`."
          },
          "start_local": {
            "type": [
              "string",
              "null"
            ],
            "description": "`HH:MM` local time. Required unless `status` is `to_be_confirmed`."
          },
          "end_local": {
            "type": [
              "string",
              "null"
            ],
            "description": "`HH:MM` local time. Required unless `status` is `to_be_confirmed`."
          },
          "charge_year": {
            "type": "string"
          },
          "effective_from": {
            "type": "string",
            "format": "date"
          },
          "effective_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "publication": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "`confirmed`, `to_be_confirmed`, `contracted` or `synthetic`."
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "version": {
            "type": "string"
          }
        },
        "required": [
          "dno_region_id",
          "tariff_code",
          "band",
          "charge_year",
          "effective_from",
          "publication",
          "status",
          "version"
        ]
      },
      "MpanConfig": {
        "type": "object",
        "description": "One effective dated charge configuration row for an MPAN.",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "workspace_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "mpan": {
            "type": "string",
            "description": "The 13 digit core MPAN."
          },
          "dno_region_id": {
            "type": "integer",
            "description": "MPAN distributor id, 10 to 23. Agrees with the first two digits of `mpan`."
          },
          "voltage_level": {
            "type": "string",
            "description": "`LV`, `HV` or `EHV`."
          },
          "duos_tariff_code": {
            "type": "string"
          },
          "tnuos_zone": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Defaults to the zone of the licence area when not supplied."
          },
          "assigned_tcr_band": {
            "type": [
              "string",
              "null"
            ],
            "description": "The band the supplier or DNO has assigned. Compared against the band the agreed capacity implies; a mismatch is flagged, never silently fixed."
          },
          "agreed_supply_capacity_kva": {
            "type": [
              "number",
              "null"
            ],
            "description": "Agreed supply capacity, kVA."
          },
          "asc_source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where the agreed capacity figure came from."
          },
          "power_factor": {
            "type": [
              "number",
              "null"
            ],
            "description": "Used to express a kW maximum demand in kVA. Null means no power factor is assumed."
          },
          "ccl_exempt": {
            "type": "boolean"
          },
          "ccl_exempt_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "effective_from": {
            "type": "string",
            "format": "date"
          },
          "effective_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "source": {
            "type": "string",
            "description": "Where this configuration came from."
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "organisation_id",
          "workspace_id",
          "mpan",
          "dno_region_id",
          "voltage_level",
          "duos_tariff_code",
          "tnuos_zone",
          "assigned_tcr_band",
          "agreed_supply_capacity_kva",
          "asc_source",
          "power_factor",
          "ccl_exempt",
          "ccl_exempt_reason",
          "effective_from",
          "effective_to",
          "source",
          "note"
        ]
      },
      "MpanConfigIn": {
        "type": "object",
        "properties": {
          "mpan": {
            "type": "string",
            "minLength": 13,
            "maxLength": 13,
            "description": "The 13 digit core MPAN. Digits only."
          },
          "dno_region_id": {
            "type": "integer",
            "minimum": 10,
            "maximum": 23,
            "description": "Must equal the first two digits of `mpan`."
          },
          "voltage_level": {
            "type": "string",
            "enum": [
              "LV",
              "HV",
              "EHV"
            ]
          },
          "duos_tariff_code": {
            "type": "string",
            "description": "`LV_HH` or `HV_HH`."
          },
          "workspace_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "tnuos_zone": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 14,
            "description": "Defaults to the zone of the licence area."
          },
          "assigned_tcr_band": {
            "type": [
              "string",
              "null"
            ],
            "description": "`band_1` to `band_4`."
          },
          "agreed_supply_capacity_kva": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "description": "kVA."
          },
          "asc_source": {
            "type": [
              "string",
              "null"
            ]
          },
          "power_factor": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "maximum": 1
          },
          "ccl_exempt": {
            "type": "boolean",
            "default": false
          },
          "ccl_exempt_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "effective_from": {
            "type": "string",
            "format": "date"
          },
          "effective_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "source": {
            "type": "string"
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "mpan",
          "dno_region_id",
          "voltage_level",
          "duos_tariff_code",
          "effective_from",
          "source"
        ]
      },
      "PriceRequest": {
        "type": "object",
        "properties": {
          "mpan": {
            "type": "string",
            "minLength": 13,
            "maxLength": 13
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 2000,
            "items": {
              "type": "object",
              "properties": {
                "period_start": {
                  "type": "string",
                  "description": "ISO 8601 start of the half hour. A value without an offset is taken as UTC."
                },
                "kwh": {
                  "type": "number",
                  "minimum": 0,
                  "description": "Energy in the half hour, kWh. Never negative; use `direction` for export."
                },
                "direction": {
                  "type": "string",
                  "default": "import",
                  "description": "`import` or `export`."
                }
              },
              "required": [
                "period_start",
                "kwh"
              ]
            }
          }
        },
        "required": [
          "mpan",
          "items"
        ]
      },
      "PriceComponentLine": {
        "type": "object",
        "description": "One component of the stack, with its rate, source and status.",
        "properties": {
          "component": {
            "type": "string",
            "description": "Component code. `DUOS_UNIT` when the band could not be resolved."
          },
          "label": {
            "type": "string"
          },
          "basis": {
            "type": "string",
            "description": "What `rate` is multiplied by. See `components` on the library."
          },
          "unit": {
            "type": "string",
            "description": "The unit `rate` is in."
          },
          "rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "The rate as published, in `unit`. Null when unpriced."
          },
          "gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Pounds for this quantity. Null when unpriced."
          },
          "status": {
            "type": "string",
            "enum": [
              "confirmed",
              "derived",
              "contracted",
              "synthetic",
              "to_be_confirmed",
              "not_applicable"
            ],
            "description": "`not_applicable` lines carry `rate` 0 and `gbp` 0 and state the rule in `reason`."
          },
          "source": {
            "type": "string",
            "description": "The publication or contract the rate came from, or must be confirmed from."
          },
          "version": {
            "type": [
              "string",
              "null"
            ],
            "description": "The rate row's version tag. Null when no row was found."
          },
          "band": {
            "type": [
              "string",
              "null"
            ],
            "description": "DUoS or TCR band the rate applies to, if any."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the line is unpriced or not applicable. Null on a priced line."
          },
          "effective_from": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "row_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Id of the rate row used, for reproduction."
          }
        },
        "required": [
          "component",
          "label",
          "basis",
          "unit",
          "rate",
          "gbp",
          "status",
          "source",
          "version",
          "band",
          "reason",
          "effective_from",
          "row_id"
        ]
      },
      "PricedQuantity": {
        "type": "object",
        "description": "kWh plus GBP plus the tariff version that priced it. `gbp` is a price only\nwhen `priced` is true; otherwise it is the sum of the priced lines, a lower\nbound, and `unpriced` names what is missing.\n",
        "properties": {
          "mpan": {
            "type": "string"
          },
          "direction": {
            "type": "string",
            "enum": [
              "import",
              "export"
            ]
          },
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "kwh": {
            "type": "number"
          },
          "gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Sum of every priced line, GBP. Null when no line was priced."
          },
          "currency": {
            "type": "string",
            "description": "Always `GBP`."
          },
          "tariff_version": {
            "type": "string",
            "description": "`lib:{library_version}|mpan:{config id prefix}|rows:{hash of row ids used}`,\nor `lib:{library_version}` when no configuration was in force.\n"
          },
          "priced": {
            "type": "boolean",
            "description": "True only when every applicable component was priced."
          },
          "partially_priced": {
            "type": "boolean",
            "description": "True when some but not all lines were priced."
          },
          "duos_band": {
            "type": [
              "string",
              "null"
            ],
            "description": "`red`, `amber` or `green` for import when resolved, else null."
          },
          "lines": {
            "type": "array",
            "description": "One line per component. Empty when no configuration was in force.",
            "items": {
              "$ref": "#/components/schemas/PriceComponentLine"
            }
          },
          "unpriced": {
            "type": "array",
            "description": "The lines with `gbp` null, each with its reason.",
            "items": {
              "type": "object",
              "properties": {
                "component": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                },
                "reason": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              },
              "required": [
                "component",
                "label",
                "reason"
              ]
            }
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when nothing could be priced: no configuration in force for the MPAN\non that date, or no component of the stack priced. Null otherwise.\n"
          }
        },
        "required": [
          "mpan",
          "direction",
          "period_start",
          "kwh",
          "gbp",
          "currency",
          "tariff_version",
          "priced",
          "partially_priced",
          "duos_band",
          "lines",
          "unpriced",
          "reason"
        ]
      },
      "PriceResponse": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "mpan": {
            "type": "string"
          },
          "results": {
            "type": "array",
            "description": "One per request item, in request order.",
            "items": {
              "$ref": "#/components/schemas/PricedQuantity"
            }
          },
          "totals": {
            "type": "object",
            "properties": {
              "kwh": {
                "type": "number",
                "description": "Sum of item kWh."
              },
              "gbp": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Sum of results with a `gbp`. Null when none has one. A lower bound unless `priced` is true."
              },
              "priced": {
                "type": "boolean",
                "description": "True only when every result is fully priced."
              },
              "fully_priced_items": {
                "type": "integer"
              },
              "items": {
                "type": "integer"
              }
            },
            "required": [
              "kwh",
              "gbp",
              "priced",
              "fully_priced_items",
              "items"
            ]
          }
        },
        "required": [
          "organisation_id",
          "mpan",
          "results",
          "totals"
        ]
      },
      "StackResponse": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "mpan": {
            "type": "string"
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "The instant priced, UTC."
          },
          "per_kwh_import": {
            "$ref": "#/components/schemas/PricedQuantity"
          },
          "per_kwh_export": {
            "$ref": "#/components/schemas/PricedQuantity"
          },
          "per_day": {
            "description": "Per day fixed and capacity charges. Two shapes: the full shape when a\nconfiguration is in force, and the short shape (`config` null, `lines`\nempty, `reason` set) when none is.\n",
            "oneOf": [
              {
                "type": "object",
                "title": "Configuration in force",
                "properties": {
                  "mpan": {
                    "type": "string"
                  },
                  "date": {
                    "type": "string",
                    "format": "date",
                    "description": "The UTC date of `at`."
                  },
                  "config": {
                    "$ref": "#/components/schemas/MpanConfig"
                  },
                  "implied_tcr_band": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Band implied by the agreed capacity and voltage level. Null without an agreed capacity."
                  },
                  "assigned_tcr_band": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Band on the configuration."
                  },
                  "band_used": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The assigned band when present, else the implied band."
                  },
                  "gbp_per_day": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Sum of priced per day lines, GBP. Null when none was priced."
                  },
                  "priced": {
                    "type": "boolean",
                    "description": "True only when every per day line was priced."
                  },
                  "lines": {
                    "type": "array",
                    "description": "DUoS fixed, DUoS capacity and TNUoS demand residual, each with its source.",
                    "items": {
                      "$ref": "#/components/schemas/PriceComponentLine"
                    }
                  },
                  "unpriced": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "component": {
                          "type": "string"
                        },
                        "label": {
                          "type": "string"
                        },
                        "reason": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      },
                      "required": [
                        "component",
                        "label",
                        "reason"
                      ]
                    }
                  },
                  "tariff_version": {
                    "type": "string"
                  }
                },
                "required": [
                  "mpan",
                  "date",
                  "config",
                  "implied_tcr_band",
                  "assigned_tcr_band",
                  "band_used",
                  "gbp_per_day",
                  "priced",
                  "lines",
                  "unpriced",
                  "tariff_version"
                ]
              },
              {
                "type": "object",
                "title": "No configuration in force",
                "properties": {
                  "mpan": {
                    "type": "string"
                  },
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "config": {
                    "type": "null"
                  },
                  "lines": {
                    "type": "array",
                    "maxItems": 0,
                    "items": {}
                  },
                  "reason": {
                    "type": "string"
                  }
                },
                "required": [
                  "mpan",
                  "date",
                  "config",
                  "lines",
                  "reason"
                ]
              }
            ]
          }
        },
        "required": [
          "organisation_id",
          "mpan",
          "at",
          "per_kwh_import",
          "per_kwh_export",
          "per_day"
        ]
      },
      "ExportColumn": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "string",
              "integer",
              "number",
              "boolean",
              "timestamp",
              "json"
            ],
            "description": "`timestamp` is RFC 3339 in UTC with a trailing `Z`. `json` is a nested value whose internal shape is set by the producing subsystem."
          },
          "meaning": {
            "type": "string"
          },
          "redacted": {
            "type": "boolean",
            "description": "Present and true only on a redacted column. Its value is exported as `__redacted__` when one is stored, null when none is."
          },
          "redaction_reason": {
            "type": "string",
            "description": "Present only when `redacted` is true."
          }
        },
        "required": [
          "name",
          "type",
          "meaning"
        ]
      },
      "ExportCollectionSpec": {
        "type": "object",
        "description": "One exported collection, a table or a documented derivation of one.",
        "properties": {
          "name": {
            "type": "string"
          },
          "source": {
            "type": "string",
            "description": "The table, or the derivation, the rows come from."
          },
          "description": {
            "type": "string"
          },
          "derived": {
            "type": "boolean",
            "description": "True when built by a documented transformation rather than selected directly."
          },
          "order_by": {
            "type": "string",
            "description": "The timestamp column used for the `as_of` cut and the sort key."
          },
          "columns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportColumn"
            }
          },
          "row_count": {
            "type": "integer",
            "description": "Rows this organisation holds at `as_of`. Present on the manifest and on the bundle's manifest line."
          }
        },
        "required": [
          "name",
          "source",
          "description",
          "derived",
          "order_by",
          "columns"
        ]
      },
      "ExportExclusion": {
        "type": "object",
        "description": "Something a reader might expect in the export and will not find.",
        "properties": {
          "name": {
            "type": "string"
          },
          "reason": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "reason"
        ]
      },
      "ExportUnexportedTable": {
        "type": "object",
        "properties": {
          "table": {
            "type": "string"
          },
          "reason": {
            "type": "string"
          }
        },
        "required": [
          "table",
          "reason"
        ]
      },
      "ExportRedaction": {
        "type": "object",
        "properties": {
          "collection": {
            "type": "string"
          },
          "column": {
            "type": "string"
          },
          "reason": {
            "type": "string"
          }
        },
        "required": [
          "collection",
          "column",
          "reason"
        ]
      },
      "ExportSettlementIntervalQuality": {
        "type": "object",
        "description": "Tally of the BSC quality flag across settlement intervals.",
        "properties": {
          "A": {
            "type": "integer",
            "description": "Intervals with a metered reading."
          },
          "E": {
            "type": "integer",
            "description": "Intervals settled under estimation policy AV-SETT-EST-1. Not measured."
          },
          "unknown": {
            "type": "integer",
            "description": "Intervals whose ledger predates the flag. Exported with `quality_flag` null. Null is not `A`."
          }
        },
        "required": [
          "A",
          "E",
          "unknown"
        ]
      },
      "ExportTelemetryQuality": {
        "type": "object",
        "description": "Tally of telemetry readings by effective quality state.",
        "properties": {
          "measured": {
            "type": "integer"
          },
          "interpolated": {
            "type": "integer"
          },
          "estimated": {
            "type": "integer"
          },
          "missing": {
            "type": "integer"
          },
          "unmarked": {
            "type": "integer",
            "description": "Rows that predate the quality flag and make no claim about themselves. Unmarked is not measured."
          }
        },
        "additionalProperties": {
          "type": "integer"
        }
      },
      "ExportManifest": {
        "type": "object",
        "description": "The export schema with this organisation's current row counts.",
        "properties": {
          "schema_version": {
            "type": "string",
            "description": "For example `ampverve-export-2`. Bumped whenever a collection or column changes."
          },
          "formats": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "`jsonl` and `csv`."
          },
          "redacted_placeholder": {
            "type": "string",
            "description": "The literal emitted for a redacted value that is present, `__redacted__`."
          },
          "documentation": {
            "type": "string",
            "description": "`docs/api/export-schema.md`."
          },
          "collections": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportCollectionSpec"
            }
          },
          "not_included": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportExclusion"
            }
          },
          "not_exported_tables": {
            "type": "array",
            "description": "Every tenant scoped table not covered, one by one, with its reason.",
            "items": {
              "$ref": "#/components/schemas/ExportUnexportedTable"
            }
          },
          "organisation_id": {
            "type": "string"
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "The instant the counts were taken, UTC."
          },
          "row_count_total": {
            "type": "integer"
          },
          "settlement_interval_quality": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ExportSettlementIntervalQuality"
              },
              {
                "type": "object",
                "properties": {
                  "telemetry_quality": {
                    "$ref": "#/components/schemas/ExportTelemetryQuality"
                  }
                },
                "required": [
                  "telemetry_quality"
                ]
              }
            ]
          },
          "redactions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportRedaction"
            }
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Statements a reader needs in order not to over read the export, including how many intervals and readings are estimated, unknown or unmarked."
          }
        },
        "required": [
          "schema_version",
          "formats",
          "redacted_placeholder",
          "documentation",
          "collections",
          "not_included",
          "not_exported_tables",
          "organisation_id",
          "as_of",
          "row_count_total",
          "settlement_interval_quality",
          "redactions",
          "notes"
        ]
      },
      "ExportJobSummary": {
        "type": "object",
        "properties": {
          "job_id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "requested_by": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `sub` of the token that created the job."
          },
          "requested_at": {
            "type": "string",
            "format": "date-time"
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Rows created at or before this instant are included. Contents are not frozen."
          },
          "schema_version": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "`ready` on creation; the job is executed inside the create request."
          },
          "row_count_total": {
            "type": "integer",
            "description": "Sum of `collection_counts`, taken when the job was created."
          },
          "collection_counts": {
            "type": "object",
            "description": "Row count per collection name, taken when the job was created.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "settlement_interval_quality": {
            "$ref": "#/components/schemas/ExportSettlementIntervalQuality"
          },
          "telemetry_quality": {
            "$ref": "#/components/schemas/ExportTelemetryQuality"
          }
        },
        "required": [
          "job_id",
          "organisation_id",
          "requested_by",
          "requested_at",
          "as_of",
          "schema_version",
          "status",
          "row_count_total",
          "collection_counts",
          "settlement_interval_quality",
          "telemetry_quality"
        ]
      },
      "ExportDownloads": {
        "type": "object",
        "description": "Service relative paths. `collection_jsonl` and `collection_csv` contain the\nliteral placeholder `{collection}` for the caller to substitute. Prefix\nwith `/api` when calling through the public gateway.\n",
        "properties": {
          "bundle_jsonl": {
            "type": "string",
            "description": "`/organisations/export/jobs/{job_id}/download`."
          },
          "collection_jsonl": {
            "type": "string",
            "description": "`/organisations/export/jobs/{job_id}/collections/{collection}?format=jsonl`."
          },
          "collection_csv": {
            "type": "string",
            "description": "`/organisations/export/jobs/{job_id}/collections/{collection}?format=csv`."
          }
        },
        "required": [
          "bundle_jsonl",
          "collection_jsonl",
          "collection_csv"
        ]
      },
      "ExportJob": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ExportJobSummary"
          },
          {
            "type": "object",
            "properties": {
              "formats": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "documentation": {
                "type": "string",
                "description": "`docs/api/export-schema.md`."
              },
              "downloads": {
                "$ref": "#/components/schemas/ExportDownloads"
              },
              "redactions": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ExportRedaction"
                }
              },
              "not_included": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ExportExclusion"
                }
              },
              "not_exported_tables": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ExportUnexportedTable"
                }
              },
              "notes": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            },
            "required": [
              "formats",
              "documentation",
              "downloads",
              "redactions",
              "not_included",
              "not_exported_tables",
              "notes"
            ]
          }
        ]
      },
      "ExportJobCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ExportJob"
          },
          {
            "type": "object",
            "properties": {
              "collections": {
                "type": "array",
                "description": "Collection names in the order the bundle emits them.",
                "items": {
                  "type": "string"
                }
              }
            },
            "required": [
              "collections"
            ]
          }
        ]
      },
      "ExportBundleManifestLine": {
        "description": "The first line of the bundle stream. The manifest as `ExportManifest`\ncarries it minus the organisation level fields (`organisation_id`,\n`as_of`, `row_count_total`, `settlement_interval_quality`, `redactions`,\n`notes`), plus the job summary and the `_collection` tag. Each\ncollection entry carries the `row_count` recorded on the job.\n",
        "type": "object",
        "properties": {
          "_collection": {
            "type": "string",
            "const": "_export_manifest"
          },
          "schema_version": {
            "type": "string"
          },
          "formats": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "redacted_placeholder": {
            "type": "string"
          },
          "documentation": {
            "type": "string"
          },
          "collections": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportCollectionSpec"
            }
          },
          "not_included": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportExclusion"
            }
          },
          "not_exported_tables": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportUnexportedTable"
            }
          },
          "job": {
            "$ref": "#/components/schemas/ExportJobSummary"
          }
        },
        "required": [
          "_collection",
          "schema_version",
          "formats",
          "redacted_placeholder",
          "documentation",
          "collections",
          "not_included",
          "not_exported_tables",
          "job"
        ]
      },
      "EmsControlDeviceModelState": {
        "type": "object",
        "description": "One synthetic device model row. The figures are the model's, not measurements\nfrom hardware. Battery positive is charging, meter positive is import.\n",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "asset_class": {
            "type": "string",
            "enum": [
              "battery",
              "inverter",
              "charger",
              "meter"
            ]
          },
          "online": {
            "type": "boolean",
            "description": "False makes commands fail and read-back unobtainable."
          },
          "accept_but_ignore": {
            "type": "boolean",
            "description": "True makes the device acknowledge every command and apply none."
          },
          "setpoint_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Last setpoint applied, in kW."
          },
          "measured_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "What the model reads back, in kW."
          },
          "state": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free-form model state passed through as stored. Keys the loops read include\n`rating_kw`, `capacity_kwh`, `soc`, `load_kw`, `pv_available_kw`,\n`control_error_pct`, `resolution_kw`, `comms_failure_mode`,\n`stored_safe_setpoint_kw`, `fail_safe`, `fail_safe_applied_at`,\n`last_command`, `last_command_at` and `edge_runtime`.\n"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "EmsControlSitesResponse": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "sites": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "devices": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      },
                      "name": {
                        "type": "string"
                      },
                      "asset_type": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "description": "From the device row's `details.asset_type`."
                      },
                      "synthetic": {
                        "type": "boolean"
                      },
                      "provider": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "model_state": {
                        "oneOf": [
                          {
                            "$ref": "#/components/schemas/EmsControlDeviceModelState"
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "commands": {
                        "type": "array",
                        "description": "Commands the device's asset class accepts. Empty when there is no model state or the class takes none.",
                        "items": {
                          "type": "string",
                          "enum": [
                            "set_power_kw",
                            "set_export_limit_kw",
                            "set_max_kw"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "EmsControlSetpointRequest": {
        "type": "object",
        "required": [
          "device_id",
          "command",
          "target_kw"
        ],
        "properties": {
          "device_id": {
            "type": "string"
          },
          "command": {
            "type": "string",
            "description": "One of the device class's commands, e.g. set_power_kw"
          },
          "target_kw": {
            "type": "number",
            "description": "Target in kW. Battery positive is charging."
          },
          "tolerance_kw": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "description": "Read-back tolerance in kW. Defaults to the larger of 0.25 kW and two per cent of the target."
          },
          "settle_seconds": {
            "type": "number",
            "minimum": 0,
            "maximum": 10,
            "default": 0.0,
            "description": "Pause between the acknowledgement and the read-back, in seconds."
          }
        }
      },
      "EmsControlSetpointCommand": {
        "type": "object",
        "description": "One setpoint command with its three facts recorded separately. `sent_at`,\n`accepted_at` and `readback_at` are never inferred from one another. When the\nrecord comes straight from a write (this call's response, an export limit action,\nan import limit dispatch) `organisation_id` and `created_at` are absent; the\nstored row returned by the history call carries them.\n",
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null only on a realm-guard rejection that could not be recorded."
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "adapter": {
            "type": "string",
            "enum": [
              "synthetic"
            ]
          },
          "command": {
            "type": "string"
          },
          "target_kw": {
            "type": "number"
          },
          "tolerance_kw": {
            "type": "number"
          },
          "source": {
            "type": "string",
            "enum": [
              "manual",
              "export_limit",
              "import_limit"
            ]
          },
          "sent_at": {
            "type": "string",
            "format": "date-time"
          },
          "accepted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the device acknowledged. Absent or null if it did not."
          },
          "accept_detail": {
            "type": [
              "string",
              "null"
            ]
          },
          "readback_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the read-back was attempted. Absent when the command never reached acceptance."
          },
          "readback_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "The value read back, in kW. Null when no read-back was obtained."
          },
          "status": {
            "type": "string",
            "enum": [
              "sent",
              "accepted",
              "verified",
              "readback_mismatch",
              "unconfirmed",
              "rejected"
            ]
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "The verification sentence, or why the write was unconfirmed or rejected."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "EmsControlFailSafeStates": {
        "type": "object",
        "description": "Declared fail-safe state per device class, keyed by `inverter`, `battery` or\n`charger`. The watchdog reads `value_kw` from each entry when simulating the local\nbehaviour; the other keys are carried through. Accepted as any object on write.\n",
        "additionalProperties": {
          "$ref": "#/components/schemas/CommsFailSafeDeclaration"
        }
      },
      "EmsControlSiteConfigRequest": {
        "type": "object",
        "description": "Every field is optional. A null or omitted field is left unchanged.",
        "properties": {
          "import_limit_kw": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "description": "Import threshold in kW."
          },
          "export_limit_kw": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "description": "Export limit at the boundary in kW."
          },
          "export_comms_failure_mode": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^(safe_setpoint|hold_last)$",
            "description": "What the inverter holds when the cloud cannot reach it. `safe_setpoint` holds a stored safe setpoint; `hold_last` holds the last setpoint received."
          },
          "export_safe_setpoint_kw": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "description": "The stored safe setpoint in kW, provisioned to the inverter."
          },
          "fail_safe_states": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/EmsControlFailSafeStates"
              },
              {
                "type": "null"
              }
            ]
          },
          "heartbeat_timeout_s": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 5,
            "maximum": 3600,
            "description": "Seconds without a gateway heartbeat before the link is declared lost."
          },
          "import_mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "export_mpan": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "EmsControlSiteConfig": {
        "type": "object",
        "description": "The site control configuration row. On first creation through the PUT, keys\nthat were not sent are absent rather than null.\n",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "import_limit_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "kW."
          },
          "export_limit_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "kW."
          },
          "export_comms_failure_mode": {
            "type": "string",
            "enum": [
              "safe_setpoint",
              "hold_last"
            ]
          },
          "export_safe_setpoint_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "kW."
          },
          "fail_safe_states": {
            "$ref": "#/components/schemas/EmsControlFailSafeStates"
          },
          "heartbeat_timeout_s": {
            "type": "integer",
            "description": "Seconds."
          },
          "import_mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "export_mpan": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Who wrote the row, for example `console` or the seed marker."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "EmsControlSiteBalance": {
        "type": "object",
        "description": "The boundary meter reading derived from the device model. All power in kW.\nMeter positive is import; `export_kw` is `max(0, -grid_kw)`.\n",
        "properties": {
          "load_kw": {
            "type": "number",
            "description": "Building load from the meter model."
          },
          "pv_available_kw": {
            "type": "number",
            "description": "PV the sun allows before any inverter cap."
          },
          "pv_kw": {
            "type": "number",
            "description": "PV actually produced after the inverter cap and control error."
          },
          "battery_kw": {
            "type": "number",
            "description": "Sum of battery measured power, positive charging."
          },
          "charger_kw": {
            "type": "number",
            "description": "Sum of charger measured power."
          },
          "grid_kw_true": {
            "type": "number",
            "description": "The model's true boundary flow before measurement resolution."
          },
          "grid_kw": {
            "type": "number",
            "description": "The boundary flow as the simulated meter reads it, quantised to `resolution_kw`."
          },
          "export_kw": {
            "type": "number"
          },
          "import_kw": {
            "type": "number"
          },
          "resolution_kw": {
            "type": "number",
            "description": "Measurement resolution of the simulated meter."
          },
          "meter_online": {
            "type": "boolean",
            "description": "False when there is no meter model or it is offline."
          }
        }
      },
      "ExportLimitAction": {
        "type": "object",
        "description": "One curtailment step. Either a setpoint command record with `actuator` added, or\n(when an inverter was unreachable) an `actuator: inverter` entry carrying\n`local_fail_safe`, the simulated local behaviour of the offline inverter. That\nentry is labelled as the device's own behaviour, not a command from the cloud.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/EmsControlSetpointCommand"
          },
          {
            "type": "object",
            "properties": {
              "actuator": {
                "type": "string",
                "enum": [
                  "battery",
                  "inverter"
                ]
              },
              "local_fail_safe": {
                "type": "object",
                "properties": {
                  "device_id": {
                    "type": "string"
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "safe_setpoint",
                      "hold_last"
                    ]
                  },
                  "local_behaviour_simulated": {
                    "type": "boolean",
                    "enum": [
                      true
                    ]
                  },
                  "setpoint_kw": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "measured_kw": {
                    "type": [
                      "number",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        ]
      },
      "ExportLimitEvent": {
        "type": "object",
        "description": "One recorded export limitation event. Power in kW, `response_ms` in\nmilliseconds. Null figures mean the quantity could not be measured on that\nevent (for example `export_kw_after` when the meter was offline at the re-read).\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "detected_at": {
            "type": "string",
            "format": "date-time"
          },
          "export_kw_detected": {
            "type": [
              "number",
              "null"
            ],
            "description": "Export measured at detection. Null when the meter was unreachable."
          },
          "limit_kw": {
            "type": "number"
          },
          "actions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportLimitAction"
            }
          },
          "within_limit_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the re-read showed export within the limit. Null if it did not."
          },
          "response_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`within_limit_at` minus `detected_at`."
          },
          "export_kw_after": {
            "type": [
              "number",
              "null"
            ]
          },
          "measurement_error_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Meter reading against the model's true boundary flow."
          },
          "control_error_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Largest absolute difference between commanded and read-back across the actions."
          },
          "residual_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Limit minus export afterwards."
          },
          "comms_failure": {
            "type": "boolean"
          },
          "comms_failure_mode": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "safe_setpoint",
              "hold_last",
              null
            ],
            "description": "Set only when `comms_failure` is true."
          },
          "outcome": {
            "type": "string",
            "enum": [
              "within_limit",
              "not_within_limit",
              "meter_unreachable",
              "comms_failure_safe_setpoint",
              "comms_failure_hold_last"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ExportLimitRunResult": {
        "type": "object",
        "description": "The result of one loop iteration. The `no_meter` branch carries only `site_id`,\n`action`, `outcome`, `reason` and `scheme`. The no-action `within_limit` branch\ncarries `export_kw` rather than `export_kw_detected` and no `actions`. The\n`meter_unreachable` and `curtail` branches carry the full event record.\n",
        "properties": {
          "id": {
            "type": "string",
            "description": "The recorded event id. Absent on the `no_meter` branch."
          },
          "site_id": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "none",
              "curtail"
            ]
          },
          "outcome": {
            "type": "string",
            "enum": [
              "no_meter",
              "meter_unreachable",
              "within_limit",
              "not_within_limit",
              "comms_failure_safe_setpoint",
              "comms_failure_hold_last"
            ]
          },
          "reason": {
            "type": "string",
            "description": "Present on the `no_meter` branch only."
          },
          "scheme": {
            "type": "string",
            "description": "The fixed scheme statement, see `ExportLimitSummary.scheme`."
          },
          "detected_at": {
            "type": "string",
            "format": "date-time"
          },
          "export_kw": {
            "type": "number",
            "description": "Present on the no-action `within_limit` branch only."
          },
          "export_kw_detected": {
            "type": [
              "number",
              "null"
            ]
          },
          "limit_kw": {
            "type": "number"
          },
          "actions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportLimitAction"
            }
          },
          "within_limit_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "response_ms": {
            "type": [
              "integer",
              "null"
            ]
          },
          "export_kw_after": {
            "type": [
              "number",
              "null"
            ]
          },
          "measurement_error_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "control_error_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "residual_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "comms_failure": {
            "type": "boolean"
          },
          "comms_failure_mode": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "safe_setpoint",
              "hold_last",
              null
            ]
          },
          "notes": {
            "type": "string"
          }
        }
      },
      "ExportLimitSummary": {
        "type": "object",
        "description": "Timing and accuracy over the events returned. Null when no event carried the figure.",
        "properties": {
          "events": {
            "type": "integer"
          },
          "interventions": {
            "type": "integer",
            "description": "Events that carried at least one action."
          },
          "within_limit": {
            "type": "integer",
            "description": "Interventions whose outcome was `within_limit`."
          },
          "comms_failures": {
            "type": "integer"
          },
          "response_ms_median": {
            "type": [
              "integer",
              "null"
            ]
          },
          "response_ms_max": {
            "type": [
              "integer",
              "null"
            ]
          },
          "measurement_error_kw_max": {
            "type": [
              "number",
              "null"
            ]
          },
          "control_error_kw_max": {
            "type": [
              "number",
              "null"
            ]
          },
          "scheme": {
            "type": "string",
            "description": "Fixed statement: the loop runs in the cloud on the site's device model, is\nnot type tested and is not G100 certified, and accuracy figures are those\nmeasured on this site.\n"
          }
        }
      },
      "CommsFailSafeDeclaration": {
        "type": "object",
        "description": "The fail-safe a device class holds when the gateway link is lost. The seed\nwrites `command`, `value_kw` and `meaning`; the watchdog reads only `value_kw`.\n",
        "additionalProperties": true,
        "properties": {
          "command": {
            "type": "string",
            "description": "The device command the value corresponds to, for example `set_export_limit_kw`."
          },
          "value_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "The setpoint the device is expected to hold, in kW."
          },
          "meaning": {
            "type": "string"
          }
        }
      },
      "CommsHeartbeat": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string"
          },
          "last_seen_at": {
            "type": "string",
            "format": "date-time"
          },
          "seq": {
            "type": "integer",
            "description": "The gateway's counter as last reported, 0 if never given."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CommsCheckResult": {
        "type": "object",
        "description": "The watchdog's evaluation of the site's link at `checked_at`.",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string",
            "description": "From the heartbeat row, or `gateway-1` when none exists."
          },
          "link": {
            "type": "string",
            "enum": [
              "up",
              "lost"
            ]
          },
          "last_heartbeat_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "silent_for_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Seconds since the last heartbeat. Null when none was ever recorded."
          },
          "timeout_s": {
            "type": "integer"
          },
          "declared_fail_safe": {
            "$ref": "#/components/schemas/EmsControlFailSafeStates"
          },
          "open_loss_event_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The loss event still open after this check, if any."
          },
          "transition": {
            "description": "The event this check recorded, or null if nothing changed.",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "properties": {
                  "kind": {
                    "type": "string",
                    "enum": [
                      "loss",
                      "restored"
                    ]
                  },
                  "id": {
                    "type": "string"
                  }
                }
              }
            ]
          },
          "checked_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CommsLinkStatus": {
        "type": "object",
        "description": "Read-only link view. Records nothing.",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "configured": {
            "type": "boolean",
            "description": "Whether a site control configuration row exists."
          },
          "gateway_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "link": {
            "type": "string",
            "enum": [
              "unknown",
              "up",
              "lost"
            ],
            "description": "`unknown` when no heartbeat was ever recorded."
          },
          "last_heartbeat_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "silent_for_s": {
            "type": [
              "number",
              "null"
            ]
          },
          "timeout_s": {
            "type": "integer"
          },
          "declared_fail_safe": {
            "$ref": "#/components/schemas/EmsControlFailSafeStates"
          },
          "last_exercised_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Detection time of the most recent loss event, rehearsal or real. Null if never."
          },
          "last_exercise_restored_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "open_loss": {
            "type": "boolean",
            "description": "True when the most recent loss has not been restored."
          }
        }
      },
      "CommsEvent": {
        "type": "object",
        "description": "One communications event. `fail_safe_applied` items differ by kind: on a\n`loss` in the demo realm each carries `device_id`, `asset_class`, `declared`,\n`expected_setpoint_kw`, `local_behaviour_simulated: true` and `note`; on a\n`restored` each carries `device_id` and `reachable_again: true`; for a production\norganisation, and on rehearsal markers, the list is empty.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "loss",
              "restored",
              "rehearsal_start",
              "rehearsal_end"
            ]
          },
          "detected_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_heartbeat_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "timeout_s": {
            "type": [
              "integer",
              "null"
            ]
          },
          "silent_for_s": {
            "type": [
              "number",
              "null"
            ]
          },
          "fail_safe_declared": {
            "$ref": "#/components/schemas/EmsControlFailSafeStates"
          },
          "fail_safe_applied": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true,
              "properties": {
                "device_id": {
                  "type": "string"
                },
                "asset_class": {
                  "type": "string"
                },
                "declared": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CommsFailSafeDeclaration"
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "expected_setpoint_kw": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "local_behaviour_simulated": {
                  "type": "boolean"
                },
                "reachable_again": {
                  "type": "boolean"
                },
                "note": {
                  "type": "string"
                }
              }
            }
          },
          "restored_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "On a `loss`, when it was closed."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BufferedReading": {
        "type": "object",
        "required": [
          "device_id",
          "seq",
          "ts"
        ],
        "properties": {
          "device_id": {
            "type": "string"
          },
          "seq": {
            "type": "integer",
            "minimum": 0,
            "description": "Per-device sequence number assigned by the gateway. Only ever goes up."
          },
          "ts": {
            "type": "string",
            "format": "date-time",
            "description": "When the reading was taken."
          },
          "power_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "soc_pct": {
            "type": [
              "number",
              "null"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ]
          },
          "quality": {
            "type": [
              "string",
              "null"
            ],
            "description": "measured, estimated or interpolated; default measured"
          },
          "raw": {
            "type": "object",
            "additionalProperties": true,
            "description": "Vendor payload passed through to telemetry as stored."
          },
          "buffered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the gateway put the reading in its buffer."
          }
        }
      },
      "BufferReplayRequest": {
        "type": "object",
        "required": [
          "readings"
        ],
        "properties": {
          "gateway_id": {
            "type": "string",
            "default": "gateway-1"
          },
          "link_lost_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the gateway saw its link drop, by its own clock"
          },
          "readings": {
            "type": "array",
            "minItems": 1,
            "maxItems": 5000,
            "items": {
              "$ref": "#/components/schemas/BufferedReading"
            }
          }
        }
      },
      "BufferDeviceReplay": {
        "type": "object",
        "description": "Per-device outcome of one replay. Gap ranges are `[from_seq, to_seq]` pairs, inclusive.",
        "properties": {
          "device_id": {
            "type": "string"
          },
          "received": {
            "type": "integer"
          },
          "stored": {
            "type": "integer",
            "description": "Written to telemetry."
          },
          "duplicates": {
            "type": "integer",
            "description": "Same (device, seq) already held or repeated in the batch."
          },
          "already_stored": {
            "type": "integer",
            "description": "Same natural key already in telemetry; logged but not written again."
          },
          "out_of_order": {
            "type": "integer",
            "description": "Descending steps in the arrival order."
          },
          "lowest_seq": {
            "type": [
              "integer",
              "null"
            ]
          },
          "highest_seq": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Highest sequence in this batch."
          },
          "last_contiguous_seq": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Highest sequence such that every number up to it has been received."
          },
          "new_gaps": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "minItems": 2,
              "maxItems": 2
            }
          },
          "closed_gaps": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "minItems": 2,
              "maxItems": 2
            }
          },
          "open_gaps": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "minItems": 2,
              "maxItems": 2
            }
          }
        }
      },
      "BufferReplayResult": {
        "type": "object",
        "properties": {
          "replay_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string"
          },
          "received": {
            "type": "integer"
          },
          "stored": {
            "type": "integer"
          },
          "duplicates": {
            "type": "integer"
          },
          "already_stored": {
            "type": "integer"
          },
          "gap_events": {
            "type": "integer",
            "description": "Gap events recorded by this replay."
          },
          "gaps_closed": {
            "type": "integer"
          },
          "devices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BufferDeviceReplay"
            }
          },
          "comms_event_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The restored event that closed an open loss, or the loss recorded from the gateway's account. Null when neither applied."
          },
          "link_note": {
            "type": "string",
            "description": "Which link accounting case applied."
          },
          "replayed_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BufferCursor": {
        "type": "object",
        "description": "One device's replay cursor.",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string"
          },
          "sequence_origin": {
            "type": "integer",
            "description": "One below the first sequence ever received for the device."
          },
          "last_contiguous_seq": {
            "type": "integer"
          },
          "highest_seq": {
            "type": "integer"
          },
          "received_total": {
            "type": "integer"
          },
          "stored_total": {
            "type": "integer"
          },
          "duplicate_total": {
            "type": "integer"
          },
          "already_stored_total": {
            "type": "integer"
          },
          "open_gaps": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "minItems": 2,
              "maxItems": 2
            }
          },
          "last_replay_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "last_replay_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BufferEvent": {
        "type": "object",
        "description": "A `replay` event summarises one batch (`device_id` null, `detail.devices` the\nper-device results). A `gap` event names a missing range for one device\n(`detail.missing`, `detail.last_contiguous_seq`) and a `gap_closed` event records\nthat it arrived.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string"
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "replay",
              "gap",
              "gap_closed"
            ]
          },
          "detected_at": {
            "type": "string",
            "format": "date-time"
          },
          "replay_id": {
            "type": "string"
          },
          "from_seq": {
            "type": [
              "integer",
              "null"
            ]
          },
          "to_seq": {
            "type": [
              "integer",
              "null"
            ]
          },
          "received": {
            "type": "integer"
          },
          "stored": {
            "type": "integer"
          },
          "duplicates": {
            "type": "integer"
          },
          "already_stored": {
            "type": "integer"
          },
          "gaps": {
            "type": "integer"
          },
          "comms_event_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "detail": {
            "type": "object",
            "additionalProperties": true
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BufferStatus": {
        "type": "object",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "totals": {
            "type": "object",
            "properties": {
              "buffered": {
                "type": "integer",
                "description": "Readings received over every replay, from the cursors."
              },
              "replayed": {
                "type": "integer",
                "description": "Readings written to telemetry, from the cursors."
              },
              "duplicates": {
                "type": "integer"
              },
              "already_stored": {
                "type": "integer"
              },
              "gap_events": {
                "type": "integer",
                "description": "Counted over the events returned."
              },
              "open_gaps": {
                "type": "integer",
                "description": "Open gap ranges across all cursors."
              },
              "replays": {
                "type": "integer",
                "description": "Counted over the events returned."
              }
            }
          },
          "cursors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BufferCursor"
            }
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BufferEvent"
            }
          },
          "edge_runtime": {
            "type": "object",
            "properties": {
              "devices_with_edge_runtime": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "device_id": {
                      "type": "string"
                    },
                    "runtime": {
                      "description": "The value of `state.edge_runtime` on the device model row, passed through."
                    }
                  }
                }
              },
              "devices_without_edge_runtime": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "local_optimisation_available": {
                "type": "boolean"
              },
              "statement": {
                "type": "string"
              }
            }
          }
        }
      },
      "BufferRow": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "seq": {
            "type": "integer"
          },
          "site_id": {
            "type": "string"
          },
          "gateway_id": {
            "type": "string"
          },
          "ts": {
            "type": "string",
            "format": "date-time"
          },
          "power_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "soc_pct": {
            "type": [
              "number",
              "null"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ]
          },
          "quality": {
            "type": "string",
            "enum": [
              "measured",
              "estimated",
              "interpolated"
            ]
          },
          "raw": {
            "type": "object",
            "additionalProperties": true,
            "description": "The reading's `raw` with `quality`, `source`, `gateway_id`, `buffer_seq`, `replay_id` and (if given) `buffered_at` added."
          },
          "buffered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "received_at": {
            "type": "string",
            "format": "date-time"
          },
          "replay_id": {
            "type": "string"
          },
          "outcome": {
            "type": "string",
            "enum": [
              "stored",
              "already_stored"
            ]
          },
          "reading_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Lineage id, sha256 of `tenant|asset|UTC timestamp` truncated to 32 hex characters."
          }
        }
      },
      "ImportLimitSchedulePeriod": {
        "type": "object",
        "description": "One half hour of the plan. Power in kW, energy in kWh.",
        "properties": {
          "ts": {
            "type": "string",
            "format": "date-time"
          },
          "load_kw": {
            "type": "number",
            "description": "Stored building load."
          },
          "battery_kw": {
            "type": "number",
            "description": "Planned battery power, positive charging."
          },
          "import_kw": {
            "type": "number",
            "description": "Load plus battery."
          },
          "above_threshold_kwh": {
            "type": "number",
            "description": "Energy above the threshold after the plan."
          },
          "avoided_kwh": {
            "type": "number",
            "description": "Energy above the threshold before the plan minus after."
          }
        }
      },
      "ImportLimitDispatch": {
        "type": "object",
        "description": "The live iteration on the device model. `{attempted: false}` when `dispatch` was\nfalse or the site has no battery model. When attempted, either `reason` explains\nwhy nothing was commanded (import already at or below the threshold, battery\ndevice row not found, boundary meter unreachable) or the command and the re-read\nare reported. Empty object on a `no_stored_series` run.\n",
        "properties": {
          "attempted": {
            "type": "boolean"
          },
          "import_kw_measured": {
            "type": "number"
          },
          "threshold_kw": {
            "type": "number"
          },
          "reason": {
            "type": "string"
          },
          "excess_kw": {
            "type": "number",
            "description": "Import above the threshold plus the margin."
          },
          "battery_target_kw": {
            "type": "number",
            "description": "Commanded battery power, negative discharging."
          },
          "command": {
            "$ref": "#/components/schemas/EmsControlSetpointCommand"
          },
          "import_kw_after": {
            "type": "number"
          },
          "within_limit_after": {
            "type": "boolean"
          }
        }
      },
      "ImportLimitRun": {
        "type": "object",
        "description": "A stored run record. On `not_configured` and `no_stored_series` runs the\nseries and result figures are absent or null and `schedule` is empty.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "run_at": {
            "type": "string",
            "format": "date-time"
          },
          "threshold_kw": {
            "type": "number",
            "description": "kW. 0 on a `not_configured` run."
          },
          "load_basis": {
            "type": "string",
            "enum": [
              "measured",
              "none"
            ]
          },
          "series_periods": {
            "type": "integer"
          },
          "series_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "series_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "solver": {
            "type": "string",
            "enum": [
              "milp_cbc",
              "greedy_fallback",
              "none"
            ]
          },
          "peak_before_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "peak_after_kw": {
            "type": [
              "number",
              "null"
            ]
          },
          "above_threshold_kwh_before": {
            "type": [
              "number",
              "null"
            ]
          },
          "above_threshold_kwh_after": {
            "type": [
              "number",
              "null"
            ]
          },
          "avoided_kwh": {
            "type": [
              "number",
              "null"
            ]
          },
          "avoided_cost_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "GBP. Null when not priced."
          },
          "pricing_status": {
            "type": "string",
            "enum": [
              "priced",
              "not_priced"
            ]
          },
          "pricing_detail": {
            "type": [
              "string",
              "null"
            ],
            "description": "How it was priced, or the literal reason it was not."
          },
          "schedule": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ImportLimitSchedulePeriod"
            }
          },
          "dispatch": {
            "$ref": "#/components/schemas/ImportLimitDispatch"
          },
          "status": {
            "type": "string",
            "enum": [
              "not_configured",
              "no_stored_series",
              "completed"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ImportLimitRunResult": {
        "type": "object",
        "description": "The run call's response: the run record as written plus `id`, and either\n`reason` (on `no_stored_series`) or `battery` (on `completed`). `organisation_id`\nand `created_at` are not included.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/ImportLimitRun"
          },
          {
            "type": "object",
            "properties": {
              "reason": {
                "type": "string",
                "description": "Present on `no_stored_series` only."
              },
              "battery": {
                "type": "object",
                "description": "Present on `completed` only. Figures from the device model.",
                "properties": {
                  "device_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Null when the site has no battery model."
                  },
                  "rating_kw": {
                    "type": "number"
                  },
                  "capacity_kwh": {
                    "type": "number"
                  }
                }
              }
            }
          }
        ]
      },
      "IntegrityAssetSummary": {
        "type": "object",
        "description": "One registered device as the data integrity surface describes it.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "site_id": {
            "type": "string",
            "description": "The device's workspace id."
          },
          "site_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "asset_type": {
            "type": "string",
            "description": "The declared `asset_type` in the device details, else the lower cased device type enum value."
          },
          "provider": {
            "type": [
              "string",
              "null"
            ]
          },
          "live_enabled": {
            "type": "boolean"
          },
          "synthetic": {
            "type": "boolean",
            "description": "True for seeded or fixture created devices."
          },
          "pv_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "PV nameplate, kWp."
          },
          "battery_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Battery power rating, kW."
          },
          "battery_kwh": {
            "type": [
              "number",
              "null"
            ],
            "description": "Battery capacity, kWh."
          },
          "site_lat": {
            "type": [
              "number",
              "null"
            ]
          },
          "site_lon": {
            "type": [
              "number",
              "null"
            ]
          }
        },
        "required": [
          "id",
          "name",
          "site_id",
          "site_name",
          "asset_type",
          "provider",
          "live_enabled",
          "synthetic",
          "pv_kwp",
          "battery_kw",
          "battery_kwh",
          "site_lat",
          "site_lon"
        ]
      },
      "IntegrityAssetsResponse": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "sites": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "site_id": {
                  "type": "string"
                },
                "site_name": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "assets": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/IntegrityAssetSummary"
                  }
                }
              },
              "required": [
                "site_id",
                "site_name",
                "assets"
              ]
            }
          },
          "asset_count": {
            "type": "integer"
          },
          "quality_taxonomy": {
            "type": "array",
            "description": "The reading quality words the backend writes, in rank order.",
            "items": {
              "type": "string",
              "enum": [
                "measured",
                "interpolated",
                "estimated",
                "missing"
              ]
            }
          }
        },
        "required": [
          "organisation_id",
          "sites",
          "asset_count",
          "quality_taxonomy"
        ]
      },
      "IntegrityCheck": {
        "type": "object",
        "description": "One entry of the check catalogue.",
        "properties": {
          "id": {
            "type": "string",
            "enum": [
              "ct_polarity",
              "ct_ratio",
              "pv_at_night",
              "power_factor",
              "sentinel_values",
              "timestamp_dst"
            ]
          },
          "title": {
            "type": "string"
          },
          "what": {
            "type": "string",
            "description": "What the check looks for, in one sentence."
          }
        },
        "required": [
          "id",
          "title",
          "what"
        ]
      },
      "IntegritySiteContext": {
        "type": "object",
        "description": "Nameplate and coordinates for the site, read from its device rows. Coordinates\ncome from the first device recording `site_lat` and `site_lon`; `pv_kwp` is the\nsum over the site's inverters; anything not recorded is null and the checks that\nneed it report `skipped`.\n",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "site_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ]
          },
          "pv_kwp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Summed PV nameplate, kWp."
          },
          "grid_supply_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Grid supply capacity, kW."
          }
        },
        "required": [
          "site_id",
          "site_name",
          "latitude",
          "longitude",
          "pv_kwp",
          "grid_supply_kw"
        ]
      },
      "IntegrityFinding": {
        "type": "object",
        "description": "One check result for one asset (or for the site when `asset_id` is null).",
        "properties": {
          "check_id": {
            "type": "string",
            "enum": [
              "ct_polarity",
              "ct_ratio",
              "pv_at_night",
              "power_factor",
              "sentinel_values",
              "timestamp_dst"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "fail",
              "pass",
              "skipped"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning",
              "info"
            ],
            "description": "Per check severity on a fail (`ct_polarity`, `ct_ratio` and `sentinel_values` are critical, the rest warning); `info` on pass or skipped."
          },
          "summary": {
            "type": "string",
            "description": "Human readable result. On skipped, the reason the check could not run."
          },
          "asset_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "evidence": {
            "type": "object",
            "additionalProperties": true,
            "description": "Check specific evidence (timestamps, values, implied multiples). Shape varies by check."
          },
          "sample_count": {
            "type": "integer"
          }
        },
        "required": [
          "check_id",
          "status",
          "severity",
          "summary",
          "asset_id",
          "evidence",
          "sample_count"
        ]
      },
      "IntegrityStoredFinding": {
        "type": "object",
        "description": "A finding read back from the results table. Same fields as `IntegrityFinding` plus the row id.",
        "properties": {
          "id": {
            "type": "string"
          },
          "asset_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "check_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "fail",
              "pass",
              "skipped"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning",
              "info"
            ]
          },
          "summary": {
            "type": "string"
          },
          "evidence": {
            "type": "object",
            "additionalProperties": true
          },
          "sample_count": {
            "type": "integer"
          }
        },
        "required": [
          "id",
          "asset_id",
          "check_id",
          "status",
          "severity",
          "summary",
          "evidence",
          "sample_count"
        ]
      },
      "IntegrityRunSummary": {
        "type": "object",
        "description": "Count of findings by status.",
        "properties": {
          "fail": {
            "type": "integer"
          },
          "pass": {
            "type": "integer"
          },
          "skipped": {
            "type": "integer"
          }
        },
        "required": [
          "fail",
          "pass",
          "skipped"
        ]
      },
      "IntegrityLatestRun": {
        "type": "object",
        "description": "The most recent stored run for a site.",
        "properties": {
          "run_id": {
            "type": "string"
          },
          "run_kind": {
            "type": "string",
            "enum": [
              "manual",
              "onboarding",
              "scheduled"
            ]
          },
          "run_at": {
            "type": "string",
            "format": "date-time"
          },
          "window_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "window_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "summary": {
            "$ref": "#/components/schemas/IntegrityRunSummary"
          },
          "findings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/IntegrityStoredFinding"
            }
          }
        },
        "required": [
          "run_id",
          "run_kind",
          "run_at",
          "window_start",
          "window_end",
          "summary",
          "findings"
        ]
      },
      "IntegrityCommissioningResponse": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "allOf": [
              {
                "$ref": "#/components/schemas/IntegritySiteContext"
              },
              {
                "type": "object",
                "properties": {
                  "assets": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/IntegrityAssetSummary"
                    }
                  }
                },
                "required": [
                  "assets"
                ]
              }
            ]
          },
          "checks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/IntegrityCheck"
            }
          },
          "state": {
            "type": "string",
            "enum": [
              "ready",
              "never_run"
            ],
            "description": "`never_run` when no run is stored for the site or the results table is unreachable."
          },
          "latest": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/IntegrityLatestRun"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "organisation_id",
          "site",
          "checks",
          "state",
          "latest"
        ]
      },
      "IntegrityRunResponse": {
        "type": "object",
        "description": "The run just performed by `POST /api/hardware/data-integrity/commissioning/run`.",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "run_id": {
            "type": "string"
          },
          "run_kind": {
            "type": "string",
            "enum": [
              "manual",
              "onboarding"
            ]
          },
          "run_at": {
            "type": "string",
            "format": "date-time"
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "site": {
            "$ref": "#/components/schemas/IntegritySiteContext"
          },
          "readings": {
            "type": "object",
            "description": "Asset id to number of samples found in the window. Every asset of the site is a key.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "summary": {
            "$ref": "#/components/schemas/IntegrityRunSummary"
          },
          "findings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/IntegrityFinding"
            }
          },
          "stored": {
            "type": "boolean",
            "description": "False when the results table could not be written."
          }
        },
        "required": [
          "organisation_id",
          "run_id",
          "run_kind",
          "run_at",
          "window_start",
          "window_end",
          "site",
          "readings",
          "summary",
          "findings",
          "stored"
        ]
      },
      "OnboardingPoint": {
        "type": "object",
        "description": "A telemetry point a slot expects its device to supply.",
        "properties": {
          "point": {
            "type": "string"
          },
          "required": {
            "type": "boolean"
          },
          "unit": {
            "type": "string",
            "description": "Unit label, for example kW, kWh, %, C. Empty when unitless."
          }
        },
        "required": [
          "point",
          "required",
          "unit"
        ]
      },
      "OnboardingSlot": {
        "type": "object",
        "properties": {
          "slot": {
            "type": "string",
            "description": "Slot name, unique within the template."
          },
          "label": {
            "type": "string"
          },
          "device_class": {
            "type": "string",
            "enum": [
              "meter",
              "solar",
              "battery",
              "charger",
              "heatpump"
            ]
          },
          "required": {
            "type": "boolean"
          },
          "max_devices": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Null means any number of devices may fill the slot."
          },
          "points": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingPoint"
            }
          }
        },
        "required": [
          "slot",
          "label",
          "device_class",
          "required",
          "max_devices",
          "points"
        ]
      },
      "OnboardingTemplate": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "archetype": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "scope": {
            "type": "string",
            "enum": [
              "platform",
              "organisation"
            ],
            "description": "`platform` for the defaults shipped with the platform, `organisation` for templates your organisation added."
          },
          "slots": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingSlot"
            }
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "`platform-default` or `operator`."
          },
          "version": {
            "type": [
              "integer",
              "null"
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "name",
          "archetype",
          "description",
          "scope",
          "slots",
          "source",
          "version",
          "updated_at"
        ]
      },
      "OnboardingTemplateIn": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "archetype": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "slots": {
            "type": "array",
            "description": "Slot objects. Each needs `slot` and `device_class`; `label` defaults to the\nslot name, `required` to true, `max_devices` to 1 (null for unlimited).\n`points` entries may be a point name string or an object with `point`,\n`required` and `unit`.\n",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          }
        },
        "required": [
          "name",
          "archetype",
          "slots"
        ]
      },
      "OnboardingCredentialSetIn": {
        "type": "object",
        "description": "Credentials for one provider account to scan. Used for the scan only, never stored or echoed.",
        "properties": {
          "provider": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64
          },
          "device_class": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32,
            "description": "One of the five device classes; other spellings used in the codebase are normalised."
          },
          "credentials": {
            "type": "object",
            "additionalProperties": true,
            "description": "Provider specific. The adapter path reads `endpoint`, `serial` or `site_id` as the endpoint handle."
          },
          "label": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "provider",
          "device_class"
        ]
      },
      "OnboardingFromTemplateIn": {
        "type": "object",
        "properties": {
          "template_id": {
            "type": "string",
            "minLength": 1
          },
          "site_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "credential_sets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingCredentialSetIn"
            }
          }
        },
        "required": [
          "template_id",
          "site_name"
        ]
      },
      "OnboardingDecisionIn": {
        "type": "object",
        "properties": {
          "slot": {
            "type": "string",
            "description": "A slot name from the proposal, including indexed forms such as `ev_chargers[1]`."
          },
          "device_ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "Defaults to the device proposed for the slot."
          },
          "accept": {
            "type": "boolean",
            "default": true
          },
          "credentials": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "For a live discovered device, the credentials to store so the poller can read it."
          }
        },
        "required": [
          "slot"
        ]
      },
      "OnboardingConfirmIn": {
        "type": "object",
        "properties": {
          "run_id": {
            "type": "string"
          },
          "decisions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingDecisionIn"
            }
          }
        },
        "required": [
          "run_id",
          "decisions"
        ]
      },
      "OnboardingSite": {
        "type": "object",
        "description": "The workspace row created for the site.",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": [
              "string",
              "null"
            ],
            "description": "`site` for sites created here."
          }
        },
        "required": [
          "id",
          "organisation_id",
          "name",
          "type"
        ]
      },
      "OnboardingDiscoveredDevice": {
        "type": "object",
        "description": "One candidate device, normalised whatever its source.",
        "properties": {
          "ref": {
            "type": "string",
            "description": "Handle used by confirm-mapping. Unique within a run."
          },
          "source": {
            "type": "string",
            "enum": [
              "fixture",
              "live",
              "registered"
            ]
          },
          "external_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "device_class": {
            "type": "string",
            "enum": [
              "meter",
              "solar",
              "battery",
              "charger",
              "heatpump"
            ]
          },
          "provider": {
            "type": "string"
          },
          "adapter": {
            "type": [
              "string",
              "null"
            ]
          },
          "method": {
            "type": [
              "string",
              "null"
            ],
            "description": "Connection method, for example `cloud`, `fixture`."
          },
          "brand": {
            "type": [
              "string",
              "null"
            ]
          },
          "model": {
            "type": [
              "string",
              "null"
            ]
          },
          "capabilities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "points": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Telemetry points the device can supply."
          },
          "points_derived": {
            "type": "boolean",
            "description": "True when `points` was inferred from `capabilities` rather than reported by the device."
          },
          "registered_device_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "rating": {
            "type": "object",
            "additionalProperties": true,
            "description": "Nameplate from a fixture (`kwp`, `kwh`, `kw`). Empty for other sources."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "ref",
          "source",
          "external_id",
          "name",
          "device_class",
          "provider",
          "adapter",
          "method",
          "brand",
          "model",
          "capabilities",
          "points",
          "points_derived",
          "registered_device_id",
          "rating",
          "notes"
        ]
      },
      "OnboardingCredentialOutcome": {
        "type": "object",
        "description": "Sanitised result of scanning one credential set. Never carries the credentials.",
        "properties": {
          "provider": {
            "type": "string"
          },
          "device_class": {
            "type": "string"
          },
          "label": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "verified",
              "verified_no_enumeration",
              "failed",
              "not_scannable",
              "unknown_provider",
              "error",
              "ignored"
            ]
          },
          "detail": {
            "type": "string"
          },
          "method": {
            "type": "string",
            "description": "Present once the adapter resolved."
          },
          "adapter": {
            "type": "string",
            "description": "Present once the adapter resolved."
          }
        },
        "required": [
          "provider",
          "device_class",
          "label",
          "status"
        ]
      },
      "OnboardingDiscovery": {
        "type": "object",
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "fixture",
              "live",
              "registered",
              "none"
            ]
          },
          "fixture_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set in `fixture` mode only."
          },
          "notice": {
            "type": "string",
            "description": "One or two sentences saying what discovery did and did not do."
          },
          "devices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingDiscoveredDevice"
            }
          },
          "credential_outcomes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingCredentialOutcome"
            }
          },
          "device_count": {
            "type": "integer"
          }
        },
        "required": [
          "mode",
          "fixture_name",
          "notice",
          "devices",
          "credential_outcomes",
          "device_count"
        ]
      },
      "OnboardingPointMap": {
        "type": "object",
        "description": "How one expected point is served by the proposed device.",
        "properties": {
          "point": {
            "type": "string"
          },
          "unit": {
            "type": "string"
          },
          "required": {
            "type": "boolean"
          },
          "source_point": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "mapped",
              "not_exposed",
              "no_device"
            ]
          }
        },
        "required": [
          "point",
          "unit",
          "required",
          "source_point",
          "status"
        ]
      },
      "OnboardingSlotProposal": {
        "type": "object",
        "properties": {
          "slot": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "device_class": {
            "type": "string"
          },
          "required": {
            "type": "boolean"
          },
          "device_ref": {
            "type": [
              "string",
              "null"
            ]
          },
          "device": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/OnboardingDiscoveredDevice"
              },
              {
                "type": "null"
              }
            ]
          },
          "confidence": {
            "type": "number",
            "description": "0 to 1, two decimals. 0.5 base for a class match, plus up to 0.3 for required points covered, plus 0.2 for a name keyword hit. 0 when no device."
          },
          "band": {
            "type": "string",
            "enum": [
              "high",
              "medium",
              "low",
              "none"
            ]
          },
          "reason": {
            "type": "string",
            "description": "Semicolon separated sentences explaining the score."
          },
          "points": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingPointMap"
            }
          }
        },
        "required": [
          "slot",
          "label",
          "device_class",
          "required",
          "device_ref",
          "device",
          "confidence",
          "band",
          "reason",
          "points"
        ]
      },
      "OnboardingProposal": {
        "type": "object",
        "properties": {
          "slots": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingSlotProposal"
            }
          },
          "unmapped_devices": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "device": {
                  "$ref": "#/components/schemas/OnboardingDiscoveredDevice"
                },
                "reason": {
                  "type": "string"
                }
              },
              "required": [
                "device",
                "reason"
              ]
            }
          },
          "missing_required_slots": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "confidence_bands": {
            "type": "object",
            "description": "The thresholds used for `band`.",
            "properties": {
              "high": {
                "type": "number"
              },
              "medium": {
                "type": "number"
              }
            },
            "required": [
              "high",
              "medium"
            ]
          }
        },
        "required": [
          "slots",
          "unmapped_devices",
          "missing_required_slots",
          "confidence_bands"
        ]
      },
      "OnboardingTiming": {
        "type": "object",
        "description": "The onboarding clock. Milliseconds are integers.",
        "properties": {
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "discovered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "confirmed_at": {
            "type": "string",
            "format": "date-time"
          },
          "discovery_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Start to end of discovery."
          },
          "review_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "End of discovery to confirmation."
          },
          "elapsed_ms": {
            "type": "integer",
            "description": "Start to confirmation."
          }
        },
        "required": [
          "started_at",
          "discovered_at",
          "confirmed_at",
          "discovery_ms",
          "review_ms",
          "elapsed_ms"
        ]
      },
      "OnboardingAcceptedEntry": {
        "type": "object",
        "description": "One accepted decision and how the device was materialised.",
        "properties": {
          "slot": {
            "type": "string"
          },
          "device_ref": {
            "type": "string"
          },
          "device_name": {
            "type": "string"
          },
          "source": {
            "type": "string",
            "enum": [
              "fixture",
              "live",
              "registered"
            ]
          },
          "device_class": {
            "type": "string"
          },
          "confidence": {
            "type": [
              "number",
              "null"
            ]
          },
          "band": {
            "type": [
              "string",
              "null"
            ]
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "points": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingPointMap"
            }
          },
          "device_id": {
            "type": "string",
            "description": "The device row now on the site."
          },
          "how": {
            "type": "string",
            "enum": [
              "placed_registered_device",
              "created_synthetic_from_fixture",
              "connected_live_device",
              "placed_without_credentials"
            ]
          },
          "live_enabled": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Null for a placed registered device, whose flag is unchanged."
          }
        },
        "required": [
          "slot",
          "device_ref",
          "device_name",
          "source",
          "device_class",
          "confidence",
          "band",
          "reason",
          "points",
          "device_id",
          "how",
          "live_enabled"
        ]
      },
      "OnboardingConfirmed": {
        "type": "object",
        "description": "Written on the run when the mapping is confirmed. An empty object while the run is `proposed`.",
        "properties": {
          "accepted": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingAcceptedEntry"
            }
          },
          "rejected": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slot": {
                  "type": "string"
                },
                "device_ref": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "reason": {
                  "type": "string",
                  "enum": [
                    "rejected by operator",
                    "no device"
                  ]
                }
              },
              "required": [
                "slot",
                "device_ref",
                "reason"
              ]
            }
          },
          "required_slots_unfilled": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "timing": {
            "$ref": "#/components/schemas/OnboardingTiming"
          }
        }
      },
      "OnboardingRun": {
        "type": "object",
        "description": "One onboarding run, as stored, with datetimes rendered ISO 8601.",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site_name": {
            "type": "string"
          },
          "template_id": {
            "type": "string"
          },
          "template_name": {
            "type": "string"
          },
          "template_scope": {
            "type": "string",
            "enum": [
              "platform",
              "organisation"
            ],
            "description": "`platform` when `template_id` starts with `platform-`."
          },
          "status": {
            "type": "string",
            "enum": [
              "proposed",
              "confirmed"
            ]
          },
          "discovery_mode": {
            "type": "string",
            "enum": [
              "fixture",
              "live",
              "registered",
              "none"
            ]
          },
          "discovery": {
            "$ref": "#/components/schemas/OnboardingDiscovery"
          },
          "proposal": {
            "$ref": "#/components/schemas/OnboardingProposal"
          },
          "confirmed": {
            "$ref": "#/components/schemas/OnboardingConfirmed"
          },
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "discovered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "confirmed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "elapsed_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Start to confirmation. Null while proposed."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "organisation_id",
          "site_id",
          "site_name",
          "template_id",
          "template_name",
          "template_scope",
          "status",
          "discovery_mode",
          "discovery",
          "proposal",
          "confirmed",
          "started_at",
          "discovered_at",
          "confirmed_at",
          "elapsed_ms",
          "created_at"
        ]
      },
      "OnboardingRunListItem": {
        "type": "object",
        "description": "A run as listed, without the discovery, proposal and confirmed objects.",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site_name": {
            "type": "string"
          },
          "template_id": {
            "type": "string"
          },
          "template_name": {
            "type": "string"
          },
          "template_scope": {
            "type": "string",
            "enum": [
              "platform",
              "organisation"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "proposed",
              "confirmed"
            ]
          },
          "discovery_mode": {
            "type": "string",
            "enum": [
              "fixture",
              "live",
              "registered",
              "none"
            ]
          },
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "discovered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "confirmed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "elapsed_ms": {
            "type": [
              "integer",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "device_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Devices discovered in the run."
          },
          "accepted_count": {
            "type": "integer",
            "description": "Accepted decisions. 0 while proposed."
          }
        },
        "required": [
          "id",
          "organisation_id",
          "site_id",
          "site_name",
          "template_id",
          "template_name",
          "template_scope",
          "status",
          "discovery_mode",
          "started_at",
          "discovered_at",
          "confirmed_at",
          "elapsed_ms",
          "created_at",
          "device_count",
          "accepted_count"
        ]
      },
      "OnboardingMapping": {
        "type": "object",
        "description": "One slot to device mapping in force for a site.",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "slot": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "device_class": {
            "type": "string"
          },
          "template_id": {
            "type": "string"
          },
          "run_id": {
            "type": "string"
          },
          "points": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OnboardingPointMap"
            }
          },
          "confidence": {
            "type": [
              "number",
              "null"
            ]
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "confirmed_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "organisation_id",
          "site_id",
          "slot",
          "device_id",
          "device_class",
          "template_id",
          "run_id",
          "points",
          "confidence",
          "reason",
          "confirmed_at"
        ]
      },
      "AlertHistoryItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "org_name": {
            "type": "string",
            "description": "Organisation name, else the organisation id."
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "provider": {
            "type": [
              "string",
              "null"
            ]
          },
          "alert_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "severity": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "message": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "resolved": {
            "type": "boolean"
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "organisation_id",
          "org_name",
          "device_id",
          "provider",
          "alert_type",
          "severity",
          "title",
          "message",
          "created_at",
          "resolved",
          "resolved_at"
        ]
      },
      "AlertHistoryList": {
        "type": "object",
        "properties": {
          "total": {
            "type": "integer",
            "description": "Rows matching the filters across all pages. 0 when the table is unavailable."
          },
          "alerts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AlertHistoryItem"
            }
          }
        },
        "required": [
          "total",
          "alerts"
        ]
      },
      "AlertHistoryAssignIn": {
        "type": "object",
        "properties": {
          "assigned_to": {
            "type": "string",
            "description": "Name or identifier of the new owner. Trimmed; must not be blank."
          }
        },
        "required": [
          "assigned_to"
        ],
        "additionalProperties": true
      },
      "AlertHistoryResolveIn": {
        "type": "object",
        "properties": {
          "resolution_note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free text. Blank is stored as null."
          }
        },
        "additionalProperties": true
      },
      "AlertHistoryCommentIn": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "maxLength": 2000,
            "description": "The comment. Trimmed; blank after trimming is refused."
          }
        },
        "required": [
          "text"
        ],
        "additionalProperties": true
      },
      "AlertHistoryStats": {
        "type": "object",
        "properties": {
          "window_days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 90
          },
          "cross_tenant": {
            "type": "boolean",
            "description": "True when the counts cover every organisation because the caller holds a platform role."
          },
          "raised": {
            "type": "integer"
          },
          "resolved": {
            "type": "integer"
          },
          "acknowledged": {
            "type": "integer"
          },
          "open_unowned": {
            "type": "integer",
            "description": "Open alerts with no `assigned_to`."
          },
          "closed_without_note": {
            "type": "integer",
            "description": "Resolved alerts with no `resolution_note`."
          },
          "actioned_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Acknowledged as a percentage of raised, one decimal place. Null when none were raised."
          },
          "mean_hours_to_acknowledge": {
            "type": [
              "number",
              "null"
            ]
          },
          "mean_hours_to_close": {
            "type": [
              "number",
              "null"
            ]
          },
          "note": {
            "type": "string"
          }
        },
        "required": [
          "window_days",
          "cross_tenant",
          "raised",
          "resolved",
          "acknowledged",
          "open_unowned",
          "closed_without_note",
          "actioned_pct",
          "mean_hours_to_acknowledge",
          "mean_hours_to_close",
          "note"
        ]
      },
      "DeviceVehicle": {
        "type": "object",
        "properties": {
          "vehicle_id": {
            "type": "string"
          },
          "provider": {
            "type": "string",
            "description": "`smartcar`, `enode` or `tesla`."
          },
          "vin": {
            "type": [
              "string",
              "null"
            ]
          },
          "make": {
            "type": [
              "string",
              "null"
            ]
          },
          "model": {
            "type": [
              "string",
              "null"
            ]
          },
          "year": {
            "type": [
              "integer",
              "null"
            ]
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "connected_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "last_reported_at": {
            "$ref": "#/components/schemas/DeviceLastReportedAt"
          },
          "last_reported_source": {
            "$ref": "#/components/schemas/DeviceLastReportedSource"
          }
        },
        "required": [
          "vehicle_id",
          "provider",
          "last_reported_at",
          "last_reported_source"
        ]
      },
      "DeviceVehicleTelemetryEntry": {
        "type": "object",
        "description": "One collapsed vehicle. The full shape is returned when the adapter call\ncompleted (with or without a provider error); when reading the vehicle raised,\nonly `vehicle_id` and `error` are present.\n",
        "properties": {
          "vehicle_id": {
            "type": "string"
          },
          "provider": {
            "type": "string"
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "telemetry": {
            "type": "object",
            "additionalProperties": true,
            "description": "The provider adapter's telemetry dict, passed through. Carries `error` when the read failed or the provider is unsupported."
          },
          "status": {
            "type": "string",
            "enum": [
              "online",
              "offline"
            ]
          },
          "online": {
            "type": "boolean"
          },
          "capabilities": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The adapter's declared capabilities."
          },
          "duplicate_of": {
            "type": "object",
            "description": "Present when other connection rows were collapsed into this one.",
            "properties": {
              "matched_on": {
                "type": "string",
                "enum": [
                  "vin",
                  "make_model"
                ]
              },
              "also_connected_via": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Providers of the collapsed rows."
              },
              "note": {
                "type": "string",
                "description": "Present for a `make_model` match only."
              }
            },
            "required": [
              "matched_on",
              "also_connected_via"
            ]
          },
          "error": {
            "type": "string",
            "description": "Present on the reduced entry only."
          },
          "last_reported_at": {
            "$ref": "#/components/schemas/DeviceLastReportedAt"
          },
          "last_reported_source": {
            "$ref": "#/components/schemas/DeviceLastReportedSource"
          }
        },
        "required": [
          "vehicle_id",
          "last_reported_at",
          "last_reported_source"
        ]
      },
      "DeviceVehicleTelemetryAll": {
        "type": "object",
        "properties": {
          "tenant_id": {
            "type": "string"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "UTC, no offset suffix."
          },
          "vehicles": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DeviceVehicleTelemetryEntry"
            }
          }
        },
        "required": [
          "tenant_id",
          "timestamp",
          "vehicles"
        ]
      },
      "DeviceSmartMeter": {
        "type": "object",
        "description": "A connected smart meter. The first fourteen fields are always present; the\nreading fields are present only when a reading path succeeded, and which set\ndepends on the path (see the operation description). Further rate fields from\nthe poller's stored telemetry are merged in when present.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "device_type": {
            "type": "string",
            "description": "`smart_meter`."
          },
          "provider": {
            "type": [
              "string",
              "null"
            ]
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "External device id at the provider."
          },
          "brand": {
            "type": [
              "string",
              "null"
            ]
          },
          "model": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "description": "Stored status, `unknown` when none."
          },
          "connection_method": {
            "type": "string"
          },
          "integration_path": {
            "type": "string"
          },
          "capabilities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "live_enabled": {
            "type": "boolean"
          },
          "connected_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "last_seen_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "The stored device details with credential values redacted to `***`."
          },
          "live_telemetry": {
            "type": "object",
            "additionalProperties": true,
            "description": "The Octopus or adapter reading dict, passed through, with the poller's stored `live_*` fields filled in where missing."
          },
          "telemetry_kind": {
            "type": "string",
            "enum": [
              "delayed_settlement",
              "live_home_mini",
              "live_instantaneous"
            ]
          },
          "is_live_power": {
            "type": "boolean"
          },
          "data_delay_minutes_estimate": {
            "type": [
              "number",
              "null"
            ]
          },
          "power_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Instantaneous power, kW. Null on the Octopus path."
          },
          "interval_average_import_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Octopus path. Latest half hour average import, kW."
          },
          "interval_average_export_kw": {
            "type": [
              "number",
              "null"
            ],
            "description": "Octopus path. Latest half hour average export, kW."
          },
          "import_today_kwh": {
            "type": "number",
            "description": "Octopus path."
          },
          "export_today_kwh": {
            "type": "number",
            "description": "Octopus path."
          },
          "electricity_today_kwh": {
            "type": "number",
            "description": "Both paths."
          },
          "electricity_24h_kwh": {
            "type": "number",
            "description": "Adapter path."
          },
          "import_30d_kwh": {
            "type": "number",
            "description": "Octopus path. Rolling 30 days."
          },
          "export_30d_kwh": {
            "type": "number",
            "description": "Octopus path. Rolling 30 days."
          },
          "latest_import_kwh": {
            "type": "number",
            "description": "Octopus path. Latest half hour."
          },
          "latest_export_kwh": {
            "type": "number",
            "description": "Octopus path. Latest half hour."
          },
          "tariff_code": {
            "type": "string"
          },
          "tariff_name": {
            "type": "string"
          },
          "unit_rate_pence": {
            "type": [
              "number",
              "null"
            ],
            "description": "Pence per kWh."
          },
          "standing_charge_pence": {
            "type": [
              "number",
              "null"
            ],
            "description": "Pence per day."
          },
          "min_rate_pence": {
            "type": "number"
          },
          "max_rate_pence": {
            "type": "number"
          },
          "export_rate_pence": {
            "type": "number"
          },
          "outgoing_rate_pence": {
            "type": "number"
          },
          "current_rate_pence": {
            "type": "number"
          },
          "avg_rate_pence": {
            "type": "number"
          },
          "live_demand_w": {
            "type": "number",
            "description": "Home Mini instantaneous demand, W, from the poller."
          },
          "live_import_kw": {
            "type": "number",
            "description": "Home Mini import, kW, from the poller."
          },
          "live_export_kw": {
            "type": "number",
            "description": "Home Mini export, kW, from the poller."
          },
          "telemetry_error": {
            "type": "string",
            "description": "Octopus path failure message."
          },
          "last_reported_at": {
            "$ref": "#/components/schemas/DeviceLastReportedAt"
          },
          "last_reported_source": {
            "$ref": "#/components/schemas/DeviceLastReportedSource"
          }
        },
        "required": [
          "id",
          "name",
          "device_type",
          "provider",
          "device_id",
          "brand",
          "model",
          "status",
          "connection_method",
          "integration_path",
          "capabilities",
          "live_enabled",
          "connected_at",
          "last_seen_at",
          "details",
          "last_reported_at",
          "last_reported_source"
        ]
      },
      "DeviceLastReportedAt": {
        "type": [
          "string",
          "null"
        ],
        "format": "date-time",
        "description": "When the device last reported, ISO 8601 UTC to the second with a `Z`\nsuffix, for example `2026-10-06T08:14:03Z`. Null when nothing is known.\nNever the time of the request, the connect time or `last_seen_at`.\n",
        "example": "2026-10-06T08:14:03Z"
      },
      "DeviceLastReportedSource": {
        "type": [
          "string",
          "null"
        ],
        "enum": [
          "ocpp_heartbeat",
          "provider_reading",
          "telemetry",
          "provider_api",
          null
        ],
        "description": "Where `last_reported_at` came from. Null exactly when it is null.\n\n- `ocpp_heartbeat`: the charger's own last OCPP message to the AmpVerve CSMS.\n- `provider_reading`: the manufacturer's own statement of when the device\n  last reported or took its reading (a vehicle's reading time, Enode's last\n  seen time, a Home Mini's reading time).\n- `telemetry`: the newest reading AmpVerve stored for the device.\n- `provider_api`: the last successful sync of the device with its\n  manufacturer's cloud.\n\nA time the device stated (`ocpp_heartbeat`, `provider_reading`) is preferred,\nnewest first, because a manufacturer's cloud can keep answering with a cached\nreading long after the device went quiet. Only when there is none does the\nnewer of `telemetry` and `provider_api` answer.\n",
        "example": "telemetry"
      },
      "DeviceConnected": {
        "type": "object",
        "description": "A connected device as stored, with credential values in `details` redacted\nto `***`. Listings add reading fields for their asset type when a live read\nsucceeded, and `telemetry_error` when it failed; those vary by adapter and\nare not listed here.\n",
        "additionalProperties": true,
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "device_type": {
            "type": "string",
            "description": "The asset type, for example `charger`, `solar`, `battery`, `heatpump`."
          },
          "provider": {
            "type": [
              "string",
              "null"
            ]
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "External device id at the provider."
          },
          "brand": {
            "type": [
              "string",
              "null"
            ]
          },
          "model": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "description": "Stored status, `unknown` when none."
          },
          "connection_method": {
            "type": "string"
          },
          "integration_path": {
            "type": "string"
          },
          "capabilities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "live_enabled": {
            "type": "boolean"
          },
          "connected_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "last_seen_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "The connect time. Not a reporting time; read `last_reported_at`."
          },
          "details": {
            "type": "object",
            "additionalProperties": true
          },
          "telemetry_error": {
            "type": "string"
          },
          "last_reported_at": {
            "$ref": "#/components/schemas/DeviceLastReportedAt"
          },
          "last_reported_source": {
            "$ref": "#/components/schemas/DeviceLastReportedSource"
          }
        },
        "required": [
          "id",
          "name",
          "device_type",
          "status",
          "last_reported_at",
          "last_reported_source"
        ]
      },
      "AuthMe": {
        "type": "object",
        "properties": {
          "user": {
            "type": "object",
            "properties": {
              "id": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The token's `sub` claim."
              },
              "email": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "roles": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Roles on the token, for example `ORG_ADMIN`, `OPERATOR`, `ANALYST`, `VIEWER`, `PLATFORM_ADMIN`."
              },
              "is_platform": {
                "type": "boolean"
              }
            },
            "required": [
              "id",
              "email",
              "roles",
              "is_platform"
            ]
          },
          "tenant": {
            "type": "object",
            "properties": {
              "id": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The token's `tenant_id` claim, else `org_id`."
              },
              "type": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Organisation type from the organisation row, for example `residential`, `fleet`, `energy_partner`."
              },
              "status": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Organisation status from the organisation row."
              },
              "display_name": {
                "type": "string"
              },
              "audience": {
                "type": "string",
                "enum": [
                  "b2c",
                  "b2b",
                  "b2b2c"
                ]
              },
              "primary_site_id": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "primary_site_name": {
                "type": "string"
              },
              "primary_site_address": {
                "type": "string"
              },
              "logo_url": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "theme": {
                "type": "string",
                "description": "Console colour theme name."
              },
              "core_modules": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Console module paths the audience receives by default."
              },
              "custom_modules": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "allowed_modules": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "`core_modules` followed by `custom_modules`."
              },
              "allowed_protocols": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Charger protocols the organisation may connect, for example `ocpp1.6`, `ocpp2.0.1`, `ocpp2.1`, `modbus_tcp`."
              },
              "allowed_markets": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Lower case country codes."
              }
            },
            "required": [
              "id",
              "type",
              "status",
              "display_name",
              "audience",
              "primary_site_id",
              "primary_site_name",
              "primary_site_address",
              "logo_url",
              "theme",
              "core_modules",
              "custom_modules",
              "allowed_modules",
              "allowed_protocols",
              "allowed_markets"
            ]
          }
        },
        "required": [
          "user",
          "tenant"
        ]
      },
      "EmsControlOverride": {
        "type": "object",
        "description": "A human override of the EMS control loops (row F5). Enforced by the verified\nsetpoint write while `status` is `active`.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "scope_kind": {
            "type": "string",
            "enum": [
              "site",
              "device"
            ]
          },
          "device_id": {
            "type": "string",
            "nullable": true
          },
          "action": {
            "type": "string",
            "enum": [
              "hold",
              "manual_setpoint",
              "suspend_automation"
            ]
          },
          "target_kw": {
            "type": "number",
            "nullable": true
          },
          "reason": {
            "type": "string"
          },
          "actor_user_id": {
            "type": "string",
            "description": "Subject of the verified token that granted it."
          },
          "actor_email": {
            "type": "string",
            "nullable": true
          },
          "actor_roles": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "authority": {
            "type": "string",
            "description": "`role:<ROLE>` in organisation global mode, `persona:<persona>` in per-site\naccess mode, or `platform`. The permission that allowed the grant.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "revoked_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "revoked_by": {
            "type": "string",
            "nullable": true
          },
          "revoke_reason": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "revoked",
              "expired"
            ]
          },
          "setpoint_command_id": {
            "type": "string",
            "nullable": true
          },
          "refusals": {
            "type": "integer",
            "description": "Setpoint writes refused while active."
          },
          "last_refusal_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "EmsDispatchDecision": {
        "type": "object",
        "description": "An append-only dispatch decision record (row F8). `inputs` are values, not\nreferences, so the decision can be replayed after the source tables have\nmoved on.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "decided_at": {
            "type": "string",
            "format": "date-time"
          },
          "loop": {
            "type": "string",
            "enum": [
              "import_limit",
              "export_limit",
              "tariff_schedule",
              "override"
            ]
          },
          "model_version": {
            "type": "string",
            "description": "Qualified name of the pure decision function, a SHA-256 of its source, and the solver build."
          },
          "rule_version": {
            "type": "string"
          },
          "inputs": {
            "type": "object",
            "additionalProperties": true
          },
          "constraints": {
            "type": "object",
            "additionalProperties": true
          },
          "objective": {
            "type": "string",
            "description": "The objective function in words."
          },
          "alternatives": {
            "type": "array",
            "description": "Each with `name`, `rejected_because` and its computed figures.",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "result": {
            "type": "object",
            "additionalProperties": true
          },
          "could_intervene": {
            "type": "object",
            "properties": {
              "basis": {
                "type": "string"
              },
              "people": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "user_id": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string",
                      "nullable": true
                    },
                    "authority": {
                      "type": "string"
                    }
                  }
                }
              },
              "active_overrides": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "input_hash": {
            "type": "string",
            "description": "SHA-256 of the canonical JSON of inputs."
          },
          "result_hash": {
            "type": "string",
            "description": "SHA-256 of the canonical JSON of result."
          },
          "run_ref": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "EmsTariffScheduleRun": {
        "type": "object",
        "description": "One tariff optimised schedule run (row F4). `pricing_status` is a state, never\na number: `not_priced` runs carry no schedule.\n",
        "properties": {
          "id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "run_at": {
            "type": "string",
            "format": "date-time"
          },
          "horizon_start": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "horizon_end": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "periods": {
            "type": "integer"
          },
          "load_basis": {
            "type": "string",
            "enum": [
              "measured_persistence",
              "none"
            ]
          },
          "import_mpan": {
            "type": "string",
            "nullable": true
          },
          "export_mpan": {
            "type": "string",
            "nullable": true
          },
          "tariff_version": {
            "type": "string",
            "nullable": true,
            "description": "The charge library version tag for the rates used."
          },
          "pricing_status": {
            "type": "string",
            "enum": [
              "priced",
              "partially_priced",
              "not_priced"
            ]
          },
          "pricing_detail": {
            "type": "string",
            "nullable": true
          },
          "unpriced_components": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "component": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                },
                "reason": {
                  "type": "string"
                }
              }
            }
          },
          "solver": {
            "type": "string",
            "enum": [
              "milp_cbc",
              "heuristic_fallback",
              "none"
            ]
          },
          "objective": {
            "type": "string",
            "nullable": true
          },
          "cost_without_gbp": {
            "type": "number",
            "nullable": true
          },
          "cost_with_gbp": {
            "type": "number",
            "nullable": true
          },
          "saving_gbp": {
            "type": "number",
            "nullable": true,
            "description": "The sum of saving_by_component."
          },
          "saving_by_component": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "component": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "import_charge",
                    "export_revenue",
                    "cycling_cost"
                  ]
                },
                "without_gbp": {
                  "type": "number"
                },
                "with_gbp": {
                  "type": "number"
                },
                "saving_gbp": {
                  "type": "number"
                }
              }
            }
          },
          "cycling_cost_gbp": {
            "type": "number",
            "nullable": true
          },
          "throughput_kwh": {
            "type": "number",
            "nullable": true
          },
          "cycles_equivalent": {
            "type": "number",
            "nullable": true
          },
          "warranty": {
            "type": "object",
            "description": "The warranty model applied and the headroom used (row F9), or the stated absence of one.",
            "additionalProperties": true
          },
          "schedule": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "ts": {
                  "type": "string",
                  "format": "date-time"
                },
                "basis_ts": {
                  "type": "string",
                  "format": "date-time"
                },
                "load_kw": {
                  "type": "number"
                },
                "pv_kw": {
                  "type": "number"
                },
                "battery_kw": {
                  "type": "number",
                  "description": "Positive charging",
                  "negative discharging.": null
                },
                "import_kw": {
                  "type": "number"
                },
                "export_kw": {
                  "type": "number"
                },
                "import_kw_without": {
                  "type": "number"
                },
                "soc": {
                  "type": "number"
                },
                "import_rate_gbp_per_kwh": {
                  "type": "number"
                },
                "export_rate_gbp_per_kwh": {
                  "type": "number"
                },
                "duos_band": {
                  "type": "string",
                  "nullable": true
                }
              }
            }
          },
          "decision_id": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "completed",
              "no_battery",
              "no_stored_series",
              "not_priced"
            ]
          },
          "reason": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "EmsBatteryWarranty": {
        "type": "object",
        "description": "The warranty terms held for one battery (row F9) and the figures derived from them.",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "device_id": {
            "type": "string"
          },
          "capacity_kwh": {
            "type": "number"
          },
          "warranty_years": {
            "type": "number",
            "nullable": true
          },
          "warranty_cycles": {
            "type": "number",
            "nullable": true
          },
          "warranty_throughput_kwh": {
            "type": "number",
            "nullable": true
          },
          "max_dod_pct": {
            "type": "number",
            "nullable": true
          },
          "max_cycles_per_day": {
            "type": "number",
            "nullable": true
          },
          "replacement_cost_gbp": {
            "type": "number",
            "nullable": true
          },
          "commissioned_on": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "source": {
            "type": "string",
            "enum": [
              "vendor_datasheet",
              "site_profile",
              "contract",
              "manual"
            ]
          },
          "source_note": {
            "type": "string",
            "nullable": true
          },
          "updated_by": {
            "type": "string",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "derived": {
            "type": "object",
            "properties": {
              "warranted_throughput_kwh": {
                "type": "number",
                "nullable": true
              },
              "cycling_cost_gbp_per_kwh": {
                "type": "number",
                "nullable": true
              },
              "daily_cycle_budget": {
                "type": "number",
                "nullable": true
              },
              "daily_throughput_cap_kwh": {
                "type": "number",
                "nullable": true
              },
              "soc_floor": {
                "type": "number",
                "nullable": true
              },
              "method": {
                "type": "string"
              }
            }
          }
        }
      },
      "FleetEvidenceItem": {
        "type": "object",
        "description": "One input to an assessment, where it came from and how current it was.",
        "properties": {
          "name": {
            "type": "string"
          },
          "source": {
            "type": "string"
          },
          "available": {
            "type": "boolean"
          },
          "value": {
            "nullable": true
          },
          "unit": {
            "type": "string",
            "nullable": true
          },
          "observed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "freshness": {
            "type": "string",
            "nullable": true,
            "enum": [
              "LIVE",
              "RECENT",
              "STALE",
              "ESTIMATED",
              "UNAVAILABLE",
              null
            ]
          },
          "note": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "FleetDepartureRequirement": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "vehicle_id": {
            "type": "string"
          },
          "departure_at": {
            "type": "string",
            "format": "date-time"
          },
          "required_soc_pct": {
            "type": "number"
          },
          "recurrence_rule": {
            "type": "string",
            "nullable": true
          },
          "priority_class": {
            "type": "string",
            "nullable": true,
            "enum": [
              "critical",
              "high",
              "normal",
              "low",
              null
            ]
          },
          "source": {
            "type": "string",
            "enum": [
              "csv_import",
              "shift_schedule",
              "telematics",
              "operator"
            ]
          },
          "created_by": {
            "type": "string",
            "nullable": true
          },
          "label": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "FleetDepartureRequirementIn": {
        "type": "object",
        "required": [
          "vehicle_id",
          "departure_at",
          "required_soc_pct"
        ],
        "properties": {
          "vehicle_id": {
            "type": "string"
          },
          "departure_at": {
            "type": "string",
            "description": "ISO 8601 or HH:MM (next occurrence)."
          },
          "required_soc_pct": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 100
          },
          "recurrence_rule": {
            "type": "string",
            "nullable": true
          },
          "priority_class": {
            "type": "string",
            "nullable": true,
            "enum": [
              "critical",
              "high",
              "normal",
              "low",
              null
            ]
          },
          "label": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "FleetDepartureAssessment": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "vehicle_id": {
            "type": "string"
          },
          "vehicle_label": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "READY",
              "CHARGING",
              "AT_RISK",
              "NOT_CONNECTED",
              "DATA_UNAVAILABLE"
            ]
          },
          "assessed_at": {
            "type": "string",
            "format": "date-time"
          },
          "current_soc_pct": {
            "type": "number",
            "nullable": true
          },
          "required_departure_soc_pct": {
            "type": "number",
            "nullable": true
          },
          "predicted_departure_soc_pct": {
            "type": "number",
            "nullable": true
          },
          "departure_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "required_kwh": {
            "type": "number",
            "nullable": true
          },
          "assigned_charger": {
            "type": "string",
            "nullable": true
          },
          "planned_start": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "expected_completion": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "flex_eligible": {
            "type": "boolean"
          },
          "flex_headroom_kwh": {
            "type": "number",
            "nullable": true
          },
          "flex_slack_hours": {
            "type": "number",
            "nullable": true
          },
          "evidence": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetEvidenceItem"
            }
          },
          "reason_codes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "DEPARTURE_UNKNOWN",
                "SOC_UNAVAILABLE",
                "SOC_STALE",
                "DEPARTURE_PASSED",
                "AT_OR_ABOVE_REQUIREMENT",
                "PLUG_STATE_UNAVAILABLE",
                "NOT_PLUGGED_IN",
                "CAPACITY_UNKNOWN",
                "CHARGER_POWER_UNKNOWN",
                "CHARGE_IN_PROGRESS",
                "CHARGE_SCHEDULED",
                "INSUFFICIENT_TIME"
              ]
            }
          },
          "requirement": {
            "nullable": true,
            "type": "object"
          }
        }
      },
      "FleetReadinessStats": {
        "type": "object",
        "properties": {
          "total_vehicles": {
            "type": "integer"
          },
          "ready": {
            "type": "integer"
          },
          "charging": {
            "type": "integer"
          },
          "at_risk": {
            "type": "integer"
          },
          "not_connected": {
            "type": "integer"
          },
          "data_unavailable": {
            "type": "integer"
          },
          "assessed": {
            "type": "integer"
          },
          "readiness_pct": {
            "type": "number",
            "nullable": true
          },
          "avg_soc_pct": {
            "type": "number",
            "nullable": true
          }
        }
      },
      "FleetSiteRef": {
        "type": "object",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "HouseholdCarbon": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "example": "octopus_meter_and_neso_regional_carbon_intensity"
          },
          "available": {
            "type": "boolean"
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why there is no footprint",
            "when available is false.": null
          },
          "granularity": {
            "type": "string",
            "enum": [
              "day",
              "week",
              "month"
            ]
          },
          "timezone": {
            "type": "string",
            "example": "Europe/London"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "window_from": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "window_to": {
            "type": "string",
            "format": "date-time"
          },
          "region": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "gsp_group": {
                "type": "string",
                "example": "H"
              },
              "neso_region_id": {
                "type": "integer",
                "minimum": 1,
                "maximum": 14
              },
              "name": {
                "type": "string",
                "example": "South England"
              },
              "resolved_from": {
                "type": "string",
                "enum": [
                  "mpan",
                  "tariff_code"
                ]
              }
            }
          },
          "basis": {
            "type": [
              "string",
              "null"
            ]
          },
          "periods": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "key": {
                  "type": "string",
                  "example": "2026-09"
                },
                "label": {
                  "type": "string"
                },
                "first_day": {
                  "type": "string",
                  "format": "date"
                },
                "end_day": {
                  "type": "string",
                  "format": "date",
                  "description": "Exclusive."
                },
                "is_current": {
                  "type": "boolean"
                },
                "days_elapsed": {
                  "type": "integer"
                },
                "closed_days": {
                  "type": "integer"
                },
                "kwh": {
                  "type": "number",
                  "description": "Measured import in the period",
                  "today included.": null
                },
                "covered_days": {
                  "type": "integer"
                },
                "covered_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "covered_kgco2": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "Sum over the counted days only."
                },
                "kgco2": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "Set only when every finished day was counted."
                },
                "mean_intensity_g_per_kwh": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "uncovered_reasons": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "totals": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "kwh": {
                "type": "number"
              },
              "closed_days": {
                "type": "integer"
              },
              "covered_days": {
                "type": "integer"
              },
              "covered_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "covered_kgco2": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "kgco2": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "mean_intensity_g_per_kwh": {
                "type": [
                  "number",
                  "null"
                ]
              }
            }
          }
        }
      },
      "FleetTelematicsVehicles": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "as_of": {
            "type": "string",
            "format": "date-time"
          },
          "vehicles": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "asset_id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "last_seen": {
                  "type": "string",
                  "format": "date-time"
                },
                "age_seconds": {
                  "type": "integer"
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "moving",
                    "idling",
                    "parked",
                    "stale",
                    "unknown"
                  ]
                },
                "ignition": {
                  "type": "boolean",
                  "nullable": true
                },
                "speed_kph": {
                  "type": "number",
                  "nullable": true
                },
                "latitude": {
                  "type": "number",
                  "nullable": true
                },
                "longitude": {
                  "type": "number",
                  "nullable": true
                },
                "odometer": {
                  "type": "number",
                  "nullable": true
                }
              }
            }
          },
          "counts": {
            "type": "object",
            "properties": {
              "moving": {
                "type": "integer"
              },
              "idling": {
                "type": "integer"
              },
              "parked": {
                "type": "integer"
              },
              "stale": {
                "type": "integer"
              },
              "unknown": {
                "type": "integer"
              }
            }
          },
          "classification": {
            "type": "string",
            "enum": [
              "MEASURED"
            ]
          },
          "capability": {
            "type": "object",
            "additionalProperties": true,
            "description": "What the telematics feed can and cannot answer."
          }
        }
      },
      "FleetReadinessBoard": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string",
            "nullable": true
          },
          "site_name": {
            "type": "string",
            "nullable": true
          },
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetSiteRef"
            }
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "vehicles": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetDepartureAssessment"
            }
          },
          "stats": {
            "$ref": "#/components/schemas/FleetReadinessStats"
          },
          "requirements": {
            "type": "object",
            "properties": {
              "count": {
                "type": "integer"
              },
              "unmatched_vehicle_ids": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "sources": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "chargers": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sources": {
            "type": "object",
            "additionalProperties": true
          },
          "note": {
            "type": "string",
            "description": "Present when the organisation has no site."
          }
        }
      },
      "FleetElectrificationAssessment": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string",
            "nullable": true
          },
          "classification": {
            "type": "string",
            "enum": [
              "ELECTRIFY_NOW",
              "ELECTRIFY_WITH_INFRASTRUCTURE_CHANGE",
              "REVIEW_LATER",
              "INSUFFICIENT_EVIDENCE"
            ]
          },
          "insufficient_evidence": {
            "type": "boolean"
          },
          "assessed_at": {
            "type": "string",
            "format": "date-time"
          },
          "evidence": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetEvidenceItem"
            }
          },
          "reasons": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "missing": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "figures": {
            "type": "object",
            "additionalProperties": true
          },
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetSiteRef"
            }
          }
        }
      },
      "FleetDepot": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "$ref": "#/components/schemas/FleetSiteRef"
          },
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetSiteRef"
            }
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "operating_envelope": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "constraints_applied": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "configured": {
            "type": "boolean"
          },
          "chargers": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "charger_count": {
            "type": "integer"
          },
          "vehicle_count": {
            "type": "integer"
          },
          "live_ev_charging_kw": {
            "type": "number",
            "nullable": true
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "updated": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "FleetDepotConstraints": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "$ref": "#/components/schemas/FleetSiteRef"
          },
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetSiteRef"
            }
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "window": {
            "type": "object",
            "additionalProperties": true
          },
          "operating_envelope": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "site_envelope": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true,
            "description": "FlexibilityEnvelope.to_dict() of scope SITE."
          },
          "assets": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "FlexibilityEnvelope.to_dict() per vehicle."
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "FleetShiftSchedule": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": "string",
            "nullable": true
          },
          "site_name": {
            "type": "string",
            "nullable": true
          },
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetSiteRef"
            }
          },
          "requirements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FleetDepartureRequirement"
            }
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "written": {
            "type": "integer"
          },
          "imported": {
            "type": "integer"
          },
          "columns": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "SitePersona": {
        "type": "object",
        "properties": {
          "persona": {
            "type": "string",
            "enum": [
              "landlord",
              "occupier",
              "site_engineer",
              "operations",
              "finance"
            ]
          },
          "label": {
            "type": "string"
          },
          "meaning": {
            "type": "string"
          },
          "permissions": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "read",
                "control",
                "configure",
                "finance_read",
                "alerts",
                "export"
              ]
            }
          }
        }
      },
      "SiteMembership": {
        "type": "object",
        "description": "One persona held by one user on one site (row G2).",
        "properties": {
          "id": {
            "type": "string"
          },
          "site_id": {
            "type": "string"
          },
          "site_name": {
            "type": "string",
            "nullable": true
          },
          "user_id": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "organisation_role": {
            "type": "string",
            "nullable": true
          },
          "persona": {
            "type": "string",
            "enum": [
              "landlord",
              "occupier",
              "site_engineer",
              "operations",
              "finance"
            ]
          },
          "granted_by": {
            "type": "string",
            "nullable": true
          },
          "granted_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "note": {
            "type": "string",
            "nullable": true
          },
          "source": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "CrmStage": {
        "type": "string",
        "enum": [
          "lead",
          "contacted",
          "call_booked",
          "study_proposed",
          "won",
          "lost"
        ]
      },
      "CrmProduct": {
        "type": "string",
        "enum": [
          "depottwin_study",
          "plan_study",
          "power_passport",
          "capacityos",
          "partner"
        ]
      },
      "CrmAccountInput": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "segment": {
            "type": "string"
          },
          "website": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "country": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          },
          "owner_user_id": {
            "type": "string"
          },
          "linked_opportunity_id": {
            "type": "string",
            "description": "An opportunity (lead) of the same organisation."
          }
        }
      },
      "CrmAccount": {
        "type": "object",
        "description": "An organisation being sold to.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "segment": {
            "type": [
              "string",
              "null"
            ]
          },
          "website": {
            "type": [
              "string",
              "null"
            ]
          },
          "domain": {
            "type": [
              "string",
              "null"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": "string"
          },
          "owner_user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "linked_opportunity_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CrmContactInput": {
        "type": "object",
        "properties": {
          "account_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "role": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "description": "A business address. Consumer mailboxes are refused."
          },
          "linkedin_url": {
            "type": "string"
          },
          "channel": {
            "type": "string"
          },
          "source": {
            "type": "string"
          },
          "lawful_basis_note": {
            "type": "string",
            "minLength": 3
          }
        }
      },
      "CrmContact": {
        "type": "object",
        "description": "A person at an account. Personal data under UK GDPR.",
        "properties": {
          "id": {
            "type": "string"
          },
          "account_id": {
            "type": "string"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "role": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "linkedin_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "channel": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": "string"
          },
          "lawful_basis_note": {
            "type": "string"
          },
          "opted_out_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "opt_out_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "suppressed": {
            "type": "boolean",
            "description": "Opted out",
            "or the address or its domain is on the outreach suppression list.": null
          },
          "suppression_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "can_message": {
            "type": "boolean",
            "description": "Not suppressed and has a business email."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CrmDealInput": {
        "type": "object",
        "properties": {
          "account_id": {
            "type": "string"
          },
          "contact_id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "stage": {
            "$ref": "#/components/schemas/CrmStage"
          },
          "lost_reason": {
            "type": "string"
          },
          "value_gbp": {
            "type": "number",
            "minimum": 0
          },
          "expected_close_date": {
            "type": "string",
            "format": "date"
          },
          "owner_user_id": {
            "type": "string"
          },
          "product": {
            "$ref": "#/components/schemas/CrmProduct"
          },
          "linked_opportunity_id": {
            "type": "string"
          },
          "linked_study_id": {
            "type": "string",
            "description": "A plain reference to a Plan study."
          }
        }
      },
      "CrmDeal": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "account_id": {
            "type": "string"
          },
          "account_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": "string"
          },
          "stage": {
            "$ref": "#/components/schemas/CrmStage"
          },
          "lost_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "value_gbp": {
            "type": [
              "number",
              "null"
            ]
          },
          "expected_close_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "owner_user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "product": {
            "$ref": "#/components/schemas/CrmProduct"
          },
          "product_label": {
            "type": "string"
          },
          "linked_opportunity_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "linked_study_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "stage_changed_at": {
            "type": "string",
            "format": "date-time"
          },
          "closed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CrmActivity": {
        "type": "object",
        "description": "A logged call, meeting, email or note, or (source outreach) an email sent through the outreach flow.",
        "properties": {
          "id": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "call",
              "meeting",
              "email",
              "note"
            ]
          },
          "subject": {
            "type": "string"
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          },
          "author_user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "account_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "deal_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": "string",
            "enum": [
              "manual",
              "tracker_import",
              "outreach"
            ]
          },
          "outreach_message_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "outreach_status": {
            "type": [
              "string",
              "null"
            ]
          },
          "replied_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "CrmTask": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "due_date": {
            "type": "string",
            "format": "date"
          },
          "assignee_user_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "done": {
            "type": "boolean"
          },
          "done_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "overdue": {
            "type": "boolean"
          },
          "account_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "deal_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CrmSettings": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "stage_probabilities": {
            "type": "object",
            "additionalProperties": {
              "type": "number"
            }
          },
          "prospect_retention_days": {
            "type": "integer"
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "stages": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CrmStage"
            }
          },
          "products": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CrmProduct"
            }
          },
          "product_labels": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      },
      "CrmForecast": {
        "type": "object",
        "properties": {
          "currency": {
            "type": "string",
            "const": "GBP"
          },
          "probabilities": {
            "type": "object",
            "additionalProperties": {
              "type": "number"
            }
          },
          "probabilities_source": {
            "type": "string"
          },
          "by_stage": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "stage": {
                  "$ref": "#/components/schemas/CrmStage"
                },
                "probability": {
                  "type": "number"
                },
                "deals": {
                  "type": "integer"
                },
                "value_gbp": {
                  "type": "number"
                },
                "weighted_gbp": {
                  "type": "number"
                }
              }
            }
          },
          "by_month": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "month": {
                  "type": "string",
                  "description": "YYYY-MM",
                  "or \"unscheduled\" for deals with no expected close date.": null
                },
                "deals": {
                  "type": "integer"
                },
                "value_gbp": {
                  "type": "number"
                },
                "weighted_gbp": {
                  "type": "number"
                },
                "by_stage": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "totals": {
            "type": "object",
            "properties": {
              "open_value_gbp": {
                "type": "number"
              },
              "open_weighted_gbp": {
                "type": "number"
              },
              "won_value_gbp": {
                "type": "number"
              },
              "lost_value_gbp": {
                "type": "number"
              },
              "weighted_forecast_gbp": {
                "type": "number"
              },
              "deals_without_value": {
                "type": "integer"
              }
            }
          }
        }
      },
      "PartnerApiKey": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "example": "avk_3f9a1c2b7d4e",
            "description": "Public identifier; not a secret."
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "expired",
              "revoked"
            ]
          },
          "rate_limit_per_minute": {
            "type": "integer"
          },
          "created_by": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_used_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "revoked_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "revoked_reason": {
            "type": "string",
            "nullable": true
          },
          "rotated_from_id": {
            "type": "string",
            "nullable": true
          },
          "expiring_soon": {
            "type": "boolean",
            "description": "True from 14 days before `expires_at` until expiry, while the key is not revoked. Rotate the key before it expires."
          }
        }
      },
      "PartnerWebhookEndpoint": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "disabled"
            ]
          },
          "secret_hint": {
            "type": "string",
            "nullable": true,
            "description": "First characters of the current signing secret."
          },
          "previous_secret_expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "consecutive_failures": {
            "type": "integer"
          },
          "disabled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "disabled_reason": {
            "type": "string",
            "nullable": true
          },
          "last_success_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_failure_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_by": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "PartnerWebhookDelivery": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "event_id": {
            "type": "string"
          },
          "event_type": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed",
              "abandoned"
            ]
          },
          "attempt_count": {
            "type": "integer"
          },
          "next_attempt_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_attempt_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_status_code": {
            "type": "integer",
            "nullable": true
          },
          "last_error": {
            "type": "string",
            "nullable": true
          },
          "delivered_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "NextCursor": {
        "type": "string",
        "nullable": true,
        "description": "The `cursor` of the next page, or `null` on the last page. Opaque: do not\nbuild, parse or store it beyond the walk it belongs to.\n"
      },
      "HasMore": {
        "type": "boolean",
        "description": "`true` when there is a next page, that is when `next_cursor` is not `null`."
      },
      "PartnerAlert": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "alert_type": {
            "type": "string"
          },
          "severity": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "device_id": {
            "type": "string",
            "nullable": true
          },
          "provider": {
            "type": "string",
            "nullable": true
          },
          "resolved": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "acknowledged_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "resolved_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "PartnerWebhookEvent": {
        "type": "object",
        "description": "The JSON body of every delivery. Verify the signature over the raw bytes\nbefore parsing.\n",
        "required": [
          "id",
          "type",
          "api_version",
          "organisation_id",
          "occurred_at",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "evt_5c0f...",
            "description": "Stable across retries; also in AmpVerve-Webhook-Id. Deduplicate on it."
          },
          "type": {
            "type": "string",
            "enum": [
              "alert.raised",
              "alert.resolved",
              "device.offline",
              "webhook.test"
            ]
          },
          "api_version": {
            "type": "string",
            "example": "2026-10-03"
          },
          "organisation_id": {
            "type": "string"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          },
          "data": {
            "type": "object",
            "description": "`alert.raised` and `alert.resolved`: `{\"alert\": <alert>}`.\n`device.offline`: `{\"device_id\", \"provider\", \"since\", \"alert\": <alert>}`.\n`webhook.test`: `{\"message\", \"endpoint_id\"}` when an administrator\nsends it, `{\"message\", \"sandbox\": true}` from the sandbox test operation.\n`<alert>` has the fields of `PartnerAlert` except `acknowledged_at`:\n`id`, `alert_type`, `severity`, `title`, `device_id`, `provider`,\n`resolved`, `created_at`, `resolved_at`. A sample sent from the\nsandbox also carries `\"sandbox\": true` in `data`. The whole body is\nserialised with sorted keys and no spaces.\n"
          }
        }
      },
      "AuditLogPage": {
        "type": "object",
        "description": "Read from `backend/core/audit_read.py`; not enforced by a response model.",
        "required": [
          "total",
          "limit",
          "offset",
          "returned",
          "scope",
          "filter",
          "source",
          "chain_verification",
          "entries"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "description": "Rows matching the filter within the scope."
          },
          "limit": {
            "type": "integer",
            "description": "The page size applied",
            "after clamping to 1 to 200.": null
          },
          "offset": {
            "type": "integer"
          },
          "returned": {
            "type": "integer",
            "description": "Rows in this page."
          },
          "scope": {
            "type": "object",
            "required": [
              "organisation_id",
              "cross_tenant"
            ],
            "properties": {
              "organisation_id": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Null for the platform cross tenant view."
              },
              "cross_tenant": {
                "type": "boolean"
              }
            }
          },
          "filter": {
            "type": "object",
            "required": [
              "action_prefix",
              "since",
              "until"
            ],
            "properties": {
              "action_prefix": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "since": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "until": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              }
            }
          },
          "source": {
            "type": "string",
            "const": "database"
          },
          "chain_verification": {
            "type": "object",
            "additionalProperties": true,
            "description": "For one organisation: `available` and `verified` true, with\n`organisation_id`, `intact`, `chained_entries`, `unchained_entries`\n(rows that predate the chain and are excluded from the integrity claim),\n`first_divergent_seq`, `head_hash` and `algorithm`. For the cross tenant\nview: `available` true, `verified` false and a `reason`. On a build\nwithout the chain module: `available` false and a `reason`.\n",
            "properties": {
              "available": {
                "type": "boolean"
              },
              "verified": {
                "type": "boolean"
              },
              "reason": {
                "type": "string"
              },
              "intact": {
                "type": "boolean"
              },
              "chained_entries": {
                "type": "integer"
              },
              "unchained_entries": {
                "type": "integer"
              },
              "first_divergent_seq": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "head_hash": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "algorithm": {
                "type": "string"
              }
            }
          },
          "entries": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "organisation_id",
                "org_name",
                "user_id",
                "actor_type",
                "action",
                "details",
                "created_at",
                "chain_seq",
                "entry_hash"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "organisation_id": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "org_name": {
                  "type": "string",
                  "description": "The organisation's name, or `Platform` for a row with no organisation."
                },
                "user_id": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "actor_type": {
                  "type": "string"
                },
                "action": {
                  "type": "string",
                  "description": "See `GET /api/hardware/admin/audit-log/actions` for the catalogue."
                },
                "details": {
                  "type": [
                    "object",
                    "null"
                  ],
                  "additionalProperties": true
                },
                "created_at": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date-time"
                },
                "chain_seq": {
                  "type": [
                    "integer",
                    "null"
                  ]
                },
                "entry_hash": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          }
        }
      },
      "DeviceHealthList": {
        "type": "object",
        "description": "Read from `routers/devices.py`; not enforced by a response model.",
        "required": [
          "devices",
          "summary",
          "freshness_summary",
          "freshness_thresholds"
        ],
        "properties": {
          "devices": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name",
                "provider",
                "asset_type",
                "brand",
                "status",
                "capabilities",
                "v2x_capable",
                "needs_relink",
                "is_stale",
                "last_reported_at",
                "last_reported_source",
                "freshness",
                "health",
                "live_telemetry"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "provider": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "asset_type": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "brand": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "status": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "capabilities": {
                  "type": "array",
                  "description": "The device's stored capabilities merged with its adapter's declared ones.",
                  "items": {
                    "type": "string"
                  }
                },
                "v2x_capable": {
                  "type": "boolean"
                },
                "needs_relink": {
                  "type": "boolean"
                },
                "is_stale": {
                  "type": "boolean"
                },
                "last_reported_at": {
                  "$ref": "#/components/schemas/DeviceLastReportedAt"
                },
                "last_reported_source": {
                  "$ref": "#/components/schemas/DeviceLastReportedSource"
                },
                "freshness": {
                  "type": "object",
                  "required": [
                    "band",
                    "age_seconds",
                    "cadence_seconds",
                    "missed_cycles",
                    "ever_ok",
                    "last_ok_at"
                  ],
                  "properties": {
                    "band": {
                      "type": "string",
                      "enum": [
                        "healthy",
                        "stale",
                        "dark",
                        "never_worked",
                        "pending"
                      ]
                    },
                    "age_seconds": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Seconds since the last successful read."
                    },
                    "cadence_seconds": {
                      "type": "integer",
                      "description": "The adapter cadence the device was judged against."
                    },
                    "missed_cycles": {
                      "type": [
                        "number",
                        "null"
                      ],
                      "description": "Age in the device's own cycles",
                      "one decimal.": null
                    },
                    "ever_ok": {
                      "type": "boolean",
                      "description": "False for a device that has never once reported."
                    },
                    "last_ok_at": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    }
                  }
                },
                "health": {
                  "type": "object",
                  "required": [
                    "last_poll_at",
                    "last_ok_at",
                    "last_status",
                    "last_error",
                    "consecutive_failures",
                    "needs_relink"
                  ],
                  "properties": {
                    "last_poll_at": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    },
                    "last_ok_at": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    },
                    "last_status": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "`ok`, `auth_failed`, `rate_limited`, `network_error` or `unknown`, as the poller recorded it."
                    },
                    "last_error": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Free text",
                      "truncated to 500 characters.": null
                    },
                    "consecutive_failures": {
                      "type": "integer"
                    },
                    "needs_relink": {
                      "type": "boolean"
                    }
                  }
                },
                "live_telemetry": {
                  "type": "object",
                  "description": "The poller's last stored values. Every field may be null.",
                  "properties": {
                    "power_kw": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "grid_power_kw": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "vehicle_soc_pct": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "mode": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "direction": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "is_plugged_in": {
                      "type": [
                        "boolean",
                        "null"
                      ]
                    },
                    "room_temp_c": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "outdoor_temp_c": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "today_kwh": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "vehicle_make": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "vehicle_model": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "summary": {
            "type": "object",
            "required": [
              "total",
              "needs_relink",
              "stale",
              "healthy"
            ],
            "properties": {
              "total": {
                "type": "integer"
              },
              "needs_relink": {
                "type": "integer"
              },
              "stale": {
                "type": "integer",
                "description": "Devices with `is_stale` true."
              },
              "healthy": {
                "type": "integer",
                "description": "`total - needs_relink - stale`."
              }
            }
          },
          "freshness_summary": {
            "type": "object",
            "required": [
              "healthy",
              "stale",
              "dark",
              "never_worked",
              "pending",
              "needs_attention"
            ],
            "properties": {
              "healthy": {
                "type": "integer"
              },
              "stale": {
                "type": "integer"
              },
              "dark": {
                "type": "integer"
              },
              "never_worked": {
                "type": "integer"
              },
              "pending": {
                "type": "integer"
              },
              "needs_attention": {
                "type": "integer",
                "description": "`stale + dark + never_worked`."
              }
            }
          },
          "freshness_thresholds": {
            "type": "object",
            "required": [
              "basis",
              "stale_after_cycles",
              "dark_after_cycles"
            ],
            "properties": {
              "basis": {
                "type": "string",
                "const": "per_adapter_cadence"
              },
              "stale_after_cycles": {
                "type": "integer",
                "const": 3
              },
              "dark_after_cycles": {
                "type": "integer",
                "const": 12
              }
            }
          }
        }
      },
      "EmsAvoidedFigure": {
        "allOf": [
          {
            "$ref": "#/components/schemas/EmsFigureSummary"
          },
          {
            "type": "object",
            "required": [
              "factor_rows_used",
              "factor_sets",
              "counterfactual"
            ],
            "properties": {
              "factor_rows_used": {
                "type": "integer",
                "description": "Distinct intensity rows applied."
              },
              "factor_sets": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/EmsFactorSet"
                }
              },
              "counterfactual": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The counterfactual the figure assumes",
                "in words.": null
              }
            }
          }
        ]
      },
      "EmsAvoided": {
        "type": "object",
        "description": "Read from `routers/ems.py` (`avoided_view`); not enforced by a response model.",
        "required": [
          "organisation_id",
          "site",
          "window_start",
          "window_end",
          "settings",
          "policy_id",
          "policy",
          "region",
          "pv_generation",
          "self_consumed_pv",
          "grid_export",
          "scope2_location",
          "self_consumed",
          "exported",
          "total_kgco2e",
          "daily",
          "method_notes"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "site": {
            "type": "object",
            "required": [
              "site_id",
              "name"
            ],
            "properties": {
              "site_id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "window_start": {
            "type": "string",
            "format": "date-time"
          },
          "window_end": {
            "type": "string",
            "format": "date-time"
          },
          "settings": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsSiteSettings"
              }
            ],
            "description": "The site's settings row, or an empty object when none exists."
          },
          "policy_id": {
            "type": "string",
            "const": "AV-EMS-AVD-1"
          },
          "policy": {
            "type": "string",
            "description": "The rules A1 to A5 in words."
          },
          "region": {
            "type": [
              "object",
              "null"
            ],
            "description": "Null when the site has no `neso_region_id`.",
            "properties": {
              "region_id": {
                "type": "integer"
              },
              "factor_set_id": {
                "type": "string"
              }
            }
          },
          "pv_generation": {
            "$ref": "#/components/schemas/EmsFigureSummary"
          },
          "self_consumed_pv": {
            "$ref": "#/components/schemas/EmsFigureSummary"
          },
          "grid_export": {
            "$ref": "#/components/schemas/EmsFigureSummary"
          },
          "scope2_location": {
            "allOf": [
              {
                "$ref": "#/components/schemas/EmsFigureSummary"
              },
              {
                "type": "object",
                "properties": {
                  "factor_sets": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/EmsFactorSet"
                    }
                  }
                }
              }
            ]
          },
          "self_consumed": {
            "$ref": "#/components/schemas/EmsAvoidedFigure"
          },
          "exported": {
            "$ref": "#/components/schemas/EmsAvoidedFigure"
          },
          "total_kgco2e": {
            "type": [
              "number",
              "null"
            ],
            "description": "Sum of the two figures",
            "null when neither has a value.": null
          },
          "daily": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "date",
                "periods",
                "readings_periods",
                "self_consumed_kwh",
                "export_kwh",
                "self_consumed_kgco2e",
                "self_consumed_periods",
                "export_kgco2e",
                "export_periods",
                "vintages"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "periods": {
                  "type": "integer"
                },
                "readings_periods": {
                  "type": "integer",
                  "description": "Half hours with both a meter and a PV reading."
                },
                "self_consumed_kwh": {
                  "type": "number"
                },
                "export_kwh": {
                  "type": "number"
                },
                "self_consumed_kgco2e": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "self_consumed_periods": {
                  "type": "integer"
                },
                "export_kgco2e": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "export_periods": {
                  "type": "integer"
                },
                "vintages": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "method_notes": {
            "type": "object",
            "required": [
              "intensity",
              "self_consumed",
              "exported",
              "not_claimed"
            ],
            "properties": {
              "intensity": {
                "type": "string"
              },
              "self_consumed": {
                "type": "string"
              },
              "exported": {
                "type": "string"
              },
              "not_claimed": {
                "type": "string"
              }
            }
          }
        }
      },
      "EmsFactorVersion": {
        "type": "object",
        "required": [
          "id",
          "organisation_id",
          "method",
          "factor_set_id",
          "vintage",
          "publisher",
          "valid_from",
          "valid_to",
          "status"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "method": {
            "type": "string",
            "enum": [
              "location_based",
              "market_based"
            ]
          },
          "factor_set_id": {
            "type": "string"
          },
          "vintage": {
            "type": "string"
          },
          "publisher": {
            "type": "string"
          },
          "valid_from": {
            "type": "string",
            "format": "date"
          },
          "valid_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status": {
            "type": "string",
            "enum": [
              "current",
              "superseded"
            ]
          },
          "supersedes_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "superseded_by_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "superseded_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "restatement_note": {
            "type": [
              "string",
              "null"
            ]
          },
          "evidence_ref": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "EmsFactorChange": {
        "type": "object",
        "required": [
          "id",
          "organisation_id",
          "version_id",
          "action",
          "changed_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "version_id": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "registered",
              "restated",
              "superseded",
              "market_factor_recorded",
              "market_factor_updated"
            ]
          },
          "actor": {
            "type": [
              "string",
              "null"
            ]
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "before": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "after": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "changed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "EmsRoutingMeta": {
        "type": "object",
        "required": [
          "organisation_id",
          "policy_id",
          "policy",
          "channels",
          "severities",
          "kinds",
          "not_offered"
        ],
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "policy_id": {
            "type": "string",
            "const": "AV-EMS-RTE-1"
          },
          "policy": {
            "type": "string",
            "description": "The rules R1 to R6 in words."
          },
          "channels": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "channel",
                "label",
                "configured",
                "detail"
              ],
              "properties": {
                "channel": {
                  "type": "string",
                  "enum": [
                    "email",
                    "teams_webhook",
                    "webhook",
                    "sms"
                  ]
                },
                "label": {
                  "type": "string"
                },
                "configured": {
                  "type": [
                    "boolean",
                    "null"
                  ],
                  "description": "Null for the webhook channels, whose state is per rule. Always false for `sms`."
                },
                "detail": {
                  "type": "string"
                }
              }
            }
          },
          "severities": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "critical",
                "warning",
                "info",
                "low",
                "unpriced"
              ]
            }
          },
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "alert",
                "weekly_digest"
              ]
            }
          },
          "not_offered": {
            "type": "object",
            "description": "Channels deliberately not offered, with the reason.",
            "properties": {
              "sms": {
                "type": "string"
              },
              "push": {
                "type": "string"
              }
            }
          }
        }
      },
      "EmsRoutingRuleInput": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "Required on create."
          },
          "channel": {
            "type": "string",
            "enum": [
              "email",
              "teams_webhook",
              "webhook"
            ],
            "description": "Required on create."
          },
          "site_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Match one site only. Every site when null."
          },
          "role": {
            "type": [
              "string",
              "null"
            ],
            "description": "`email` only: deliver to the organisation's active users holding this role. Stored upper case."
          },
          "min_severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning",
              "info",
              "low",
              "unpriced"
            ],
            "default": "warning",
            "description": "The lowest severity the rule delivers."
          },
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "alert",
                "weekly_digest"
              ]
            },
            "default": [
              "alert"
            ]
          },
          "target": {
            "type": [
              "string",
              "null"
            ],
            "description": "An email address for `email`; an `https://` URL for `teams_webhook` and `webhook`."
          },
          "enabled": {
            "type": "boolean",
            "default": true
          },
          "quiet_hours_start": {
            "type": [
              "string",
              "null"
            ],
            "description": "`HH:MM`, UTC. Needs `quiet_hours_end`."
          },
          "quiet_hours_end": {
            "type": [
              "string",
              "null"
            ],
            "description": "`HH:MM`, UTC. Needs `quiet_hours_start`."
          },
          "escalate_after_minutes": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "Needs `escalation_rule_id`."
          },
          "escalation_rule_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Another rule of the organisation to escalate to."
          }
        }
      },
      "EmsRoutingRule": {
        "type": "object",
        "required": [
          "id",
          "organisation_id",
          "name",
          "site_id",
          "role",
          "min_severity",
          "kinds",
          "channel",
          "target",
          "enabled"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "site_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "role": {
            "type": [
              "string",
              "null"
            ]
          },
          "min_severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning",
              "info",
              "low",
              "unpriced"
            ]
          },
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "alert",
                "weekly_digest"
              ]
            }
          },
          "channel": {
            "type": "string",
            "enum": [
              "email",
              "teams_webhook",
              "webhook"
            ]
          },
          "target": {
            "type": [
              "string",
              "null"
            ]
          },
          "secret_ref": {
            "type": [
              "string",
              "null"
            ],
            "readOnly": true,
            "description": "The name of the signing secret AmpVerve provisioned for the rule, or null. Not a secret value."
          },
          "enabled": {
            "type": "boolean"
          },
          "quiet_hours_start": {
            "type": [
              "string",
              "null"
            ]
          },
          "quiet_hours_end": {
            "type": [
              "string",
              "null"
            ]
          },
          "escalate_after_minutes": {
            "type": [
              "integer",
              "null"
            ]
          },
          "escalation_rule_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "EmsRoutingRuleState": {
        "allOf": [
          {
            "$ref": "#/components/schemas/EmsRoutingRule"
          },
          {
            "type": "object",
            "required": [
              "channel_configured",
              "channel_detail"
            ],
            "properties": {
              "channel_configured": {
                "type": "boolean",
                "description": "Whether the rule's channel can deliver at all."
              },
              "channel_detail": {
                "type": "string",
                "description": "Why",
                "in words.": null
              }
            }
          }
        ]
      },
      "EmsRoutingDelivery": {
        "type": "object",
        "required": [
          "id",
          "organisation_id",
          "rule_id",
          "alert_id",
          "kind",
          "channel",
          "recipient",
          "status",
          "detail",
          "attempted_at",
          "escalated_delivery_id"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "rule_id": {
            "type": "string"
          },
          "alert_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null for a weekly digest."
          },
          "kind": {
            "type": "string",
            "enum": [
              "alert",
              "escalation",
              "weekly_digest"
            ]
          },
          "channel": {
            "type": "string",
            "enum": [
              "email",
              "teams_webhook",
              "webhook"
            ]
          },
          "recipient": {
            "type": [
              "string",
              "null"
            ],
            "description": "The email address, or the host of a webhook target, or null when nothing was sent."
          },
          "status": {
            "type": "string",
            "enum": [
              "sent",
              "failed",
              "not_configured",
              "suppressed_quiet_hours",
              "no_recipients"
            ]
          },
          "detail": {
            "type": [
              "string",
              "null"
            ],
            "description": "The transport's answer or the reason nothing was sent."
          },
          "attempted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "escalated_delivery_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set by the escalation check to the escalation delivery's id, or to `grouped`, `not_needed` or `attempted`."
          }
        }
      },
      "EmsRoutingAlertResult": {
        "type": "object",
        "required": [
          "alert_id",
          "matched_rules",
          "already_delivered",
          "deliveries",
          "policy_id"
        ],
        "properties": {
          "alert_id": {
            "type": "string"
          },
          "matched_rules": {
            "type": "integer"
          },
          "already_delivered": {
            "type": "integer",
            "description": "Matching rules skipped because they had already sent or suppressed this alert."
          },
          "deliveries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmsRoutingDelivery"
            }
          },
          "policy_id": {
            "type": "string",
            "const": "AV-EMS-RTE-1"
          }
        }
      },
      "EmsAlertWebhookPayload": {
        "type": "object",
        "description": "The JSON body of a `webhook` channel delivery for kinds `alert` and `escalation`, serialised with sorted keys.",
        "required": [
          "kind",
          "alert_id",
          "organisation_id",
          "site_id",
          "site_name",
          "alert_type",
          "severity",
          "title",
          "message",
          "impact_gbp_per_day",
          "monetary_impact_gbp",
          "band_reason",
          "root_cause",
          "parent_alert_id",
          "created_at",
          "policy_id"
        ],
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "alert",
              "escalation"
            ]
          },
          "alert_id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "site_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "site_name": {
            "type": "string",
            "description": "The site's name, else its id, else `site`."
          },
          "alert_type": {
            "type": "string",
            "description": "For EMS alerts one of the six sources, for example `comms_gap`."
          },
          "severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning",
              "info",
              "low",
              "unpriced"
            ]
          },
          "title": {
            "type": "string"
          },
          "message": {
            "type": [
              "string",
              "null"
            ]
          },
          "impact_gbp_per_day": {
            "type": [
              "number",
              "null"
            ],
            "description": "Null when the alert is unpriced."
          },
          "monetary_impact_gbp": {
            "type": [
              "number",
              "null"
            ],
            "description": "Total over the alert window. Null when unpriced."
          },
          "band_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "The arithmetic and threshold behind the severity."
          },
          "root_cause": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when the alert was grouped under a root cause."
          },
          "parent_alert_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "policy_id": {
            "type": "string",
            "const": "AV-EMS-RTE-1"
          }
        }
      },
      "Notification": {
        "type": "object",
        "description": "Enforced by the service's `NotificationOut` response model.",
        "required": [
          "id",
          "type",
          "title",
          "body",
          "data",
          "channel",
          "priority",
          "is_read",
          "is_sent",
          "sent_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "For example `charging_complete` or `price_alert`."
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "data": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free form. `route` is the app path the notification opens."
          },
          "channel": {
            "type": "string",
            "description": "For example `push`."
          },
          "priority": {
            "type": "string"
          },
          "is_read": {
            "type": "boolean"
          },
          "is_sent": {
            "type": "boolean"
          },
          "sent_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "push_delivered": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Set only on creation; null on reads."
          }
        }
      },
      "NotificationPreferences": {
        "type": "object",
        "description": "Enforced by the service's `NotificationPreferenceOut` response model.",
        "required": [
          "id",
          "charging_complete",
          "price_alerts",
          "storm_watch",
          "maintenance_reminders",
          "weekly_reports",
          "marketing",
          "push_enabled",
          "email_enabled",
          "sms_enabled",
          "quiet_hours_start",
          "quiet_hours_end"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "charging_complete": {
            "type": "boolean"
          },
          "price_alerts": {
            "type": "boolean"
          },
          "storm_watch": {
            "type": "boolean"
          },
          "maintenance_reminders": {
            "type": "boolean"
          },
          "weekly_reports": {
            "type": "boolean"
          },
          "marketing": {
            "type": "boolean"
          },
          "push_enabled": {
            "type": "boolean"
          },
          "email_enabled": {
            "type": "boolean"
          },
          "sms_enabled": {
            "type": "boolean",
            "description": "A stored flag only. No SMS provider is integrated."
          },
          "quiet_hours_start": {
            "type": [
              "string",
              "null"
            ],
            "description": "For example `22:00`."
          },
          "quiet_hours_end": {
            "type": [
              "string",
              "null"
            ],
            "description": "For example `07:00`."
          }
        }
      },
      "NotificationPreferencesUpdate": {
        "type": "object",
        "description": "Every field optional; only the fields sent are changed.",
        "properties": {
          "charging_complete": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "price_alerts": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "storm_watch": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "maintenance_reminders": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "weekly_reports": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "marketing": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "push_enabled": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "email_enabled": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "sms_enabled": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "quiet_hours_start": {
            "type": [
              "string",
              "null"
            ]
          },
          "quiet_hours_end": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "FieldWorkOrderStatus": {
        "type": "string",
        "enum": [
          "open",
          "assigned",
          "in_progress",
          "on_hold",
          "resolved",
          "closed",
          "cancelled"
        ]
      },
      "FieldRaiseRequest": {
        "type": "object",
        "properties": {
          "priority": {
            "type": "string",
            "nullable": true,
            "enum": [
              "P1",
              "P2",
              "P3",
              "P4"
            ]
          },
          "title": {
            "type": "string",
            "nullable": true,
            "maxLength": 200
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 4000
          }
        }
      },
      "FieldSlaPolicy": {
        "type": "object",
        "properties": {
          "priority": {
            "type": "string",
            "enum": [
              "P1",
              "P2",
              "P3",
              "P4"
            ]
          },
          "response_minutes": {
            "type": "integer"
          },
          "resolution_minutes": {
            "type": "integer"
          },
          "clock": {
            "type": "string",
            "enum": [
              "elapsed",
              "business_hours"
            ]
          },
          "timezone": {
            "type": "string"
          },
          "business_start": {
            "type": "string"
          },
          "business_end": {
            "type": "string"
          },
          "business_days": {
            "type": "array",
            "items": {
              "type": "integer"
            }
          },
          "pause_on_hold": {
            "type": "boolean"
          },
          "source": {
            "type": "string",
            "enum": [
              "default",
              "organisation"
            ]
          },
          "description": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "FieldSlaPolicies": {
        "type": "object",
        "properties": {
          "organisation_id": {
            "type": "string"
          },
          "policies": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldSlaPolicy"
            }
          },
          "defaults": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldSlaPolicy"
            }
          },
          "missing": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "rule": {
            "type": "string"
          }
        }
      },
      "FieldSlaLeg": {
        "type": "object",
        "properties": {
          "due_at": {
            "type": "string",
            "format": "date-time"
          },
          "state": {
            "type": "string",
            "enum": [
              "pending",
              "met",
              "breached",
              "paused",
              "not_applicable"
            ]
          },
          "met_at": {
            "type": "string",
            "format": "date-time"
          },
          "elapsed_minutes": {
            "type": "number"
          },
          "minutes_remaining": {
            "type": "number"
          },
          "paused_since": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "FieldSlaPosition": {
        "type": "object",
        "description": "The order's SLA position at request time, on its own policy clock.",
        "properties": {
          "risk": {
            "type": "string",
            "enum": [
              "on_track",
              "due_soon",
              "breached",
              "met",
              "not_applicable"
            ]
          },
          "response": {
            "$ref": "#/components/schemas/FieldSlaLeg"
          },
          "resolution": {
            "$ref": "#/components/schemas/FieldSlaLeg"
          },
          "response_breached": {
            "type": "boolean"
          },
          "resolution_breached": {
            "type": "boolean"
          },
          "next_due_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "clock": {
            "type": "string",
            "enum": [
              "elapsed",
              "business_hours"
            ]
          }
        }
      },
      "FieldSlaFigures": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer"
          },
          "by_status": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "sla_applicable": {
            "type": "integer"
          },
          "responded": {
            "type": "integer"
          },
          "resolved": {
            "type": "integer"
          },
          "median_response_minutes": {
            "type": "number",
            "nullable": true
          },
          "median_resolution_minutes": {
            "type": "number",
            "nullable": true
          },
          "response_breaches": {
            "type": "integer"
          },
          "resolution_breaches": {
            "type": "integer"
          },
          "orders_breached": {
            "type": "integer"
          },
          "compliance_pct": {
            "type": "number",
            "nullable": true
          },
          "site_name": {
            "type": "string",
            "nullable": true,
            "description": "Present in by_site entries only."
          }
        }
      },
      "FieldWorkOrder": {
        "type": "object",
        "description": "One AmpVerve Field work order (table field_work_orders).",
        "properties": {
          "id": {
            "type": "string"
          },
          "organisation_id": {
            "type": "string"
          },
          "seq": {
            "type": "integer"
          },
          "reference": {
            "type": "string",
            "description": "WO-nnnnnn, numbered per organisation."
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "priority": {
            "type": "string",
            "enum": [
              "P1",
              "P2",
              "P3",
              "P4"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/FieldWorkOrderStatus"
          },
          "source_kind": {
            "type": "string",
            "enum": [
              "manual",
              "alert",
              "stale_feed"
            ]
          },
          "source_alert_id": {
            "type": "string",
            "nullable": true
          },
          "source_snapshot": {
            "type": "object",
            "additionalProperties": true
          },
          "device_id": {
            "type": "string",
            "nullable": true
          },
          "site_id": {
            "type": "string",
            "nullable": true
          },
          "assignee_kind": {
            "type": "string",
            "nullable": true,
            "enum": [
              "user",
              "contractor"
            ]
          },
          "assignee_ref": {
            "type": "string",
            "nullable": true
          },
          "assignee_name": {
            "type": "string",
            "nullable": true
          },
          "assignee_contact": {
            "type": "string",
            "nullable": true
          },
          "assigned_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "sla_policy": {
            "$ref": "#/components/schemas/FieldSlaPolicy"
          },
          "response_due_at": {
            "type": "string",
            "format": "date-time"
          },
          "resolution_due_at": {
            "type": "string",
            "format": "date-time"
          },
          "responded_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "resolved_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "closed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "hold_started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "paused_minutes": {
            "type": "number"
          },
          "response_breached": {
            "type": "boolean"
          },
          "resolution_breached": {
            "type": "boolean"
          },
          "resolution_code": {
            "type": "string",
            "nullable": true
          },
          "resolution_note": {
            "type": "string",
            "nullable": true
          },
          "cancel_reason": {
            "type": "string",
            "nullable": true
          },
          "reopen_count": {
            "type": "integer"
          },
          "created_by": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "sla": {
            "$ref": "#/components/schemas/FieldSlaPosition"
          }
        }
      }
    }
  }
}