{
  "openapi": "3.1.0",
  "info": {
    "title": "Postqron API",
    "version": "1.15.0",
    "summary": "Manage, sync and monitor cron jobs (R8, R9, R51).",
    "description": "The contract of the Postqron public API. It describes **what the service\nactually does**: paths, bodies, error codes and limits are compared against\nthe code on every `make ci` run — see \"What keeps this document honest\".\n\n## What keeps this document honest\n\nA hand-written contract drifts from the code within a month, and from that\nmoment it is worse than having none: whoever reads it builds a client on a\nfalse promise, and the defect shows up at their end. Here drift is prevented\nby an automated check\n(`services/api/internal/httpapi/contract_test.go`) that compares the\ndocument against the code and fails CI when they disagree:\n\n- **routes** — the `(method, path)` list comes from the real router, not\n  from a second hand-written copy;\n- **permissions** — `x-api-key-scope` is compared against the scope the code\n  enforces, and no operation may list `apiKey` among its authentication\n  methods unless the code registers it behind a scope;\n- **error codes** — the `ErrorCode` enumeration is compared against the\n  codes the service actually writes into responses;\n- **response fields** — the names, requiredness and nullability of every\n  schema are compared against the serialised structures. This is also what\n  defends R43: **a field added to a response does not pass CI until it is\n  declared here**, and declaring it is the moment you notice you are\n  exposing something;\n- **enumerations** — environments, statuses, methods, scopes, providers and\n  plans come from the same constants the service enforces.\n\nA second check, `services/api/internal/contract`, answers a different\nquestion that the first one cannot: not *is this document true today*, but\n*has it withdrawn anything*. A field removed from the code and from this\ndocument at the same time passes the alignment check with no complaint — code\nand contract agree perfectly on a promise that was quietly taken back. See\n\"Versioning and deprecation\" below.\n\nThe document is also validated against the OpenAPI 3.1 schema (`make\nopenapi`), locally and without network access: this repository's CI runs\nlocally only (AGENTS.md §2).\n\n## Contract version\n\n`info.version` is the version of **the contract**, not of the service: the\nbuild of the API is in `GET /healthz`. It changes when what this document\npromises changes:\n\n| | |\n|---|---|\n| major | a promise is withdrawn or narrowed: a route or field disappears, an error code changes meaning, a constraint tightens |\n| minor | a promise is added: routes, optional fields, new values in an enumeration |\n| patch | the wording changes, the behaviour does not |\n\n**Routes carry no version prefix** (`/jobs`, not `/v1/jobs`): the version\nlives here. Two versions of a route never run side by side — the alternative\nis a second implementation of every operation, maintained until someone\nremembers to delete it, and what actually happens is that nobody does. What\nreplaces it is notice: the next section.\n\n## Versioning and deprecation (R52)\n\n### What is compatible, and what is not\n\nA change is **compatible** when it neither takes away something you could\nrely on nor demands something you did not have to give. It ships in a minor\nversion, with no notice:\n\n- a new route, or a new optional parameter on an existing one;\n- a new field in a response — including a required one: that is one more\n  promise, not one less;\n- a new value in an enumeration;\n- a field that stops being required in a request body;\n- a new value accepted where fewer were accepted before.\n\nA change is **incompatible** when it does either of those things. It needs a\nmajor version *and* the notice below:\n\n- a route, a field or an enumeration value disappears;\n- a parameter or a body field becomes required;\n- a response stops guaranteeing a field it declared required;\n- a field that could never be `null` starts being able to;\n- an operation stops accepting a credential, or requires a different API key\n  scope;\n- a documented `2xx` response stops being produced.\n\nThe line is not a matter of taste and it is not applied by hand: it is\n`services/api/internal/contract`, and every change to this document is\nclassified by it on every CI run. See \"What keeps this promise\" below.\n\n### What we ask of a client\n\nTwo things, and the compatibility rule above is only true if you do them:\n\n1. **Ignore fields you do not know.** A response will grow fields.\n2. **Tolerate values you do not know** in an enumeration you receive. New\n   plans, new AI providers, new alert channels will appear; treat an\n   unrecognised value as \"something else\", not as an error.\n\nWithout these, adding an alert channel would be a breaking change, and a\nproduct that cannot add an alert channel without ninety days' notice does not\nadd one.\n\n### How much notice, and how you get it\n\n**Never less than 30 days between the announcement and the removal — and the\nexact date is always the one in the `Sunset` header.**\n\nRead those two halves together, because the second is the one that is worth\nsomething to you. We do not publish a single number here and ask you to trust\nit: **each deprecation carries its own retirement date**, and from the day it\nis announced that date is on every response of the affected route. You do not\nget a general promise, you get *your* date, and you can read it with a\nprogram.\n\nWhy not one number for everything: an experimental route nobody calls and a\nfield that production integrations depend on do not deserve the same wait. A\nsingle figure would be too long for the first and — the day it mattered —\narguably too short for the second, and a rule that imposes an obviously\npointless wait is a rule that gets worked around rather than followed. So the\nlength is chosen per case, above a floor that is not ours to move: **30 days\nis what §9 of the Terms of Service promises anyone**, and an API removal\ncannot promise less than the contract with the user already does. A register\nentry that tried would fail our build, naming the entry and both its dates.\n\nIn practice you should expect considerably more than the floor. Our default,\nused whenever there is no reason to choose otherwise, is **90 days** —\n`RemovalNoticeDefault` in `services/api/internal/contract`, declared there\nwith the reason: the recipient of the notice is a program somebody else\nwrote, which has to be changed, tested and redeployed by people with other\npriorities, and 30 days is enough to read an announcement, not to rewrite an\nintegration.\n\nThe announcement reaches you three ways:\n\n- the operation or field is marked `deprecated: true` **in this document**,\n  which keeps describing it for the whole notice period — a deprecated\n  element still works;\n- every response of a deprecated route carries `Deprecation` (RFC 9745) with\n  the day of the announcement and `Sunset` (RFC 8594) with the first day it\n  may be withdrawn, plus a `Link` to what to use instead. **These are\n  machine-checkable**: you can find out without reading anything, and\n  `Sunset` is the authoritative date for that element;\n- the removal, when it happens, is a major version.\n\n### What this policy does not promise\n\nSaying this is part of keeping the rest true:\n\n- **the prose of `message`** — it is for humans, it changes without notice,\n  and `code` is what you branch on;\n- **the order of results** where this document does not declare one;\n- **the opaque contents of a cursor**, which you send back as it arrived and\n  do not parse;\n- **anything undocumented that happens to work today**;\n- **an urgent security fix.** If a promise turns out to expose something it\n  should never have exposed, it is withdrawn as fast as it takes to withdraw\n  it. That is not a loophole in this policy: it is outside it, and the\n  decision and its reason are recorded in the deprecation register.\n\n### What keeps this promise\n\nThe same thing that keeps the rest of this document honest — a check that\nfails on its own. The surface of this contract, at the last release, is\nrecorded in `services/api/internal/contract/superficie.json`: every route,\nparameter, field, requiredness and enumeration value. On every CI run the\nsurface of today is extracted from this document and compared against it, and\n**anything withdrawn that is not declared in the deprecation register, with\nits announcement date and its sunset date, fails the build**.\n\nA withdrawn promise is therefore not something that can happen by\ninattention. It can still happen deliberately — but deliberately means a\ndated entry in a register, in a diff somebody reads.\n\nThe same check holds the floor above. The register will not load if any entry\nputs fewer than 30 days between its announcement and its retirement date, and\nthe build fails naming the entry and both dates. That is where the promise at\nthe top of this section stops being a sentence and starts being enforced.\n\n## Authentication\n\nTwo credentials, and the difference matters:\n\n- **session** (R14) — the `pq_session` cookie, or `Authorization: Bearer\n  <token>` for clients without a cookie jar. It covers everything the user\n  can do: whoever holds the password does not need permission from\n  themselves.\n- **API key** (R9) — `Authorization: Bearer pq_live_...`, with **scopes**. It\n  works only where declared, and only for the scope named in\n  `x-api-key-scope`.\n\nWhen a request carries both, **the key wins**: running the operation with\nthe full powers of the session while ignoring the limits of the key the\ncaller explicitly chose to present would be a silent escalation.\n\nRoutes that **issue or replace credentials** — `/keys`, `/secrets`,\n`/ai/keys` — that **commit to a payment** — `/billing` — and that **destroy\nthe account** — `/account/deletion` — accept the session only. This is not an\noversight: a read-only key one request away from issuing a write key would\nmake scopes a formality; a key able to replace a secret would redirect the\nuser's credentials to a target chosen by whoever holds the key; and a key\nable to request account deletion would make a service credential forgotten\nin a config file enough to destroy someone's work.\n\n## Language\n\n**This API answers in English, and in English only.** Every `message` it\nwrites is English prose; nothing it returns is translated.\n\n**`Accept-Language` is ignored.** The service does not read the header, does\nnot vary on it, and does not fail when it is present: the same request\ncarrying `en-US` and the same request carrying `it-IT` get the same bytes\nback. There is no list of supported languages, because there is no\nnegotiation to support one — an API that looks localisable and is not is\nworse than one that says it speaks a single language.\n\nTwo things are localised, and neither of them is this API's responses:\n\n- **transactional email**, in the language the user chose, which lives in\n  the account and not in a request header;\n- **the content of the website and the dashboard**, in five languages.\n\nThe consequence for a client: do not build a language selector on top of a\nheader this service does not read, and do not show `message` to a user whose\nlanguage you do not know is English. Show your own text, chosen by `code` —\nwhich is what `code` is for.\n\nIf these responses are ever localised, that is a change to **this document**:\nit would name the header, list the supported languages, and say what happens\nwhen the language asked for is not among them. Until such a section exists\nhere, the answer to \"which languages does the API speak\" is one: English.\n\n## Errors\n\nEvery error uses the same envelope and the same stable field:\n\n```json\n{ \"error\": { \"code\": \"plan_limit_jobs\", \"message\": \"…\", \"limit\": \"jobs\", \"plan\": \"free\" } }\n```\n\n`code` is **stable and untranslated**, and it is what you branch on (R53).\n`message` is prose for a human and may change without notice: do not match\non it. It is written in English — see \"Language\" above for what that does\nand does not promise.\n\n### The two `429` responses are not the same error (R10)\n\nConfusing them produces a hostile product, and this contract keeps them\napart down to the schema level:\n\n- **plan quota** — `plan_limit_write_rate`, `plan_limit_manual_trigger`.\n  Schema [PlanLimitError](#/components/schemas/PlanLimitError): it names the\n  `plan` and the `limit`, because there upgrading really is the answer.\n- **technical ceiling** — `rate_limited`, `execution_ceiling`,\n  `stream_ceiling`. Schema\n  [ServiceLimitError](#/components/schemas/ServiceLimitError): it **has no\n  `plan` and no `limit`, and the schema forbids them from appearing**. It is\n  a defence of the service, identical on every plan: no plan grants more of\n  it, and suggesting an upgrade to whoever hits it would be a commercial lie.\n\nA client decides whether to show an upgrade prompt by looking at those two\nfields, not at the text of the message.\n\n### Retention applies to reads too (R10-bis)\n\nThe plan limits (3, 15, 30, 90 days) apply to the execution log. A request\nthat **explicitly** asks beyond the plan's retention is rejected with `403\nplan_limit_retention`, which says which plan keeps how much and from which\ninstant: it is not silently narrowed. A request that asks for no window at\nall gets the plan's retention as the default, which is not a disguised\nrejection — there is nothing the caller asked for and is not getting.\n\n## Pagination (R53)\n\nPaginated lists are **cursor-based**, with the same `page` envelope\neverywhere. On a one-second job the log grows by 86,400 rows a day, and an\n`offset` would force the database to discard thousands of rows to serve\nfifty.\n\n`page.next_cursor` set to `null` is **the only** end-of-list condition: it\nmust not be inferred from the number of rows returned, because a full page\nmay well be the last one. The cursor is opaque: send it back exactly as it\narrived.\n\nA `limit` above the maximum is not an error, it is a resized request: the\nsize actually served is always in `page.limit`.\n\n## Write idempotency (R53)\n\n- There is no `PUT`: modifications are `PATCH` with \"what you do not send\n  does not change\" semantics, so they are repeatable. An explicit `null` is a\n  value — it clears the field — and is distinct from an absent field.\n- `POST /jobs` is idempotent on the **name**, which is the stable identity of\n  a job: a second attempt with the same name gets `409 job_name_taken`, not a\n  duplicate.\n- `POST /ai/keys` **replaces** the provider's live key instead of rejecting\n  it: pasting a provider's key a second time is not creating a duplicate, it\n  is updating one.\n- `POST /jobs/{id}/executions` is idempotent on the occurrence (R4): the\n  quadruple `(job, instant, environment, attempt)` is the key, and an already\n  recorded occurrence gets `409 execution_already_exists`.\n- `POST /account/deletion` is idempotent on the pending request: a second\n  call gets `409 deletion_already_requested`, not a second grace window.\n- Inbound webhooks recognise repeated deliveries and answer `duplicate`\n  without producing a second effect.\n\n## Secrets (R9, R18, R42, R43)\n\n**No response schema contains a secret**, and not because of a filter applied\nwhen responding: they are fields the structures do not have, because what is\nnever serialised cannot escape by accident. In particular:\n\n- the plaintext value of an API key appears **exactly once**, in\n  `ApiKeyCreated.secret`, at creation time. There is no `GET /keys/{id}`, no\n  parameter to reveal it, and after that response the value is not\n  recoverable by anyone, not even an administrator;\n- workspace secrets are **never** returned, not even on creation: the user\n  already has them;\n- AI keys are never returned, not even a fragment of them;\n- `Job.request.headers` returns what was written, i.e. the `${VAR}`\n  **references**, not the resolved values: resolution happens at execution\n  time, inside the engine;\n- `Execution.response_excerpt` is a truncated excerpt of the target's\n  response, with secrets redacted (R43).\n",
    "contact": {
      "name": "APDSoftware",
      "url": "https://postqron.com"
    },
    "license": {
      "name": "UNLICENSED"
    }
  },
  "servers": [
    {
      "url": "https://api.postqron.com",
      "description": "Production. Declared here since 2026-08-29, when the address stopped being\na plan and became a machine that answers: the note that used to stand in\nthis place said the production base URL was not decided anywhere in the\nrepository, and leaving it would have made this contract say something\nfalse about itself.\n\nIt comes first on purpose. A client generated from this document picks the\nfirst server, and a generated client that defaults to a developer's\nlaptop is the kind of mistake nobody notices until it reaches someone\nelse's machine.\n"
    }
  ],
  "tags": [
    {
      "name": "jobs",
      "description": "Cron jobs and executions (R8)."
    },
    {
      "name": "secrets",
      "description": "Workspace secrets resolved at execution time (R42, R43)."
    }
  ],
  "paths": {
    "/jobs": {
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "listJobs",
        "summary": "List jobs (R8)",
        "x-api-key-scope": "jobs:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "enabled",
            "in": "query",
            "description": "Only enabled (`true`) or paused (`false`) jobs. Absent returns both.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "environment",
            "in": "query",
            "description": "Only jobs living in this environment.",
            "schema": {
              "$ref": "#/components/schemas/Environment"
            }
          },
          {
            "name": "include_archived",
            "in": "query",
            "description": "Include archived jobs, i.e. those no longer present in the\n`cron.yaml` they came from (R13). Defaults to `false`.\n",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of the list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobList"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` with `details` per parameter, or\n`invalid_cursor`. A `limit=abc` is not silently ignored: a page of a\ndifferent size than the one asked for is only discovered by counting\nrows.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "jobs"
        ],
        "operationId": "createJob",
        "summary": "Create a job (R1, R8)",
        "description": "The body mirrors the `cron.yaml` schema (SPEC §9) — `schedule` and\n`every`, `request`, `timeout`, `retries`, `alerts` — because a job created\nthrough the API and one synced from a repository are the same row of the\nsame table: two vocabularies for the same thing would force a mental\ntranslation every time, and translation is where defects are born.\n\nAbsent fields take their default values, which the response returns\nalready resolved.\n",
        "x-api-key-scope": "jobs:write",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Job created.",
            "headers": {
              "Location": {
                "description": "`/jobs/{id}` of the created job.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "description": "`insufficient_scope`, or a **capacity** plan limit (R15):\n`plan_limit_jobs`, `plan_limit_resolution`,\n`plan_limit_environments`. `403` and not `429` because retrying\nwould change nothing: the plan or the request has to change.\n",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/PlanLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "`job_name_taken` — the name is the stable identity of a job, and the\nkey on which reconciliation of a `cron.yaml` decides whether to\ncreate or update (R13).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/BodyTooLarge"
          },
          "429": {
            "$ref": "#/components/responses/WriteRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "getJob",
        "summary": "Read one job",
        "x-api-key-scope": "jobs:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "The job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "jobs"
        ],
        "operationId": "updateJob",
        "summary": "Partially update a job",
        "description": "**What you do not send does not change**, so the request is repeatable.\nAn explicit `null` is a value and not an absent field: it clears the\ndescription or the body, and on `schedule` or `every` it retires a\nscheduling mode.\n\nA request with no fields at all gets `400 empty_patch`: it is almost\nalways a client that built the body wrong, and accepting it silently\nwould answer `200` to someone who believes they changed something.\n",
        "x-api-key-scope": "jobs:write",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed`, `invalid_request`, `empty_patch`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "description": "`insufficient_scope`, `plan_limit_resolution`,\n`plan_limit_environments`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/PlanLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "409": {
            "description": "`job_name_taken`; `job_managed_by_repository` — the job is defined in\na `cron.yaml` and syncing would revert anything changed from here, so\nit can only be paused; `job_archived`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/BodyTooLarge"
          },
          "429": {
            "$ref": "#/components/responses/WriteRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "jobs"
        ],
        "operationId": "deleteJob",
        "summary": "Delete a job",
        "x-api-key-scope": "jobs:write",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "responses": {
          "204": {
            "$ref": "#/components/responses/NoContent"
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "409": {
            "description": "`job_managed_by_repository` — remove it from the `cron.yaml` that defines it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/WriteRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{id}/executions": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "listExecutions",
        "summary": "Execution log (R6)",
        "description": "One page of the log, most recent first. **The plan's retention applies\nhere too** (R10-bis): without `since`, the default window is the plan's\nretention; with a `since` or an `until` that predates it, the request is\nrejected with `403 plan_limit_retention` instead of being silently\nnarrowed.\n",
        "x-api-key-scope": "executions:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "status",
            "in": "query",
            "description": "Filter by outcome. Repeatable (`?status=failed&status=timed_out`) or\ncomma-separated (`?status=failed,timed_out`): the first is what HTTP\nclients generate on their own, the second is what people type.\n",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/ExecutionStatus"
              }
            }
          },
          {
            "name": "trigger",
            "in": "query",
            "description": "Filter by attempt origin. Repeatable or comma-separated.",
            "explode": true,
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/ExecutionTrigger"
              }
            }
          },
          {
            "name": "environment",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Environment"
            }
          },
          {
            "name": "since",
            "in": "query",
            "description": "RFC 3339 instant. Subject to the plan's retention (R10-bis).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "until",
            "in": "query",
            "description": "RFC 3339 instant, later than `since`.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of the log.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExecutionList"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` with `details` per parameter, `invalid_cursor`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/RetentionExceeded"
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "`executions_unavailable` — the periodic maintenance of the log\npartitions has not run. It is not the caller's fault and retrying\nmakes sense: the response carries `Retry-After`.\n",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "jobs"
        ],
        "operationId": "triggerExecution",
        "summary": "Run now (R8)",
        "description": "Records a manual execution. It answers **`202` and not `201`**: the row\nhas been created, but the execution has not happened — the engine will\nrun it. A `201` would promise the target has already been called, and a\nclient reading its outcome straight away would find nothing.\n\nThe body is optional: it only matters for jobs living in more than one\nenvironment.\n\nThe scope is **`executions:trigger`**, distinct from `jobs:write`:\nchanging a job's definition and firing an outbound call right now are two\ndifferent powers, and a dashboard key wants the second without the first.\n",
        "x-api-key-scope": "executions:trigger",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TriggerInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Execution recorded. The engine will run it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Execution"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed`, `invalid_request`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "description": "`insufficient_scope`, `plan_limit_environments`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "409": {
            "description": "`job_disabled` — the job is paused and must be re-enabled before it\ncan run; `job_archived`; `execution_already_exists` — that occurrence\nis already recorded (R4).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/BodyTooLarge"
          },
          "429": {
            "description": "**Two different errors under the same status** (R10), and the\ndifference reaches the body:\n\n- `plan_limit_manual_trigger` — plan quota: it names `plan` and\n  `limit`, because there upgrading is the answer;\n- `execution_ceiling` — technical ceiling on the workspace's\n  concurrent executions: **no `plan`, no `limit`**, because no plan\n  grants more of it;\n- `rate_limited` — technical ceiling on requests.\n\nBoth carry `Retry-After`.\n",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/PlanLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ServiceLimitEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{id}/executions/stream": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "streamExecutions",
        "summary": "Execution log in real time (SPEC §4.2)",
        "description": "A `text/event-stream` (SSE) feed of a job's executions. It is the same\nread of the log delivered another way, hence **the same scope**: asking\nfor a different one would force a second key to see the same rows.\n\n### What OpenAPI cannot express\n\nThe format describes bodies, not streams: the `content` below can only\nstate that the response is text. The wire grammar is this, and it is part\nof the contract:\n\n```\nretry: 3000                  suggested reconnection delay, in ms\n: ping                       heartbeat every 20s (SSE comment, no event)\n\nevent: execution             one execution\nid: <position>               present only on terminal states\ndata: {…}                    the Execution schema, byte for byte the same\n                             as GET /jobs/{id}/executions\n\nevent: overflow              the client was not reading: reconnect\ndata: {…}                    the StreamNotice schema, code=stream_overflow\n\nevent: reopen                end of the connection's lifetime (30 min)\ndata: {…}                    the StreamNotice schema, code=stream_reopen\n```\n\nThe `Execution` and `StreamNotice` schemas describe the **`data:`\npayloads**: that binding is what the format gives no way to declare, and\ndeclaring it in prose beats leaving it unsaid.\n\n**`id:` is not on every event, and that is deliberate.** A log row is\nupdated in place, so only a **terminal** state can be a position to\nresume from; SSE says an event without `id:` does not move the client's\n`Last-Event-ID`, so intermediate states are seen without entering the\nhistory.\n\n**Reconnection:** the browser resends `Last-Event-ID` by itself. The\n`last_event_id` parameter exists because `EventSource` cannot set\nheaders, so a client resuming *after an application error* — rather than\nafter a network drop — would have no other way to say where to restart.\n\n**A connection lives at most 30 minutes**, then closes with `event:\nreopen`. This is not a resource defence: the stream authorises the job\nand reads the plan exactly once, and a connection living a whole day\nwould keep delivering rows to an expired session with yesterday's plan\nretention.\n\n**Errors arrive before the stream**, as JSON and with the right status:\nafter the first byte of `text/event-stream` there is no way to change the\nstatus, which is why everything that can fail happens before anything is\nwritten.\n",
        "x-api-key-scope": "executions:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "Last-Event-ID",
            "in": "header",
            "description": "The position to resume from. Browsers resend it on their own.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "last_event_id",
            "in": "query",
            "description": "The same position, for `EventSource` clients that cannot set headers.\nThe header takes precedence.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "since",
            "in": "query",
            "description": "RFC 3339 instant to start from. Subject to the plan's retention\n(R10-bis). Absent, the stream starts just before now — which is what\n\"real time\" means.\n",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The stream is open. See the grammar in the description.",
            "headers": {
              "Cache-Control": {
                "description": "Always `no-store`: a stream has no version to revalidate.",
                "schema": {
                  "type": "string"
                }
              },
              "X-Accel-Buffering": {
                "description": "Always `no`. It is for reverse proxies that buffer the response\nbefore forwarding it: without it events would arrive in batches,\nand \"real time\" would be a lie.\n",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "SSE event stream. The `data:` payloads are described by\n`Execution` and `StreamNotice`; OpenAPI has no way to bind them\nto this media type.\n"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_cursor` — unreadable `Last-Event-ID`; `validation_failed` — `since` is not an RFC 3339 instant.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/RetentionExceeded"
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "429": {
            "description": "`stream_ceiling` — a **technical ceiling** on concurrently open\nstreaming connections. Like every technical ceiling it has no `plan`\nand no `limit`: no plan grants more of it, and no upgrade raises it.\nOr `rate_limited`.\n",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServiceLimitEnvelope"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "`stream_unavailable` — streaming is not available on this instance.\nThe log stays readable through `GET /jobs/{id}/executions`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/executions/export": {
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "exportExecutions",
        "summary": "Download the execution log (R27)",
        "description": "The execution log as a file to work with — a spreadsheet to open, a table\nto load — in **CSV** or **JSON**. Included in Team and Agency (SPEC §8);\non the other plans the request is rejected with `plan_limit_log_export`,\nand the log stays readable a page at a time through\n`GET /jobs/{id}/executions`.\n\n### Why its own path, and not `?format=csv` on the log\n\nBecause the job filter is **optional**, and a subresource cannot have its\nown parent be optional. The normal case is exporting everything — a month\nof every job, to chart elsewhere — and forcing a loop per job would hand\nback N files to stitch, each with a window computed at a slightly\ndifferent moment.\n\nAn API key with `executions:read` is enough, and that is the opposite of\n`/account/export`: these are the same rows that endpoint already serves,\nin another format, not a person's archive. A scheduled weekly export is\nexactly what API keys are for.\n\n### Where the file stops, and how it says so\n\n**There is no row limit.** The manifest says so out loud — `row_limit` is\n`null` in JSON, `row_limit=none` in the CSV preamble — because a silent\ncap is the classic way a file looks complete and is not. The only thing\nbounding the file is the window, and the window is declared in it:\n`since` (inclusive) and `until` (exclusive), both RFC 3339 in UTC.\n\nWithout `since` the window starts at the plan's **retention floor**, and\nthe manifest flags that with `bounded_by_retention`. The distinction is\nthe point: rows older than that are not hidden by this export, they were\n**deleted**, as Privacy Policy §2.2 declares. Without the flag the file\nwould read as \"nothing ran before that day\", which is false. Asking for a\n`since` that predates the floor is rejected with `plan_limit_retention`,\nnot silently narrowed.\n\nWithout `until` the window closes at the instant the export started,\nwhich is what makes the file **resumable**: a next export can start where\nthis one stopped.\n\n### How to tell a complete file from a truncated one\n\n**Not by the status code.** `200` is sent with the first bytes, long\nbefore anyone knows there will be a last one.\n\n- **CSV** ends with a `# summary … complete=true` comment line.\n- **JSON** has `summary.complete` true. A failure on our side still\n  closes the document, with `complete` false and a reason; a dropped\n  connection leaves the document unterminated, which is invalid JSON —\n  the loudest possible way to notice, and the reason this format is one\n  document rather than one object per line.\n\n### Types are declared, not guessed\n\nInstants are RFC 3339 in UTC, durations are whole milliseconds,\n`response_status` is the HTTP code as an integer, and an empty field\nmeans **absent** — not zero. The CSV declares all of this in its\npreamble, along with one transformation it applies and JSON does not:\ntext fields starting with `=`, `+`, `-` or `@` are prefixed with an\napostrophe, because those characters make Excel and Google Sheets\n**execute** the cell, and job names and error texts are not written by\nus.\n\n### Cost, and the two ceilings on it\n\nExecutions are read a job at a time, in batches, walking the partition\nkey: the service holds one row, never a file, and no query lasts as long\nas the download. Two ceilings sit on top, both **technical** (R10) — no\nplan grants more of either:\n\n- ten exports per hour per account (`export_too_soon`);\n- two exports **in flight at once** across the service\n  (`export_ceiling`), which is the one that protects the engine: the\n  database pool is shared with the dispatcher.\n",
        "x-api-key-scope": "executions:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "description": "Defaults to `csv`, which is the one a browser can open straight away. The file states which format it is regardless.",
            "schema": {
              "$ref": "#/components/schemas/ExecutionExportFormat"
            }
          },
          {
            "name": "job",
            "in": "query",
            "description": "Restrict to one job. Absent means every job of the account, archived and paused ones included — their rows exist and are often the ones worth exporting.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "environment",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Environment"
            }
          },
          {
            "name": "since",
            "in": "query",
            "description": "RFC 3339 instant, inclusive. Defaults to the plan's retention floor. Subject to the plan's retention (R10-bis).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "until",
            "in": "query",
            "description": "RFC 3339 instant, exclusive and later than `since`. Defaults to the moment the export starts.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The file, streamed. Compressed when the request says\n`Accept-Encoding: gzip` — worth asking for: execution rows are highly\nrepetitive and compress about ten to one.\n",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "`attachment; filename=\"postqron-executions-<instant>.csv\"` (or `.json`). The name carries the instant, not the window: the window is decided by the service, after the headers have left, and a name guessing it would lie exactly when the parameters are absent."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "`no-store`: this body is an account's log and must not rest on an intermediate disk."
              }
            },
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "description": "A comment block (`#`), then the header row, then one row per\nexecution, then a `# summary` line. Readers skip the comments\nwith their comment option (`comment='#'` in pandas,\n`comment.char='#'` in R); a spreadsheet shows them as\nsingle-cell rows, which is where a person reads them.\n"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "One document, streamed. `summary` is what makes it complete.",
                  "properties": {
                    "export": {
                      "$ref": "#/components/schemas/ExecutionExportManifest"
                    },
                    "executions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ExecutionExportRow"
                      }
                    },
                    "summary": {
                      "$ref": "#/components/schemas/ExecutionExportSummary"
                    }
                  },
                  "required": [
                    "export",
                    "executions",
                    "summary"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` with `details` per parameter: unknown `format` or `environment`, `until` not after `since`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "description": "`plan_limit_log_export` — the plan does not include the export (R27,\nSPEC §8). The rejection names the plan **and the alternative that\nremains**: a bare `403` on a read-only feature reads as \"your logs\nare unreachable\", which is not what happened.\n\nAlso `plan_limit_retention` (R10-bis) and `insufficient_scope`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/PlanLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "429": {
            "description": "`export_too_soon` — this account asked for an export recently;\n`export_ceiling` — the service is already serving as many exports as\nit will serve at once; `rate_limited` — the general ceiling. All\nthree are **technical**: no `plan`, no `limit`, always a\n`Retry-After`.\n",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/ServiceLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "503": {
            "description": "`export_unavailable` — the export is not configured on this instance.\nThe route stays described and registered on purpose: a `404` would\nsend whoever calls it looking for a wrong path instead of a missing\nconfiguration. The log stays readable through\n`GET /jobs/{id}/executions`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/executions/metrics": {
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "executionMetrics",
        "summary": "Duration, failure rate and trend (R28)",
        "description": "How long executions take, how many fail, and how both move over time —\nper job and per environment. Included in Team and Agency (SPEC §8); on\nthe other plans the request is rejected with `plan_limit_metrics`, and\nthe log stays readable a page at a time through\n`GET /jobs/{id}/executions`.\n\nOne route and not three, because the three answers are three readings of\nthe same buckets. Three routes would have resolved three windows at three\nslightly different instants, and the screen would have shown a total\ncomputed over one window and a chart over another — a difference visible\nonly in the moment an hour closes, which is exactly when nobody can\nexplain it.\n\n### Nothing here has to be recomputed by the caller\n\nThat is SPEC §8 applied to a metrics screen: the failure rate comes out\ndivided, the quantiles estimated, the empty buckets already in line, the\nwindow already declared. A dashboard dividing on its own would end up\ndividing differently from us the day either side changes its mind about\nwhat sits in the denominator — and nobody would notice, because both\nnumbers would look plausible.\n\n### An occurrence is not an attempt\n\nThe distinction decides every number in the response, so it is stated\nrather than implied. An **occurrence** is the triple (job,\n`scheduled_for`, environment) — the thing that was due. An **attempt** is\none call. A retry (R5) does not create a new occurrence: it shares\n`scheduled_for` and raises `attempt`.\n\n- Outcomes count **occurrences**, and an occurrence's outcome is that of\n  its highest-numbered attempt. **A job that succeeds on the third try is\n  one success**, not two failures and a success.\n- `retried` counts occurrences that took more than one attempt. It is\n  there so retries do not vanish inside successes: a job that always\n  succeeds on the third try has a failure rate of zero and is still worth\n  looking at.\n- Durations count **attempts**, because a duration is a property of a\n  call. Summing the three attempts of one occurrence would produce a\n  number that is nobody's call time and cannot be compared with the\n  `timeout` in the caller's own `cron.yaml`.\n\n`GET /executions/export` (R27) makes the opposite choice — one row per\nattempt — and the two do not contradict each other: an export is the\n**log**, and a log lists instead of deciding. Whoever wants to count\ndifferently has every attempt there.\n\n### A rate without its denominator is not a number\n\n`failure_rate` is `(failed + timed_out) / ran`, and it is **null** when\n`ran` is zero — not `0`, which reads as \"never fails\". `ran` travels\nbeside it, and so do the numerators: on three occurrences a third of\nfailures is noise, on three thousand it is an outage.\n\n`ran` is successes plus failures plus timeouts, and **not**\n`occurrences`. The two differ by skipped occurrences (`on_overlap: skip`,\nR41) and by ones not yet settled: the first called nobody so it cannot\nhave failed, the second is not known yet. Counting them would push down\nthe failure rate of a job that skips half its occurrences — that is, of a\njob that is going badly. Both come out as fields anyway, because a total\nthat does not add up from its parts is the first thing that makes a\nscreen doubted.\n\n### The average hides the tail\n\nA mean of 400 ms can be a thousand calls at 200 ms and ten at twenty\nseconds, and the ten are why anyone opened the screen. So `p50_ms`,\n`p95_ms` and `p99_ms` come out beside `average_ms`, and they are\n**declared upper bounds**, not interpolations: `p95_ms = 500` means \"95%\nof attempts took **at most** 500 ms\". `max_ms` is separate and exact,\nbecause the histogram cannot say it — its last bucket means \"over five\nminutes\", not how far over, and on the bad day that is the number wanted.\n\n### The window is declared, and retention bounds it\n\nLogs live 3, 15, 30 or 90 days depending on the plan (Privacy Policy §5),\nand a \"30-day trend\" for someone who keeps 3 is a lie with a chart around\nit. Without `since` the window starts at the plan's **retention floor**\nand `window.bounded_by_retention` says so — the difference between\n\"nothing ran before then\" and \"it was deleted\". Asking for a `since` older\nthan the floor is rejected with `plan_limit_retention`, not silently\nnarrowed. The pre-computed aggregates are dropped with the same boundary,\nso the window is not narrowed after the fact: it is everything there is.\n\nBuckets sit on the **epoch grid** (SPEC §9), the same one `every: 1h`\nfires on — not on the hour of the request. Two requests ten minutes apart\nland on the same buckets.\n\n`window.until` is not always what was asked for: aggregates are written\nfor closed buckets, so the window stops at `complete_through`, which the\nresponse also declares. A series ending there without saying why would\nshow a last point lower than the others — the way a chart announces an\noutage that is not happening.\n\n### Cost\n\nReads sum pre-computed rows, not executions: the ninety days of an Agency\nplan are 90 daily rows per job and environment, not the millions of\nexecutions behind them. What bounds the cost of one request is the number\nof points — at most 750, which is 31 days hourly and two years daily.\nOver that the request is refused on `grain` rather than truncated: a\nseries cut at 750 points reads as \"nothing happened before\", which is the\nlie this endpoint exists not to tell. On top sits a technical ceiling of\nfour concurrent reads (`metrics_ceiling`, R10) — the database pool is\nshared with the dispatcher, and no plan grants more.\n",
        "x-api-key-scope": "executions:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "job",
            "in": "query",
            "description": "Restrict to one job. Absent means every job of the account.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "environment",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Environment"
            }
          },
          {
            "name": "since",
            "in": "query",
            "description": "RFC 3339 instant, inclusive. Defaults to the plan's retention floor. Moved **back** to the start of its bucket, so the first point is a whole one. Subject to the plan's retention (R10-bis).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "until",
            "in": "query",
            "description": "RFC 3339 instant, exclusive and later than `since`. Defaults to now, and never exceeds `complete_through`.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "grain",
            "in": "query",
            "description": "Bucket width. Defaults to `day`, which is the one that carries the longest window.",
            "schema": {
              "$ref": "#/components/schemas/ExecutionMetricsGrain"
            }
          },
          {
            "name": "group_by",
            "in": "query",
            "description": "How `groups` is keyed. Defaults to `none`, which leaves `groups` empty.",
            "schema": {
              "$ref": "#/components/schemas/ExecutionMetricsGroupBy"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The metrics. `Cache-Control: no-store` — this body describes how an account's jobs are doing.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "`no-store`: this body must not rest on an intermediate disk, and it is not usefully cacheable anyway — the window moves every time an hour closes."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExecutionMetrics"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` with `details` per parameter: unknown `grain`, `group_by` or `environment`, `until` not after `since`, or a window that would hold more than 750 buckets at the requested grain.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "description": "`plan_limit_metrics` — the plan does not include metrics and charts\n(R28, SPEC §8). The rejection names the plan **and what remains**:\nthe numbers these are derived from are still readable execution by\nexecution, and a bare `403` on a read-only feature would read as \"your\nnumbers are unreachable\". What Team buys is **the arithmetic already\ndone**, not access to the data.\n\nAlso `plan_limit_retention` (R10-bis) and `insufficient_scope`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/PlanLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "429": {
            "description": "`metrics_ceiling` — the service is already computing as many metric\nreads as it will at once; `rate_limited` — the general ceiling. Both\nare **technical**: no `plan`, no `limit`, always a `Retry-After`, and\nthe suggested wait is short because a metrics read takes fractions of\na second.\n",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/ServiceLimitEnvelope"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  ]
                }
              }
            }
          },
          "503": {
            "description": "`metrics_unavailable` — metrics are not configured on this instance.\nThe route stays described and registered for the reason\n`/executions/export` does: a `404` would send whoever calls it looking\nfor a wrong path instead of a missing configuration.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/secrets": {
      "get": {
        "tags": [
          "secrets"
        ],
        "operationId": "listSecrets",
        "summary": "List workspace secrets (R42)",
        "description": "**No route in this API returns the value of a secret**, and it is not a\nforgotten route: there is no `GET /secrets/{id}`, no `reveal` parameter,\nno administrative variant. There is not even a `last_four` preview: that\nmakes sense for a known provider's key, recognisable by its tail, not for\nan arbitrary customer value that might *be* four meaningful characters.\n\n**This is the only secrets route an API key can reach**, and it needs\n`secrets:read`. The three that write stay session-only: a key able to\nreplace a secret's value would redirect the credentials with which the\nuser's jobs authenticate to their own services, and no log of theirs\nwould show a changed value. Reading is a different thing — what comes\nback is a fact, not a power — and it is what makes it possible to tell,\nfrom a terminal, whether the `${VAR}` an execution failed on exists or\nwas revoked.\n\nThe consequence, stated rather than left to be deduced: a key forgotten\non a laptop can list the **names** of a workspace's secrets.\n",
        "x-api-key-scope": "secrets:read",
        "security": [
          {
            "sessionCookie": []
          },
          {
            "sessionBearer": []
          },
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "include_revoked",
            "in": "query",
            "description": "Include revoked secrets, kept as a trace for whoever investigates a failed execution.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The workspace secrets, **without their values**.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SecretList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "sessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "pq_session",
        "description": "Browser session (R14). `HttpOnly`: the token is out of reach of\nJavaScript, so an XSS on the dashboard cannot carry it away.\n"
      },
      "sessionBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same session token, for clients without a cookie jar. When both\narrive, the cookie wins — it is the session the user expects to be using.\n"
      },
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Scoped API key (R9): `Authorization: Bearer pq_live_...`. The prefix is\nwhat tells it apart from a session token without having to try both.\n\nThe scope each operation requires is declared in `x-api-key-scope`, and it\nis compared against the code on every CI run. Write scopes do **not**\nimply read scopes: otherwise someone looking at a key's scopes would see\n`jobs:write` and have to remember that it covers reading too.\n\nOperations that do not declare this scheme **do not accept a key**, ever:\nthey are the ones that issue credentials, commit to a payment, or destroy\nthe account.\n"
      }
    },
    "parameters": {
      "JobId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Job identifier.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "description": "Rows per page. Defaults to 50, maximum 200. A higher value **is not an\nerror**: the request is resized, and the size actually served is in\n`page.limit`.\n",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 50
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "description": "Cursor of the next page, taken from `page.next_cursor`. It is opaque by\ncontract: send it back exactly as it arrived.\n",
        "schema": {
          "type": "string"
        }
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "Whole seconds after which retrying makes sense. **This is the source to\nread**: it is present on every rejection that clears by itself, whereas\nthe body's `retry_after` field — which repeats it in machine-readable form\n— is absent on authentication rejections (see\n`ServiceLimitError.retry_after`).\n\nOn plan limits and on ceilings applied at the routing layer it is never\nzero: \"retry in 0 seconds\" would be an invitation to hit the same limit\nagain. On authentication attempts it is the remaining wait truncated to\nseconds, which within a second of the window's end is `0`.\n",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      }
    },
    "responses": {
      "NoContent": {
        "description": "Done. No body."
      },
      "Unauthenticated": {
        "description": "`unauthenticated` — session missing or expired; or `invalid_api_key` —\nkey missing, revoked or expired (R9). Two distinct codes because the\nremedy differs: whoever gets the second does not need to sign in again,\nthey need to check the key. The message does not distinguish\n\"nonexistent\" from \"revoked\" from \"expired\": that difference would turn\nthe API into an oracle about a key found lying around.\n",
        "headers": {
          "WWW-Authenticate": {
            "description": "`Bearer error=\"invalid_token\"` when the credential was an API key.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "InsufficientScope": {
        "description": "`insufficient_scope` — the credential is valid but does not carry the\nrequired permission. `403` and not `401`: retrying it changes nothing, a\ndifferent key is needed. The `scope` field and the `WWW-Authenticate`\nheader say **which** permission was missing, instead of leaving it to be\nguessed.\n",
        "headers": {
          "WWW-Authenticate": {
            "description": "`Bearer error=\"insufficient_scope\", scope=\"...\"` (RFC 6750 §3.1).",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "`validation_failed` with `details` per field, or `invalid_request` if the\nbody is unreadable or carries an unknown field. A misspelled `passwrod`\nwould otherwise be an empty password, with no way for the client to\nnotice.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "BodyTooLarge": {
        "description": "`body_too_large` — the body exceeds the route's cap (8 KiB, 64 KiB on the job routes).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "JobNotFound": {
        "description": "`job_not_found`. \"Does not exist\" and \"is not yours\" answer the same way:\ntelling them apart would tell anyone whether somebody else's identifier is\nalive.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "RetentionExceeded": {
        "description": "`plan_limit_retention` — the requested window predates the plan's\nretention (R10-bis, SPEC §8: 3, 15, 30, 90 days). The rejection **says\nwhy**: it names the plan, how long it keeps logs, and the instant before\nwhich there are no rows. It is not silently narrowed, because a user who\ncannot see their own rows without knowing why opens a ticket.\n\nIt may also be `insufficient_scope`.\n",
        "content": {
          "application/json": {
            "schema": {
              "anyOf": [
                {
                  "$ref": "#/components/schemas/PlanLimitEnvelope"
                },
                {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              ]
            }
          }
        }
      },
      "RateLimited": {
        "description": "`rate_limited` — a **technical ceiling** on requests (R10). It is a defence\nof the service, identical on every plan: the body has **no `plan` and no\n`limit`**, because no plan grants more of it and suggesting an upgrade\nwould be a commercial lie.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ServiceLimitEnvelope"
            }
          }
        }
      },
      "WriteRateLimited": {
        "description": "Two different errors under the same status, and the difference is the\nwhole point of R10:\n\n- `plan_limit_write_rate` — the **plan's quota** on writes: it names\n  `plan` and `limit`, because there upgrading really is the answer;\n- `rate_limited` — a **technical ceiling**: no plan grants more of it.\n\nReads do not consume the plan quota: making them would render the\ndashboard unusable on the Free plan, i.e. punish normal use in order to\ndefend against abuse.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/PlanLimitEnvelope"
                },
                {
                  "$ref": "#/components/schemas/ServiceLimitEnvelope"
                }
              ]
            }
          }
        }
      },
      "InternalError": {
        "description": "`internal_error`. The message is generic by construction: the detail might\ncontain information about the database, and it belongs in the service log.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    },
    "schemas": {
      "Environment": {
        "type": "string",
        "enum": [
          "staging",
          "production"
        ],
        "description": "A job's environment (R23). The Free plan has one only."
      },
      "HttpMethod": {
        "type": "string",
        "enum": [
          "GET",
          "POST",
          "PUT",
          "PATCH",
          "DELETE",
          "HEAD",
          "OPTIONS"
        ],
        "description": "Method of the call the job performs. Postqron performs **HTTP calls\nonly** against the user's endpoints: no shell commands, no containers\n(SPEC §10).\n"
      },
      "RetryBackoff": {
        "type": "string",
        "enum": [
          "exponential",
          "linear",
          "fixed"
        ]
      },
      "OverlapPolicy": {
        "type": "string",
        "enum": [
          "skip",
          "queue",
          "allow"
        ],
        "description": "What to do when an occurrence fires while the previous one is still\nrunning (R41). At one-second resolution this is not a rare case, it is the\nnorm.\n\nThe default is `skip`, the only one of the three that does no harm to a\njob you know nothing about: `allow` calls a target twice at once when it\nmight issue an invoice per call, and `queue` builds an unbounded backlog\nwhenever the job is steadily slower than its own interval.\n"
      },
      "AlertChannel": {
        "type": "string",
        "enum": [
          "email",
          "slack",
          "discord",
          "webhook"
        ]
      },
      "ExecutionStatus": {
        "type": "string",
        "enum": [
          "pending",
          "running",
          "succeeded",
          "failed",
          "timed_out",
          "skipped"
        ]
      },
      "ExecutionTrigger": {
        "type": "string",
        "enum": [
          "schedule",
          "manual",
          "retry"
        ]
      },
      "SuspensionReason": {
        "type": "string",
        "enum": [
          "plan_job_limit",
          "plan_resolution",
          "plan_environments"
        ],
        "description": "Why a plan change switched the job off (R58), i.e. what it takes to switch\nit back on:\n\n- `plan_job_limit` — active jobs exceeded the plan's cap. As many as the\n  plan allows can be resumed, and the choice is the user's: two jobs\n  identical in schedule and destination may be worth one the monthly\n  invoicing run and the other a reminder, and no automatic criterion can\n  know which;\n- `plan_resolution` — the schedule is denser than the plan allows. It has\n  to change: this is not about room, and resuming another job frees none;\n- `plan_environments` — the job lived **only** in environments the plan\n  does not have, so there was no half to keep running. Add `production`,\n  or move up a plan. This is the edge case, not the rule: a job in\n  staging *and* production is not switched off — it loses staging and\n  carries on in production, and the staging override is kept so it comes\n  back if the plan does.\n"
      },
      "ApiKeyScope": {
        "type": "string",
        "enum": [
          "jobs:read",
          "jobs:write",
          "executions:read",
          "executions:trigger",
          "secrets:read"
        ],
        "description": "Write scopes do **not** imply read ones. That is a choice: the\nimplication would live only in the code, and whoever looked at a key's\nscopes would see `jobs:write` and have to remember it covered reading\ntoo.\n\n`executions:trigger` is separate from `jobs:write` because they are two\ndifferent powers: changing a job's definition, and making an outbound\ncall happen right now. A dashboard key wants the second without the\nfirst.\n\n`secrets:read` lists the workspace secrets — **names, dates and\nrevocation state**. It does not give values, and that is not this scope\nwithholding them: no route in this API returns the value of a secret to\nanybody, there is no `GET /secrets/{id}`, no `reveal` parameter and no\nadministrative variant. What it does give is the knowledge that a\nworkspace has a secret by a given name, which is enough to tell why an\nexecution failed on an unresolved `${VAR}` and is not material anyone can\nauthenticate with.\n\n**There is no `secrets:write`.** Replacing a secret's value would\nredirect the credentials a user's jobs authenticate to their own services\nwith, and no log of theirs would show a changed value: the three writing\nroutes stay behind a session.\n"
      },
      "PlanCode": {
        "type": "string",
        "enum": [
          "free",
          "pro",
          "team",
          "agency"
        ]
      },
      "ErrorCode": {
        "type": "string",
        "description": "Every code an error response can carry. It is **stable and untranslated**,\nand it is what a client branches on (R53): `message` is prose for a person\nand may change.\n\nThe enumeration is compared on every CI run against the codes the service\nactually writes: a new code in the code and not here fails the check, and\nso does the reverse.\n",
        "enum": [
          "account_suspended",
          "already_a_member",
          "already_invited",
          "admin_required",
          "ai_analysis_ceiling",
          "ai_consent_required",
          "ai_debugging_unavailable",
          "ai_key_not_found",
          "ai_key_required",
          "ai_provider_refused",
          "ai_provider_unavailable",
          "alert_webhook_name_taken",
          "alert_webhook_not_found",
          "alert_webhook_not_suspended",
          "api_key_not_found",
          "audience_changed",
          "audit_unavailable",
          "billing_unavailable",
          "body_too_large",
          "business_use_required",
          "contact_unavailable",
          "deletion_already_requested",
          "deletion_not_requested",
          "document_not_found",
          "empty_patch",
          "execution_already_exists",
          "execution_ceiling",
          "execution_not_failed",
          "execution_not_found",
          "export_ceiling",
          "executions_unavailable",
          "export_too_soon",
          "export_unavailable",
          "image_too_large",
          "impersonation_forbidden",
          "impersonation_refused",
          "insufficient_scope",
          "internal_error",
          "invalid_api_key",
          "invalid_credentials",
          "invalid_cursor",
          "invalid_email",
          "invalid_request",
          "invalid_signature",
          "invalid_token",
          "invitation_expired",
          "invitation_not_found",
          "invitation_wrong_address",
          "job_archived",
          "job_disabled",
          "job_managed_by_repository",
          "job_name_taken",
          "job_not_found",
          "legal_version_not_in_force",
          "metrics_ceiling",
          "message_not_sent",
          "metrics_unavailable",
          "notification_not_found",
          "not_impersonating",
          "owner_only",
          "payload_too_large",
          "period_not_available",
          "plan_changes_blocked",
          "plan_limit_ai_debugging",
          "plan_limit_environments",
          "plan_limit_jobs",
          "plan_limit_log_export",
          "plan_limit_manual_trigger",
          "plan_limit_metrics",
          "plan_limit_rbac",
          "plan_limit_resolution",
          "plan_limit_retention",
          "plan_grant_absent",
          "plan_grant_not_higher",
          "plan_grant_refused",
          "plan_limit_write_rate",
          "plan_not_purchasable",
          "plans_unavailable",
          "rate_limited",
          "secret_name_taken",
          "spam_suspected",
          "secret_not_found",
          "role_forbidden",
          "session_not_found",
          "stream_ceiling",
          "stream_unavailable",
          "subscription_active",
          "suspension_refused",
          "team_member_not_found",
          "team_seats_exhausted",
          "plan_limit_multi_workspace",
          "workspace_archived",
          "workspace_is_default",
          "workspace_limit_reached",
          "workspace_name_taken",
          "workspace_not_found",
          "too_many_alert_webhooks",
          "too_many_api_keys",
          "too_many_secrets",
          "unauthenticated",
          "unsupported_media_type",
          "user_not_found",
          "verification_page_off",
          "workspace_forbidden",
          "workspace_unavailable",
          "validation_failed",
          "weak_password"
        ]
      },
      "ErrorEnvelope": {
        "type": "object",
        "description": "The envelope of every error response.",
        "properties": {
          "error": {
            "$ref": "#/components/schemas/Error"
          }
        },
        "required": [
          "error"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "message": {
            "type": "string",
            "description": "Prose for a person, currently in Italian (localisation is issue #445).\n**Do not match on it**: it may change without notice.\n"
          },
          "details": {
            "type": "array",
            "description": "The reasons for rejection anchored to the fields that caused them, so\na form can highlight them without interpreting the message.\n",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            }
          },
          "limit": {
            "type": "string",
            "description": "Which **plan** limit was hit. Absent on technical ceilings."
          },
          "plan": {
            "$ref": "#/components/schemas/PlanCode",
            "description": "On which plan. Absent on technical ceilings."
          },
          "retry_after": {
            "type": "integer",
            "minimum": 0,
            "description": "Repeats the `Retry-After` header in machine-readable form."
          },
          "scope": {
            "$ref": "#/components/schemas/ApiKeyScope",
            "description": "The permission the key was missing (R9)."
          },
          "capability": {
            "type": "string",
            "description": "On `role_forbidden`: the capability the operation needed, as named in\n`GET /team/permissions`. It is a field and not prose because the\nremedy belongs to somebody else — the caller has to ask whoever\nmanages the team — and a client can compose that request only if it\nknows what to ask for.\n"
          },
          "role": {
            "$ref": "#/components/schemas/TeamRole",
            "description": "On `role_forbidden`: the role the caller was acting with.\n"
          }
        },
        "required": [
          "code",
          "message"
        ]
      },
      "FieldError": {
        "type": "object",
        "description": "A rejection reason anchored to the field that caused it. `field` uses the\ndotted notation of the submitted body (`request.url`,\n`alerts.on_failure`): it is the path inside the JSON, not a column name.\n",
        "properties": {
          "field": {
            "type": "string"
          },
          "code": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "field",
          "code",
          "message"
        ]
      },
      "PlanLimitEnvelope": {
        "type": "object",
        "description": "A rejection caused by a **plan quota** (R10, R15).",
        "properties": {
          "error": {
            "$ref": "#/components/schemas/PlanLimitError"
          }
        },
        "required": [
          "error"
        ]
      },
      "PlanLimitError": {
        "type": "object",
        "description": "The rejection **names the plan**, because there upgrading really is the\nanswer. It is the commercial half of R10: the other is\n[ServiceLimitError](#/components/schemas/ServiceLimitError), and the\ndifference between the two is what a client uses to decide whether to show\nan upgrade prompt.\n",
        "properties": {
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "message": {
            "type": "string"
          },
          "limit": {
            "type": "string",
            "enum": [
              "jobs",
              "resolution",
              "environments",
              "manual_trigger",
              "write_rate",
              "retention",
              "log_export",
              "metrics",
              "ai_debugging"
            ]
          },
          "plan": {
            "$ref": "#/components/schemas/PlanCode"
          },
          "retry_after": {
            "type": "integer",
            "minimum": 1,
            "description": "Only on **rate** limits (`429`). On capacity limits (`403`) retrying would change nothing."
          },
          "details": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            }
          }
        },
        "required": [
          "code",
          "message",
          "limit",
          "plan"
        ]
      },
      "ServiceLimitEnvelope": {
        "type": "object",
        "description": "A rejection caused by a **technical ceiling of the service** (R10).",
        "properties": {
          "error": {
            "$ref": "#/components/schemas/ServiceLimitError"
          }
        },
        "required": [
          "error"
        ]
      },
      "ServiceLimitError": {
        "type": "object",
        "description": "The rejection **names no plan and promises none**: the ceiling is the same\nfor everybody, nobody gets more of it, and retrying after `retry_after`\nseconds is the only useful thing to do.\n\nThe `not` constraint below is not decorative: it states, in a form a\nmachine can verify, that `plan` and `limit` **never appear** in this\nresponse. A client using them to decide whether to offer an upgrade will\nnever find them on a technical ceiling — which is exactly what R10\ndemands.\n",
        "properties": {
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "message": {
            "type": "string"
          },
          "retry_after": {
            "type": "integer",
            "minimum": 0,
            "description": "**Not always present, and the reason is a divergence in the code, not\na choice.** Ceilings applied at the routing layer and by the domain\nlimits repeat it in the body; the ceiling on authentication attempts\n(`POST /auth/login`, `/auth/register`, `/auth/password/*`) writes it\n**only in the `Retry-After` header**, and there the value is the\nremaining wait truncated to seconds rather than rounded up to a\nminimum of one.\n\nThe header is always there in both cases: **that is the source to\nread.** The divergence is reported in the pull request for issue #465;\nthis document describes what the service does, not what it should do.\n"
          }
        },
        "required": [
          "code",
          "message"
        ],
        "not": {
          "anyOf": [
            {
              "required": [
                "plan"
              ]
            },
            {
              "required": [
                "limit"
              ]
            }
          ]
        }
      },
      "Job": {
        "type": "object",
        "description": "A cron job (R1). **Exactly one of `schedule` and `every` is set**, and the\nother is explicitly `null` rather than absent: a client should not have to\ntell \"missing field\" from \"mode not in use\".\n\nDurations are strings (`\"30s\"`, `\"5m\"`, `\"1h\"`) and not numbers, for the\nsame reason the vocabulary is that of `cron.yaml`: what you read you can\nsend back without converting.\n",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "**The stable identity of the job**, unique per user: it is the key on\nwhich reconciliation of a `cron.yaml` decides whether to create,\nupdate or disable (R13). Renaming a job is equivalent to deleting it\nand creating another.\n"
          },
          "description": {
            "type": "string"
          },
          "schedule": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cron expression with 5 fields, minimum granularity 1 minute.",
            "examples": [
              "0 9 * * *"
            ]
          },
          "every": {
            "type": [
              "string",
              "null"
            ],
            "description": "Interval, for sub-minute resolution (R22). The minimum resolution\ndepends on the plan (SPEC §8): 1 minute on Free, 10 seconds on Pro, 1\nsecond on Team and Agency.\n",
            "examples": [
              "10s"
            ]
          },
          "timezone": {
            "type": "string",
            "examples": [
              "Europe/Rome"
            ]
          },
          "environments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Environment"
            }
          },
          "request": {
            "$ref": "#/components/schemas/JobTarget"
          },
          "timeout": {
            "type": "string",
            "examples": [
              "30s"
            ]
          },
          "retries": {
            "$ref": "#/components/schemas/JobRetries"
          },
          "on_overlap": {
            "allOf": [
              {
                "$ref": "#/components/schemas/OverlapPolicy"
              }
            ],
            "description": "**Always present**, even when it holds the default: a field that\nappeared only when chosen would force the client to know the default\nin order to know what actually happens.\n"
          },
          "alerts": {
            "$ref": "#/components/schemas/JobAlerts"
          },
          "overrides": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/JobEnvironmentOverride"
            },
            "description": "What changes from one environment to the next (R23), keyed by\nenvironment. **Always present**, possibly empty: an object that showed\nup only once something had been overridden would force the client to\ntell \"nothing overridden\" from \"a field I do not know\".\n\nWhat is inherited stays **absent**, not empty. Returning `\"url\": \"\"`\nfor an environment that uses the job's target would tell the client\nthe target is the empty string.\n"
          },
          "enabled": {
            "type": "boolean"
          },
          "repository_id": {
            "type": "string",
            "format": "uuid",
            "description": "Set for jobs that come from a `cron.yaml` (R13). It lets a client know\nin advance that changes must be made in the file, instead of finding\nout from a `409` after filling in a form.\n"
          },
          "suspended": {
            "allOf": [
              {
                "$ref": "#/components/schemas/JobSuspension"
              }
            ],
            "description": "Set when it was **a plan change** that switched the job off (R58), not\nthe user. Without it, `enabled: false` would be indistinguishable from\na pause the user remembers setting.\n"
          },
          "next_run_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Computed by the scheduler. `null` on a freshly created job, until the engine picks it up."
          },
          "archived_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "name",
          "schedule",
          "every",
          "timezone",
          "environments",
          "request",
          "timeout",
          "retries",
          "on_overlap",
          "alerts",
          "overrides",
          "enabled",
          "next_run_at",
          "created_at",
          "updated_at"
        ]
      },
      "JobTarget": {
        "type": "object",
        "description": "The job's HTTP target (SPEC §10). Internal addresses are refused —\nloopback, private ranges, link-local, the cloud metadata endpoint — on the\n**resolved address** and on every redirect (R38).\n\n`headers` returns what was written, i.e. the `${VAR}` **references** to\nworkspace secrets, not the resolved values: resolution happens at\nexecution time, inside the engine (R43).\n",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "method": {
            "$ref": "#/components/schemas/HttpMethod"
          },
          "headers": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "url",
          "method",
          "headers",
          "body"
        ]
      },
      "JobRetries": {
        "type": "object",
        "properties": {
          "max": {
            "type": "integer",
            "minimum": 0
          },
          "backoff": {
            "$ref": "#/components/schemas/RetryBackoff"
          }
        },
        "required": [
          "max",
          "backoff"
        ]
      },
      "JobAlerts": {
        "type": "object",
        "properties": {
          "on_failure": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AlertChannel"
            }
          }
        },
        "required": [
          "on_failure"
        ]
      },
      "JobEnvironmentOverride": {
        "type": "object",
        "description": "What one environment changes about the job (R23). Absent fields **inherit**\nfrom the job, and the difference between absent and empty is the whole\npoint of the feature: `alerts` absent means \"the job's channels\", while\n`alerts: { on_failure: [] }` means **nobody is alerted in this\nenvironment** — the way a failure in staging does not wake anyone while the\nsame job failing in production does.\n\n`method` cannot be overridden. A test target may live at another address\nand want other credentials, but if it answers a different verb it is not\nthe same job — it is another job, with a name of its own.\n",
        "properties": {
          "request": {
            "$ref": "#/components/schemas/JobEnvironmentTarget"
          },
          "alerts": {
            "$ref": "#/components/schemas/JobAlerts"
          }
        }
      },
      "JobEnvironmentTarget": {
        "type": "object",
        "description": "The overridden target. Unlike `JobTarget` every field is optional: the ones\nleft out stay the job's. `headers`, when set, **replaces** the job's\nheaders as a whole — they are not merged, because merging could not express\nremoving one without a sentinel value.\n\nAs on `JobTarget`, what comes back are the `${VAR}` **references** to\nworkspace secrets, never their resolved values (R43).\n",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "headers": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "JobSuspension": {
        "type": "object",
        "properties": {
          "at": {
            "type": "string",
            "format": "date-time"
          },
          "reason": {
            "$ref": "#/components/schemas/SuspensionReason"
          }
        },
        "required": [
          "at",
          "reason"
        ]
      },
      "JobList": {
        "type": "object",
        "properties": {
          "jobs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Job"
            }
          },
          "page": {
            "$ref": "#/components/schemas/Page"
          }
        },
        "required": [
          "jobs",
          "page"
        ]
      },
      "Page": {
        "type": "object",
        "description": "The page served. `next_cursor` set to `null` is **the only** end-of-list\ncondition: it must not be inferred from the row count, because a full page\nmay be the last one.\n",
        "properties": {
          "limit": {
            "type": "integer"
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "limit",
          "next_cursor"
        ]
      },
      "JobInput": {
        "type": "object",
        "description": "Body for creation and modification. **What you do not send does not\nchange**: on creation absent fields take their defaults, on modification\nthey leave the existing value alone. An explicit `null` is a value — it\nclears the field, or retires a scheduling mode — and is distinct from an\nabsent field.\n\n`schedule` and `every` are **mutually exclusive**: declaring both, or\nneither, is a validation error (SPEC §9).\n",
        "properties": {
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "schedule": {
            "type": [
              "string",
              "null"
            ]
          },
          "every": {
            "type": [
              "string",
              "null"
            ]
          },
          "timezone": {
            "type": "string"
          },
          "environments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Environment"
            }
          },
          "request": {
            "$ref": "#/components/schemas/JobTargetInput"
          },
          "timeout": {
            "type": "string"
          },
          "retries": {
            "$ref": "#/components/schemas/JobRetriesInput"
          },
          "on_overlap": {
            "type": [
              "string",
              "null"
            ]
          },
          "alerts": {
            "$ref": "#/components/schemas/JobAlertsInput"
          },
          "overrides": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/JobEnvironmentOverrideInput"
            },
            "description": "What changes per environment (R23), keyed by environment. Unlike the\nother fields of this body it **replaces the whole set**: a PATCH\nsending only `staging` would otherwise leave the `production` override\nthe client believed it had removed, and the difference would only show\nup at the next execution. `{}` removes them all.\n\nAn override for an environment the job does not declare is refused\nnaming both: it would be saved and never run.\n",
            "examples": [
              {
                "staging": {
                  "request": {
                    "url": "https://staging.example.com/hook"
                  },
                  "alerts": {
                    "on_failure": []
                  }
                }
              }
            ]
          },
          "enabled": {
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "JobEnvironmentOverrideInput": {
        "type": "object",
        "description": "Inheritance is **per field**: sending `request.url` and not `alerts` means\n\"change the target, keep the job's alerts\", which is the ordinary case.\n",
        "properties": {
          "request": {
            "$ref": "#/components/schemas/JobEnvironmentTargetInput"
          },
          "alerts": {
            "$ref": "#/components/schemas/JobAlertsInput"
          }
        },
        "additionalProperties": false
      },
      "JobEnvironmentTargetInput": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "headers": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "additionalProperties": false
      },
      "JobTargetInput": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "method": {
            "$ref": "#/components/schemas/HttpMethod"
          },
          "headers": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "additionalProperties": false
      },
      "JobRetriesInput": {
        "type": "object",
        "properties": {
          "max": {
            "type": "integer",
            "minimum": 0
          },
          "backoff": {
            "$ref": "#/components/schemas/RetryBackoff"
          }
        },
        "additionalProperties": false
      },
      "JobAlertsInput": {
        "type": "object",
        "properties": {
          "on_failure": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AlertChannel"
            }
          }
        },
        "additionalProperties": false
      },
      "TriggerInput": {
        "type": "object",
        "description": "Body of a manual trigger. Optional: it only matters for jobs living in more than one environment.",
        "properties": {
          "environment": {
            "$ref": "#/components/schemas/Environment"
          }
        },
        "additionalProperties": false
      },
      "Execution": {
        "type": "object",
        "description": "One attempt (R6). **It has no synthetic identifier** because the row has\nnone: the key is the natural quadruple `(job, instant, environment,\nattempt)`, and inventing one here would produce a value that cannot be\nused to read anything back.\n",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "scheduled_for": {
            "type": "string",
            "format": "date-time"
          },
          "environment": {
            "$ref": "#/components/schemas/Environment"
          },
          "attempt": {
            "type": "integer",
            "minimum": 1
          },
          "status": {
            "$ref": "#/components/schemas/ExecutionStatus"
          },
          "triggered_by": {
            "$ref": "#/components/schemas/ExecutionTrigger"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "duration_ms": {
            "type": [
              "integer",
              "null"
            ]
          },
          "response_status": {
            "type": [
              "integer",
              "null"
            ]
          },
          "response_excerpt": {
            "type": "string",
            "description": "A **truncated** excerpt of the target's response, with secrets\nredacted (R43): this log is visible to the user, and a value landing\nin it would be a secret that got out.\n"
          },
          "error": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "job_id",
          "scheduled_for",
          "environment",
          "attempt",
          "status",
          "triggered_by",
          "started_at",
          "finished_at",
          "duration_ms",
          "response_status",
          "created_at"
        ]
      },
      "ExecutionList": {
        "type": "object",
        "properties": {
          "executions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Execution"
            }
          },
          "page": {
            "$ref": "#/components/schemas/Page"
          }
        },
        "required": [
          "executions",
          "page"
        ]
      },
      "Secret": {
        "type": "object",
        "description": "A workspace secret. **It carries neither the value nor a piece of it.** The\nname the user gave it is enough to recognise it, and it is also what they\nwrite in their `cron.yaml`.\n",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "reference": {
            "type": "string",
            "description": "The text to paste into `cron.yaml`. It is redundant with `name` and it\nis there on purpose: it is what gets copied with one click, and\nwithout it the user has to remember that the syntax is `${...}`.\n",
            "examples": [
              "${DIGEST_TOKEN}"
            ]
          },
          "description": {
            "type": "string"
          },
          "last_used_at": {
            "type": "string",
            "format": "date-time"
          },
          "revoked_at": {
            "type": "string",
            "format": "date-time"
          },
          "revoked": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "name",
          "reference",
          "revoked",
          "created_at",
          "updated_at"
        ]
      },
      "SecretList": {
        "type": "object",
        "properties": {
          "secrets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Secret"
            }
          }
        },
        "required": [
          "secrets"
        ]
      },
      "ExecutionExportFormat": {
        "type": "string",
        "description": "The two formats R27 asks for. CSV is a spreadsheet; JSON is one document\n— see the endpoint for why it is a document and not one object per line,\nwhich is the opposite of the choice `/account/export` makes and for a\nstated reason.\n",
        "enum": [
          "csv",
          "json"
        ]
      },
      "ExecutionExportManifest": {
        "type": "object",
        "description": "What the file says about itself before it says anything else. It exists\nin both formats with the same content — a `#` comment block in the CSV,\nthe `export` key in the JSON — because the questions it answers do not\ndepend on the format: what a column holds, how a value is written, where\nthe file stops and why.\n",
        "properties": {
          "product": {
            "type": "string",
            "const": "postqron"
          },
          "export": {
            "type": "string",
            "const": "executions"
          },
          "version": {
            "type": "integer",
            "description": "The format version, in the file. The day a column changes meaning, an old file and a new one tell themselves apart without guessing."
          },
          "format": {
            "$ref": "#/components/schemas/ExecutionExportFormat"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "reading": {
            "type": "string",
            "description": "How to read the file, in prose. Addressed to a person — an export is\nopened with a spreadsheet too, not only with a parser.\n"
          },
          "types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "How the values are written. This is the line that keeps the file from\nlying about its types: a date with no zone and a duration with no\nunit are numbers everyone reads their own way.\n"
          },
          "row_limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "**Always null**, and written on purpose. A file that stayed quiet\nabout this would leave the reader wondering whether they hold the\nfirst page of something. The only thing bounding the file is\n`window`.\n"
          },
          "window": {
            "$ref": "#/components/schemas/ExecutionExportWindow"
          },
          "filters": {
            "$ref": "#/components/schemas/ExecutionExportFilters"
          },
          "columns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExecutionExportColumn"
            }
          }
        },
        "required": [
          "product",
          "export",
          "version",
          "format",
          "generated_at",
          "reading",
          "types",
          "row_limit",
          "window",
          "filters",
          "columns"
        ]
      },
      "ExecutionExportWindow": {
        "type": "object",
        "description": "Where the file starts and where it ends, and why.",
        "properties": {
          "plan": {
            "$ref": "#/components/schemas/PlanCode"
          },
          "retention_days": {
            "type": "integer",
            "description": "Days of logs the plan keeps (SPEC §8: 3, 15, 30, 90)."
          },
          "retention_floor": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The instant before which executions **no longer exist**. Null when the plan declares no retention."
          },
          "since": {
            "type": "string",
            "format": "date-time",
            "description": "Inclusive lower bound on `scheduled_for`."
          },
          "until": {
            "type": "string",
            "format": "date-time",
            "description": "Exclusive upper bound on `scheduled_for`. Always set — the instant the\nexport started, when it was not asked for — because a file stopping at\n\"now\" without saying which now cannot be resumed from the next row.\n"
          },
          "bounded_by_retention": {
            "type": "boolean",
            "description": "True when `since` **is** the retention floor, that is when the file\nstarts where retention ends and not where the account's history\nstarts. It is the difference between \"nothing ran before 28 July\" and\n\"what ran before 28 July has been deleted\".\n"
          },
          "note": {
            "type": "string",
            "description": "What the fields above say in numbers, said in words."
          }
        },
        "required": [
          "plan",
          "retention_days",
          "retention_floor",
          "since",
          "until",
          "bounded_by_retention",
          "note"
        ]
      },
      "ExecutionExportFilters": {
        "type": "object",
        "description": "What was asked for, repeated inside the file. Repeated because a\ndownloaded file comes loose from the request that produced it: three\nmonths later the parameters are gone, and without them an export of one\nenvironment is indistinguishable from one where the other environment did\nno work.\n",
        "properties": {
          "job_id": {
            "type": "string",
            "description": "The job asked for. Empty means every job."
          },
          "environment": {
            "type": "string",
            "description": "The environment asked for. Empty means every environment."
          },
          "jobs": {
            "type": "integer",
            "description": "How many jobs the export actually walks."
          }
        },
        "required": [
          "job_id",
          "environment",
          "jobs"
        ]
      },
      "ExecutionExportColumn": {
        "type": "object",
        "description": "One column, described to whoever opens the file.",
        "properties": {
          "name": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "description": "How the value is to be read. Not the PostgreSQL type nor the Go one:\nthe four ways a value can be written into a text file, which is the\nonly question a reader has to answer.\n",
            "enum": [
              "text",
              "enum",
              "uuid",
              "integer",
              "timestamp"
            ]
          },
          "note": {
            "type": "string"
          },
          "redacted": {
            "type": "string",
            "description": "Why the value in this column is not the raw one. Absent when it is.\nThe reason is not written in the export: it is inherited from the\nregister of `internal/export`, the same one the personal-data export\npublishes, which in turn is tied by a test to the type that imposes\nthe redaction at write time (R43). A second list would have diverged\nat the first migration.\n"
          }
        },
        "required": [
          "name",
          "kind",
          "note"
        ]
      },
      "ExecutionExportRow": {
        "type": "object",
        "description": "One execution. The property names are the CSV header, in the same order\nand with the same values: both formats read the same register\n(`logexport.Columns`), so choosing a format never changes the content.\n\nEmpty means **absent**: `null` for numbers and instants, an empty string\nfor texts. `response_excerpt` and `error` carry text the target of the\ncall answered, and they come out **redacted** — the manifest says with\nwhat reason.\n",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "job_name": {
            "type": "string"
          },
          "scheduled_for": {
            "type": "string",
            "format": "date-time"
          },
          "environment": {
            "$ref": "#/components/schemas/Environment"
          },
          "attempt": {
            "type": "integer"
          },
          "status": {
            "$ref": "#/components/schemas/ExecutionStatus"
          },
          "triggered_by": {
            "$ref": "#/components/schemas/ExecutionTrigger"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "duration_ms": {
            "type": [
              "integer",
              "null"
            ]
          },
          "response_status": {
            "type": [
              "integer",
              "null"
            ]
          },
          "response_excerpt": {
            "type": "string"
          },
          "error": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "job_id",
          "job_name",
          "scheduled_for",
          "environment",
          "attempt",
          "status",
          "triggered_by",
          "started_at",
          "finished_at",
          "duration_ms",
          "response_status",
          "response_excerpt",
          "error",
          "created_at"
        ]
      },
      "ExecutionExportSummary": {
        "type": "object",
        "description": "The last thing the file says, and what declares it complete. An\ninterrupted export either lacks it — the connection dropped — or carries\nit with `complete` false.\n",
        "properties": {
          "complete": {
            "type": "boolean"
          },
          "rows": {
            "type": "integer",
            "description": "How many executions the file holds. This is the number a consumer compares against the rows it read."
          },
          "jobs": {
            "type": "integer"
          },
          "batches": {
            "type": "integer",
            "description": "The reads the executions were walked with. It is the measure of the cost on the database."
          },
          "bytes": {
            "type": "integer",
            "description": "The size of the file **without this summary**, which is not yet written when the number is taken."
          },
          "elapsed_ms": {
            "type": "integer"
          },
          "message": {
            "type": "string",
            "description": "Present only when `complete` is false, and it does not carry the real\nerror: a database error quotes the text it tripped on, and in these\nqueries that text is a response excerpt. The real error goes to the\nservice log, where it has a `user_id` beside it.\n"
          }
        },
        "required": [
          "complete",
          "rows",
          "jobs",
          "batches",
          "bytes",
          "elapsed_ms"
        ]
      },
      "ExecutionMetricsGrain": {
        "type": "string",
        "description": "Bucket width, always on the epoch grid in UTC (SPEC §9): `hour` is the\nfull hour, `day` is UTC midnight. Two and not one because of cost — a\nninety-day trend over a thousand jobs would read millions of hourly rows,\nwhich moves the problem rather than solving it. A day is built by summing\nits twenty-four hours, not by walking the executions again.\n",
        "enum": [
          "hour",
          "day"
        ]
      },
      "ExecutionMetricsGroupBy": {
        "type": "string",
        "description": "How `groups` is keyed. R28 asks for \"per job and per environment\", which\nare two questions and not one: `environment` answers \"is staging failing\nand production fine?\", which has no answer with the two mixed together.\n`none` leaves `groups` empty and returns only the total and the series.\n",
        "enum": [
          "none",
          "job",
          "environment",
          "job_environment"
        ]
      },
      "ExecutionMetrics": {
        "type": "object",
        "description": "Everything the screen needs, already computed. Nothing visible has to be\nderived by dividing (SPEC §8).\n",
        "properties": {
          "window": {
            "$ref": "#/components/schemas/ExecutionMetricsWindow"
          },
          "total": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ExecutionMetricsStats"
              }
            ],
            "description": "The total over the window, for the filters asked for. It always adds up from `groups` and from `series` — they cover exactly the same interval."
          },
          "histogram": {
            "type": "array",
            "description": "The duration distribution over the whole window. It comes out once,\nand not inside every group and every point, because it is the\nheaviest part of the response and the most rarely drawn: the\nquantiles are enough to read the tail, the histogram is what\n**verifies** them. Whoever wants it for one job narrows the filters.\n",
            "items": {
              "$ref": "#/components/schemas/ExecutionMetricsHistogramBucket"
            }
          },
          "groups": {
            "type": "array",
            "description": "Totals per group, empty when `group_by` is `none`.",
            "items": {
              "$ref": "#/components/schemas/ExecutionMetricsGroup"
            }
          },
          "series": {
            "type": "array",
            "description": "One point per bucket, **including empty ones**. The empty ones are\nnot a convenience: a chart that skips days without executions draws a\ncontinuous line over a hole, and a job stopped for a week reads\nexactly like a job that ran every day. A point at zero shows.\n",
            "items": {
              "$ref": "#/components/schemas/ExecutionMetricsPoint"
            }
          }
        },
        "required": [
          "window",
          "total",
          "histogram",
          "groups",
          "series"
        ]
      },
      "ExecutionMetricsWindow": {
        "type": "object",
        "description": "Where the window starts, where it ends, and why — a chart and a file lie the same way, by making what they do not show look like it never existed.",
        "properties": {
          "plan": {
            "$ref": "#/components/schemas/PlanCode"
          },
          "retention_days": {
            "type": "integer",
            "description": "Days of execution log the plan keeps (SPEC §8, Privacy Policy §5)."
          },
          "retention_floor": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The instant before which executions **no longer exist**, and neither do their aggregates. Null when the plan declares no retention."
          },
          "since": {
            "type": "string",
            "format": "date-time",
            "description": "Inclusive, on the epoch grid. Not necessarily what was asked for, which is why it is in the response instead of assumed known."
          },
          "until": {
            "type": "string",
            "format": "date-time",
            "description": "Exclusive, on the epoch grid. Never past `complete_through`."
          },
          "grain": {
            "$ref": "#/components/schemas/ExecutionMetricsGrain"
          },
          "buckets": {
            "type": "integer",
            "description": "How many points `series` holds. At most 750."
          },
          "group_by": {
            "$ref": "#/components/schemas/ExecutionMetricsGroupBy"
          },
          "bounded_by_retention": {
            "type": "boolean",
            "description": "`since` **is** the retention floor, meaning the trend starts where\nretention ends and not where the account's history starts. It is the\ndifference between \"nothing happened before\" and \"it was deleted\".\n"
          },
          "complete_through": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "How far aggregates are computed for **every** job in the window.\nAggregates are written for closed buckets, not on every execution, so\nthe last stretch of time is not aggregated yet. Null when no job in\nthe window has been aggregated at all — a new account, or a pass that\nhas not run yet; the series is then empty, which is true rather than\noptimistic.\n"
          },
          "note": {
            "type": "string",
            "description": "What the fields above say in numbers, said in words. Meant to be shown next to the chart."
          }
        },
        "required": [
          "plan",
          "retention_days",
          "retention_floor",
          "since",
          "until",
          "grain",
          "buckets",
          "group_by",
          "bounded_by_retention",
          "complete_through",
          "note"
        ]
      },
      "ExecutionMetricsStats": {
        "type": "object",
        "description": "What is read of a set of occurrences. Counts and ratios sit together on\npurpose: a ratio cannot be judged without its own denominator beside it.\n",
        "properties": {
          "occurrences": {
            "type": "integer",
            "description": "Triples (job, `scheduled_for`, environment). A retry does not add one."
          },
          "ran": {
            "type": "integer",
            "description": "The **denominator of the rate** — succeeded plus failed plus timed out, that is, the occurrences that actually called someone and settled."
          },
          "succeeded": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "timed_out": {
            "type": "integer"
          },
          "skipped": {
            "type": "integer",
            "description": "Occurrences closed without starting (`on_overlap: skip`, R41). Outside the denominator: they called nobody, so they cannot have failed."
          },
          "unsettled": {
            "type": "integer",
            "description": "Occurrences whose last attempt is not terminal yet. Outside the denominator for the opposite reason — it is not known yet how they went."
          },
          "retried": {
            "type": "integer",
            "description": "Occurrences with more than one attempt (R5). This is the field that keeps retries from disappearing into successes."
          },
          "attempts": {
            "type": "integer",
            "description": "Attempts, that is, rows of the execution log. The difference from `occurrences` is exactly what retries add."
          },
          "failure_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "`(failed + timed_out) / ran`, between 0 and 1. **Null when `ran` is zero** — a rate without a denominator is not a low number, it is a question with no answer."
          },
          "durations": {
            "$ref": "#/components/schemas/ExecutionMetricsDurations"
          }
        },
        "required": [
          "occurrences",
          "ran",
          "succeeded",
          "failed",
          "timed_out",
          "skipped",
          "unsettled",
          "retried",
          "attempts",
          "failure_rate",
          "durations"
        ]
      },
      "ExecutionMetricsDurations": {
        "type": "object",
        "description": "How long the calls took. Counts **attempts**, because a call is the thing\nthat takes time: summing the three attempts of one occurrence would give a\nnumber that is nobody's call time and cannot be compared with the\n`timeout` the user wrote in their own `cron.yaml`.\n",
        "properties": {
          "attempts": {
            "type": "integer",
            "description": "The denominator — how many attempts settled and therefore have a duration. Not the same as `stats.attempts`: an attempt in flight has none."
          },
          "average_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Rounded mean. Null when there is no duration at all — a mean with no terms is not zero. It never comes out alone, because alone it is the number that hides ten twenty-second calls among a thousand at 200 ms."
          },
          "p50_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Median, as a declared upper bound — the top edge of the bucket it falls in."
          },
          "p95_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "95th percentile. `500` means \"95% of attempts took at most 500 ms\", not \"500 ms exactly\": interpolating inside a bucket would invent precision the histogram never had."
          },
          "p99_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "99th percentile, same reading. When the quantile falls in the overflow bucket there is no edge to give, and the value returned is `max_ms`, which is exact and wider."
          },
          "max_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The largest observed duration, and it is **exact**. It is kept apart because the histogram cannot say it — its last bucket means \"over five minutes\", not how far over, and on the bad day that is the number wanted."
          }
        },
        "required": [
          "attempts",
          "average_ms",
          "p50_ms",
          "p95_ms",
          "p99_ms",
          "max_ms"
        ]
      },
      "ExecutionMetricsHistogramBucket": {
        "type": "object",
        "properties": {
          "le_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Inclusive upper edge in milliseconds: the bucket counts attempts that\ntook **at most** `le_ms` and more than the previous edge. **Null in\nthe last bucket**, which is the overflow and has no edge — null and\nnot some very large number, because an invented edge would blend into\nthe others on a chart.\n"
          },
          "count": {
            "type": "integer"
          }
        },
        "required": [
          "le_ms",
          "count"
        ]
      },
      "ExecutionMetricsGroup": {
        "type": "object",
        "description": "A group and its numbers. The two keys are empty when the grouping does not\nuse them — `job_id` empty on `group_by=environment`, `environment` empty\non `group_by=job` — and that is not missing data: it is the key that\ngrouping does not have.\n",
        "properties": {
          "job_id": {
            "type": "string"
          },
          "job_name": {
            "type": "string"
          },
          "environment": {
            "type": "string"
          },
          "stats": {
            "$ref": "#/components/schemas/ExecutionMetricsStats"
          }
        },
        "required": [
          "job_id",
          "job_name",
          "environment",
          "stats"
        ]
      },
      "ExecutionMetricsPoint": {
        "type": "object",
        "properties": {
          "bucket_start": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the bucket, on the epoch grid in UTC (SPEC §9). The right edge is `bucket_start` plus the grain and is not written out — it would be the same number repeated on every point of the series."
          },
          "stats": {
            "$ref": "#/components/schemas/ExecutionMetricsStats"
          }
        },
        "required": [
          "bucket_start",
          "stats"
        ]
      },
      "TeamRole": {
        "type": "string",
        "description": "A member's role inside a workspace (R24, SPEC §8). Declared from the most\npowerful to the least, which is also the order the database enum uses —\nso `ORDER BY role` lists members by power without a helper column.\n\n`owner` is **not** one of these. The owner of a workspace has no\nmembership row, because they can do things no role grants: close the\naccount, buy, issue their own API keys, accept the legal documents. An\nAdmin who could close the account of whoever invited them would make an\ninvitation a more dangerous act than the person issuing it could imagine.\n",
        "enum": [
          "admin",
          "developer",
          "viewer"
        ]
      }
    }
  }
}
