{"info":{"name":"Plune API","description":"Backend API for the Plune eval/TMS platform. Two credential surfaces: a **web session cookie** (magic-link sign-in) manages CLI tokens; a **bearer API token** ingests runs from the CLI.\n\n**Projects.** Every record belongs to a project, and every request acts in exactly one: the one named by the `X-Plune-Project` header (an id from `GET /v1/projects`), else your default project. A project you are not a member of answers 404, the same as one that does not exist. A `reader` is refused every write with 403 `read-only member`, except two (#1440): creating a comment on a case, a suite or a run, and proposing in the review queue (`POST /v1/review-items`, created `pending`; a body that asks for any other state is refused 403). Approving, rejecting and everything else stay with a paid seat. A bearer token is bound to the project it was minted in and ignores the header. `/v1/projects` stands outside a project — it is how a client learns which ones it may name.\n\n**Companies.** A company sits above projects (ADR 0038): it has people with a company role and projects whose names every member sees — and it opens none of them. A project is read and written through its own membership only; a member of the company becomes one by joining (`POST /v1/companies/{id}/projects/{projectId}/join`) or by being added by an owner or admin, with the project role their company role maps to — owner and admin join as `owner`. `/v1/companies` stands outside a project too; a company you are not in answers 404, the same as one that does not exist.\n\nGenerated from the OpenAPI document the API serves at `/openapi.json` — not edited by hand. Set `baseUrl` (beta by default) and `token` (a `plune_…` token from Account → API tokens) in the collection variables; every request that takes an API token sends it as a bearer. Path parameters are Postman variables (`:id`); query parameters are listed and switched off until you need them.","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","version":"1.0.0"},"auth":{"type":"bearer","bearer":[{"key":"token","value":"{{token}}","type":"string"}]},"variable":[{"key":"baseUrl","value":"https://beta-api.plune.ai","type":"string"},{"key":"token","value":"","type":"string"}],"item":[{"name":"health","description":"Liveness / readiness","item":[{"name":"Liveness + DB readiness","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/health","host":["{{baseUrl}}"],"path":["health"]},"description":"Returns 200 when the app is up AND its database is reachable, else 503. Also reports the outbound-email state, which is informational and does not affect the status code.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]}]},{"name":"public","description":"Reachable with no account: the address is the credential (#1263)","item":[{"name":"One result file by a signed link from a run report - no account","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/public/files/:token","host":["{{baseUrl}}"],"path":["public","files",":token"],"variable":[{"key":"token","value":"","description":"Three dot-separated parts: base64url id, the end in epoch seconds, base64url signature."}]},"description":"The target of the attachment links in a run report (XLSX or PDF, #1265, AC-05; #1643). The token is the file id, the second it ends at, and an HMAC-SHA256 under a key derived from the server secret; nothing is stored. A link ends at most 7 days after the report was made (the report says when, in UTC); the server checks the end and refuses one further off than 7 days. The report is kept 24 hours, so a link outlives its file. There is no revoking one link before its end; deleting the file, or its run going to the Bin, makes it 404, and changing the server secret ends every link. A bad signature, an ended link, an invented or deleted file, a file in the Bin and a file whose bucket is gone are ONE answer: 404 with the body `{ error: \"not found\" }`. A file the platform keeps is the answer itself with its stored type (text as `text/plain; charset=utf-8`), `Content-Disposition` (`inline` for PNG, JPEG and WebP; `attachment` for the rest), `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`; a file in the project bucket is a 302 to a ten-minute link of the bucket. No cookie is read or set. Sent with `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow` and `Referrer-Policy: no-referrer`. 120 a minute for a client address (429 with `Retry-After`; behind a proxy that does not pass the client address it is the edge address, docs/RUNBOOK.md 4e/4f).\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"The public report of a run — no account","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/public/report/:token","host":["{{baseUrl}}"],"path":["public","report",":token"],"variable":[{"key":"token","value":"","description":"43 characters of base64url."}]},"description":"What anyone who holds the address sees of a run (#1263), with no credential and no cookie, set or read: its title, environment and labels, when it started and finished, the six counters (`passed`, `failed`, `broken`, `blocked`, `skipped`, `muted`), the sub-statuses its results carry, the time it took and what it cost (`null` for a run nobody timed or priced), and per test its title, status, sub-status and time (at most 1000, then `testsTruncated`). Nothing else: no ids, no people, no CI address, branch or commit, no error text, no files — and the page has no write element. Off, revoked, never made, malformed and a run in the Bin are ONE answer, the same 404 with the same body. A link ends (#1264): after `expiresAt` it answers 410 with `{ error, code: \"expired\" }`. A link may have a password, sent in the `X-Report-Password` header (never in the address, which is logged and shared; no cookie is set or read): none sent is 401 `password_required`, a wrong one 401 `wrong_password`. The password is checked before the date. A token nobody holds is 404 whatever is sent, with no derivation spent on it; the difference between 404 (no link), 401 (a password is set) and 200 or 410 (none is) is accepted, because the token is 256 random bits and only someone who already holds a valid one can tell them apart. Wrong passwords are counted per token (10 in 15 minutes, slots taken before the check so parallel requests cannot exceed it); past that every attempt, the right one too, is 429 until the window frees. A client may send 30 requests a minute with a password, and at most 2 derivations run at once in a process: another is 429 with `Retry-After: 1`. Sent with `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow` and `Referrer-Policy: no-referrer`. 120 a minute for a client address.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]}]},{"name":"auth","description":"Passwordless magic-link sign-in (web session)","item":[{"name":"Sign out — clear the session cookie","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/auth/logout","host":["{{baseUrl}}"],"path":["v1","auth","logout"]},"description":"Clears the `plune_session` cookie and ends EVERY session of that person on the server (#1820): any other copy of a cookie issued before this moment answers 401 like an expired one; a sign-in after it works. API and agent tokens are not affected. Public + idempotent (a no-op if not signed in).\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"Request a magic-link sign-in email","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"you@example.com\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/auth/request","host":["{{baseUrl}}"],"path":["v1","auth","request"]},"description":"Any well-formed email → uniform 202 (never reveals whether an account exists). When the deployment is invite-only (see `door` on `/health`), an address that is neither invited nor already registered gets the SAME 202 and no email — the answer must not become a way to ask whether an address is invited. The sign-in screen says the beta is invite-only before the request is made.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"The assertion consumer service - the identity provider’s response","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/auth/sso/:companyId/acs","host":["{{baseUrl}}"],"path":["v1","auth","sso",":companyId","acs"],"variable":[{"key":"companyId","value":""}]},"description":"The browser posts here what the provider answered (`SAMLResponse`, `RelayState`; HTTP-POST binding). Accepted only if: the assertion is signed by one of the company’s certificates with SHA-256 or stronger; `Issuer`, `Audience`, `Destination` and `Recipient` are ours; `InResponseTo` is a request THIS company issued in the last ten minutes (used once, good or bad); the clocks agree within two minutes and the assertion is under five minutes old; its id was not seen; the address is in a domain the company proved; the company has the option and has it on. Then the person is found or made by the same `findOrCreateUser` as a magic link (the beta list does not apply to a verified domain of such a company), joins the company only if `jit` is on (default role, a reader when no write seat is free, never an admin or an owner) and was not removed from it, and gets the same `plune_session` cookie with the sign-in method recorded, for at most seven days and not past the provider’s `SessionNotOnOrAfter`. With a web app configured the answer is a 303 into it (`/` on success, `/login?sso=failed` otherwise); without one, 200 `{ ok, userId }`, 403, or 409 `session_conflict` when the browser already holds somebody else’s live session (a web app gets `/login?sso=session_conflict`). The reason of a refusal is in the company’s log and never in the answer. There is no provider-initiated login and no single logout. 30 a minute a client address. Every answer is `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex, nofollow` and `X-Content-Type-Options: nosniff`.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"The service provider metadata of a company","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/auth/sso/:companyId/metadata","host":["{{baseUrl}}"],"path":["v1","auth","sso",":companyId","metadata"],"variable":[{"key":"companyId","value":""}]},"description":"What the company’s identity provider is configured from (#1449): the entity id, the assertion consumer service (HTTP-POST), `WantAssertionsSigned=\"true\"`, and, when the company turned signing on, its public certificate. Without signing there is no key at all and `AuthnRequestsSigned=\"false\"`. 404 for a company that does not exist or does not have the `saml` option. 60 a minute a client address. Every answer is `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex, nofollow` and `X-Content-Type-Options: nosniff`.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"Does this address sign in through its company?","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/auth/sso/discover?email=&domain=","host":["{{baseUrl}}"],"path":["v1","auth","sso","discover"],"query":[{"key":"email","value":"","disabled":true},{"key":"domain","value":"","disabled":true}]},"description":"The sign-in screen asks it after the address is typed (#1449, ADR 0054 §4). It is the DOMAIN that is looked up - verified by a company that has the `saml` option and has switched single sign-on on - so the answer is the same for every address of a domain and says nothing about whether the address has an account. `?domain=` may be given in place of `?email=`. 60 a minute a client address. Every answer is `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex, nofollow` and `X-Content-Type-Options: nosniff`.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"Start a single sign-on (SP-initiated)","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/auth/sso/start?email=&domain=","host":["{{baseUrl}}"],"path":["v1","auth","sso","start"],"query":[{"key":"email","value":"","disabled":true},{"key":"domain","value":"","disabled":true}]},"description":"Issues a request for the company that has proven the domain, stores its id and a RelayState for ten minutes, and redirects the browser (302) to the company’s identity provider with the AuthnRequest (HTTP-Redirect binding; signed only if the company turned signing on). The same address is the bookmark for the provider’s application tile: a login started by the provider with no request of ours is refused at the ACS. The response sets an HttpOnly cookie (`__Host-plune_sso_req` over https, `SameSite=None; Secure`, path `/`, ten minutes) that names the request; the ACS accepts a response only from the browser that holds it. 404 when the domain has no single sign-on, whatever the reason (no company, off, no option). 30 a minute a client address. Every answer is `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex, nofollow` and `X-Content-Type-Options: nosniff`.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"Verify a magic-link token → open a session","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/auth/verify?token=","host":["{{baseUrl}}"],"path":["v1","auth","verify"],"query":[{"key":"token","value":"","disabled":true}]},"description":"Where the link in the sign-in email lands. The dashboard calls it (`api.ts` → `verify`) with the token from the URL, receives the session cookie, and every later read goes with that cookie. A token is single-use and short-lived, so a second click on the same link answers 401. The browser’s step alone — nothing for a CLI or a collection to call; a CLI gets its credential from `POST /v1/tokens`.\n\nPublic — no credential.","auth":{"type":"noauth"}},"response":[]},{"name":"Who am I — the signed-in user","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/me","host":["{{baseUrl}}"],"path":["v1","me"]},"description":"The current session user (id, email, role, display name, language, theme). The dashboard reads it to show the account and, when you sign in, to open in the language and the theme you chose in another browser.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The signed-in user","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/me","host":["{{baseUrl}}"],"path":["v1","me"]},"description":"The current session user (id, email, role, display name, language, theme). The dashboard reads it to show the account and, when you sign in, to open in the language and the theme you chose in another browser.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The signed-in user","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"userId\": \"u-shop\",\n  \"email\": \"owner@example.com\",\n  \"role\": \"user\",\n  \"name\": \"Olena Kovalenko\",\n  \"locale\": \"uk\",\n  \"theme\": \"light\",\n  \"tier\": \"personal\",\n  \"tierState\": \"active\",\n  \"options\": {},\n  \"tierValidUntil\": \"2027-10-15T09:00:00.000Z\"\n}"}]},{"name":"Set your display name, language and theme — The display name","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Olena Kovalenko\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/me","host":["{{baseUrl}}"],"path":["v1","me"]},"description":"The Account page, «Your name» (`api.ts` → `setMyName`). The name is what every screen shows where a row names you — a case you wrote, a run you launched, a result you marked, the Members list — in place of your address; `null` clears it. The language (`en`, `uk`) and the theme (`dark`, `light`) of the dashboard are kept here too (#1016, `api.ts` → `setMyPreferences`), so another browser opens the way this one does: the dashboard reads them from `GET /v1/me` when you sign in, and sends one only when you chose it — `null` is «never chose», and the browser’s own preference stands in. Any of the three, and at least one; what the body does not name is left as it was. Session-only, as the tokens are: a leaked CLI token must not be able to rename you.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The signed-in user, as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Olena Kovalenko\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/me","host":["{{baseUrl}}"],"path":["v1","me"]},"description":"The Account page, «Your name» (`api.ts` → `setMyName`). The name is what every screen shows where a row names you — a case you wrote, a run you launched, a result you marked, the Members list — in place of your address; `null` clears it. The language (`en`, `uk`) and the theme (`dark`, `light`) of the dashboard are kept here too (#1016, `api.ts` → `setMyPreferences`), so another browser opens the way this one does: the dashboard reads them from `GET /v1/me` when you sign in, and sends one only when you chose it — `null` is «never chose», and the browser’s own preference stands in. Any of the three, and at least one; what the body does not name is left as it was. Session-only, as the tokens are: a leaked CLI token must not be able to rename you.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The signed-in user, as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"userId\": \"u-shop\",\n  \"email\": \"owner@example.com\",\n  \"role\": \"user\",\n  \"name\": \"Olena Kovalenko\",\n  \"locale\": \"uk\",\n  \"theme\": \"light\",\n  \"tier\": \"personal\",\n  \"tierState\": \"active\",\n  \"options\": {},\n  \"tierValidUntil\": \"2027-10-15T09:00:00.000Z\"\n}"},{"name":"400 — not JSON, an empty body, a name that is empty or over 100 characters, or a language or a theme the dashboard does not offer","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Olena Kovalenko\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/me","host":["{{baseUrl}}"],"path":["v1","me"]},"description":"The Account page, «Your name» (`api.ts` → `setMyName`). The name is what every screen shows where a row names you — a case you wrote, a run you launched, a result you marked, the Members list — in place of your address; `null` clears it. The language (`en`, `uk`) and the theme (`dark`, `light`) of the dashboard are kept here too (#1016, `api.ts` → `setMyPreferences`), so another browser opens the way this one does: the dashboard reads them from `GET /v1/me` when you sign in, and sends one only when you chose it — `null` is «never chose», and the browser’s own preference stands in. Any of the three, and at least one; what the body does not name is left as it was. Session-only, as the tokens are: a leaked CLI token must not be able to rename you.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"not JSON, an empty body, a name that is empty or over 100 characters, or a language or a theme the dashboard does not offer","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — name: a name cannot be empty — send null to clear it\"\n}"}]},{"name":"Set your display name, language and theme — The language and the theme, as the person chose them","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"locale\": \"uk\",\n  \"theme\": \"light\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/me","host":["{{baseUrl}}"],"path":["v1","me"]},"description":"The Account page, «Your name» (`api.ts` → `setMyName`). The name is what every screen shows where a row names you — a case you wrote, a run you launched, a result you marked, the Members list — in place of your address; `null` clears it. The language (`en`, `uk`) and the theme (`dark`, `light`) of the dashboard are kept here too (#1016, `api.ts` → `setMyPreferences`), so another browser opens the way this one does: the dashboard reads them from `GET /v1/me` when you sign in, and sends one only when you chose it — `null` is «never chose», and the browser’s own preference stands in. Any of the three, and at least one; what the body does not name is left as it was. Session-only, as the tokens are: a leaked CLI token must not be able to rename you.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"What my account and my companies have used","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/usage","host":["{{baseUrl}}"],"path":["v1","usage"]},"description":"Usage accounting (#1050): for the signed-in person, their account (the projects they made outside a company) and each company they own or administer — the tier it is on, and per project and in total the members, cases, runs, results, files and their bytes, and the Plune AI drafts of this month. It is accounting, not enforcement: no limit is compared here. Rows are counted, not measured; only the files kept in the database are in bytes. The session only, as `GET /v1/me`. Nothing in the request names an account, so there is no other account’s figure to ask for.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The account and its companies","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/usage","host":["{{baseUrl}}"],"path":["v1","usage"]},"description":"Usage accounting (#1050): for the signed-in person, their account (the projects they made outside a company) and each company they own or administer — the tier it is on, and per project and in total the members, cases, runs, results, files and their bytes, and the Plune AI drafts of this month. It is accounting, not enforcement: no limit is compared here. Rows are counted, not measured; only the files kept in the database are in bytes. The session only, as `GET /v1/me`. Nothing in the request names an account, so there is no other account’s figure to ask for.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The account and its companies","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"account\": {\n    \"subject\": {\n      \"kind\": \"user\",\n      \"id\": \"u-shop\"\n    },\n    \"label\": \"owner@example.com\",\n    \"tier\": {\n      \"tier\": \"personal\",\n      \"tierState\": \"active\",\n      \"options\": {},\n      \"tierValidUntil\": \"2027-10-15T09:00:00.000Z\"\n    },\n    \"limits\": {\n      \"projects\": 10,\n      \"writeSeats\": 1,\n      \"readerSeats\": 3,\n      \"fileBytes\": 5368709120,\n      \"fileBytesPer\": \"project\",\n      \"results\": 5000000,\n      \"resultsPer\": \"month\",\n      \"historyDays\": null\n    },\n    \"usage\": {\n      \"projects\": 1,\n      \"members\": 3,\n      \"cases\": 214,\n      \"runs\": 41,\n      \"results\": 18902,\n      \"files\": 37,\n      \"fileBytes\": 5242880,\n      \"bucketFiles\": 0,\n      \"aiDraftsThisMonth\": 12\n    },\n    \"projects\": [\n      {\n        \"projectId\": \"u-shop::default\",\n        \"name\": \"Checkout\",\n        \"members\": 3,\n        \"cases\": 214,\n        \"runs\": 41,\n        \"results\": 18902,\n        \"files\": 37,\n        \"fileBytes\": 5242880,\n        \"bucketFiles\": 0,\n        \"aiDraftsThisMonth\": 12\n      }\n    ]\n  },\n  \"companies\": [\n    {\n      \"subject\": {\n        \"kind\": \"company\",\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n      },\n      \"label\": \"Acme\",\n      \"tier\": {\n        \"tier\": \"team\",\n        \"tierState\": \"active\",\n        \"options\": {\n          \"saml\": true\n        },\n        \"tierValidUntil\": \"2099-12-31T23:59:59.000Z\"\n      },\n      \"limits\": {\n        \"projects\": null,\n        \"writeSeats\": 50,\n        \"readerSeats\": 5,\n        \"fileBytes\": 10737418240,\n        \"fileBytesPer\": \"project\",\n        \"results\": 5000000,\n        \"resultsPer\": \"month-per-paid-user\",\n        \"historyDays\": null\n      },\n      \"usage\": {\n        \"projects\": 1,\n        \"members\": 3,\n        \"cases\": 214,\n        \"runs\": 41,\n        \"results\": 18902,\n        \"files\": 37,\n        \"fileBytes\": 5242880,\n        \"bucketFiles\": 0,\n        \"aiDraftsThisMonth\": 12\n      },\n      \"projects\": [\n        {\n          \"projectId\": \"u-shop::default\",\n          \"name\": \"Checkout\",\n          \"members\": 3,\n          \"cases\": 214,\n          \"runs\": 41,\n          \"results\": 18902,\n          \"files\": 37,\n          \"fileBytes\": 5242880,\n          \"bucketFiles\": 0,\n          \"aiDraftsThisMonth\": 12\n        }\n      ]\n    }\n  ],\n  \"enforcement\": \"report\",\n  \"note\": \"Rows are counted, not measured: results, cases and runs are numbers of rows, not the bytes they take in the database. Only files are in bytes, and only those kept in the database (a file in the customer’s bucket is counted, not measured). Cases and runs in the Bin are not counted; results and files are counted as long as they are stored.\"\n}"}]}]},{"name":"tokens","description":"CLI API-token management (session-guarded)","item":[{"name":"List your CLI tokens (metadata only)","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/tokens","host":["{{baseUrl}}"],"path":["v1","tokens"]},"description":"The same screen (`api.ts` → `listTokens`): prefix, dates and expiry of every token, never the plaintext — that was shown once at minting. `limit` beside the list is the ceiling `POST /v1/tokens` enforces, so the screen can say “N of M” before the click.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Mint a CLI API token","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"scope\": \"read\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/tokens","host":["{{baseUrl}}"],"path":["v1","tokens"]},"description":"Account → API tokens on the dashboard (`api.ts` → `createToken`). The body is optional: `{\"scope\": \"read\" | \"write\"}` (#1283), and none is a `write` token. A token is the account’s, not a named thing, and it writes for good to the project `X-Plune-Project` names — the one picked beside Generate — else to your default one; a project you are not in answers 404, one you only read 403. The plaintext comes back once, in this response, and is what goes into `PLUNE_TOKEN` for the CLI and into `{{token}}` in the collection; the server keeps only its hash. Session-only on purpose: a leaked API token must not be able to mint more of them. An account holds at most `limit` live tokens (see `GET /v1/tokens`) — past it the answer is 409, and revoking one is the only way on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"409 — the account already holds the maximum of live tokens; the body names the ceiling. Not temporary — waiting changes nothing, revoking does","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"scope\": \"read\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/tokens","host":["{{baseUrl}}"],"path":["v1","tokens"]},"description":"Account → API tokens on the dashboard (`api.ts` → `createToken`). The body is optional: `{\"scope\": \"read\" | \"write\"}` (#1283), and none is a `write` token. A token is the account’s, not a named thing, and it writes for good to the project `X-Plune-Project` names — the one picked beside Generate — else to your default one; a project you are not in answers 404, one you only read 403. The plaintext comes back once, in this response, and is what goes into `PLUNE_TOKEN` for the CLI and into `{{token}}` in the collection; the server keeps only its hash. Session-only on purpose: a leaked API token must not be able to mint more of them. An account holds at most `limit` live tokens (see `GET /v1/tokens`) — past it the answer is 409, and revoking one is the only way on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the account already holds the maximum of live tokens; the body names the ceiling. Not temporary — waiting changes nothing, revoking does","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"you already hold 10 live tokens (maximum 10) — revoke one before minting another\",\n  \"limit\": 10\n}"}]},{"name":"Change what a token may do","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"scope\": \"write\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/tokens/:id","host":["{{baseUrl}}"],"path":["v1","tokens",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Account → API tokens (`api.ts` → `setTokenScope`), #1283. `{\"scope\": \"read\" | \"write\"}`: the next request that carries the token is judged by the new scope, and the token is not reissued - the secret in a CI job stays the same. A change writes an `api_token` entry (before and after) to the audit log of the token’s project; saying the scope it already has writes none. Session-only on purpose: a token must not be able to widen itself.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Revoke one of your tokens","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/tokens/:id","host":["{{baseUrl}}"],"path":["v1","tokens",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Account → API tokens (`api.ts` → `revokeToken`). Takes effect on the next request that carries the token; a CI job holding it starts failing with 401 at once. The one way to free a slot under the ceiling.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]}]},{"name":"projects","description":"Projects and who is in them — the boundary every other surface stands inside; `X-Plune-Project` names which one","item":[{"name":"Withdraw an invitation","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/invitations/:id","host":["{{baseUrl}}"],"path":["v1","invitations",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Owner only (`api.ts` → `revokeInvitation`, “Revoke” on the pending list). The address can no longer get in on its strength — if it was the only reason the beta door opened, the door is shut again. An invitation that was already accepted is a membership now, and is removed as one."},"response":[{"name":"403 — a member or a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/invitations/:id","host":["{{baseUrl}}"],"path":["v1","invitations",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Owner only (`api.ts` → `revokeInvitation`, “Revoke” on the pending list). The address can no longer get in on its strength — if it was the only reason the beta door opened, the door is shut again. An invitation that was already accepted is a membership now, and is removed as one."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"}]},{"name":"Which of your projects holds this entity","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/locate/:kind/:id","host":["{{baseUrl}}"],"path":["v1","locate",":kind",":id"],"variable":[{"key":"kind","value":"","description":"The dashboard path segment the id sits under — `/runs/<id>`, `/test-cases/<id>`, `/plans/<id>`, `/review/<id>` One of: runs, test-cases, plans, review."},{"key":"id","value":""}]},"description":"For a dashboard address with no project in it — a link sent before #810, or one the CLI and eval-action print (`api.ts` → `locate`, ADR 0037): the dashboard asks here and replaces the address with `/projects/<id>/…`. Only the projects you are a member of are searched, and only its own project for a token minted in one — so a stranger’s entity, a binned one and an id nobody has all answer the same 404. Reads no `X-Plune-Project`: the project is what it answers."},"response":[{"name":"200 — The project that holds it","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/locate/:kind/:id","host":["{{baseUrl}}"],"path":["v1","locate",":kind",":id"],"variable":[{"key":"kind","value":"","description":"The dashboard path segment the id sits under — `/runs/<id>`, `/test-cases/<id>`, `/plans/<id>`, `/review/<id>` One of: runs, test-cases, plans, review."},{"key":"id","value":""}]},"description":"For a dashboard address with no project in it — a link sent before #810, or one the CLI and eval-action print (`api.ts` → `locate`, ADR 0037): the dashboard asks here and replaces the address with `/projects/<id>/…`. Only the projects you are a member of are searched, and only its own project for a token minted in one — so a stranger’s entity, a binned one and an id nobody has all answer the same 404. Reads no `X-Plune-Project`: the project is what it answers."},"status":"The project that holds it","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projectId\": \"u-shop::default\"\n}"},{"name":"404 — not in any project of yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/locate/:kind/:id","host":["{{baseUrl}}"],"path":["v1","locate",":kind",":id"],"variable":[{"key":"kind","value":"","description":"The dashboard path segment the id sits under — `/runs/<id>`, `/test-cases/<id>`, `/plans/<id>`, `/review/<id>` One of: runs, test-cases, plans, review."},{"key":"id","value":""}]},"description":"For a dashboard address with no project in it — a link sent before #810, or one the CLI and eval-action print (`api.ts` → `locate`, ADR 0037): the dashboard asks here and replaces the address with `/projects/<id>/…`. Only the projects you are a member of are searched, and only its own project for a token minted in one — so a stranger’s entity, a binned one and an id nobody has all answer the same 404. Reads no `X-Plune-Project`: the project is what it answers."},"status":"not in any project of yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"not found\"\n}"}]},{"name":"Who is in the project","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Settings → Members (`api.ts` → `listMembers`): every member of the current project with their role, oldest first, and every invitation nobody has signed in on yet. Any member may look — the list is how a member finds out who to ask for a role. `name` is `null` until people have display names (T3)."},"response":[{"name":"200 — Members and open invitations","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Settings → Members (`api.ts` → `listMembers`): every member of the current project with their role, oldest first, and every invitation nobody has signed in on yet. Any member may look — the list is how a member finds out who to ask for a role. `name` is `null` until people have display names (T3)."},"status":"Members and open invitations","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"members\": [\n    {\n      \"userId\": \"u-shop\",\n      \"email\": \"owner@example.com\",\n      \"name\": \"Olena Kovalenko\",\n      \"role\": \"owner\",\n      \"joinedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"invitedBy\": null\n    },\n    {\n      \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n      \"email\": \"colleague@example.com\",\n      \"name\": null,\n      \"role\": \"member\",\n      \"joinedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"invitedBy\": \"u-shop\"\n    }\n  ],\n  \"invitations\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a3\",\n      \"email\": \"newcomer@example.com\",\n      \"role\": \"reader\",\n      \"invitedBy\": \"u-shop\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"}]},{"name":"Invite an address — An address without an account — an invitation","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Owner only (`api.ts` → `inviteMember`, the Invite form). Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other address gets an **invitation** — a mail says where to sign in, the beta door lets the address through, and the sign-in itself turns the invitation into a membership. No token in the mail: the magic link is the proof. The address is lower-cased before it is compared or stored. Twice for the same address is 409, and so is a project at its member limit. In a company’s project (ADR 0038) the company decides: only its owners and admins ask anybody in (403 `company owner or admin only` for the rest); a member of the company comes in with the role their company role maps to, whatever role was asked; an address from outside is asked into the company as well — one step below owner — and the sign-in makes it a member of both. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[{"name":"201 — Invited — `kind` says which of the two it became","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Owner only (`api.ts` → `inviteMember`, the Invite form). Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other address gets an **invitation** — a mail says where to sign in, the beta door lets the address through, and the sign-in itself turns the invitation into a membership. No token in the mail: the magic link is the proof. The address is lower-cased before it is compared or stored. Twice for the same address is 409, and so is a project at its member limit. In a company’s project (ADR 0038) the company decides: only its owners and admins ask anybody in (403 `company owner or admin only` for the rest); a member of the company comes in with the role their company role maps to, whatever role was asked; an address from outside is asked into the company as well — one step below owner — and the sign-in makes it a member of both. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"Invited — `kind` says which of the two it became","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"kind\": \"invitation\",\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a3\",\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"reader\",\n  \"invitedBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — a member or a reader asked — or, in a company’s project, somebody who is not an owner or admin of the company","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Owner only (`api.ts` → `inviteMember`, the Invite form). Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other address gets an **invitation** — a mail says where to sign in, the beta door lets the address through, and the sign-in itself turns the invitation into a membership. No token in the mail: the magic link is the proof. The address is lower-cased before it is compared or stored. Twice for the same address is 409, and so is a project at its member limit. In a company’s project (ADR 0038) the company decides: only its owners and admins ask anybody in (403 `company owner or admin only` for the rest); a member of the company comes in with the role their company role maps to, whatever role was asked; an address from outside is asked into the company as well — one step below owner — and the sign-in makes it a member of both. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"a member or a reader asked — or, in a company’s project, somebody who is not an owner or admin of the company","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — already in, already invited, or the project is at its member limit","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Owner only (`api.ts` → `inviteMember`, the Invite form). Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other address gets an **invitation** — a mail says where to sign in, the beta door lets the address through, and the sign-in itself turns the invitation into a membership. No token in the mail: the magic link is the proof. The address is lower-cased before it is compared or stored. Twice for the same address is 409, and so is a project at its member limit. In a company’s project (ADR 0038) the company decides: only its owners and admins ask anybody in (403 `company owner or admin only` for the rest); a member of the company comes in with the role their company role maps to, whatever role was asked; an address from outside is asked into the company as well — one step below owner — and the sign-in makes it a member of both. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"already in, already invited, or the project is at its member limit","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"newcomer@example.com is already in this project, or already invited\"\n}"}]},{"name":"Invite an address — An address that already signed in — a member at once","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"colleague@example.com\",\n  \"role\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members","host":["{{baseUrl}}"],"path":["v1","members"]},"description":"Owner only (`api.ts` → `inviteMember`, the Invite form). Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other address gets an **invitation** — a mail says where to sign in, the beta door lets the address through, and the sign-in itself turns the invitation into a membership. No token in the mail: the magic link is the proof. The address is lower-cased before it is compared or stored. Twice for the same address is 409, and so is a project at its member limit. In a company’s project (ADR 0038) the company decides: only its owners and admins ask anybody in (403 `company owner or admin only` for the rest); a member of the company comes in with the role their company role maps to, whatever role was asked; an address from outside is asked into the company as well — one step below owner — and the sign-in makes it a member of both. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[]},{"name":"Change a member’s role","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId`"}]},"description":"Owner only (`api.ts` → `setMemberRole`, the role select on a row). The id is the member’s user id, as `GET /v1/members` lists it. The same role again is a no-op that answers 200. The one refusal that is not about who asked: the last owner cannot be demoted, because a project nobody can manage is a project nobody can leave either — make somebody else an owner first (409). In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company changes a role (403 `company owner or admin only` for the rest). Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[{"name":"200 — The member as they are now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId`"}]},"description":"Owner only (`api.ts` → `setMemberRole`, the role select on a row). The id is the member’s user id, as `GET /v1/members` lists it. The same role again is a no-op that answers 200. The one refusal that is not about who asked: the last owner cannot be demoted, because a project nobody can manage is a project nobody can leave either — make somebody else an owner first (409). In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company changes a role (403 `company owner or admin only` for the rest). Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"The member as they are now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n  \"email\": \"colleague@example.com\",\n  \"name\": null,\n  \"role\": \"reader\",\n  \"joinedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"invitedBy\": \"u-shop\"\n}"},{"name":"403 — a member or a reader asked — or, in a company’s project, somebody who is not an owner or admin of the company","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId`"}]},"description":"Owner only (`api.ts` → `setMemberRole`, the role select on a row). The id is the member’s user id, as `GET /v1/members` lists it. The same role again is a no-op that answers 200. The one refusal that is not about who asked: the last owner cannot be demoted, because a project nobody can manage is a project nobody can leave either — make somebody else an owner first (409). In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company changes a role (403 `company owner or admin only` for the rest). Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"a member or a reader asked — or, in a company’s project, somebody who is not an owner or admin of the company","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such member in this project","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId`"}]},"description":"Owner only (`api.ts` → `setMemberRole`, the role select on a row). The id is the member’s user id, as `GET /v1/members` lists it. The same role again is a no-op that answers 200. The one refusal that is not about who asked: the last owner cannot be demoted, because a project nobody can manage is a project nobody can leave either — make somebody else an owner first (409). In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company changes a role (403 `company owner or admin only` for the rest). Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"no such member in this project","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"member '6f1c2b3a-0000-4000-8000-0000000000a2' not found\"\n}"},{"name":"409 — the last owner","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"reader\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId`"}]},"description":"Owner only (`api.ts` → `setMemberRole`, the role select on a row). The id is the member’s user id, as `GET /v1/members` lists it. The same role again is a no-op that answers 200. The one refusal that is not about who asked: the last owner cannot be demoted, because a project nobody can manage is a project nobody can leave either — make somebody else an owner first (409). In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company changes a role (403 `company owner or admin only` for the rest). Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"the last owner","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a project needs an owner — make somebody else one first\"\n}"}]},{"name":"Remove a member, or leave","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId` — your own to leave"}]},"description":"An owner removes anybody; a member removes only themself — that is “Leave” (`api.ts` → `removeMember`). Their records stay: a case somebody wrote is the project’s, not theirs. The last owner can do neither (409). A reader cannot leave — the read-only rule has no exceptions, so an owner removes them. In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company removes somebody else (403 `company owner or admin only` for the rest); leaving is as before. Recorded in the audit feed as `removed <email>` or `<email> left`."},"response":[{"name":"403 — a member asked about somebody else, or a reader asked at all — or, in a company’s project, somebody who is not an owner or admin of the company asked about somebody else","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId` — your own to leave"}]},"description":"An owner removes anybody; a member removes only themself — that is “Leave” (`api.ts` → `removeMember`). Their records stay: a case somebody wrote is the project’s, not theirs. The last owner can do neither (409). A reader cannot leave — the read-only rule has no exceptions, so an owner removes them. In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company removes somebody else (403 `company owner or admin only` for the rest); leaving is as before. Recorded in the audit feed as `removed <email>` or `<email> left`."},"status":"a member asked about somebody else, or a reader asked at all — or, in a company’s project, somebody who is not an owner or admin of the company asked about somebody else","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — the last owner","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/members/:id","host":["{{baseUrl}}"],"path":["v1","members",":id"],"variable":[{"key":"id","value":"","description":"The member’s `userId` — your own to leave"}]},"description":"An owner removes anybody; a member removes only themself — that is “Leave” (`api.ts` → `removeMember`). Their records stay: a case somebody wrote is the project’s, not theirs. The last owner can do neither (409). A reader cannot leave — the read-only rule has no exceptions, so an owner removes them. In a company’s project (ADR 0038) only an owner of the project who is also an owner or admin of the company removes somebody else (403 `company owner or admin only` for the rest); leaving is as before. Recorded in the audit feed as `removed <email>` or `<email> left`."},"status":"the last owner","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a project needs an owner — make somebody else one first\"\n}"}]},{"name":"The projects you are in","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects","host":["{{baseUrl}}"],"path":["v1","projects"]},"description":"Every project the caller is a member of, with their role in each and its last run, oldest first (`api.ts` → `listProjects`; the dashboard reads it at boot to fill the project switcher). The one surface that takes no `X-Plune-Project`: it is how a client learns which ids it may put there. A brand-new account lists exactly one — its default project, provisioned on first sight — never none."},"response":[{"name":"200 — Your projects","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects","host":["{{baseUrl}}"],"path":["v1","projects"]},"description":"Every project the caller is a member of, with their role in each and its last run, oldest first (`api.ts` → `listProjects`; the dashboard reads it at boot to fill the project switcher). The one surface that takes no `X-Plune-Project`: it is how a client learns which ids it may put there. A brand-new account lists exactly one — its default project, provisioned on first sight — never none."},"status":"Your projects","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projects\": [\n    {\n      \"id\": \"u-shop::default\",\n      \"name\": \"Checkout\",\n      \"role\": \"owner\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"lastRun\": {\n        \"at\": \"2026-09-16T09:12:04.151Z\",\n        \"total\": 48,\n        \"passed\": 45\n      },\n      \"company\": null\n    },\n    {\n      \"id\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n      \"name\": \"Mobile app\",\n      \"role\": \"member\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"lastRun\": null,\n      \"company\": {\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n        \"name\": \"Acme\"\n      }\n    }\n  ]\n}"}]},{"name":"Create a project","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Mobile app\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects","host":["{{baseUrl}}"],"path":["v1","projects"]},"description":"A name, and the caller is its owner (`api.ts` → `createProject`, the “New project…” dialog of the switcher). Nothing is copied into it — settings, tags and the steps database are per project and start empty. Put its id in `X-Plune-Project` to work in it; mint a token for it on Account → API tokens to give CI one bound to it. The tier caps the projects an account owns (Free 3, Personal 10; `GET /v1/usage` shows `limits.projects`). That cap only refuses (`429` with `quota.kind: \"projects\"`; no `Retry-After`, there is no reset time to give) once the operator sets `TIER_LIMITS_ENFORCE=true`; until then it is counted and logged."},"response":[{"name":"201 — Created — you are its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Mobile app\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects","host":["{{baseUrl}}"],"path":["v1","projects"]},"description":"A name, and the caller is its owner (`api.ts` → `createProject`, the “New project…” dialog of the switcher). Nothing is copied into it — settings, tags and the steps database are per project and start empty. Put its id in `X-Plune-Project` to work in it; mint a token for it on Account → API tokens to give CI one bound to it. The tier caps the projects an account owns (Free 3, Personal 10; `GET /v1/usage` shows `limits.projects`). That cap only refuses (`429` with `quota.kind: \"projects\"`; no `Retry-After`, there is no reset time to give) once the operator sets `TIER_LIMITS_ENFORCE=true`; until then it is counted and logged."},"status":"Created — you are its owner","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n  \"name\": \"Mobile app\",\n  \"role\": \"owner\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Rename a project","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Checkout\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id","host":["{{baseUrl}}"],"path":["v1","projects",":id"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of THAT project renames it (`api.ts` → `renameProject`), whichever project the request is standing in — the id is in the path, not the header. A member of it is told it is the owner’s to rename (403); a stranger gets the 404 an invented id gets. Recorded in the audit feed of the project as a `project` update."},"response":[{"name":"200 — The project as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Checkout\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id","host":["{{baseUrl}}"],"path":["v1","projects",":id"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of THAT project renames it (`api.ts` → `renameProject`), whichever project the request is standing in — the id is in the path, not the header. A member of it is told it is the owner’s to rename (403); a stranger gets the 404 an invented id gets. Recorded in the audit feed of the project as a `project` update."},"status":"The project as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"u-shop::default\",\n  \"name\": \"Checkout\",\n  \"role\": \"owner\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — you are in it, but not as an owner","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Checkout\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id","host":["{{baseUrl}}"],"path":["v1","projects",":id"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of THAT project renames it (`api.ts` → `renameProject`), whichever project the request is standing in — the id is in the path, not the header. A member of it is told it is the owner’s to rename (403); a stranger gets the 404 an invented id gets. Recorded in the audit feed of the project as a `project` update."},"status":"you are in it, but not as an owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Checkout\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id","host":["{{baseUrl}}"],"path":["v1","projects",":id"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of THAT project renames it (`api.ts` → `renameProject`), whichever project the request is standing in — the id is in the path, not the header. A member of it is told it is the owner’s to rename (403); a stranger gets the 404 an invented id gets. Recorded in the audit feed of the project as a `project` update."},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"}]},{"name":"The CI the project starts its automated tests in","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the CI (GitHub Actions, GitLab CI or CircleCI), the repository or project, the workflow or pipeline file and the branch or tag, who connected it and when, and what its last check found. The owner reads four more fields — who saved the token, when, when the CI says it expires, and whose it is — and nobody reads the token. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The connection, or `{ \"configured\": false }`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the CI (GitHub Actions, GitLab CI or CircleCI), the repository or project, the workflow or pipeline file and the branch or tag, who connected it and when, and what its last check found. The owner reads four more fields — who saved the token, when, when the CI says it expires, and whose it is — and nobody reads the token. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The connection, or `{ \"configured\": false }`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"savedBy\": \"u-shop\",\n  \"savedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"verifiedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"verifyResult\": {\n    \"ok\": true,\n    \"steps\": [\n      {\n        \"step\": \"token\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"repository\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"actions\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"workflow\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"inputs\",\n        \"result\": \"ok\"\n      }\n    ]\n  },\n  \"tokenSavedBy\": \"u-shop\",\n  \"tokenSavedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"tokenExpiresAt\": null\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the CI (GitHub Actions, GitLab CI or CircleCI), the repository or project, the workflow or pipeline file and the branch or tag, who connected it and when, and what its last check found. The owner reads four more fields — who saved the token, when, when the CI says it expires, and whose it is — and nobody reads the token. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"503 — the server has no key to keep tokens under — the operator has not set `INTEGRATIONS_ENCRYPTION_KEY`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the CI (GitHub Actions, GitLab CI or CircleCI), the repository or project, the workflow or pipeline file and the branch or tag, who connected it and when, and what its last check found. The owner reads four more fields — who saved the token, when, when the CI says it expires, and whose it is — and nobody reads the token. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep tokens under — the operator has not set `INTEGRATIONS_ENCRYPTION_KEY`","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"integrations are not configured on this server — contact your operator\"\n}"}]},{"name":"Test and connect GitHub Actions, GitLab CI or CircleCI, or change the connection","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — Connected — the same body as `GET`","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"Connected — the same body as `GET`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"savedBy\": \"u-shop\",\n  \"savedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"verifiedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"verifyResult\": {\n    \"ok\": true,\n    \"steps\": [\n      {\n        \"step\": \"token\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"repository\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"actions\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"workflow\",\n        \"result\": \"ok\"\n      },\n      {\n        \"step\": \"inputs\",\n        \"result\": \"ok\"\n      }\n    ]\n  },\n  \"tokenSavedBy\": \"u-shop\",\n  \"tokenSavedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"tokenExpiresAt\": null\n}"},{"name":"400 — A first connect with no token","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body, or a first connect with no token; the message names the field and never a value. Nothing was sent to GitHub","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — token: is required for a new connection\"\n}"},{"name":"400 — A classic token","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body, or a first connect with no token; the message names the field and never a value. Nothing was sent to GitHub","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — token: must be a fine-grained personal access token — it starts with github_pat_\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"409 — another CI is connected — one CI a project for now: disconnect it first. Nothing was sent to the CI","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"another CI is connected — one CI a project for now: disconnect it first. Nothing was sent to the CI","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"GitHub Actions is connected — one CI a project for now: disconnect it to use GitLab CI\"\n}"},{"name":"422 — a check failed; nothing was saved. The body is the check, as `POST …/test` answers it, with `error`. A failed check carries one of these `code`s: `unreachable` — the CI did not answer in time; `rate_limited` — the CI is limiting the token’s requests: try again in a minute; `token_rejected` — the CI does not accept the token: it is mistyped, expired or revoked; `repository_not_found` — the token does not reach the repository, project or project slug: a private one it was not given, or a name that is not there; `actions_denied` — the token may not start workflows or pipelines: on GitHub, Repository permissions → Actions → Read and write; on GitLab, role Developer and scope `api`; on CircleCI, a user who may start the project’s pipelines; `workflow_not_found` — the workflow file, `.gitlab-ci.yml` or `.circleci/config.yml` is not there; `workflow_disabled` — the workflow is switched off on GitHub; `ref_not_found` — the branch or tag is not there; `no_workflow_dispatch` — the GitHub workflow does not run on `workflow_dispatch`; `inputs_missing` — the workflow, job or config does not take everything Plune sends (GitHub: the inputs `plune_run`, `grep`, `ref`; GitLab: a job for `$PLUNE_RUN` that greps by `$PLUNE_GREP`; CircleCI: the parameters `plune_run` and `grep`): `missing` names them; `variables_denied` — the GitLab project keeps pipeline variables from the token’s role: Settings → CI/CD → Variables → Minimum role to use pipeline variables → Developer; `address_refused` — the GitLab address is not https, is an IP address, or is on a private network: Plune sends no request there; `unexpected` — the CI answered with a status the check did not expect: the step carries it.","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"a check failed; nothing was saved. The body is the check, as `POST …/test` answers it, with `error`. A failed check carries one of these `code`s: `unreachable` — the CI did not answer in time; `rate_limited` — the CI is limiting the token’s requests: try again in a minute; `token_rejected` — the CI does not accept the token: it is mistyped, expired or revoked; `repository_not_found` — the token does not reach the repository, project or project slug: a private one it was not given, or a name that is not there; `actions_denied` — the token may not start workflows or pipelines: on GitHub, Repository permissions → Actions → Read and write; on GitLab, role Developer and scope `api`; on CircleCI, a user who may start the project’s pipelines; `workflow_not_found` — the workflow file, `.gitlab-ci.yml` or `.circleci/config.yml` is not there; `workflow_disabled` — the workflow is switched off on GitHub; `ref_not_found` — the branch or tag is not there; `no_workflow_dispatch` — the GitHub workflow does not run on `workflow_dispatch`; `inputs_missing` — the workflow, job or config does not take everything Plune sends (GitHub: the inputs `plune_run`, `grep`, `ref`; GitLab: a job for `$PLUNE_RUN` that greps by `$PLUNE_GREP`; CircleCI: the parameters `plune_run` and `grep`): `missing` names them; `variables_denied` — the GitLab project keeps pipeline variables from the token’s role: Settings → CI/CD → Variables → Minimum role to use pipeline variables → Developer; `address_refused` — the GitLab address is not https, is an IP address, or is on a private network: Plune sends no request there; `unexpected` — the CI answered with a status the check did not expect: the step carries it.","code":422,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the connection did not pass its checks — nothing was saved\",\n  \"ok\": false,\n  \"steps\": [\n    {\n      \"step\": \"token\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"repository\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"actions\",\n      \"result\": \"failed\",\n      \"code\": \"actions_denied\"\n    },\n    {\n      \"step\": \"workflow\",\n      \"result\": \"skipped\"\n    },\n    {\n      \"step\": \"inputs\",\n      \"result\": \"skipped\"\n    }\n  ],\n  \"testedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"503 — the server has no key to keep tokens under; nothing is saved","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"github\",\n  \"repository\": \"plune-ai/cli\",\n  \"workflow\": \".github/workflows/plune.yml\",\n  \"ref\": \"main\",\n  \"token\": \"github_pat_EXAMPLE_not_a_real_token_0000000000000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Runs the five checks of `POST …/test` with what was sent, and saves the connection only if every one passed — the token encrypted with the server’s key, bound to this project, never returned — answering with what `GET` shows, the check as its last one. A check that failed saves nothing and answers 422 with the checks. One CI a project for now: with another one connected, 409 and nothing is sent. Left out of an edit of a project already connected, `token` is the saved one, and so are who saved it and when. Recorded in the audit feed of the project as a `ci_integration` entry, every `token…` field masked. At most 10 checks a minute for a project, this and `POST …/test` together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep tokens under; nothing is saved","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"integrations are not configured on this server — contact your operator\"\n}"}]},{"name":"Disconnect the CI","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its token (revoke the token on GitHub too). Launches in CI stop: plans and runs give the command to copy instead. Recorded in the audit feed of the project as a `ci_integration` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its token (revoke the token on GitHub too). Launches in CI stop: plans and runs give the command to copy instead. Recorded in the audit feed of the project as a `ci_integration` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its token (revoke the token on GitHub too). Launches in CI stop: plans and runs give the command to copy instead. Recorded in the audit feed of the project as a `ci_integration` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no CI is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing to disconnect","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its token (revoke the token on GitHub too). Launches in CI stop: plans and runs give the command to copy instead. Recorded in the audit feed of the project as a `ci_integration` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no CI is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no CI is connected\"\n}"},{"name":"503 — the server has no key to keep tokens under","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its token (revoke the token on GitHub too). Launches in CI stop: plans and runs give the command to copy instead. Recorded in the audit feed of the project as a `ci_integration` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep tokens under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"integrations are not configured on this server — contact your operator\"\n}"}]},{"name":"Where the pipelines of these runs stand","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches?runs=","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"query":[{"key":"runs","value":"","disabled":true,"description":"Run ids, comma-separated"}],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member, a reader too. `?runs=` names 1 to 200 runs of the project (a list, or the key repeated); the answer holds a launch for each that has one, and nothing for the rest — another project’s run among them included. Plune asks GitHub about every pipeline still going once a minute; this reads what it last saw. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The launches of the runs named","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches?runs=","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"query":[{"key":"runs","value":"","disabled":true,"description":"Run ids, comma-separated"}],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member, a reader too. `?runs=` names 1 to 200 runs of the project (a list, or the key repeated); the answer holds a launch for each that has one, and nothing for the rest — another project’s run among them included. Plune asks GitHub about every pipeline still going once a minute; this reads what it last saw. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The launches of the runs named","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"launches\": [\n    {\n      \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"provider\": \"github\",\n      \"repository\": \"plune-ai/cli\",\n      \"workflow\": \".github/workflows/plune.yml\",\n      \"ref\": \"main\",\n      \"state\": \"queued\",\n      \"code\": null,\n      \"status\": null,\n      \"runUrl\": \"https://github.com/plune-ai/cli/actions/runs/900001\",\n      \"conclusion\": null,\n      \"dispatchedBy\": \"u-shop\",\n      \"dispatchedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"startedAt\": null,\n      \"completedAt\": null,\n      \"unreachableSince\": null,\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"},{"name":"503 — the server has no key to keep tokens under","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches?runs=","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"query":[{"key":"runs","value":"","disabled":true,"description":"Run ids, comma-separated"}],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member, a reader too. `?runs=` names 1 to 200 runs of the project (a list, or the key repeated); the answer holds a launch for each that has one, and nothing for the rest — another project’s run among them included. Plune asks GitHub about every pipeline still going once a minute; this reads what it last saw. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep tokens under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"integrations are not configured on this server — contact your operator\"\n}"}]},{"name":"Start a run’s automated part in CI","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner or member. One `workflow_dispatch` of the connected workflow with three inputs — `plune_run` (the run’s key, which the reporter in CI joins by), `grep` (the run’s automated cases, as `plune run grep` builds it) and `ref` (the branch or tag to test) — and `return_run_details`, so the launch names its pipeline from the first moment. `PLUNE_TOKEN` is the repository’s secret and never an input. A scheduled run is launched by it, and one whose automated part Plune closed is opened again: Try again and Launch again in CI send a new dispatch under the same key. `schedule: true` (Save) only keeps the choice. Then Plune watches the pipeline: queued 15 minutes — it cancels it; ended — 5 more minutes for the reporter; 24 hours after the dispatch — it stops. At most 60 launches a minute a person. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"201 — Dispatched (`queued`), or kept for the launch (`scheduled`)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner or member. One `workflow_dispatch` of the connected workflow with three inputs — `plune_run` (the run’s key, which the reporter in CI joins by), `grep` (the run’s automated cases, as `plune run grep` builds it) and `ref` (the branch or tag to test) — and `return_run_details`, so the launch names its pipeline from the first moment. `PLUNE_TOKEN` is the repository’s secret and never an input. A scheduled run is launched by it, and one whose automated part Plune closed is opened again: Try again and Launch again in CI send a new dispatch under the same key. `schedule: true` (Save) only keeps the choice. Then Plune watches the pipeline: queued 15 minutes — it cancels it; ended — 5 more minutes for the reporter; 24 hours after the dispatch — it stops. At most 60 launches a minute a person. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"Dispatched (`queued`), or kept for the launch (`scheduled`)","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"launch\": {\n    \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"provider\": \"github\",\n    \"repository\": \"plune-ai/cli\",\n    \"workflow\": \".github/workflows/plune.yml\",\n    \"ref\": \"main\",\n    \"state\": \"queued\",\n    \"code\": null,\n    \"status\": null,\n    \"runUrl\": \"https://github.com/plune-ai/cli/actions/runs/900001\",\n    \"conclusion\": null,\n    \"dispatchedBy\": \"u-shop\",\n    \"dispatchedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"startedAt\": null,\n    \"completedAt\": null,\n    \"unreachableSince\": null,\n    \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n  }\n}"},{"name":"403 — a reader of the project; or (#1048, only once the operator sets `TIER_LIMITS_ENFORCE=true`) a tier without the `ci_launch` permission, answered in English then Ukrainian, naming the feature, with `feature` and `tier`","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner or member. One `workflow_dispatch` of the connected workflow with three inputs — `plune_run` (the run’s key, which the reporter in CI joins by), `grep` (the run’s automated cases, as `plune run grep` builds it) and `ref` (the branch or tag to test) — and `return_run_details`, so the launch names its pipeline from the first moment. `PLUNE_TOKEN` is the repository’s secret and never an input. A scheduled run is launched by it, and one whose automated part Plune closed is opened again: Try again and Launch again in CI send a new dispatch under the same key. `schedule: true` (Save) only keeps the choice. Then Plune watches the pipeline: queued 15 minutes — it cancels it; ended — 5 more minutes for the reporter; 24 hours after the dispatch — it stops. At most 60 launches a minute a person. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"a reader of the project; or (#1048, only once the operator sets `TIER_LIMITS_ENFORCE=true`) a tier without the `ci_launch` permission, answered in English then Ukrainian, naming the feature, with `feature` and `tier`","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a reader starts nothing in CI\"\n}"},{"name":"404 — no such project among yours, no such run in it, or no CI is connected","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner or member. One `workflow_dispatch` of the connected workflow with three inputs — `plune_run` (the run’s key, which the reporter in CI joins by), `grep` (the run’s automated cases, as `plune run grep` builds it) and `ref` (the branch or tag to test) — and `return_run_details`, so the launch names its pipeline from the first moment. `PLUNE_TOKEN` is the repository’s secret and never an input. A scheduled run is launched by it, and one whose automated part Plune closed is opened again: Try again and Launch again in CI send a new dispatch under the same key. `schedule: true` (Save) only keeps the choice. Then Plune watches the pipeline: queued 15 minutes — it cancels it; ended — 5 more minutes for the reporter; 24 hours after the dispatch — it stops. At most 60 launches a minute a person. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, no such run in it, or no CI is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no CI is connected\"\n}"},{"name":"409 — Its pipeline is still watched","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner or member. One `workflow_dispatch` of the connected workflow with three inputs — `plune_run` (the run’s key, which the reporter in CI joins by), `grep` (the run’s automated cases, as `plune run grep` builds it) and `ref` (the branch or tag to test) — and `return_run_details`, so the launch names its pipeline from the first moment. `PLUNE_TOKEN` is the repository’s secret and never an input. A scheduled run is launched by it, and one whose automated part Plune closed is opened again: Try again and Launch again in CI send a new dispatch under the same key. `schedule: true` (Save) only keeps the choice. Then Plune watches the pipeline: queued 15 minutes — it cancels it; ended — 5 more minutes for the reporter; 24 hours after the dispatch — it stops. At most 60 launches a minute a person. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the run is manual, finished, has no key or no automated case, names more tests than one dispatch takes, or is in CI already; or the saved token does not open under this server’s key","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"run '6f1c2b3a-0000-4000-8000-0000000000d1' is in CI already\"\n}"},{"name":"503 — the server has no key to keep tokens under; nothing was sent","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/launches","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","launches"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner or member. One `workflow_dispatch` of the connected workflow with three inputs — `plune_run` (the run’s key, which the reporter in CI joins by), `grep` (the run’s automated cases, as `plune run grep` builds it) and `ref` (the branch or tag to test) — and `return_run_details`, so the launch names its pipeline from the first moment. `PLUNE_TOKEN` is the repository’s secret and never an input. A scheduled run is launched by it, and one whose automated part Plune closed is opened again: Try again and Launch again in CI send a new dispatch under the same key. `schedule: true` (Save) only keeps the choice. Then Plune watches the pipeline: queued 15 minutes — it cancels it; ended — 5 more minutes for the reporter; 24 hours after the dispatch — it stops. At most 60 launches a minute a person. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep tokens under; nothing was sent","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"integrations are not configured on this server — contact your operator\"\n}"}]},{"name":"Check the saved connection again","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — What the check found. A failed check carries one of these `code`s: `unreachable` — the CI did not answer in time; `rate_limited` — the CI is limiting the token’s requests: try again in a minute; `token_rejected` — the CI does not accept the token: it is mistyped, expired or revoked; `repository_not_found` — the token does not reach the repository, project or project slug: a private one it was not given, or a name that is not there; `actions_denied` — the token may not start workflows or pipelines: on GitHub, Repository permissions → Actions → Read and write; on GitLab, role Developer and scope `api`; on CircleCI, a user who may start the project’s pipelines; `workflow_not_found` — the workflow file, `.gitlab-ci.yml` or `.circleci/config.yml` is not there; `workflow_disabled` — the workflow is switched off on GitHub; `ref_not_found` — the branch or tag is not there; `no_workflow_dispatch` — the GitHub workflow does not run on `workflow_dispatch`; `inputs_missing` — the workflow, job or config does not take everything Plune sends (GitHub: the inputs `plune_run`, `grep`, `ref`; GitLab: a job for `$PLUNE_RUN` that greps by `$PLUNE_GREP`; CircleCI: the parameters `plune_run` and `grep`): `missing` names them; `variables_denied` — the GitLab project keeps pipeline variables from the token’s role: Settings → CI/CD → Variables → Minimum role to use pipeline variables → Developer; `address_refused` — the GitLab address is not https, is an IP address, or is on a private network: Plune sends no request there; `unexpected` — the CI answered with a status the check did not expect: the step carries it.","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"What the check found. A failed check carries one of these `code`s: `unreachable` — the CI did not answer in time; `rate_limited` — the CI is limiting the token’s requests: try again in a minute; `token_rejected` — the CI does not accept the token: it is mistyped, expired or revoked; `repository_not_found` — the token does not reach the repository, project or project slug: a private one it was not given, or a name that is not there; `actions_denied` — the token may not start workflows or pipelines: on GitHub, Repository permissions → Actions → Read and write; on GitLab, role Developer and scope `api`; on CircleCI, a user who may start the project’s pipelines; `workflow_not_found` — the workflow file, `.gitlab-ci.yml` or `.circleci/config.yml` is not there; `workflow_disabled` — the workflow is switched off on GitHub; `ref_not_found` — the branch or tag is not there; `no_workflow_dispatch` — the GitHub workflow does not run on `workflow_dispatch`; `inputs_missing` — the workflow, job or config does not take everything Plune sends (GitHub: the inputs `plune_run`, `grep`, `ref`; GitLab: a job for `$PLUNE_RUN` that greps by `$PLUNE_GREP`; CircleCI: the parameters `plune_run` and `grep`): `missing` names them; `variables_denied` — the GitLab project keeps pipeline variables from the token’s role: Settings → CI/CD → Variables → Minimum role to use pipeline variables → Developer; `address_refused` — the GitLab address is not https, is an IP address, or is on a private network: Plune sends no request there; `unexpected` — the CI answered with a status the check did not expect: the step carries it.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"ok\": true,\n  \"steps\": [\n    {\n      \"step\": \"token\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"repository\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"actions\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"workflow\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"inputs\",\n      \"result\": \"ok\"\n    }\n  ],\n  \"testedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no CI is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing saved to check","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no CI is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no CI is connected\"\n}"},{"name":"409 — the saved token does not open under this server’s key (the key was replaced): connect again with the token","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the saved token does not open under this server’s key (the key was replaced): connect again with the token","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the saved token cannot be read on this server — replace it\"\n}"},{"name":"503 — the server has no key to keep tokens under; nothing was sent","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/ci/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","ci","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks GitHub five things about the saved connection, with the saved token, and keeps what it found as the last check: `token` (GitHub accepts it), `repository` (it reaches the repository), `actions` (it may start workflows — Actions: Read and write), `workflow` (the file is on the branch, and switched on) and `inputs` (the workflow runs on `workflow_dispatch` and takes `plune_run`, `grep` and `ref`). A check that leans on one that failed is `skipped`. A fine-grained token cut to Actions cannot read a file of the repository, so the last three are asked of a `workflow_dispatch` that carries, besides the three inputs, one that no workflow declares: GitHub refuses it with a 422 that says what it found, and starts nothing — should it ever start a run all the same, Plune cancels it at once and the check says `unexpected`. Requests go to api.github.com only and take 10 seconds at most each; what GitHub answers is read for its status and its message, never kept. At most 10 checks a minute for a project, this and `PUT` together. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep tokens under; nothing was sent","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"integrations are not configured on this server — contact your operator\"\n}"}]},{"name":"The TestRail the project imports from","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the address, when the key was saved and by whom, and what the last check or step found out about it. The owner reads the e-mail as well. Nobody reads the key — not in full, not in part. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The connection, or `{ \"configured\": false }`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the address, when the key was saved and by whom, and what the last check or step found out about it. The owner reads the e-mail as well. Nobody reads the key — not in full, not in part. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The connection, or `{ \"configured\": false }`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"keySet\": true,\n  \"keySavedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"keySavedBy\": \"u-shop\",\n  \"status\": \"ok\",\n  \"verifiedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the address, when the key was saved and by whom, and what the last check or step found out about it. The owner reads the e-mail as well. Nobody reads the key — not in full, not in part. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under — the operator has not set `TESTRAIL_ENCRYPTION_KEY`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too: `{ \"configured\": false }` when none is connected, otherwise the address, when the key was saved and by whom, and what the last check or step found out about it. The owner reads the e-mail as well. Nobody reads the key — not in full, not in part. A project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under — the operator has not set `TESTRAIL_ENCRYPTION_KEY`","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Connect a TestRail, or replace its API key","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — Saved — the same body as `GET`","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"Saved — the same body as `GET`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"keySet\": true,\n  \"keySavedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"keySavedBy\": \"u-shop\",\n  \"status\": \"untested\",\n  \"verifiedAt\": null\n}"},{"name":"400 — An address that is not https","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body, or an address the guard refuses; the message names the field and never a value. Nothing was saved","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed - baseUrl: must be an https address without a user name or password\"\n}"},{"name":"400 — An address that is not public","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body, or an address the guard refuses; the message names the field and never a value. Nothing was saved","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed - baseUrl: the address is not allowed - it must be https and resolve to a public address\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under; nothing is saved","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"baseUrl\": \"https://tr.example.com\",\n  \"email\": \"importer@example.com\",\n  \"apiKey\": \"example-not-a-real-testrail-key-0123456789\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the address, the e-mail and the API key: the key encrypted with the server’s key for TestRail, bound to this project, never returned. Nothing is sent to TestRail here — the address is only checked (https, a public address) — so the answer has `status: untested` until `POST …/test` or an import finds the key works. A save over an existing connection replaces the ciphertext in place. Recorded in the audit feed of the project as a `testrail_connection` entry: the key masked, the e-mail left out. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under; nothing is saved","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Disconnect the TestRail","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Erases the connection and the ciphertext of the key — the row is deleted, there is no soft delete (revoke the key in TestRail too). What was imported stays: it is the project’s data. A dry run or an import in progress finds no connection at its next step and ends `cancelled`. Recorded in the audit feed as a `testrail_connection` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Erases the connection and the ciphertext of the key — the row is deleted, there is no soft delete (revoke the key in TestRail too). What was imported stays: it is the project’s data. A dry run or an import in progress finds no connection at its next step and ends `cancelled`. Recorded in the audit feed as a `testrail_connection` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Erases the connection and the ciphertext of the key — the row is deleted, there is no soft delete (revoke the key in TestRail too). What was imported stays: it is the project’s data. A dry run or an import in progress finds no connection at its next step and ends `cancelled`. Recorded in the audit feed as a `testrail_connection` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing to disconnect","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Erases the connection and the ciphertext of the key — the row is deleted, there is no soft delete (revoke the key in TestRail too). What was imported stays: it is the project’s data. A dry run or an import in progress finds no connection at its next step and ends `cancelled`. Recorded in the audit feed as a `testrail_connection` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no TestRail is connected\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Erases the connection and the ciphertext of the key — the row is deleted, there is no soft delete (revoke the key in TestRail too). What was imported stays: it is the project’s data. A dry run or an import in progress finds no connection at its next step and ends `cancelled`. Recorded in the audit feed as a `testrail_connection` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Dry run: what an import of one TestRail project would do","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Starts a job (`kind: testrail-dry-run`, in `GET /v1/jobs`) that reads the instance the way the import does and writes nothing in the project: it counts the tree and the cases that would be created, updated, left alone, in conflict or skipped, and why. The counters are those the import after it gives, from the same code. Answers 202 with the job’s view; poll `GET …/imports/{jobId}`. One dry run or import at a time in a project (409). With `history` it reads the lists of runs and plans too (never the results): it counts the runs that would come, those left out (older than the plan of the account keeps, in the Bin, finished in Plune already), estimates the results from the counts of the lists and says which sub-statuses the import would add. An import of a TestRail project needs a finished dry run of that project first. Mapping: `docs/features/BH-A1-testrail-bridge/mapping.md`. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"202 — The job, queued","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Starts a job (`kind: testrail-dry-run`, in `GET /v1/jobs`) that reads the instance the way the import does and writes nothing in the project: it counts the tree and the cases that would be created, updated, left alone, in conflict or skipped, and why. The counters are those the import after it gives, from the same code. Answers 202 with the job’s view; poll `GET …/imports/{jobId}`. One dry run or import at a time in a project (409). With `history` it reads the lists of runs and plans too (never the results): it counts the runs that would come, those left out (older than the plan of the account keeps, in the Bin, finished in Plune already), estimates the results from the counts of the lists and says which sub-statuses the import would add. An import of a TestRail project needs a finished dry run of that project first. Mapping: `docs/features/BH-A1-testrail-bridge/mapping.md`. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The job, queued","code":202,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n  \"dryRun\": true,\n  \"status\": \"running\",\n  \"trProjectId\": 1,\n  \"trProjectName\": \"Project Alpha\",\n  \"phase\": \"lookups\",\n  \"counters\": {\n    \"cases_seen\": 4,\n    \"cases_created\": 3,\n    \"cases_updated\": 0,\n    \"cases_unchanged\": 0,\n    \"cases_conflict\": 0,\n    \"cases_skipped_bin\": 0,\n    \"cases_skipped_deleted\": 1,\n    \"cases_skipped_bdd\": 0,\n    \"cases_skipped_ceiling\": 0,\n    \"cases_skipped_invalid\": 0,\n    \"nodes_created\": 6,\n    \"nodes_existing\": 0,\n    \"steps_shared_expanded\": 1,\n    \"steps_shared_missing\": 0,\n    \"cases_placeholder_expected\": 0,\n    \"cases_exploratory\": 1,\n    \"cases_estimate_not_transferred\": 1,\n    \"cases_refs\": 1,\n    \"cases_type_not_transferred\": 3,\n    \"cases_custom_fields_not_transferred\": 0,\n    \"priorities_unknown\": 0,\n    \"cases_gone\": 0,\n    \"cases_detached\": 0,\n    \"cases_reattached\": 0,\n    \"fields_created\": 0,\n    \"fields_existing\": 0,\n    \"runs_listed\": 0,\n    \"runs_beyond_plan\": 0,\n    \"runs_binned\": 0,\n    \"runs_already_imported\": 0,\n    \"runs_to_import\": 0,\n    \"runs_imported\": 0,\n    \"runs_finished\": 0,\n    \"results_estimated\": 0,\n    \"results_untested\": 0,\n    \"results_seen\": 0,\n    \"results_accepted\": 0,\n    \"results_duplicate\": 0,\n    \"results_conflict\": 0,\n    \"results_no_case\": 0,\n    \"results_skipped_untested\": 0,\n    \"results_skipped_invalid\": 0,\n    \"results_status_unknown\": 0,\n    \"substatuses_added\": 0,\n    \"substatus_overflow\": 0,\n    \"dates_clamped\": 0,\n    \"results_elapsed_unparsed\": 0,\n    \"results_attachments_not_transferred\": 0,\n    \"runs_skipped_invalid\": 0,\n    \"dates_missing\": 0\n  },\n  \"conflicts\": [],\n  \"warnings\": [],\n  \"sync\": null,\n  \"changes\": [],\n  \"gone\": [],\n  \"history\": null,\n  \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"finishedAt\": null,\n  \"job\": {\n    \"state\": \"in_progress\",\n    \"progress\": null,\n    \"reason\": null\n  }\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Starts a job (`kind: testrail-dry-run`, in `GET /v1/jobs`) that reads the instance the way the import does and writes nothing in the project: it counts the tree and the cases that would be created, updated, left alone, in conflict or skipped, and why. The counters are those the import after it gives, from the same code. Answers 202 with the job’s view; poll `GET …/imports/{jobId}`. One dry run or import at a time in a project (409). With `history` it reads the lists of runs and plans too (never the results): it counts the runs that would come, those left out (older than the plan of the account keeps, in the Bin, finished in Plune already), estimates the results from the counts of the lists and says which sub-statuses the import would add. An import of a TestRail project needs a finished dry run of that project first. Mapping: `docs/features/BH-A1-testrail-bridge/mapping.md`. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — a dry run or an import of this project is running, or the saved key cannot be read on this server","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Starts a job (`kind: testrail-dry-run`, in `GET /v1/jobs`) that reads the instance the way the import does and writes nothing in the project: it counts the tree and the cases that would be created, updated, left alone, in conflict or skipped, and why. The counters are those the import after it gives, from the same code. Answers 202 with the job’s view; poll `GET …/imports/{jobId}`. One dry run or import at a time in a project (409). With `history` it reads the lists of runs and plans too (never the results): it counts the runs that would come, those left out (older than the plan of the account keeps, in the Bin, finished in Plune already), estimates the results from the counts of the lists and says which sub-statuses the import would add. An import of a TestRail project needs a finished dry run of that project first. Mapping: `docs/features/BH-A1-testrail-bridge/mapping.md`. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"a dry run or an import of this project is running, or the saved key cannot be read on this server","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a dry run or an import of this project is running - wait for it, or cancel it\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Starts a job (`kind: testrail-dry-run`, in `GET /v1/jobs`) that reads the instance the way the import does and writes nothing in the project: it counts the tree and the cases that would be created, updated, left alone, in conflict or skipped, and why. The counters are those the import after it gives, from the same code. Answers 202 with the job’s view; poll `GET …/imports/{jobId}`. One dry run or import at a time in a project (409). With `history` it reads the lists of runs and plans too (never the results): it counts the runs that would come, those left out (older than the plan of the account keeps, in the Bin, finished in Plune already), estimates the results from the counts of the lists and says which sub-statuses the import would add. An import of a TestRail project needs a finished dry run of that project first. Mapping: `docs/features/BH-A1-testrail-bridge/mapping.md`. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"The latest dry runs and imports of the project","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too: the last 20, newest first, each as `GET …/imports/{jobId}` gives it. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The imports","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too: the last 20, newest first, each as `GET …/imports/{jobId}` gives it. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The imports","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"imports\": [\n    {\n      \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n      \"dryRun\": true,\n      \"status\": \"done\",\n      \"trProjectId\": 1,\n      \"trProjectName\": \"Project Alpha\",\n      \"phase\": \"done\",\n      \"counters\": {\n        \"cases_seen\": 4,\n        \"cases_created\": 3,\n        \"cases_updated\": 0,\n        \"cases_unchanged\": 0,\n        \"cases_conflict\": 0,\n        \"cases_skipped_bin\": 0,\n        \"cases_skipped_deleted\": 1,\n        \"cases_skipped_bdd\": 0,\n        \"cases_skipped_ceiling\": 0,\n        \"cases_skipped_invalid\": 0,\n        \"nodes_created\": 6,\n        \"nodes_existing\": 0,\n        \"steps_shared_expanded\": 1,\n        \"steps_shared_missing\": 0,\n        \"cases_placeholder_expected\": 0,\n        \"cases_exploratory\": 1,\n        \"cases_estimate_not_transferred\": 1,\n        \"cases_refs\": 1,\n        \"cases_type_not_transferred\": 3,\n        \"cases_custom_fields_not_transferred\": 0,\n        \"priorities_unknown\": 0,\n        \"cases_gone\": 0,\n        \"cases_detached\": 0,\n        \"cases_reattached\": 0,\n        \"fields_created\": 0,\n        \"fields_existing\": 0,\n        \"runs_listed\": 0,\n        \"runs_beyond_plan\": 0,\n        \"runs_binned\": 0,\n        \"runs_already_imported\": 0,\n        \"runs_to_import\": 0,\n        \"runs_imported\": 0,\n        \"runs_finished\": 0,\n        \"results_estimated\": 0,\n        \"results_untested\": 0,\n        \"results_seen\": 0,\n        \"results_accepted\": 0,\n        \"results_duplicate\": 0,\n        \"results_conflict\": 0,\n        \"results_no_case\": 0,\n        \"results_skipped_untested\": 0,\n        \"results_skipped_invalid\": 0,\n        \"results_status_unknown\": 0,\n        \"substatuses_added\": 0,\n        \"substatus_overflow\": 0,\n        \"dates_clamped\": 0,\n        \"results_elapsed_unparsed\": 0,\n        \"results_attachments_not_transferred\": 0,\n        \"runs_skipped_invalid\": 0,\n        \"dates_missing\": 0\n      },\n      \"conflicts\": [],\n      \"warnings\": [],\n      \"sync\": null,\n      \"changes\": [],\n      \"gone\": [],\n      \"history\": null,\n      \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"finishedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"job\": {\n        \"state\": \"completed\",\n        \"progress\": {\n          \"done\": 4,\n          \"total\": 4\n        },\n        \"reason\": null\n      }\n    }\n  ]\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too: the last 20, newest first, each as `GET …/imports/{jobId}` gives it. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too: the last 20, newest first, each as `GET …/imports/{jobId}` gives it. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Import one TestRail project","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only, and only after a finished dry run of the same TestRail project, made on this connection (the same address) within the last 7 days (409 otherwise; 409 too when cases of another TestRail instance were imported into this project before). Starts a job (`kind: testrail-import`) that works in bounded steps on the queue: the lookups, the suites and sections, the tree (a folder for the project, one for each suite in suite mode 3, nested folders and suites for the sections), then the cases with their steps (both templates; shared steps expanded), priority, `refs` (a custom field) and labels (tags). A step records its cursor `{ phase, id, offset }` and queues the next in one transaction, so a worker that dies goes on from there without a duplicate. A case is found again by the external key `testrail` / `C<id>`: a re-import updates, never duplicates. A case in the Bin is not brought back; a case changed in Plune since the import is not overwritten (a conflict when TestRail changed it too — see `conflicts`). Nothing is ever deleted. With `history` (BH-A3b) the same chain goes on after the cases: the runs (key `testrail:run:<id>`, kind `manual`, with the dates TestRail has) and a page of 250 results a step (source `testrail`, key `testrail:result:<id>`: a repeat adds nothing). A closed TestRail run is finished at its `completed_on`, an open one stays open; a run finished in Plune already, or in the Bin, is skipped and counted. Plans are not imported (a run inside one carries a `Plan:` label), milestones are a `Release:` label. Statuses: Passed, Blocked, Failed as they are; Retest and a status of the instance’s own are `skipped` with a sub-status of that name (added to the project, ten at most per status); an unknown status id is `skipped`. Untested makes no result. People (authors, assignees) and the attachments of runs and results are not carried. Estimates, attachments and users are not imported — the counters say what was left out. At most 100 requests a minute to the instance for the connection in the worker process; at a 429 the step stops, and the next one is started after the `Retry-After` (20 pauses without progress fail the import). An import over 200 000 records (800 000 with history) or 3 000 requests fails with a reason. Text of a case is cut to what a case holds. Only an owner can start, cancel, retry or resume it, here and in the jobs queue (403 `project owner only` for anyone else). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"202 — The job, queued","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only, and only after a finished dry run of the same TestRail project, made on this connection (the same address) within the last 7 days (409 otherwise; 409 too when cases of another TestRail instance were imported into this project before). Starts a job (`kind: testrail-import`) that works in bounded steps on the queue: the lookups, the suites and sections, the tree (a folder for the project, one for each suite in suite mode 3, nested folders and suites for the sections), then the cases with their steps (both templates; shared steps expanded), priority, `refs` (a custom field) and labels (tags). A step records its cursor `{ phase, id, offset }` and queues the next in one transaction, so a worker that dies goes on from there without a duplicate. A case is found again by the external key `testrail` / `C<id>`: a re-import updates, never duplicates. A case in the Bin is not brought back; a case changed in Plune since the import is not overwritten (a conflict when TestRail changed it too — see `conflicts`). Nothing is ever deleted. With `history` (BH-A3b) the same chain goes on after the cases: the runs (key `testrail:run:<id>`, kind `manual`, with the dates TestRail has) and a page of 250 results a step (source `testrail`, key `testrail:result:<id>`: a repeat adds nothing). A closed TestRail run is finished at its `completed_on`, an open one stays open; a run finished in Plune already, or in the Bin, is skipped and counted. Plans are not imported (a run inside one carries a `Plan:` label), milestones are a `Release:` label. Statuses: Passed, Blocked, Failed as they are; Retest and a status of the instance’s own are `skipped` with a sub-status of that name (added to the project, ten at most per status); an unknown status id is `skipped`. Untested makes no result. People (authors, assignees) and the attachments of runs and results are not carried. Estimates, attachments and users are not imported — the counters say what was left out. At most 100 requests a minute to the instance for the connection in the worker process; at a 429 the step stops, and the next one is started after the `Retry-After` (20 pauses without progress fail the import). An import over 200 000 records (800 000 with history) or 3 000 requests fails with a reason. Text of a case is cut to what a case holds. Only an owner can start, cancel, retry or resume it, here and in the jobs queue (403 `project owner only` for anyone else). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The job, queued","code":202,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n  \"dryRun\": false,\n  \"status\": \"running\",\n  \"trProjectId\": 1,\n  \"trProjectName\": \"Project Alpha\",\n  \"phase\": \"lookups\",\n  \"counters\": {\n    \"cases_seen\": 4,\n    \"cases_created\": 3,\n    \"cases_updated\": 0,\n    \"cases_unchanged\": 0,\n    \"cases_conflict\": 0,\n    \"cases_skipped_bin\": 0,\n    \"cases_skipped_deleted\": 1,\n    \"cases_skipped_bdd\": 0,\n    \"cases_skipped_ceiling\": 0,\n    \"cases_skipped_invalid\": 0,\n    \"nodes_created\": 6,\n    \"nodes_existing\": 0,\n    \"steps_shared_expanded\": 1,\n    \"steps_shared_missing\": 0,\n    \"cases_placeholder_expected\": 0,\n    \"cases_exploratory\": 1,\n    \"cases_estimate_not_transferred\": 1,\n    \"cases_refs\": 1,\n    \"cases_type_not_transferred\": 3,\n    \"cases_custom_fields_not_transferred\": 0,\n    \"priorities_unknown\": 0,\n    \"cases_gone\": 0,\n    \"cases_detached\": 0,\n    \"cases_reattached\": 0,\n    \"fields_created\": 0,\n    \"fields_existing\": 0,\n    \"runs_listed\": 0,\n    \"runs_beyond_plan\": 0,\n    \"runs_binned\": 0,\n    \"runs_already_imported\": 0,\n    \"runs_to_import\": 0,\n    \"runs_imported\": 0,\n    \"runs_finished\": 0,\n    \"results_estimated\": 0,\n    \"results_untested\": 0,\n    \"results_seen\": 0,\n    \"results_accepted\": 0,\n    \"results_duplicate\": 0,\n    \"results_conflict\": 0,\n    \"results_no_case\": 0,\n    \"results_skipped_untested\": 0,\n    \"results_skipped_invalid\": 0,\n    \"results_status_unknown\": 0,\n    \"substatuses_added\": 0,\n    \"substatus_overflow\": 0,\n    \"dates_clamped\": 0,\n    \"results_elapsed_unparsed\": 0,\n    \"results_attachments_not_transferred\": 0,\n    \"runs_skipped_invalid\": 0,\n    \"dates_missing\": 0\n  },\n  \"conflicts\": [],\n  \"warnings\": [],\n  \"sync\": null,\n  \"changes\": [],\n  \"gone\": [],\n  \"history\": null,\n  \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"finishedAt\": null,\n  \"job\": {\n    \"state\": \"in_progress\",\n    \"progress\": null,\n    \"reason\": null\n  }\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only, and only after a finished dry run of the same TestRail project, made on this connection (the same address) within the last 7 days (409 otherwise; 409 too when cases of another TestRail instance were imported into this project before). Starts a job (`kind: testrail-import`) that works in bounded steps on the queue: the lookups, the suites and sections, the tree (a folder for the project, one for each suite in suite mode 3, nested folders and suites for the sections), then the cases with their steps (both templates; shared steps expanded), priority, `refs` (a custom field) and labels (tags). A step records its cursor `{ phase, id, offset }` and queues the next in one transaction, so a worker that dies goes on from there without a duplicate. A case is found again by the external key `testrail` / `C<id>`: a re-import updates, never duplicates. A case in the Bin is not brought back; a case changed in Plune since the import is not overwritten (a conflict when TestRail changed it too — see `conflicts`). Nothing is ever deleted. With `history` (BH-A3b) the same chain goes on after the cases: the runs (key `testrail:run:<id>`, kind `manual`, with the dates TestRail has) and a page of 250 results a step (source `testrail`, key `testrail:result:<id>`: a repeat adds nothing). A closed TestRail run is finished at its `completed_on`, an open one stays open; a run finished in Plune already, or in the Bin, is skipped and counted. Plans are not imported (a run inside one carries a `Plan:` label), milestones are a `Release:` label. Statuses: Passed, Blocked, Failed as they are; Retest and a status of the instance’s own are `skipped` with a sub-status of that name (added to the project, ten at most per status); an unknown status id is `skipped`. Untested makes no result. People (authors, assignees) and the attachments of runs and results are not carried. Estimates, attachments and users are not imported — the counters say what was left out. At most 100 requests a minute to the instance for the connection in the worker process; at a 429 the step stops, and the next one is started after the `Retry-After` (20 pauses without progress fail the import). An import over 200 000 records (800 000 with history) or 3 000 requests fails with a reason. Text of a case is cut to what a case holds. Only an owner can start, cancel, retry or resume it, here and in the jobs queue (403 `project owner only` for anyone else). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — No dry run yet","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only, and only after a finished dry run of the same TestRail project, made on this connection (the same address) within the last 7 days (409 otherwise; 409 too when cases of another TestRail instance were imported into this project before). Starts a job (`kind: testrail-import`) that works in bounded steps on the queue: the lookups, the suites and sections, the tree (a folder for the project, one for each suite in suite mode 3, nested folders and suites for the sections), then the cases with their steps (both templates; shared steps expanded), priority, `refs` (a custom field) and labels (tags). A step records its cursor `{ phase, id, offset }` and queues the next in one transaction, so a worker that dies goes on from there without a duplicate. A case is found again by the external key `testrail` / `C<id>`: a re-import updates, never duplicates. A case in the Bin is not brought back; a case changed in Plune since the import is not overwritten (a conflict when TestRail changed it too — see `conflicts`). Nothing is ever deleted. With `history` (BH-A3b) the same chain goes on after the cases: the runs (key `testrail:run:<id>`, kind `manual`, with the dates TestRail has) and a page of 250 results a step (source `testrail`, key `testrail:result:<id>`: a repeat adds nothing). A closed TestRail run is finished at its `completed_on`, an open one stays open; a run finished in Plune already, or in the Bin, is skipped and counted. Plans are not imported (a run inside one carries a `Plan:` label), milestones are a `Release:` label. Statuses: Passed, Blocked, Failed as they are; Retest and a status of the instance’s own are `skipped` with a sub-status of that name (added to the project, ten at most per status); an unknown status id is `skipped`. Untested makes no result. People (authors, assignees) and the attachments of runs and results are not carried. Estimates, attachments and users are not imported — the counters say what was left out. At most 100 requests a minute to the instance for the connection in the worker process; at a 429 the step stops, and the next one is started after the `Retry-After` (20 pauses without progress fail the import). An import over 200 000 records (800 000 with history) or 3 000 requests fails with a reason. Text of a case is cut to what a case holds. Only an owner can start, cancel, retry or resume it, here and in the jobs queue (403 `project owner only` for anyone else). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no finished, recent dry run of this TestRail project on this connection, cases of another instance already in this project, a dry run or an import of this project is running, or the saved key cannot be read on this server","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"run a dry run of this TestRail project first, on this connection and within the last 7 days - it shows what an import would do\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"projectId\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only, and only after a finished dry run of the same TestRail project, made on this connection (the same address) within the last 7 days (409 otherwise; 409 too when cases of another TestRail instance were imported into this project before). Starts a job (`kind: testrail-import`) that works in bounded steps on the queue: the lookups, the suites and sections, the tree (a folder for the project, one for each suite in suite mode 3, nested folders and suites for the sections), then the cases with their steps (both templates; shared steps expanded), priority, `refs` (a custom field) and labels (tags). A step records its cursor `{ phase, id, offset }` and queues the next in one transaction, so a worker that dies goes on from there without a duplicate. A case is found again by the external key `testrail` / `C<id>`: a re-import updates, never duplicates. A case in the Bin is not brought back; a case changed in Plune since the import is not overwritten (a conflict when TestRail changed it too — see `conflicts`). Nothing is ever deleted. With `history` (BH-A3b) the same chain goes on after the cases: the runs (key `testrail:run:<id>`, kind `manual`, with the dates TestRail has) and a page of 250 results a step (source `testrail`, key `testrail:result:<id>`: a repeat adds nothing). A closed TestRail run is finished at its `completed_on`, an open one stays open; a run finished in Plune already, or in the Bin, is skipped and counted. Plans are not imported (a run inside one carries a `Plan:` label), milestones are a `Release:` label. Statuses: Passed, Blocked, Failed as they are; Retest and a status of the instance’s own are `skipped` with a sub-status of that name (added to the project, ten at most per status); an unknown status id is `skipped`. Untested makes no result. People (authors, assignees) and the attachments of runs and results are not carried. Estimates, attachments and users are not imported — the counters say what was left out. At most 100 requests a minute to the instance for the connection in the worker process; at a 429 the step stops, and the next one is started after the `Retry-After` (20 pauses without progress fail the import). An import over 200 000 records (800 000 with history) or 3 000 requests fails with a reason. Text of a case is cut to what a case holds. Only an owner can start, cancel, retry or resume it, here and in the jobs queue (403 `project owner only` for anyone else). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Where a dry run or an import stands, and what it did","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Every member of the project: the phase and cursor, every counter, the cases left alone because both sides changed them, the warnings, and the job’s own state. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The dry run or import","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Every member of the project: the phase and cursor, every counter, the cases left alone because both sides changed them, the warnings, and the job’s own state. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The dry run or import","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n  \"dryRun\": true,\n  \"status\": \"done\",\n  \"trProjectId\": 1,\n  \"trProjectName\": \"Project Alpha\",\n  \"phase\": \"done\",\n  \"counters\": {\n    \"cases_seen\": 4,\n    \"cases_created\": 3,\n    \"cases_updated\": 0,\n    \"cases_unchanged\": 0,\n    \"cases_conflict\": 0,\n    \"cases_skipped_bin\": 0,\n    \"cases_skipped_deleted\": 1,\n    \"cases_skipped_bdd\": 0,\n    \"cases_skipped_ceiling\": 0,\n    \"cases_skipped_invalid\": 0,\n    \"nodes_created\": 6,\n    \"nodes_existing\": 0,\n    \"steps_shared_expanded\": 1,\n    \"steps_shared_missing\": 0,\n    \"cases_placeholder_expected\": 0,\n    \"cases_exploratory\": 1,\n    \"cases_estimate_not_transferred\": 1,\n    \"cases_refs\": 1,\n    \"cases_type_not_transferred\": 3,\n    \"cases_custom_fields_not_transferred\": 0,\n    \"priorities_unknown\": 0,\n    \"cases_gone\": 0,\n    \"cases_detached\": 0,\n    \"cases_reattached\": 0,\n    \"fields_created\": 0,\n    \"fields_existing\": 0,\n    \"runs_listed\": 0,\n    \"runs_beyond_plan\": 0,\n    \"runs_binned\": 0,\n    \"runs_already_imported\": 0,\n    \"runs_to_import\": 0,\n    \"runs_imported\": 0,\n    \"runs_finished\": 0,\n    \"results_estimated\": 0,\n    \"results_untested\": 0,\n    \"results_seen\": 0,\n    \"results_accepted\": 0,\n    \"results_duplicate\": 0,\n    \"results_conflict\": 0,\n    \"results_no_case\": 0,\n    \"results_skipped_untested\": 0,\n    \"results_skipped_invalid\": 0,\n    \"results_status_unknown\": 0,\n    \"substatuses_added\": 0,\n    \"substatus_overflow\": 0,\n    \"dates_clamped\": 0,\n    \"results_elapsed_unparsed\": 0,\n    \"results_attachments_not_transferred\": 0,\n    \"runs_skipped_invalid\": 0,\n    \"dates_missing\": 0\n  },\n  \"conflicts\": [],\n  \"warnings\": [],\n  \"sync\": null,\n  \"changes\": [],\n  \"gone\": [],\n  \"history\": null,\n  \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"finishedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"job\": {\n    \"state\": \"completed\",\n    \"progress\": {\n      \"done\": 4,\n      \"total\": 4\n    },\n    \"reason\": null\n  }\n}"},{"name":"404 — no such project among yours, or no such import in it","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Every member of the project: the phase and cursor, every counter, the cases left alone because both sides changed them, the warnings, and the job’s own state. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no such import in it","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"import not found\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Every member of the project: the phase and cursor, every counter, the cases left alone because both sides changed them, the warnings, and the job’s own state. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Cancel a dry run or an import","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId/cancel","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId","cancel"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Owner only. Stops the job after the step in hand (the same move as Cancel in the queue panel, with its audit entry). What was imported stays; the next import goes on from what is there. A job that is not running (409) cannot be cancelled. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The job, cancelled","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId/cancel","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId","cancel"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Owner only. Stops the job after the step in hand (the same move as Cancel in the queue panel, with its audit entry). What was imported stays; the next import goes on from what is there. A job that is not running (409) cannot be cancelled. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The job, cancelled","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n  \"dryRun\": true,\n  \"status\": \"cancelled\",\n  \"trProjectId\": 1,\n  \"trProjectName\": \"Project Alpha\",\n  \"phase\": \"done\",\n  \"counters\": {\n    \"cases_seen\": 4,\n    \"cases_created\": 3,\n    \"cases_updated\": 0,\n    \"cases_unchanged\": 0,\n    \"cases_conflict\": 0,\n    \"cases_skipped_bin\": 0,\n    \"cases_skipped_deleted\": 1,\n    \"cases_skipped_bdd\": 0,\n    \"cases_skipped_ceiling\": 0,\n    \"cases_skipped_invalid\": 0,\n    \"nodes_created\": 6,\n    \"nodes_existing\": 0,\n    \"steps_shared_expanded\": 1,\n    \"steps_shared_missing\": 0,\n    \"cases_placeholder_expected\": 0,\n    \"cases_exploratory\": 1,\n    \"cases_estimate_not_transferred\": 1,\n    \"cases_refs\": 1,\n    \"cases_type_not_transferred\": 3,\n    \"cases_custom_fields_not_transferred\": 0,\n    \"priorities_unknown\": 0,\n    \"cases_gone\": 0,\n    \"cases_detached\": 0,\n    \"cases_reattached\": 0,\n    \"fields_created\": 0,\n    \"fields_existing\": 0,\n    \"runs_listed\": 0,\n    \"runs_beyond_plan\": 0,\n    \"runs_binned\": 0,\n    \"runs_already_imported\": 0,\n    \"runs_to_import\": 0,\n    \"runs_imported\": 0,\n    \"runs_finished\": 0,\n    \"results_estimated\": 0,\n    \"results_untested\": 0,\n    \"results_seen\": 0,\n    \"results_accepted\": 0,\n    \"results_duplicate\": 0,\n    \"results_conflict\": 0,\n    \"results_no_case\": 0,\n    \"results_skipped_untested\": 0,\n    \"results_skipped_invalid\": 0,\n    \"results_status_unknown\": 0,\n    \"substatuses_added\": 0,\n    \"substatus_overflow\": 0,\n    \"dates_clamped\": 0,\n    \"results_elapsed_unparsed\": 0,\n    \"results_attachments_not_transferred\": 0,\n    \"runs_skipped_invalid\": 0,\n    \"dates_missing\": 0\n  },\n  \"conflicts\": [],\n  \"warnings\": [],\n  \"sync\": null,\n  \"changes\": [],\n  \"gone\": [],\n  \"history\": null,\n  \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"finishedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"job\": {\n    \"state\": \"cancelled\",\n    \"progress\": null,\n    \"reason\": \"Cancelled by Shop Owner\"\n  }\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId/cancel","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId","cancel"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Owner only. Stops the job after the step in hand (the same move as Cancel in the queue panel, with its audit entry). What was imported stays; the next import goes on from what is there. A job that is not running (409) cannot be cancelled. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours, or no such import in it","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId/cancel","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId","cancel"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Owner only. Stops the job after the step in hand (the same move as Cancel in the queue panel, with its audit entry). What was imported stays; the next import goes on from what is there. A job that is not running (409) cannot be cancelled. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no such import in it","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"import not found\"\n}"},{"name":"409 — the job is not running any more","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId/cancel","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId","cancel"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Owner only. Stops the job after the step in hand (the same move as Cancel in the queue panel, with its audit entry). What was imported stays; the next import goes on from what is there. A job that is not running (409) cannot be cancelled. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the job is not running any more","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the import is not running any more\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/imports/:jobId/cancel","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","imports",":jobId","cancel"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"},{"key":"jobId","value":"","description":"The `id` the start answered with"}]},"description":"Owner only. Stops the job after the step in hand (the same move as Cancel in the queue panel, with its audit entry). What was imported stays; the next import goes on from what is there. A job that is not running (409) cannot be cancelled. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"The projects of the TestRail instance, with their suite and case counts","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/projects","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","projects"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only: what the saved key can see, so a member does not read it. Counts are made by reading the pages (250 a request) and spend at most 40 requests over the whole listing — a count that did not fit is `null`. At most 100 requests a minute are sent to the instance for this connection by one Plune process (the import worker has its own count), and a request that is not answered in full within a minute ends as `unreachable`. Counts are of what the instance lists, a case marked deleted included. A failure of the instance is a 422 with its code. Session cookie only. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The projects","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/projects","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","projects"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only: what the saved key can see, so a member does not read it. Counts are made by reading the pages (250 a request) and spend at most 40 requests over the whole listing — a count that did not fit is `null`. At most 100 requests a minute are sent to the instance for this connection by one Plune process (the import worker has its own count), and a request that is not answered in full within a minute ends as `unreachable`. Counts are of what the instance lists, a case marked deleted included. A failure of the instance is a 422 with its code. Session cookie only. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The projects","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projects\": [\n    {\n      \"id\": 1,\n      \"name\": \"Project Alpha\",\n      \"suiteMode\": 3,\n      \"isCompleted\": false,\n      \"suites\": 2,\n      \"cases\": 4\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Project Beta\",\n      \"suiteMode\": 1,\n      \"isCompleted\": false,\n      \"suites\": 1,\n      \"cases\": 0\n    }\n  ]\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/projects","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","projects"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only: what the saved key can see, so a member does not read it. Counts are made by reading the pages (250 a request) and spend at most 40 requests over the whole listing — a count that did not fit is `null`. At most 100 requests a minute are sent to the instance for this connection by one Plune process (the import worker has its own count), and a request that is not answered in full within a minute ends as `unreachable`. Counts are of what the instance lists, a case marked deleted included. A failure of the instance is a 422 with its code. Session cookie only. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours, or no TestRail is connected","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/projects","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","projects"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only: what the saved key can see, so a member does not read it. Counts are made by reading the pages (250 a request) and spend at most 40 requests over the whole listing — a count that did not fit is `null`. At most 100 requests a minute are sent to the instance for this connection by one Plune process (the import worker has its own count), and a request that is not answered in full within a minute ends as `unreachable`. Counts are of what the instance lists, a case marked deleted included. A failure of the instance is a 422 with its code. Session cookie only. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no TestRail is connected\"\n}"},{"name":"422 — the instance said no: `failure` is the code","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/projects","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","projects"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only: what the saved key can see, so a member does not read it. Counts are made by reading the pages (250 a request) and spend at most 40 requests over the whole listing — a count that did not fit is `null`. At most 100 requests a minute are sent to the instance for this connection by one Plune process (the import worker has its own count), and a request that is not answered in full within a minute ends as `unreachable`. Counts are of what the instance lists, a case marked deleted included. A failure of the instance is a 422 with its code. Session cookie only. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the instance said no: `failure` is the code","code":422,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"TestRail does not accept the e-mail and API key\",\n  \"failure\": \"credentials_rejected\",\n  \"status\": 401\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/projects","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","projects"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only: what the saved key can see, so a member does not read it. Counts are made by reading the pages (250 a request) and spend at most 40 requests over the whole listing — a count that did not fit is `null`. At most 100 requests a minute are sent to the instance for this connection by one Plune process (the import worker has its own count), and a request that is not answered in full within a minute ends as `unreachable`. Counts are of what the instance lists, a case marked deleted included. A failure of the instance is a 422 with its code. Session cookie only. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"The sync from TestRail: modes, imported projects, last sync","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too. A project that has no TestRail connected answers 404. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The sync of the project","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too. A project that has no TestRail connected answers 404. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The sync of the project","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"modes\": {\n    \"cases\": \"testrail\",\n    \"suites\": \"testrail\",\n    \"runs\": \"off\",\n    \"milestones\": \"off\",\n    \"fields\": \"off\"\n  },\n  \"available\": [\n    \"cases\",\n    \"suites\",\n    \"fields\"\n  ],\n  \"imported\": [\n    {\n      \"trProjectId\": 1,\n      \"trProjectName\": \"Project Alpha\",\n      \"finishedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ],\n  \"lastSync\": {\n    \"id\": \"c2a1d7f4-3b52-4c1e-8d2a-5a6f0e9b7c10\",\n    \"trProjectId\": 1,\n    \"which\": \"all\",\n    \"entities\": [\n      \"cases\",\n      \"suites\"\n    ],\n    \"finishedAt\": \"2026-09-16T09:12:04.151Z\"\n  },\n  \"goneCount\": 1\n}"},{"name":"404 — no such project among yours, or no TestRail is connected","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too. A project that has no TestRail connected answers 404. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no TestRail is connected\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project, a reader too. A project that has no TestRail connected answers 404. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Sync changes from TestRail","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Pulls from TestRail what changed in a project that was imported; **nothing is ever written to TestRail** (the client sends `GET get_*` and nothing else). Starts a job (an import row with `sync`, `kind: testrail-import` in `GET /v1/jobs`) on the import’s own chain of bounded, resumable queue steps and its rate gate, lease and ceilings; poll `GET …/imports/{jobId}`; Cancel and Retry are the import’s. `entities` must each be `testrail` in the modes (409 `off`); a TestRail project must have been imported on this connection (409). `which: new` adds what is missing (folders and cases) and writes over nothing; a case changed in TestRail and in Plune is listed in `conflicts`. `which: all` also updates a case TestRail changed that nobody changed in Plune since the import (its owned fields: name, precondition, steps, expected result, priority; the earlier version stays in the case history); a case changed in Plune after the import is **not** overwritten: it is a conflict, with the date each side changed it. A case no full pass of TestRail finds is listed in `gone` and **not deleted**; folders are never deleted. Before a case is called gone it is asked for by itself (`get_case`, a read); a pass that saw none of the linked cases, or that would call more than a fifth of them gone (at least 50), fails with a reason and marks nothing. With `which: all` an `active` gone case is moved to `detached` and any other keeps its state (it is only listed); a case that comes back is unmarked and moved back if it still carries our reason. With `which: new` the phase only reports: no case is moved or marked. Run the dry run first to see the same counters before anything is written. Every sync is an audit entry on its job: the summary (`counters`) and the diff (the first 40 of `changes`, `conflicts`, `gone`). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"202 — The job, queued","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Pulls from TestRail what changed in a project that was imported; **nothing is ever written to TestRail** (the client sends `GET get_*` and nothing else). Starts a job (an import row with `sync`, `kind: testrail-import` in `GET /v1/jobs`) on the import’s own chain of bounded, resumable queue steps and its rate gate, lease and ceilings; poll `GET …/imports/{jobId}`; Cancel and Retry are the import’s. `entities` must each be `testrail` in the modes (409 `off`); a TestRail project must have been imported on this connection (409). `which: new` adds what is missing (folders and cases) and writes over nothing; a case changed in TestRail and in Plune is listed in `conflicts`. `which: all` also updates a case TestRail changed that nobody changed in Plune since the import (its owned fields: name, precondition, steps, expected result, priority; the earlier version stays in the case history); a case changed in Plune after the import is **not** overwritten: it is a conflict, with the date each side changed it. A case no full pass of TestRail finds is listed in `gone` and **not deleted**; folders are never deleted. Before a case is called gone it is asked for by itself (`get_case`, a read); a pass that saw none of the linked cases, or that would call more than a fifth of them gone (at least 50), fails with a reason and marks nothing. With `which: all` an `active` gone case is moved to `detached` and any other keeps its state (it is only listed); a case that comes back is unmarked and moved back if it still carries our reason. With `which: new` the phase only reports: no case is moved or marked. Run the dry run first to see the same counters before anything is written. Every sync is an audit entry on its job: the summary (`counters`) and the diff (the first 40 of `changes`, `conflicts`, `gone`). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The job, queued","code":202,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n  \"dryRun\": false,\n  \"status\": \"running\",\n  \"trProjectId\": 1,\n  \"trProjectName\": \"Project Alpha\",\n  \"phase\": \"lookups\",\n  \"counters\": {\n    \"cases_seen\": 5,\n    \"cases_created\": 1,\n    \"cases_updated\": 1,\n    \"cases_unchanged\": 1,\n    \"cases_conflict\": 1,\n    \"cases_skipped_bin\": 0,\n    \"cases_skipped_deleted\": 0,\n    \"cases_skipped_bdd\": 0,\n    \"cases_skipped_ceiling\": 0,\n    \"cases_skipped_invalid\": 0,\n    \"nodes_created\": 6,\n    \"nodes_existing\": 6,\n    \"steps_shared_expanded\": 1,\n    \"steps_shared_missing\": 0,\n    \"cases_placeholder_expected\": 0,\n    \"cases_exploratory\": 1,\n    \"cases_estimate_not_transferred\": 1,\n    \"cases_refs\": 1,\n    \"cases_type_not_transferred\": 3,\n    \"cases_custom_fields_not_transferred\": 0,\n    \"priorities_unknown\": 0,\n    \"cases_gone\": 1,\n    \"cases_detached\": 1,\n    \"cases_reattached\": 0,\n    \"fields_created\": 0,\n    \"fields_existing\": 0,\n    \"runs_listed\": 0,\n    \"runs_beyond_plan\": 0,\n    \"runs_binned\": 0,\n    \"runs_already_imported\": 0,\n    \"runs_to_import\": 0,\n    \"runs_imported\": 0,\n    \"runs_finished\": 0,\n    \"results_estimated\": 0,\n    \"results_untested\": 0,\n    \"results_seen\": 0,\n    \"results_accepted\": 0,\n    \"results_duplicate\": 0,\n    \"results_conflict\": 0,\n    \"results_no_case\": 0,\n    \"results_skipped_untested\": 0,\n    \"results_skipped_invalid\": 0,\n    \"results_status_unknown\": 0,\n    \"substatuses_added\": 0,\n    \"substatus_overflow\": 0,\n    \"dates_clamped\": 0,\n    \"results_elapsed_unparsed\": 0,\n    \"results_attachments_not_transferred\": 0,\n    \"runs_skipped_invalid\": 0,\n    \"dates_missing\": 0\n  },\n  \"conflicts\": [],\n  \"warnings\": [],\n  \"sync\": {\n    \"which\": \"all\",\n    \"entities\": [\n      \"cases\",\n      \"suites\"\n    ]\n  },\n  \"changes\": [],\n  \"gone\": [],\n  \"history\": null,\n  \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"finishedAt\": null,\n  \"job\": {\n    \"state\": \"in_progress\",\n    \"progress\": null,\n    \"reason\": null\n  }\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Pulls from TestRail what changed in a project that was imported; **nothing is ever written to TestRail** (the client sends `GET get_*` and nothing else). Starts a job (an import row with `sync`, `kind: testrail-import` in `GET /v1/jobs`) on the import’s own chain of bounded, resumable queue steps and its rate gate, lease and ceilings; poll `GET …/imports/{jobId}`; Cancel and Retry are the import’s. `entities` must each be `testrail` in the modes (409 `off`); a TestRail project must have been imported on this connection (409). `which: new` adds what is missing (folders and cases) and writes over nothing; a case changed in TestRail and in Plune is listed in `conflicts`. `which: all` also updates a case TestRail changed that nobody changed in Plune since the import (its owned fields: name, precondition, steps, expected result, priority; the earlier version stays in the case history); a case changed in Plune after the import is **not** overwritten: it is a conflict, with the date each side changed it. A case no full pass of TestRail finds is listed in `gone` and **not deleted**; folders are never deleted. Before a case is called gone it is asked for by itself (`get_case`, a read); a pass that saw none of the linked cases, or that would call more than a fifth of them gone (at least 50), fails with a reason and marks nothing. With `which: all` an `active` gone case is moved to `detached` and any other keeps its state (it is only listed); a case that comes back is unmarked and moved back if it still carries our reason. With `which: new` the phase only reports: no case is moved or marked. Run the dry run first to see the same counters before anything is written. Every sync is an audit entry on its job: the summary (`counters`) and the diff (the first 40 of `changes`, `conflicts`, `gone`). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — The mode is off","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Pulls from TestRail what changed in a project that was imported; **nothing is ever written to TestRail** (the client sends `GET get_*` and nothing else). Starts a job (an import row with `sync`, `kind: testrail-import` in `GET /v1/jobs`) on the import’s own chain of bounded, resumable queue steps and its rate gate, lease and ceilings; poll `GET …/imports/{jobId}`; Cancel and Retry are the import’s. `entities` must each be `testrail` in the modes (409 `off`); a TestRail project must have been imported on this connection (409). `which: new` adds what is missing (folders and cases) and writes over nothing; a case changed in TestRail and in Plune is listed in `conflicts`. `which: all` also updates a case TestRail changed that nobody changed in Plune since the import (its owned fields: name, precondition, steps, expected result, priority; the earlier version stays in the case history); a case changed in Plune after the import is **not** overwritten: it is a conflict, with the date each side changed it. A case no full pass of TestRail finds is listed in `gone` and **not deleted**; folders are never deleted. Before a case is called gone it is asked for by itself (`get_case`, a read); a pass that saw none of the linked cases, or that would call more than a fifth of them gone (at least 50), fails with a reason and marks nothing. With `which: all` an `active` gone case is moved to `detached` and any other keeps its state (it is only listed); a case that comes back is unmarked and moved back if it still carries our reason. With `which: new` the phase only reports: no case is moved or marked. Run the dry run first to see the same counters before anything is written. Every sync is an audit entry on its job: the summary (`counters`) and the diff (the first 40 of `changes`, `conflicts`, `gone`). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"an entity asked for is `off`, no TestRail project was imported on this connection, a dry run, an import or a sync of this project is running, or the saved key cannot be read on this server","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"sync is off for: cases, suites - set the mode to \\\"TestRail is the source of truth\\\" first\",\n  \"off\": [\n    \"cases\",\n    \"suites\"\n  ]\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Pulls from TestRail what changed in a project that was imported; **nothing is ever written to TestRail** (the client sends `GET get_*` and nothing else). Starts a job (an import row with `sync`, `kind: testrail-import` in `GET /v1/jobs`) on the import’s own chain of bounded, resumable queue steps and its rate gate, lease and ceilings; poll `GET …/imports/{jobId}`; Cancel and Retry are the import’s. `entities` must each be `testrail` in the modes (409 `off`); a TestRail project must have been imported on this connection (409). `which: new` adds what is missing (folders and cases) and writes over nothing; a case changed in TestRail and in Plune is listed in `conflicts`. `which: all` also updates a case TestRail changed that nobody changed in Plune since the import (its owned fields: name, precondition, steps, expected result, priority; the earlier version stays in the case history); a case changed in Plune after the import is **not** overwritten: it is a conflict, with the date each side changed it. A case no full pass of TestRail finds is listed in `gone` and **not deleted**; folders are never deleted. Before a case is called gone it is asked for by itself (`get_case`, a read); a pass that saw none of the linked cases, or that would call more than a fifth of them gone (at least 50), fails with a reason and marks nothing. With `which: all` an `active` gone case is moved to `detached` and any other keeps its state (it is only listed); a case that comes back is unmarked and moved back if it still carries our reason. With `which: new` the phase only reports: no case is moved or marked. Run the dry run first to see the same counters before anything is written. Every sync is an audit entry on its job: the summary (`counters`) and the diff (the first 40 of `changes`, `conflicts`, `gone`). A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Dry run of a sync: what it would change","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. The same job as `POST …/sync` with the writes off (`kind: testrail-dry-run`): it reads TestRail and counts what a sync would create, update, leave alone, list as a conflict and find gone — the counters, and the first 100 `changes`, `conflicts` and `gone`, are those the sync after it gives, from the same code. Nothing is written in the project, nothing to TestRail. The same refusals as the sync. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"202 — The job, queued","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. The same job as `POST …/sync` with the writes off (`kind: testrail-dry-run`): it reads TestRail and counts what a sync would create, update, leave alone, list as a conflict and find gone — the counters, and the first 100 `changes`, `conflicts` and `gone`, are those the sync after it gives, from the same code. Nothing is written in the project, nothing to TestRail. The same refusals as the sync. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The job, queued","code":202,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"b1f0c6e2-6c1e-4b0e-9a49-3f6f1c1a9d11\",\n  \"dryRun\": true,\n  \"status\": \"running\",\n  \"trProjectId\": 1,\n  \"trProjectName\": \"Project Alpha\",\n  \"phase\": \"lookups\",\n  \"counters\": {\n    \"cases_seen\": 5,\n    \"cases_created\": 1,\n    \"cases_updated\": 1,\n    \"cases_unchanged\": 1,\n    \"cases_conflict\": 1,\n    \"cases_skipped_bin\": 0,\n    \"cases_skipped_deleted\": 0,\n    \"cases_skipped_bdd\": 0,\n    \"cases_skipped_ceiling\": 0,\n    \"cases_skipped_invalid\": 0,\n    \"nodes_created\": 6,\n    \"nodes_existing\": 6,\n    \"steps_shared_expanded\": 1,\n    \"steps_shared_missing\": 0,\n    \"cases_placeholder_expected\": 0,\n    \"cases_exploratory\": 1,\n    \"cases_estimate_not_transferred\": 1,\n    \"cases_refs\": 1,\n    \"cases_type_not_transferred\": 3,\n    \"cases_custom_fields_not_transferred\": 0,\n    \"priorities_unknown\": 0,\n    \"cases_gone\": 1,\n    \"cases_detached\": 1,\n    \"cases_reattached\": 0,\n    \"fields_created\": 0,\n    \"fields_existing\": 0,\n    \"runs_listed\": 0,\n    \"runs_beyond_plan\": 0,\n    \"runs_binned\": 0,\n    \"runs_already_imported\": 0,\n    \"runs_to_import\": 0,\n    \"runs_imported\": 0,\n    \"runs_finished\": 0,\n    \"results_estimated\": 0,\n    \"results_untested\": 0,\n    \"results_seen\": 0,\n    \"results_accepted\": 0,\n    \"results_duplicate\": 0,\n    \"results_conflict\": 0,\n    \"results_no_case\": 0,\n    \"results_skipped_untested\": 0,\n    \"results_skipped_invalid\": 0,\n    \"results_status_unknown\": 0,\n    \"substatuses_added\": 0,\n    \"substatus_overflow\": 0,\n    \"dates_clamped\": 0,\n    \"results_elapsed_unparsed\": 0,\n    \"results_attachments_not_transferred\": 0,\n    \"runs_skipped_invalid\": 0,\n    \"dates_missing\": 0\n  },\n  \"conflicts\": [],\n  \"warnings\": [],\n  \"sync\": {\n    \"which\": \"all\",\n    \"entities\": [\n      \"cases\",\n      \"suites\"\n    ]\n  },\n  \"changes\": [],\n  \"gone\": [],\n  \"history\": null,\n  \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"finishedAt\": null,\n  \"job\": {\n    \"state\": \"in_progress\",\n    \"progress\": null,\n    \"reason\": null\n  }\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. The same job as `POST …/sync` with the writes off (`kind: testrail-dry-run`): it reads TestRail and counts what a sync would create, update, leave alone, list as a conflict and find gone — the counters, and the first 100 `changes`, `conflicts` and `gone`, are those the sync after it gives, from the same code. Nothing is written in the project, nothing to TestRail. The same refusals as the sync. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entities\": [\n    \"cases\",\n    \"suites\"\n  ],\n  \"which\": \"all\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/dry-run","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","dry-run"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. The same job as `POST …/sync` with the writes off (`kind: testrail-dry-run`): it reads TestRail and counts what a sync would create, update, leave alone, list as a conflict and find gone — the counters, and the first 100 `changes`, `conflicts` and `gone`, are those the sync after it gives, from the same code. Nothing is written in the project, nothing to TestRail. The same refusals as the sync. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Set the sync mode of the entities","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"testrail\",\n  \"suites\": \"testrail\",\n  \"runs\": \"off\",\n  \"milestones\": \"off\",\n  \"fields\": \"off\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/modes","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","modes"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. `off` (the default) or `testrail` (“TestRail is the source of truth”: a sync brings its changes in) for each entity named; the others stay. `runs` and `milestones` come with the history import and accept `off` only. There is no mode in which Plune writes to TestRail: that needs a decision and an ADR of its own. Recorded in the audit feed as a `testrail_connection` entry (`update`) with the modes before and after. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The modes now","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"testrail\",\n  \"suites\": \"testrail\",\n  \"runs\": \"off\",\n  \"milestones\": \"off\",\n  \"fields\": \"off\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/modes","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","modes"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. `off` (the default) or `testrail` (“TestRail is the source of truth”: a sync brings its changes in) for each entity named; the others stay. `runs` and `milestones` come with the history import and accept `off` only. There is no mode in which Plune writes to TestRail: that needs a decision and an ADR of its own. Recorded in the audit feed as a `testrail_connection` entry (`update`) with the modes before and after. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The modes now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"modes\": {\n    \"cases\": \"testrail\",\n    \"suites\": \"testrail\",\n    \"runs\": \"off\",\n    \"milestones\": \"off\",\n    \"fields\": \"off\"\n  }\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"testrail\",\n  \"suites\": \"testrail\",\n  \"runs\": \"off\",\n  \"milestones\": \"off\",\n  \"fields\": \"off\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/modes","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","modes"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. `off` (the default) or `testrail` (“TestRail is the source of truth”: a sync brings its changes in) for each entity named; the others stay. `runs` and `milestones` come with the history import and accept `off` only. There is no mode in which Plune writes to TestRail: that needs a decision and an ADR of its own. Recorded in the audit feed as a `testrail_connection` entry (`update`) with the modes before and after. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours, or no TestRail is connected","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"testrail\",\n  \"suites\": \"testrail\",\n  \"runs\": \"off\",\n  \"milestones\": \"off\",\n  \"fields\": \"off\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/modes","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","modes"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. `off` (the default) or `testrail` (“TestRail is the source of truth”: a sync brings its changes in) for each entity named; the others stay. `runs` and `milestones` come with the history import and accept `off` only. There is no mode in which Plune writes to TestRail: that needs a decision and an ADR of its own. Recorded in the audit feed as a `testrail_connection` entry (`update`) with the modes before and after. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no TestRail is connected\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"testrail\",\n  \"suites\": \"testrail\",\n  \"runs\": \"off\",\n  \"milestones\": \"off\",\n  \"fields\": \"off\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/sync/modes","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","sync","modes"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. `off` (the default) or `testrail` (“TestRail is the source of truth”: a sync brings its changes in) for each entity named; the others stay. `runs` and `milestones` come with the history import and accept `off` only. There is no mode in which Plune writes to TestRail: that needs a decision and an ADR of its own. Recorded in the audit feed as a `testrail_connection` entry (`update`) with the modes before and after. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"Check the saved TestRail connection","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The key works","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"What the check found","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"ok\": true,\n  \"testedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"200 — TestRail refuses the key","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"What the check found","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"ok\": false,\n  \"failure\": \"credentials_rejected\",\n  \"status\": 401,\n  \"testedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing saved to check","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or no TestRail is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no TestRail is connected\"\n}"},{"name":"409 — the saved key does not open under this server’s key (the platform key was replaced): connect again with the key","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the saved key does not open under this server’s key (the platform key was replaced): connect again with the key","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the saved key cannot be read on this server - connect again with the key\"\n}"},{"name":"503 — the server has no key to keep TestRail keys under; nothing was sent","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/integrations/testrail/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","integrations","testrail","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Asks the instance for its first project (`get_projects`, one record) with the saved key — the cheapest question that needs it — and keeps what it found as the connection’s `status`. A check that went wrong is a 200 with `ok: false` and a code, not an error of this API. Only `GET get_*` methods are ever sent to a TestRail: the client has no other. A failure carries one of these codes: `address_rejected` - the address is not https, or does not resolve to a public address; `unreachable` - the instance did not answer in 30 seconds; `credentials_rejected` - TestRail answered 401 or 403: the e-mail and API key are not accepted (a revoked key included); `rate_limited` - TestRail asked Plune to wait: a short pause (30 seconds at most) is waited out up to three times in a call, a longer one ends the call at once; an import then goes on after the pause by itself, and fails after 20 pauses without progress; `redirect_refused` - the address answers with a redirect, which Plune never follows; `not_json` - the address answers, but not like TestRail; `too_large` - an answer past 20 MB; `pagination_unsupported` - the instance does not page its answers (`limit` and `offset` need TestRail 6.7): import a file instead; `unexpected` - a status the call did not expect: `status` carries it. The instance’s own text is never passed on. At most 10 requests a minute for a project, this, the list of projects and the two starts together, counted after the role. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep TestRail keys under; nothing was sent","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the TestRail bridge is not configured on this server - contact your operator\"\n}"}]},{"name":"The bucket the project keeps its result files in","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too — the connection and what is known of it, never its Secret: `{ \"configured\": false }` when none is connected (and `builtIn`, always), otherwise the provider, the bucket, the folder, the region, `accessKeyLast4` (the last four characters of the access key id — not the whole of it), who connected it and when, and what the last check of it found. The project is the one in the path; a project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401, because a leaked token must not be able to read — or, on the routes below, point — where a project’s files go.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The connection (or `{ \"configured\": false }`), and `builtIn`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too — the connection and what is known of it, never its Secret: `{ \"configured\": false }` when none is connected (and `builtIn`, always), otherwise the provider, the bucket, the folder, the region, `accessKeyLast4` (the last four characters of the access key id — not the whole of it), who connected it and when, and what the last check of it found. The project is the one in the path; a project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401, because a leaked token must not be able to read — or, on the routes below, point — where a project’s files go.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The connection (or `{ \"configured\": false }`), and `builtIn`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"prefix\": \"plune\",\n  \"region\": \"eu-central-1\",\n  \"pathStyle\": false,\n  \"accessKeyLast4\": \"MPLE\",\n  \"savedBy\": \"u-shop\",\n  \"savedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"verifiedAt\": null,\n  \"verifyResult\": null,\n  \"builtIn\": {\n    \"usedBytes\": 431882240,\n    \"limitBytes\": 1073741824\n  }\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too — the connection and what is known of it, never its Secret: `{ \"configured\": false }` when none is connected (and `builtIn`, always), otherwise the provider, the bucket, the folder, the region, `accessKeyLast4` (the last four characters of the access key id — not the whole of it), who connected it and when, and what the last check of it found. The project is the one in the path; a project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401, because a leaked token must not be able to read — or, on the routes below, point — where a project’s files go.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"503 — the server has no key to keep Secrets under — the operator has not set `STORAGE_ENCRYPTION_KEY`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Every member of the project reads it, a reader too — the connection and what is known of it, never its Secret: `{ \"configured\": false }` when none is connected (and `builtIn`, always), otherwise the provider, the bucket, the folder, the region, `accessKeyLast4` (the last four characters of the access key id — not the whole of it), who connected it and when, and what the last check of it found. The project is the one in the path; a project you are not in answers 404, the same as one that does not exist. Session cookie only: a CLI token is refused with 401, because a leaked token must not be able to read — or, on the routes below, point — where a project’s files go.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep Secrets under — the operator has not set `STORAGE_ENCRYPTION_KEY`","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"storage is not configured on this server — contact your operator\"\n}"}]},{"name":"Connect a bucket to the project, or replace the connection — Amazon S3","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — Connected — the same body as `GET`, `builtIn` included","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"Connected — the same body as `GET`, `builtIn` included","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"prefix\": \"plune\",\n  \"region\": \"eu-central-1\",\n  \"pathStyle\": false,\n  \"accessKeyLast4\": \"MPLE\",\n  \"savedBy\": \"u-shop\",\n  \"savedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"verifiedAt\": null,\n  \"verifyResult\": null,\n  \"builtIn\": {\n    \"usedBytes\": 431882240,\n    \"limitBytes\": 1073741824\n  }\n}"},{"name":"400 — A connection that changed, with no Secret to go with it","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body — a field that is missing, wrong, not used by the provider, or not an address a bucket can have; the message names the field and never a value","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — secret: is required for a new connection, and whenever the provider, the account, the endpoint or the access key id changes\"\n}"},{"name":"400 — A field the provider has no use for","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body — a field that is missing, wrong, not used by the provider, or not an address a bucket can have; the message names the field and never a value","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — accountId: is not used with aws — leave it out\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no such project among yours","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"503 — the server has no key to keep Secrets under; nothing is saved","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep Secrets under; nothing is saved","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"storage is not configured on this server — contact your operator\"\n}"}]},{"name":"Connect a bucket to the project, or replace the connection — Cloudflare R2 — the account id, no region","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"r2\",\n  \"bucket\": \"acme-test-results\",\n  \"accountId\": \"0123456789abcdef0123456789abcdef\",\n  \"accessKeyId\": \"example-r2-access-key-id\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Connect a bucket to the project, or replace the connection — Any other S3-compatible service — an endpoint, a region, a folder of your own","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"s3\",\n  \"bucket\": \"acme-test-results\",\n  \"endpoint\": \"https://minio.acme.example\",\n  \"region\": \"us-east-1\",\n  \"pathStyle\": true,\n  \"prefix\": \"ci/plune\",\n  \"accessKeyId\": \"example-minio-access-key\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Saves the bucket and its key — the Secret is encrypted with the server’s key, bound to this project, and is never returned, in whole or in part — and answers with what `GET` shows. It makes no request to the bucket: `POST …/test` does. Replacing a connection keeps the Secret already saved only when the provider, the account, the endpoint and the access key id are the same ones; for any other change `secret` is required, so a connection cannot be pointed at another account with the old key. Saving resets the last check: `verifiedAt` and `verifyResult` are `null` until the connection is checked again. The address — `bucket`, `region`, `accountId`, `endpoint` — is judged before anything is saved, and a refusal names the field, never the value. Recorded in the audit feed of the project as a `storage_settings` entry, with the access key id and the Secret masked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Disconnect the bucket","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its Secret. The files already in the bucket are the customer’s and are not touched. Recorded in the audit feed of the project as a `storage_settings` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its Secret. The files already in the bucket are the customer’s and are not touched. Recorded in the audit feed of the project as a `storage_settings` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its Secret. The files already in the bucket are the customer’s and are not touched. Recorded in the audit feed of the project as a `storage_settings` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or the project has no bucket connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing to disconnect","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its Secret. The files already in the bucket are the customer’s and are not touched. Recorded in the audit feed of the project as a `storage_settings` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or the project has no bucket connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no bucket is connected\"\n}"},{"name":"503 — the server has no key to keep Secrets under","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Forgets the connection and its Secret. The files already in the bucket are the customer’s and are not touched. Recorded in the audit feed of the project as a `storage_settings` entry. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep Secrets under","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"storage is not configured on this server — contact your operator\"\n}"}]},{"name":"Set the dashboard’s CORS rule in the bucket, once","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The rule is set","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"Done (`ok: true`), or why not (`ok: false` and a `code`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"ok\": true\n}"},{"name":"200 — The key may not change the settings","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"Done (`ok: true`), or why not (`ok: false` and a `code`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"ok\": false,\n  \"code\": \"missing_permission\"\n}"},{"name":"400 — invalid body, or the connection in it is not complete — the same rules as `PUT`; nothing was sent","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body, or the connection in it is not complete — the same rules as `PUT`; nothing was sent","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — secret: is required\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or — no `connection` in the body — nothing is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing saved","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or — no `connection` in the body — nothing is connected","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no bucket is connected\"\n}"},{"name":"503 — the server has no key to keep Secrets under, or does not know the dashboard’s address; nothing was sent","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keyId\": \"example-key-id-0001\",\n  \"secret\": \"example-not-a-real-secret\",\n  \"connection\": {\n    \"provider\": \"aws\",\n    \"bucket\": \"acme-test-results\",\n    \"region\": \"eu-central-1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/cors","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","cors"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. «Set up CORS for me»: the server makes ONE change to the bucket’s CORS rules with a key typed for this call, and forgets the key. The rule allows exactly the dashboard’s origin (`https://app.plune.ai`, on the beta `https://beta.plune.ai`) to `GET`, `HEAD` and `PUT` with the `content-type` header, under the name `plune-dashboard-<host of the origin>-<8 hex of the whole origin>`, so the beta’s rule and the production’s in one bucket are two rules; calling again replaces the rule of that origin and no other (and the old fixed name `plune-dashboard`, when it allows that origin alone). The rules the bucket already has are read first and kept: a bucket whose rules cannot be read is not written to. S3 and the services that copy it: `GetBucketCors`, then `PutBucketCors`. Backblaze B2 (an `s3.<region>.backblazeb2.com` endpoint): `b2_authorize_account`, `b2_list_buckets`, `b2_update_bucket`; the address B2 answers with must belong to `backblazeb2.com`, or nothing is sent to it. Every request goes through the one function that refuses private addresses, redirects and anything but https. The key is in no response, no log, no audit entry and no row. A refusal of the service is a `200` with `ok: false` and a `code`: `key_rejected` — the service does not know the key; `missing_permission` — the key may not read or change the bucket’s settings (nothing was changed); `unsupported` — the service does not take this call, so the rule is the owner’s to set by hand; `no_bucket` — there is no bucket of that name; `unreachable` — no answer (not a public https address, a name that does not resolve, a certificate that cannot be trusted, no answer in 5 seconds); `too_many_rules` — the bucket already has 100 CORS rules besides the dashboard’s own, which is all a bucket takes (nothing was changed); `unexpected` — an answer the call did not want. At most 5 a minute for a project, counted after the role and the body are checked. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep Secrets under, or does not know the dashboard’s address; nothing was sent","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"storage is not configured on this server — contact your operator\"\n}"}]},{"name":"Check that a connection works","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — What the check found — `ok` is true when no step failed; `warnings` holds `bucket_public` when the file could be read with no signature (anyone with the link can read the files of this bucket; a warning does not fail the check). A failed step carries one of these `code`s: `unreachable` — the address does not answer: not a public https address, a name that does not resolve, a refused connection, a redirect (on AWS a redirect is `wrong_region`), or no answer in 5 seconds; `tls` — the server’s certificate cannot be trusted (expired, for another name, self-signed); `bad_credentials` — the bucket does not know the access key id, or the Secret does not belong to it, or a token has expired; `no_bucket` — there is no bucket of that name at that address; `wrong_region` — the bucket is in another region than the one given (AWS answers a request sent to the wrong region with a redirect, which the check does not follow; the first step, `reach`, names it); `write_denied`, `read_denied`, `delete_denied` — the key may not write, read or delete files in the folder; `provider_cap` — the provider refused with its own daily limit (a cap, quota, bandwidth or transaction limit is used up; on Backblaze B2 the Class B cap): the key is fine, raise the limit in the provider’s console, it resets daily; `cors_missing` — the bucket tells a browser on the dashboard nothing that lets it read the files (a CORS rule allowing `GET` and `PUT` from the dashboard is the owner’s to add in the bucket); `cors_put_missing` — the dashboard may read the files but not put a person’s file: the rule lacks `PUT`, or the `content-type` header (the check asks a second preflight, for `PUT` with `content-type`, once `GET` is allowed); `unexpected` — the bucket answered with a status the check did not want and a word it does not know: the step carries that `status`.","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"What the check found — `ok` is true when no step failed; `warnings` holds `bucket_public` when the file could be read with no signature (anyone with the link can read the files of this bucket; a warning does not fail the check). A failed step carries one of these `code`s: `unreachable` — the address does not answer: not a public https address, a name that does not resolve, a refused connection, a redirect (on AWS a redirect is `wrong_region`), or no answer in 5 seconds; `tls` — the server’s certificate cannot be trusted (expired, for another name, self-signed); `bad_credentials` — the bucket does not know the access key id, or the Secret does not belong to it, or a token has expired; `no_bucket` — there is no bucket of that name at that address; `wrong_region` — the bucket is in another region than the one given (AWS answers a request sent to the wrong region with a redirect, which the check does not follow; the first step, `reach`, names it); `write_denied`, `read_denied`, `delete_denied` — the key may not write, read or delete files in the folder; `provider_cap` — the provider refused with its own daily limit (a cap, quota, bandwidth or transaction limit is used up; on Backblaze B2 the Class B cap): the key is fine, raise the limit in the provider’s console, it resets daily; `cors_missing` — the bucket tells a browser on the dashboard nothing that lets it read the files (a CORS rule allowing `GET` and `PUT` from the dashboard is the owner’s to add in the bucket); `cors_put_missing` — the dashboard may read the files but not put a person’s file: the rule lacks `PUT`, or the `content-type` header (the check asks a second preflight, for `PUT` with `content-type`, once `GET` is allowed); `unexpected` — the bucket answered with a status the check did not want and a word it does not know: the step carries that `status`.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"ok\": true,\n  \"steps\": [\n    {\n      \"step\": \"reach\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"write\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"read\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"cors\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"public\",\n      \"result\": \"ok\"\n    },\n    {\n      \"step\": \"delete\",\n      \"result\": \"ok\"\n    }\n  ],\n  \"warnings\": [],\n  \"testedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"400 — invalid body, or the connection to check is not complete — the same rules as `PUT`; nothing was sent to the bucket","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"invalid body, or the connection to check is not complete — the same rules as `PUT`; nothing was sent to the bucket","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — secret: is required for a new connection, and whenever the provider, the account, the endpoint or the access key id changes\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — No such project among yours","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or — no body — nothing is connected to check","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"},{"name":"404 — Nothing saved to check","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no such project among yours, or — no body — nothing is connected to check","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no bucket is connected\"\n}"},{"name":"503 — the server has no key to keep Secrets under; nothing was sent","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"aws\",\n  \"bucket\": \"acme-test-results\",\n  \"region\": \"eu-central-1\",\n  \"accessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n  \"secret\": \"example-not-a-real-secret\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/storage/test","host":["{{baseUrl}}"],"path":["v1","projects",":id","storage","test"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Owner only. Sends the bucket six requests, in this order, and reports each: `reach` (an unsigned `HEAD` on the bucket — any HTTP answer counts, a refused one included; a redirect is not followed and fails the step), `write` (a small text file under `<prefix>/.plune-check-…`), `read` (the same file, and its text is what was written), `cors` (what a browser on the dashboard would be told — a preflight for `GET`), `public` (the same file with no signature) and `delete`. A step that depends on one that failed is `skipped`; `delete` runs whenever the write was taken, however the steps between went. A `delete` that failed — whatever its `code` — means the probe file may still be in the bucket, and so may one whose write got no answer in time: the data may have arrived all the same. No body checks the SAVED connection, and what it found is kept (`verifiedAt`, `verifyResult` of `GET`); a body with the fields of `PUT` checks a connection before it is saved and keeps nothing. The Secret may be left out of such a body under the rule of `PUT`. Every request goes through the one function that refuses private addresses, redirects and anything but https, and takes 5 seconds at most. What the bucket answers is read for one word and its status — never kept, never repeated here. At most 10 checks a minute for a project, counted after the role is checked, so a refusal costs none. Session cookie only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"the server has no key to keep Secrets under; nothing was sent","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"storage is not configured on this server — contact your operator\"\n}"}]},{"name":"Move a personal project into a company, for good","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"companyId\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/transfer","host":["{{baseUrl}}"],"path":["v1","projects",":id","transfer"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of the project moves it into a company they own or run (ADR 0038). One way: there is no road back, and a project already in a company answers 409 — so does a default project, the place a request with no project lands. Its people stay: a member of the company takes the role their company role maps to, anybody else keeps theirs — except an owner from outside the company, who becomes a member, as does an address asked in as owner and not signed in yet. The refusals go project first: somebody who is not in the project gets the 404 an invented id gets, whatever company they name. Recorded in the project’s audit feed."},"response":[{"name":"200 — Moved — the project and the company it is in now","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"companyId\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/transfer","host":["{{baseUrl}}"],"path":["v1","projects",":id","transfer"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of the project moves it into a company they own or run (ADR 0038). One way: there is no road back, and a project already in a company answers 409 — so does a default project, the place a request with no project lands. Its people stay: a member of the company takes the role their company role maps to, anybody else keeps theirs — except an owner from outside the company, who becomes a member, as does an address asked in as owner and not signed in yet. The refusals go project first: somebody who is not in the project gets the 404 an invented id gets, whatever company they name. Recorded in the project’s audit feed."},"status":"Moved — the project and the company it is in now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n  \"name\": \"Mobile app\",\n  \"company\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n    \"name\": \"Acme\"\n  }\n}"},{"name":"403 — not an owner of the project, or not an owner or admin of the company","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"companyId\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/transfer","host":["{{baseUrl}}"],"path":["v1","projects",":id","transfer"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of the project moves it into a company they own or run (ADR 0038). One way: there is no road back, and a project already in a company answers 409 — so does a default project, the place a request with no project lands. Its people stay: a member of the company takes the role their company role maps to, anybody else keeps theirs — except an owner from outside the company, who becomes a member, as does an address asked in as owner and not signed in yet. The refusals go project first: somebody who is not in the project gets the 404 an invented id gets, whatever company they name. Recorded in the project’s audit feed."},"status":"not an owner of the project, or not an owner or admin of the company","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner or admin only\"\n}"},{"name":"404 — no such project among yours, or no such company among yours","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"companyId\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/transfer","host":["{{baseUrl}}"],"path":["v1","projects",":id","transfer"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of the project moves it into a company they own or run (ADR 0038). One way: there is no road back, and a project already in a company answers 409 — so does a default project, the place a request with no project lands. Its people stay: a member of the company takes the role their company role maps to, anybody else keeps theirs — except an owner from outside the company, who becomes a member, as does an address asked in as owner and not signed in yet. The refusals go project first: somebody who is not in the project gets the 404 an invented id gets, whatever company they name. Recorded in the project’s audit feed."},"status":"no such project among yours, or no such company among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company '6f1c2b3a-0000-4000-8000-0000000000a5' not found\"\n}"},{"name":"409 — a default project, or one already in a company","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"companyId\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/transfer","host":["{{baseUrl}}"],"path":["v1","projects",":id","transfer"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"An owner of the project moves it into a company they own or run (ADR 0038). One way: there is no road back, and a project already in a company answers 409 — so does a default project, the place a request with no project lands. Its people stay: a member of the company takes the role their company role maps to, anybody else keeps theirs — except an owner from outside the company, who becomes a member, as does an address asked in as owner and not signed in yet. The refusals go project first: somebody who is not in the project gets the 404 an invented id gets, whatever company they name. Recorded in the project’s audit feed."},"status":"a default project, or one already in a company","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"this project is already in a company — a transfer is one-way\"\n}"}]}]},{"name":"analytics","description":"Numbers over several of your projects at once — the Analytics page; a session reads any of its person’s projects, a token its own","item":[{"name":"Numbers over several of your projects at once","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=","host":["{{baseUrl}}"],"path":["v1","analytics"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone (`2026-08-30T00:00:00+03:00`): the browser turns a preset like «Last 30 days» into bounds in its own zone. Absent — no start. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Absent — no end. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated (the key repeated works too) — ids from `GET /v1/projects`. Absent — every project you are a member of, but none in the bin (ADR 0038). At most 100 either way. One you are not in, or one in the bin, is a 404, the same as an id nobody has."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — `facets.people` lists them — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request, as a folder belongs to one."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."}]},"description":"The Analytics page in one request (#876, ADR 0039): the cards, the latest runs, the results by priority, a row per project and the values for the filters. The one route that reads more than one project: a **session** reads the projects `projects` names — any role, a reader too — or, without it, every project you are a member of; a **bearer token** reads its own project only (the one it was minted in; a token from before projects — `X-Plune-Project`, else your default), and naming any other one is a 404. Only numbers and one row per project come back — never runs, cases or results from several projects.\n\n**Definitions** — the same on the page and in the docs. A *run of the period*: its `startedAt` within `[from, to]`, not in the bin, and passing `labels`, `environments` and `executedBy`. The *results of the period*: the results of those runs; `tags`, `suite`, `flaky` and `muted` act through the case of a result. *Executed by X*: a result X marked, or one nobody marked in a run X launched — a run passes when one of its results does. *Pass rate*: passed ÷ every result × 100, skipped included. *Period*: every result of the period by the status it is counted under, out of the same total (`kpis.results`). A finished run counts toward both as its summary froze it at `finish`, so a case deleted for good after that stays counted — unless a parameter asks of the case or the person, which reads the results as they stand (#1379). *Cases*: live cases now — the date does not act on them. *Coverage*: automated ÷ (manual + automated). *Latest*: the last finished run of each project in the period. *Flaky*: cases scored `flaky` or `critical` over their last 50 results, whatever the period, out of the cases with two results or more (`kpis.scored`). *Flagged*: cases marked flaky or muted by a person, out of `kpis.cases`. Between parameters AND, within one OR — as on the cases list.\n\nThrottled per caller: 120 requests a minute (#1529) — the page makes one per load and one per change of a filter; over it, 429 with `Retry-After`.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the set of projects, counted from the start of the request; `projects`, `flaky`, `priority` and `facets` are the four reads of the database, each from the moment it is sent until its rows are back — they run side by side, so they do not add up; `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"response":[{"name":"200 — The page","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=","host":["{{baseUrl}}"],"path":["v1","analytics"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone (`2026-08-30T00:00:00+03:00`): the browser turns a preset like «Last 30 days» into bounds in its own zone. Absent — no start. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Absent — no end. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated (the key repeated works too) — ids from `GET /v1/projects`. Absent — every project you are a member of, but none in the bin (ADR 0038). At most 100 either way. One you are not in, or one in the bin, is a 404, the same as an id nobody has."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — `facets.people` lists them — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request, as a folder belongs to one."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."}]},"description":"The Analytics page in one request (#876, ADR 0039): the cards, the latest runs, the results by priority, a row per project and the values for the filters. The one route that reads more than one project: a **session** reads the projects `projects` names — any role, a reader too — or, without it, every project you are a member of; a **bearer token** reads its own project only (the one it was minted in; a token from before projects — `X-Plune-Project`, else your default), and naming any other one is a 404. Only numbers and one row per project come back — never runs, cases or results from several projects.\n\n**Definitions** — the same on the page and in the docs. A *run of the period*: its `startedAt` within `[from, to]`, not in the bin, and passing `labels`, `environments` and `executedBy`. The *results of the period*: the results of those runs; `tags`, `suite`, `flaky` and `muted` act through the case of a result. *Executed by X*: a result X marked, or one nobody marked in a run X launched — a run passes when one of its results does. *Pass rate*: passed ÷ every result × 100, skipped included. *Period*: every result of the period by the status it is counted under, out of the same total (`kpis.results`). A finished run counts toward both as its summary froze it at `finish`, so a case deleted for good after that stays counted — unless a parameter asks of the case or the person, which reads the results as they stand (#1379). *Cases*: live cases now — the date does not act on them. *Coverage*: automated ÷ (manual + automated). *Latest*: the last finished run of each project in the period. *Flaky*: cases scored `flaky` or `critical` over their last 50 results, whatever the period, out of the cases with two results or more (`kpis.scored`). *Flagged*: cases marked flaky or muted by a person, out of `kpis.cases`. Between parameters AND, within one OR — as on the cases list.\n\nThrottled per caller: 120 requests a minute (#1529) — the page makes one per load and one per change of a filter; over it, 429 with `Retry-After`.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the set of projects, counted from the start of the request; `projects`, `flaky`, `priority` and `facets` are the four reads of the database, each from the moment it is sent until its rows are back — they run side by side, so they do not add up; `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"status":"The page","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"range\": {\n    \"from\": \"2026-08-29T21:00:00.000Z\",\n    \"to\": \"2026-09-28T20:59:59.000Z\"\n  },\n  \"kpis\": {\n    \"passRate\": 86,\n    \"cases\": 1998,\n    \"runs\": 42,\n    \"manual\": 202,\n    \"automated\": 1792,\n    \"eval\": 4,\n    \"coverage\": 89.9,\n    \"results\": 52587,\n    \"flaky\": 19,\n    \"scored\": 1712,\n    \"flagged\": {\n      \"flaky\": 23,\n      \"muted\": 6\n    },\n    \"totals\": {\n      \"cases\": 1998,\n      \"runs\": 61\n    }\n  },\n  \"notMeasured\": [\n    {\n      \"code\": \"flaky_unscored\",\n      \"params\": {\n        \"count\": 286,\n        \"total\": 1998\n      }\n    },\n    {\n      \"code\": \"no_defect_links\"\n    }\n  ],\n  \"period\": {\n    \"passed\": 45250,\n    \"failed\": 4340,\n    \"broken\": 537,\n    \"blocked\": 16,\n    \"skipped\": 2350,\n    \"muted\": 94\n  },\n  \"latest\": {\n    \"passed\": 1629,\n    \"failed\": 88,\n    \"broken\": 9,\n    \"blocked\": 0,\n    \"skipped\": 66,\n    \"muted\": 3\n  },\n  \"byPriority\": [\n    {\n      \"priority\": \"low\",\n      \"passed\": 2900,\n      \"failed\": 60,\n      \"broken\": 10,\n      \"blocked\": 0,\n      \"skipped\": 120,\n      \"muted\": 0\n    },\n    {\n      \"priority\": \"normal\",\n      \"passed\": 33530,\n      \"failed\": 3100,\n      \"broken\": 420,\n      \"blocked\": 12,\n      \"skipped\": 1810,\n      \"muted\": 64\n    },\n    {\n      \"priority\": \"important\",\n      \"passed\": 4100,\n      \"failed\": 380,\n      \"broken\": 40,\n      \"blocked\": 0,\n      \"skipped\": 230,\n      \"muted\": 9\n    },\n    {\n      \"priority\": \"high\",\n      \"passed\": 3300,\n      \"failed\": 610,\n      \"broken\": 55,\n      \"blocked\": 4,\n      \"skipped\": 150,\n      \"muted\": 21\n    },\n    {\n      \"priority\": \"critical\",\n      \"passed\": 1420,\n      \"failed\": 190,\n      \"broken\": 12,\n      \"blocked\": 0,\n      \"skipped\": 40,\n      \"muted\": 0\n    }\n  ],\n  \"projects\": [\n    {\n      \"id\": \"u-shop::default\",\n      \"name\": \"Checkout\",\n      \"role\": \"owner\",\n      \"cases\": {\n        \"manual\": 20,\n        \"automated\": 1180,\n        \"eval\": 4\n      },\n      \"latestRun\": {\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n        \"finishedAt\": \"2026-09-16T09:12:04.151Z\",\n        \"counts\": {\n          \"passed\": 1109,\n          \"failed\": 38,\n          \"broken\": 6,\n          \"blocked\": 0,\n          \"skipped\": 27,\n          \"muted\": 2\n        }\n      },\n      \"passRate\": 89.1,\n      \"flaky\": 7,\n      \"muted\": 60\n    },\n    {\n      \"id\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n      \"name\": \"Mobile app\",\n      \"role\": \"member\",\n      \"cases\": {\n        \"manual\": 182,\n        \"automated\": 612,\n        \"eval\": 0\n      },\n      \"latestRun\": {\n        \"id\": \"9c4e7d10-0000-4000-8000-0000000000d2\",\n        \"finishedAt\": \"2026-09-16T09:12:04.151Z\",\n        \"counts\": {\n          \"passed\": 520,\n          \"failed\": 50,\n          \"broken\": 3,\n          \"blocked\": 0,\n          \"skipped\": 39,\n          \"muted\": 1\n        }\n      },\n      \"passRate\": 81.4,\n      \"flaky\": 12,\n      \"muted\": 34\n    }\n  ],\n  \"facets\": {\n    \"labels\": [\n      {\n        \"value\": \"nightly\",\n        \"runs\": 12\n      },\n      {\n        \"value\": \"release\",\n        \"runs\": 4\n      }\n    ],\n    \"tags\": [\n      {\n        \"value\": \"smoke\",\n        \"cases\": 214\n      },\n      {\n        \"value\": \"checkout\",\n        \"cases\": 88\n      }\n    ],\n    \"environments\": [\n      {\n        \"value\": \"staging\",\n        \"runs\": 30\n      },\n      {\n        \"value\": \"prod\",\n        \"runs\": 12\n      }\n    ],\n    \"people\": [\n      {\n        \"userId\": \"u-shop\",\n        \"name\": \"Olena Kovalenko\",\n        \"email\": \"owner@example.com\"\n      },\n      {\n        \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n        \"name\": null,\n        \"email\": \"colleague@example.com\"\n      }\n    ]\n  }\n}"},{"name":"400 — More than 100 projects, named or yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=","host":["{{baseUrl}}"],"path":["v1","analytics"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone (`2026-08-30T00:00:00+03:00`): the browser turns a preset like «Last 30 days» into bounds in its own zone. Absent — no start. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Absent — no end. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated (the key repeated works too) — ids from `GET /v1/projects`. Absent — every project you are a member of, but none in the bin (ADR 0038). At most 100 either way. One you are not in, or one in the bin, is a 404, the same as an id nobody has."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — `facets.people` lists them — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request, as a folder belongs to one."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."}]},"description":"The Analytics page in one request (#876, ADR 0039): the cards, the latest runs, the results by priority, a row per project and the values for the filters. The one route that reads more than one project: a **session** reads the projects `projects` names — any role, a reader too — or, without it, every project you are a member of; a **bearer token** reads its own project only (the one it was minted in; a token from before projects — `X-Plune-Project`, else your default), and naming any other one is a 404. Only numbers and one row per project come back — never runs, cases or results from several projects.\n\n**Definitions** — the same on the page and in the docs. A *run of the period*: its `startedAt` within `[from, to]`, not in the bin, and passing `labels`, `environments` and `executedBy`. The *results of the period*: the results of those runs; `tags`, `suite`, `flaky` and `muted` act through the case of a result. *Executed by X*: a result X marked, or one nobody marked in a run X launched — a run passes when one of its results does. *Pass rate*: passed ÷ every result × 100, skipped included. *Period*: every result of the period by the status it is counted under, out of the same total (`kpis.results`). A finished run counts toward both as its summary froze it at `finish`, so a case deleted for good after that stays counted — unless a parameter asks of the case or the person, which reads the results as they stand (#1379). *Cases*: live cases now — the date does not act on them. *Coverage*: automated ÷ (manual + automated). *Latest*: the last finished run of each project in the period. *Flaky*: cases scored `flaky` or `critical` over their last 50 results, whatever the period, out of the cases with two results or more (`kpis.scored`). *Flagged*: cases marked flaky or muted by a person, out of `kpis.cases`. Between parameters AND, within one OR — as on the cases list.\n\nThrottled per caller: 120 requests a minute (#1529) — the page makes one per load and one per change of a filter; over it, 429 with `Retry-After`.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the set of projects, counted from the start of the request; `projects`, `flaky`, `priority` and `facets` are the four reads of the database, each from the moment it is sent until its rows are back — they run side by side, so they do not add up; `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"status":"a bound that is not a date-time with a zone, `from` after `to`, `suite` without exactly one project, more than 100 projects, a `flaky` or `muted` that is not one of its four words","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"at most 100 projects per request\"\n}"},{"name":"400 — The period the wrong way round","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=","host":["{{baseUrl}}"],"path":["v1","analytics"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone (`2026-08-30T00:00:00+03:00`): the browser turns a preset like «Last 30 days» into bounds in its own zone. Absent — no start. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Absent — no end. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated (the key repeated works too) — ids from `GET /v1/projects`. Absent — every project you are a member of, but none in the bin (ADR 0038). At most 100 either way. One you are not in, or one in the bin, is a 404, the same as an id nobody has."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — `facets.people` lists them — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request, as a folder belongs to one."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."}]},"description":"The Analytics page in one request (#876, ADR 0039): the cards, the latest runs, the results by priority, a row per project and the values for the filters. The one route that reads more than one project: a **session** reads the projects `projects` names — any role, a reader too — or, without it, every project you are a member of; a **bearer token** reads its own project only (the one it was minted in; a token from before projects — `X-Plune-Project`, else your default), and naming any other one is a 404. Only numbers and one row per project come back — never runs, cases or results from several projects.\n\n**Definitions** — the same on the page and in the docs. A *run of the period*: its `startedAt` within `[from, to]`, not in the bin, and passing `labels`, `environments` and `executedBy`. The *results of the period*: the results of those runs; `tags`, `suite`, `flaky` and `muted` act through the case of a result. *Executed by X*: a result X marked, or one nobody marked in a run X launched — a run passes when one of its results does. *Pass rate*: passed ÷ every result × 100, skipped included. *Period*: every result of the period by the status it is counted under, out of the same total (`kpis.results`). A finished run counts toward both as its summary froze it at `finish`, so a case deleted for good after that stays counted — unless a parameter asks of the case or the person, which reads the results as they stand (#1379). *Cases*: live cases now — the date does not act on them. *Coverage*: automated ÷ (manual + automated). *Latest*: the last finished run of each project in the period. *Flaky*: cases scored `flaky` or `critical` over their last 50 results, whatever the period, out of the cases with two results or more (`kpis.scored`). *Flagged*: cases marked flaky or muted by a person, out of `kpis.cases`. Between parameters AND, within one OR — as on the cases list.\n\nThrottled per caller: 120 requests a minute (#1529) — the page makes one per load and one per change of a filter; over it, 429 with `Retry-After`.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the set of projects, counted from the start of the request; `projects`, `flaky`, `priority` and `facets` are the four reads of the database, each from the moment it is sent until its rows are back — they run side by side, so they do not add up; `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"status":"a bound that is not a date-time with a zone, `from` after `to`, `suite` without exactly one project, more than 100 projects, a `flaky` or `muted` that is not one of its four words","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"`from` is later than `to`\"\n}"},{"name":"400 — A flag that is not yes or no","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=","host":["{{baseUrl}}"],"path":["v1","analytics"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone (`2026-08-30T00:00:00+03:00`): the browser turns a preset like «Last 30 days» into bounds in its own zone. Absent — no start. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Absent — no end. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated (the key repeated works too) — ids from `GET /v1/projects`. Absent — every project you are a member of, but none in the bin (ADR 0038). At most 100 either way. One you are not in, or one in the bin, is a 404, the same as an id nobody has."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — `facets.people` lists them — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request, as a folder belongs to one."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."}]},"description":"The Analytics page in one request (#876, ADR 0039): the cards, the latest runs, the results by priority, a row per project and the values for the filters. The one route that reads more than one project: a **session** reads the projects `projects` names — any role, a reader too — or, without it, every project you are a member of; a **bearer token** reads its own project only (the one it was minted in; a token from before projects — `X-Plune-Project`, else your default), and naming any other one is a 404. Only numbers and one row per project come back — never runs, cases or results from several projects.\n\n**Definitions** — the same on the page and in the docs. A *run of the period*: its `startedAt` within `[from, to]`, not in the bin, and passing `labels`, `environments` and `executedBy`. The *results of the period*: the results of those runs; `tags`, `suite`, `flaky` and `muted` act through the case of a result. *Executed by X*: a result X marked, or one nobody marked in a run X launched — a run passes when one of its results does. *Pass rate*: passed ÷ every result × 100, skipped included. *Period*: every result of the period by the status it is counted under, out of the same total (`kpis.results`). A finished run counts toward both as its summary froze it at `finish`, so a case deleted for good after that stays counted — unless a parameter asks of the case or the person, which reads the results as they stand (#1379). *Cases*: live cases now — the date does not act on them. *Coverage*: automated ÷ (manual + automated). *Latest*: the last finished run of each project in the period. *Flaky*: cases scored `flaky` or `critical` over their last 50 results, whatever the period, out of the cases with two results or more (`kpis.scored`). *Flagged*: cases marked flaky or muted by a person, out of `kpis.cases`. Between parameters AND, within one OR — as on the cases list.\n\nThrottled per caller: 120 requests a minute (#1529) — the page makes one per load and one per change of a filter; over it, 429 with `Retry-After`.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the set of projects, counted from the start of the request; `projects`, `flaky`, `priority` and `facets` are the four reads of the database, each from the moment it is sent until its rows are back — they run side by side, so they do not add up; `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"status":"a bound that is not a date-time with a zone, `from` after `to`, `suite` without exactly one project, more than 100 projects, a `flaky` or `muted` that is not one of its four words","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"unknown flaky \\\"yes\\\" — the values of flaky are 1, true, 0, false\"\n}"},{"name":"404 — a project you are not a member of, or one in the bin — or, under a token, any project but its own; the same answer as an id nobody has","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=","host":["{{baseUrl}}"],"path":["v1","analytics"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone (`2026-08-30T00:00:00+03:00`): the browser turns a preset like «Last 30 days» into bounds in its own zone. Absent — no start. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Absent — no end. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated (the key repeated works too) — ids from `GET /v1/projects`. Absent — every project you are a member of, but none in the bin (ADR 0038). At most 100 either way. One you are not in, or one in the bin, is a 404, the same as an id nobody has."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — `facets.people` lists them — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request, as a folder belongs to one."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — as on the cases list, AND with the other filters (#1248). Both values are no filter. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."}]},"description":"The Analytics page in one request (#876, ADR 0039): the cards, the latest runs, the results by priority, a row per project and the values for the filters. The one route that reads more than one project: a **session** reads the projects `projects` names — any role, a reader too — or, without it, every project you are a member of; a **bearer token** reads its own project only (the one it was minted in; a token from before projects — `X-Plune-Project`, else your default), and naming any other one is a 404. Only numbers and one row per project come back — never runs, cases or results from several projects.\n\n**Definitions** — the same on the page and in the docs. A *run of the period*: its `startedAt` within `[from, to]`, not in the bin, and passing `labels`, `environments` and `executedBy`. The *results of the period*: the results of those runs; `tags`, `suite`, `flaky` and `muted` act through the case of a result. *Executed by X*: a result X marked, or one nobody marked in a run X launched — a run passes when one of its results does. *Pass rate*: passed ÷ every result × 100, skipped included. *Period*: every result of the period by the status it is counted under, out of the same total (`kpis.results`). A finished run counts toward both as its summary froze it at `finish`, so a case deleted for good after that stays counted — unless a parameter asks of the case or the person, which reads the results as they stand (#1379). *Cases*: live cases now — the date does not act on them. *Coverage*: automated ÷ (manual + automated). *Latest*: the last finished run of each project in the period. *Flaky*: cases scored `flaky` or `critical` over their last 50 results, whatever the period, out of the cases with two results or more (`kpis.scored`). *Flagged*: cases marked flaky or muted by a person, out of `kpis.cases`. Between parameters AND, within one OR — as on the cases list.\n\nThrottled per caller: 120 requests a minute (#1529) — the page makes one per load and one per change of a filter; over it, 429 with `Retry-After`.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the set of projects, counted from the start of the request; `projects`, `flaky`, `priority` and `facets` are the four reads of the database, each from the moment it is sent until its rows are back — they run side by side, so they do not add up; `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"status":"a project you are not a member of, or one in the bin — or, under a token, any project but its own; the same answer as an id nobody has","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project 'u-shop::default' not found\"\n}"}]},{"name":"One health widget of the Analytics page","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics/health/:widget?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=&notRunDays=30&thresholdMs=","host":["{{baseUrl}}"],"path":["v1","analytics","health",":widget"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone. Only the widgets that describe a period read it; the others describe the state now. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated — as on `GET /v1/analytics`. One you are not in, or one in the bin, is a 404."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request."},{"key":"flaky","value":"","disabled":true,"description":"The case flag `isFlaky`, as on `GET /v1/analytics`. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"The case flag `isMuted`, as on `GET /v1/analytics`. One of: 1, true, 0, false."},{"key":"notRunDays","value":"30","disabled":true,"description":"Widget `never_run` only (#1260): how many days back it looks for a result; the same bounds and the same 400 as `notRunDays` on `GET /v1/test-cases`. Other widgets ignore it."},{"key":"thresholdMs","value":"","disabled":true,"description":"Widget `slowest` only (#1260): the duration in milliseconds the p95 of a case must exceed; absent, the p95 of the own project of the case in the period. Outside 1..86400000 is a 400 that names the parameter. Other widgets ignore it."}],"variable":[{"key":"widget","value":"","description":"The widget’s name."}]},"description":"A widget of the Health section of the Analytics page (#1260, epic #1242). Every widget answers the same wrapper — `n` of `N` (`denominator`), the rows behind it, whether they were cut, and what could not be measured. The filter is the one of `GET /v1/analytics`, and so is the reach: a **session** reads the projects `projects` names, a **bearer token** its own project only. A name that is not a widget is a 404. The page asks once per widget on every change of a filter, so the route has a ceiling of its own: 300 requests a minute per caller, over it 429 with `Retry-After`. **`ever_failing`** — the cases that fail every time: three verdicts or more, every one a failure (the rule of `GET /v1/test-cases?health=ever_failing`). `denominator`: `n` such cases of `N` — the cases with a pair of verdicts to score, the `scored` of `GET /v1/analytics`, so it reads beside the flaky ones («sometimes»). `items`: up to 20, the longest histories first, `{ caseId, title, projectId, runs, failures, lastFailedAt }`; `truncated` when there are more. The state now: `from` and `to` are ignored. `notMeasured` says `flaky_unscored` for the live cases without a pair of verdicts. **`slowest`** — the cases that take the longest in the period (`from`..`to`, `started_at` of the runs; with no `from` the last 90 days, said in `notMeasured` as `period_capped`). `n` of `N`: the cases whose p95 duration (`results.duration_ms`) is above the threshold — `thresholdMs`, or the p95 of all measured results of the own project of the case in the period — of the cases with at least one measured duration. `items`: up to 20 by p95 descending, `{ caseId, title, projectId, p50Ms, p95Ms, thresholdMs, runs }`; `truncated` when there are more. A result with no duration is not measured: `notMeasured` says `no_duration` with how many of how many, and it is never counted as zero. **`failures_by_message`** — the failed results of the period (`from`..`to`, `started_at` of the runs) grouped by their message: `failure.headline`, else the first line of the error context, normalised as a run’s failure groups are (numbers and quoted values do not split a group). `n` of `N` results: the failed or broken results (a muted case’s are not failures) in the groups shown, of all of them. `items`: up to 20 groups by count descending, `{ message, count, cases, firstRunId, firstRunProjectId, firstResultId, lastSeenAt }` (the first run of the group in the period, with its result); `truncated` when there are more groups. A failed result with no message is not in a group: `notMeasured` says `failure_no_message` with how many of how many. Messages can hold data from tests: they are shown to people who can read the project’s results, as every answer of this route. **`detached`** — the cases whose test left the code (`state` `detached`): `n` of `N` cases, the live manual and automated cases that are not `deprecated`. `items`: up to 20, the most recently detached first, `{ caseId, title, projectId, reason, detachedAt }` (`reason` and `detachedAt` are the case’s `stateReason` and `stateChangedAt`, `null` when unset); `truncated` when there are more. The state now: `from` and `to` are ignored."},"response":[{"name":"404 — a widget that does not exist, or a project you are not a member of or one in the bin — the same answer as an id nobody has","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics/health/:widget?from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=&flaky=&muted=&notRunDays=30&thresholdMs=","host":["{{baseUrl}}"],"path":["v1","analytics","health",":widget"],"query":[{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone. Only the widgets that describe a period read it; the others describe the state now. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated — as on `GET /v1/analytics`. One you are not in, or one in the bin, is a 404."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request."},{"key":"flaky","value":"","disabled":true,"description":"The case flag `isFlaky`, as on `GET /v1/analytics`. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"The case flag `isMuted`, as on `GET /v1/analytics`. One of: 1, true, 0, false."},{"key":"notRunDays","value":"30","disabled":true,"description":"Widget `never_run` only (#1260): how many days back it looks for a result; the same bounds and the same 400 as `notRunDays` on `GET /v1/test-cases`. Other widgets ignore it."},{"key":"thresholdMs","value":"","disabled":true,"description":"Widget `slowest` only (#1260): the duration in milliseconds the p95 of a case must exceed; absent, the p95 of the own project of the case in the period. Outside 1..86400000 is a 400 that names the parameter. Other widgets ignore it."}],"variable":[{"key":"widget","value":"","description":"The widget’s name."}]},"description":"A widget of the Health section of the Analytics page (#1260, epic #1242). Every widget answers the same wrapper — `n` of `N` (`denominator`), the rows behind it, whether they were cut, and what could not be measured. The filter is the one of `GET /v1/analytics`, and so is the reach: a **session** reads the projects `projects` names, a **bearer token** its own project only. A name that is not a widget is a 404. The page asks once per widget on every change of a filter, so the route has a ceiling of its own: 300 requests a minute per caller, over it 429 with `Retry-After`. **`ever_failing`** — the cases that fail every time: three verdicts or more, every one a failure (the rule of `GET /v1/test-cases?health=ever_failing`). `denominator`: `n` such cases of `N` — the cases with a pair of verdicts to score, the `scored` of `GET /v1/analytics`, so it reads beside the flaky ones («sometimes»). `items`: up to 20, the longest histories first, `{ caseId, title, projectId, runs, failures, lastFailedAt }`; `truncated` when there are more. The state now: `from` and `to` are ignored. `notMeasured` says `flaky_unscored` for the live cases without a pair of verdicts. **`slowest`** — the cases that take the longest in the period (`from`..`to`, `started_at` of the runs; with no `from` the last 90 days, said in `notMeasured` as `period_capped`). `n` of `N`: the cases whose p95 duration (`results.duration_ms`) is above the threshold — `thresholdMs`, or the p95 of all measured results of the own project of the case in the period — of the cases with at least one measured duration. `items`: up to 20 by p95 descending, `{ caseId, title, projectId, p50Ms, p95Ms, thresholdMs, runs }`; `truncated` when there are more. A result with no duration is not measured: `notMeasured` says `no_duration` with how many of how many, and it is never counted as zero. **`failures_by_message`** — the failed results of the period (`from`..`to`, `started_at` of the runs) grouped by their message: `failure.headline`, else the first line of the error context, normalised as a run’s failure groups are (numbers and quoted values do not split a group). `n` of `N` results: the failed or broken results (a muted case’s are not failures) in the groups shown, of all of them. `items`: up to 20 groups by count descending, `{ message, count, cases, firstRunId, firstRunProjectId, firstResultId, lastSeenAt }` (the first run of the group in the period, with its result); `truncated` when there are more groups. A failed result with no message is not in a group: `notMeasured` says `failure_no_message` with how many of how many. Messages can hold data from tests: they are shown to people who can read the project’s results, as every answer of this route. **`detached`** — the cases whose test left the code (`state` `detached`): `n` of `N` cases, the live manual and automated cases that are not `deprecated`. `items`: up to 20, the most recently detached first, `{ caseId, title, projectId, reason, detachedAt }` (`reason` and `detachedAt` are the case’s `stateReason` and `stateChangedAt`, `null` when unset); `truncated` when there are more. The state now: `from` and `to` are ignored."},"status":"a widget that does not exist, or a project you are not a member of or one in the bin — the same answer as an id nobody has","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"not found\"\n}"}]},{"name":"The time the estimates of the executed cases add up to","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics/saved-time?by=run&format=json&from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=","host":["{{baseUrl}}"],"path":["v1","analytics","saved-time"],"query":[{"key":"by","value":"run","disabled":true,"description":"How the rows are grouped. One of: run, suite."},{"key":"format","value":"json","disabled":true,"description":"`xlsx` answers the same report as a workbook (#1909): a sheet `Saved time`, a row per run or suite and a `Total` row, durations in minutes as numbers, up to 10 000 rows, a note row when cut. Any other value is a 400. One of: json, xlsx."},{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone; the `started_at` of the runs. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated — as on `GET /v1/analytics`."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request."}]},"description":"The saved time report (#1882, BH-D2). The *executed* results of the period: the current result of a case in a run, status `passed`, `failed` or `broken`; `blocked`, `skipped` and one a retest replaced are not counted. Each is weighed with the estimate the case has now, not one frozen at the run. `estimatedSeconds`: their sum. `savedSeconds`: those executed by a machine (a reporter’s result, no `executedBy`) that passed. `manualSeconds`: those a person marked. `notEstimated`: executed cases nobody estimated, counted here and never as 0 seconds in a sum. `by=run` (default) gives a row per run, newest first; `by=suite` a row per suite of the case, the longest estimate first (`name: null` is cases in no suite). Up to 200 rows, `truncated` when there are more; `totals` always cover everything. The period, the projects and the filters are those of `GET /v1/analytics`; 120 requests a minute per caller."},"response":[{"name":"404 — a project you are not a member of or one in the bin — the same answer as an id nobody has","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/analytics/saved-time?by=run&format=json&from=&to=&projects=&labels=&tags=&environments=&executedBy=&suite=","host":["{{baseUrl}}"],"path":["v1","analytics","saved-time"],"query":[{"key":"by","value":"run","disabled":true,"description":"How the rows are grouped. One of: run, suite."},{"key":"format","value":"json","disabled":true,"description":"`xlsx` answers the same report as a workbook (#1909): a sheet `Saved time`, a row per run or suite and a `Total` row, durations in minutes as numbers, up to 10 000 rows, a note row when cut. Any other value is a 400. One of: json, xlsx."},{"key":"from","value":"","disabled":true,"description":"The start of the period, inclusive — ISO 8601 with a time zone; the `started_at` of the runs. Format: date-time."},{"key":"to","value":"","disabled":true,"description":"The end of the period, inclusive, as `from`. Format: date-time."},{"key":"projects","value":"","disabled":true,"description":"Project ids, comma-separated — as on `GET /v1/analytics`."},{"key":"labels","value":"","disabled":true,"description":"Run labels. Repeat for any-of."},{"key":"tags","value":"","disabled":true,"description":"Case tags, verbatim and without `@`. Repeat for any-of."},{"key":"environments","value":"","disabled":true,"description":"Run environments. Repeat for any-of."},{"key":"executedBy","value":"","disabled":true,"description":"People, by `userId` — comma-separated or repeated."},{"key":"suite","value":"","disabled":true,"description":"A suite, or a folder with everything beneath it — only with exactly one project in the request."}]},"description":"The saved time report (#1882, BH-D2). The *executed* results of the period: the current result of a case in a run, status `passed`, `failed` or `broken`; `blocked`, `skipped` and one a retest replaced are not counted. Each is weighed with the estimate the case has now, not one frozen at the run. `estimatedSeconds`: their sum. `savedSeconds`: those executed by a machine (a reporter’s result, no `executedBy`) that passed. `manualSeconds`: those a person marked. `notEstimated`: executed cases nobody estimated, counted here and never as 0 seconds in a sum. `by=run` (default) gives a row per run, newest first; `by=suite` a row per suite of the case, the longest estimate first (`name: null` is cases in no suite). Up to 200 rows, `truncated` when there are more; `totals` always cover everything. The period, the projects and the filters are those of `GET /v1/analytics`; 120 requests a minute per caller."},"status":"a project you are not a member of or one in the bin — the same answer as an id nobody has","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"not found\"\n}"}]}]},{"name":"companies","description":"Companies above projects (ADR 0038) — their people, their projects by name, the one-way move of a project in; a company opens no project","item":[{"name":"The companies you are in","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies","host":["{{baseUrl}}"],"path":["v1","companies"]},"description":"Every company the caller is in, oldest first, with their role in it and two counts: its projects — all of them, joined or not, since their names are the company’s — and its people. A company in the bin is gone from the list. Somebody in no company gets an empty list."},"response":[{"name":"200 — Your companies","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies","host":["{{baseUrl}}"],"path":["v1","companies"]},"description":"Every company the caller is in, oldest first, with their role in it and two counts: its projects — all of them, joined or not, since their names are the company’s — and its people. A company in the bin is gone from the list. Somebody in no company gets an empty list."},"status":"Your companies","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"companies\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n      \"name\": \"Acme\",\n      \"role\": \"owner\",\n      \"projects\": 2,\n      \"members\": 2,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"}]},{"name":"Create a company","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Acme QA\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies","host":["{{baseUrl}}"],"path":["v1","companies"]},"description":"A name, and the caller is its owner. It starts with no project: a project gets into a company by `POST /v1/projects/{id}/transfer`, one way. Nothing about the caller’s own projects changes."},"response":[{"name":"201 — Created — you are its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Acme QA\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies","host":["{{baseUrl}}"],"path":["v1","companies"]},"description":"A name, and the caller is its owner. It starts with no project: a project gets into a company by `POST /v1/projects/{id}/transfer`, one way. Nothing about the caller’s own projects changes."},"status":"Created — you are its owner","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n  \"name\": \"Acme QA\",\n  \"role\": \"owner\",\n  \"projects\": 0,\n  \"members\": 1,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"A company: its projects, its people, its invitations","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Every project of the company by name, with the caller’s own standing in each (`joined`, and the role when joined) — nothing else about a project they are not in; every member with their company role; and, for its owners and admins, the invitations nobody has signed in on yet. A company you are not in, and one in the bin, answer the 404 an id nobody has gets."},"response":[{"name":"200 — The company","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Every project of the company by name, with the caller’s own standing in each (`joined`, and the role when joined) — nothing else about a project they are not in; every member with their company role; and, for its owners and admins, the invitations nobody has signed in on yet. A company you are not in, and one in the bin, answer the 404 an id nobody has gets."},"status":"The company","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n  \"name\": \"Acme\",\n  \"role\": \"owner\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"projects\": [\n    {\n      \"id\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n      \"name\": \"Mobile app\",\n      \"joined\": true,\n      \"role\": \"owner\"\n    },\n    {\n      \"id\": \"9c4e7d10-0000-4000-8000-0000000000a5\",\n      \"name\": \"Payments\",\n      \"joined\": false\n    }\n  ],\n  \"members\": [\n    {\n      \"userId\": \"u-shop\",\n      \"email\": \"owner@example.com\",\n      \"name\": \"Olena Kovalenko\",\n      \"role\": \"owner\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n    },\n    {\n      \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n      \"email\": \"colleague@example.com\",\n      \"role\": \"admin\",\n      \"invitedBy\": \"u-shop\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ],\n  \"invitations\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a6\",\n      \"email\": \"newcomer@example.com\",\n      \"role\": \"member\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ],\n  \"tier\": \"personal\",\n  \"tierState\": \"active\",\n  \"options\": {},\n  \"tierValidUntil\": \"2027-10-15T09:00:00.000Z\"\n}"},{"name":"404 — no such company among yours","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Every project of the company by name, with the caller’s own standing in each (`joined`, and the role when joined) — nothing else about a project they are not in; every member with their company role; and, for its owners and admins, the invitations nobody has signed in on yet. A company you are not in, and one in the bin, answer the 404 an id nobody has gets."},"status":"no such company among yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company '6f1c2b3a-0000-4000-8000-0000000000a5' not found\"\n}"}]},{"name":"Rename a company","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Acme\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin renames it. A member and a reader are told it is not theirs to (403). Answers the company as `GET /v1/companies` lists it."},"response":[{"name":"200 — The company as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Acme\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin renames it. A member and a reader are told it is not theirs to (403). Answers the company as `GET /v1/companies` lists it."},"status":"The company as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n  \"name\": \"Acme\",\n  \"role\": \"owner\",\n  \"projects\": 2,\n  \"members\": 2,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — a member or a reader asked","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Acme\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin renames it. A member and a reader are told it is not theirs to (403). Answers the company as `GET /v1/companies` lists it."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner or admin only\"\n}"}]},{"name":"Delete a company, with every project in it","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Owner only. The company and every project in it go into the bin together, in one transaction: gone from every list, and a request into one of the projects answers the 404 a project nobody has gets. 30 days on the purge empties the projects and removes the company; each project’s audit feed is kept, and records who deleted it. Answers how many projects went with it."},"response":[{"name":"200 — Deleted — how many projects went with it","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Owner only. The company and every project in it go into the bin together, in one transaction: gone from every list, and a request into one of the projects answers the 404 a project nobody has gets. 30 days on the purge empties the projects and removes the company; each project’s audit feed is kept, and records who deleted it. Answers how many projects went with it."},"status":"Deleted — how many projects went with it","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projects\": 2\n}"},{"name":"403 — an admin, a member or a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id","host":["{{baseUrl}}"],"path":["v1","companies",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Owner only. The company and every project in it go into the bin together, in one transaction: gone from every list, and a request into one of the projects answers the 404 a project nobody has gets. 30 days on the purge empties the projects and removes the company; each project’s audit feed is kept, and records who deleted it. Answers how many projects went with it."},"status":"an admin, a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner only\"\n}"}]},{"name":"Withdraw a company invitation","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/invitations/:invitationId","host":["{{baseUrl}}"],"path":["v1","companies",":id","invitations",":invitationId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"invitationId","value":"","description":"Format: uuid."}]},"description":"An owner or an admin. The address can no longer get in on its strength — if it was the only reason the beta door opened, the door is shut again. One that named a project is recorded in that project’s audit feed, beside the entry that sent it."},"response":[{"name":"403 — a member or a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/invitations/:invitationId","host":["{{baseUrl}}"],"path":["v1","companies",":id","invitations",":invitationId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"invitationId","value":"","description":"Format: uuid."}]},"description":"An owner or an admin. The address can no longer get in on its strength — if it was the only reason the beta door opened, the door is shut again. One that named a project is recorded in that project’s audit feed, beside the entry that sent it."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner or admin only\"\n}"}]},{"name":"Ask an address into a company — An address without an account — an invitation","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin; only an owner asks somebody in as an owner. Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other gets an **invitation** — a mail names the company, the beta door lets the address through, and the sign-in turns the invitation into a membership. Being in the company opens none of its projects. Twice for the same address is 409. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[{"name":"201 — Asked — `kind` says which of the two it became","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin; only an owner asks somebody in as an owner. Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other gets an **invitation** — a mail names the company, the beta door lets the address through, and the sign-in turns the invitation into a membership. Being in the company opens none of its projects. Twice for the same address is 409. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"Asked — `kind` says which of the two it became","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"kind\": \"invitation\",\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a6\",\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"member\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — a member or a reader asked — or an admin asked for an owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin; only an owner asks somebody in as an owner. Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other gets an **invitation** — a mail names the company, the beta door lets the address through, and the sign-in turns the invitation into a membership. Being in the company opens none of its projects. Twice for the same address is 409. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"a member or a reader asked — or an admin asked for an owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner or admin only\"\n}"},{"name":"409 — already in, or already invited","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"newcomer@example.com\",\n  \"role\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin; only an owner asks somebody in as an owner. Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other gets an **invitation** — a mail names the company, the beta door lets the address through, and the sign-in turns the invitation into a membership. Being in the company opens none of its projects. Twice for the same address is 409. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"already in, or already invited","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"newcomer@example.com is already in this company, or already invited\"\n}"}]},{"name":"Ask an address into a company — An address that already signed in — a member at once","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"colleague@example.com\",\n  \"role\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"An owner or an admin; only an owner asks somebody in as an owner. Two outcomes, told apart by `kind`: an address that already has a Plune account is a **member** at once; any other gets an **invitation** — a mail names the company, the beta door lets the address through, and the sign-in turns the invitation into a membership. Being in the company opens none of its projects. Twice for the same address is 409. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[]},{"name":"Change a company role","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"admin\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId`"}]},"description":"An owner or an admin; only an owner makes, changes or unmakes an owner. The last owner stays one — make somebody else an owner first (409). A company role changes no project role: it decides the role somebody joins a project with, not the one they already hold. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[{"name":"200 — The member as they are now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"admin\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId`"}]},"description":"An owner or an admin; only an owner makes, changes or unmakes an owner. The last owner stays one — make somebody else an owner first (409). A company role changes no project role: it decides the role somebody joins a project with, not the one they already hold. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"The member as they are now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n  \"email\": \"colleague@example.com\",\n  \"role\": \"admin\",\n  \"invitedBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — a member or a reader asked — or an admin reached for an owner","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"admin\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId`"}]},"description":"An owner or an admin; only an owner makes, changes or unmakes an owner. The last owner stays one — make somebody else an owner first (409). A company role changes no project role: it decides the role somebody joins a project with, not the one they already hold. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"a member or a reader asked — or an admin reached for an owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner only\"\n}"},{"name":"409 — the last owner","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"admin\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId`"}]},"description":"An owner or an admin; only an owner makes, changes or unmakes an owner. The last owner stays one — make somebody else an owner first (409). A company role changes no project role: it decides the role somebody joins a project with, not the one they already hold. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"the last owner","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a company needs an owner — make somebody else one first\"\n}"}]},{"name":"Remove somebody from a company, or leave it","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId` — your own to leave"}]},"description":"An owner or an admin removes anybody but an owner (an owner removes owners too); anybody removes themself — that is “Leave”. They go out of every project of the company in the same transaction, and off the cases they held there; their own projects are not the company’s business. Refused whole (409) when it would leave the company without an owner, or one of its projects — the answer names that project."},"response":[{"name":"403 — a member or a reader asked about somebody else — or an admin about an owner","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId` — your own to leave"}]},"description":"An owner or an admin removes anybody but an owner (an owner removes owners too); anybody removes themself — that is “Leave”. They go out of every project of the company in the same transaction, and off the cases they held there; their own projects are not the company’s business. Refused whole (409) when it would leave the company without an owner, or one of its projects — the answer names that project."},"status":"a member or a reader asked about somebody else — or an admin about an owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner or admin only\"\n}"},{"name":"409 — the last owner of the company, or of one of its projects — `projectId` names which project","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/members/:userId","host":["{{baseUrl}}"],"path":["v1","companies",":id","members",":userId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"userId","value":"","description":"The member’s `userId` — your own to leave"}]},"description":"An owner or an admin removes anybody but an owner (an owner removes owners too); anybody removes themself — that is “Leave”. They go out of every project of the company in the same transaction, and off the cases they held there; their own projects are not the company’s business. Refused whole (409) when it would leave the company without an owner, or one of its projects — the answer names that project."},"status":"the last owner of the company, or of one of its projects — `projectId` names which project","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"owner@example.com is the last owner of «Mobile app» — make somebody else its owner first\",\n  \"projectId\": \"9c4e7d10-0000-4000-8000-0000000000a4\"\n}"}]},{"name":"Join a project of the company","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/join","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","join"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"Anybody in the company, themself: into one of its projects with the role their company role maps to — `owner` and `admin` → `owner`, `member` → `member`, `reader` → `reader`. From then on the project opens to them as to any member of it. A project that is not the company’s answers 404; being in it already, 409."},"response":[{"name":"201 — Joined — with this role","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/join","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","join"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"Anybody in the company, themself: into one of its projects with the role their company role maps to — `owner` and `admin` → `owner`, `member` → `member`, `reader` → `reader`. From then on the project opens to them as to any member of it. A project that is not the company’s answers 404; being in it already, 409."},"status":"Joined — with this role","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projectId\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n  \"role\": \"member\"\n}"},{"name":"404 — no such company among yours, or no such project in it","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/join","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","join"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"Anybody in the company, themself: into one of its projects with the role their company role maps to — `owner` and `admin` → `owner`, `member` → `member`, `reader` → `reader`. From then on the project opens to them as to any member of it. A project that is not the company’s answers 404; being in it already, 409."},"status":"no such company among yours, or no such project in it","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project '9c4e7d10-0000-4000-8000-0000000000a4' not found\"\n}"},{"name":"409 — already in it, or the project is at its member limit","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/join","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","join"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"Anybody in the company, themself: into one of its projects with the role their company role maps to — `owner` and `admin` → `owner`, `member` → `member`, `reader` → `reader`. From then on the project opens to them as to any member of it. A project that is not the company’s answers 404; being in it already, 409."},"status":"already in it, or the project is at its member limit","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"you are already in this project\"\n}"}]},{"name":"Add somebody from the company to one of its projects","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"An owner or an admin adds another member of the company to one of its projects, with the role THEIR company role maps to. Only the company’s people: somebody outside it answers 404, the same as an id nobody has — an address from outside is asked in through `POST /v1/companies/{id}/members` or `POST /v1/members`. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"response":[{"name":"201 — Added — with this role","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"An owner or an admin adds another member of the company to one of its projects, with the role THEIR company role maps to. Only the company’s people: somebody outside it answers 404, the same as an id nobody has — an address from outside is asked in through `POST /v1/companies/{id}/members` or `POST /v1/members`. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"Added — with this role","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projectId\": \"9c4e7d10-0000-4000-8000-0000000000a4\",\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n  \"role\": \"owner\"\n}"},{"name":"403 — a member or a reader asked","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"An owner or an admin adds another member of the company to one of its projects, with the role THEIR company role maps to. Only the company’s people: somebody outside it answers 404, the same as an id nobody has — an address from outside is asked in through `POST /v1/companies/{id}/members` or `POST /v1/members`. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"company owner or admin only\"\n}"},{"name":"404 — no such company among yours, no such project in it, or nobody of that id in the company","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"userId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/projects/:projectId/members","host":["{{baseUrl}}"],"path":["v1","companies",":id","projects",":projectId","members"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"projectId","value":""}]},"description":"An owner or an admin adds another member of the company to one of its projects, with the role THEIR company role maps to. Only the company’s people: somebody outside it answers 404, the same as an id nobody has — an address from outside is asked in through `POST /v1/companies/{id}/members` or `POST /v1/members`. Seats (#1439): adding a person (or inviting an address) who is not yet counted in the account or company that owns the tier is capped by the tier — write roles Free 1, Personal 1, Team 50, Enterprise none; readers Free 1, Personal 3, Team 5, Enterprise none. `429` with `quota.kind: \"writeSeats\"` or `\"readerSeats\"` and no `Retry-After`, only once the operator sets `TIER_LIMITS_ENFORCE=true` (until then counted and logged). An invitation not yet accepted takes no seat but is checked when created and again when accepted (a refused acceptance leaves it pending)."},"status":"no such company among yours, no such project in it, or nobody of that id in the company","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"member '6f1c2b3a-0000-4000-8000-0000000000a2' not found\"\n}"}]},{"name":"A company’s single sign-on settings","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso"],"variable":[{"key":"id","value":""}]},"description":"The owner reads every setting, the certificates and the DNS record each domain waits for; an admin reads that it is on, whether it is required and which domains stand - no value (#1449, ADR 0054 §3). `sp` is what to enter at the identity provider. Never a private key. The session only. A company without the `saml` option is refused by name, the read too.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Save a company’s identity provider settings","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"idpEntityId\": \"https://idp.example.com/entity\",\n  \"idpSsoUrl\": \"https://idp.example.com/sso\",\n  \"idpCerts\": [\n    \"-----BEGIN CERTIFICATE-----\\nMIIC8DCCAdigAwIBAgIIa83dSIfL7OUwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE\\nAwwtaWRwLmV4YW1wbGUuY29tIChQbHVuZSBkb2N1bWVudGF0aW9uIGV4YW1wbGUp\\nMB4XDTI2MDEwMTAwMDAwMFoXDTQ1MTIyNzAwMDAwMFowODE2MDQGA1UEAwwtaWRw\\nLmV4YW1wbGUuY29tIChQbHVuZSBkb2N1bWVudGF0aW9uIGV4YW1wbGUpMIIBIjAN\\nBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAreuGfj5XYZ/QC8RMQdt/2hKSNwNZ\\nF+0b3EGTN2Lr/o0MKPA9Ozqjs2oUNQ5/b6w9EPH190JlLb+FKQhrXjyor4WNvcZo\\niYhkwdeEDuibUAd6ikwub37NYsVx/Y/XRux3H59Mfovsg2QYYljkJsR0Qp1KAc+2\\nYFLRu5cJcyz1V2SBwI0AY99BWdi/ACNFfiQOAR4U1o8KnoXo6Hm5jZSZBxWS8Wl2\\nqp4YeBsFY/uSA4QKPZ1ifjdYwjjUlfJUP0nAhtpMHD6oCz5+CLTn1cOB+75yeekA\\n96LZr5tzp33PShbqKykQVpFC/Nf71u5jLjHvlvQ6tCf1/y/Oty2HT8pToQIDAQAB\\nMA0GCSqGSIb3DQEBCwUAA4IBAQB7qf6Bzo54E1TzZWhtUNt8iosOBPEqM0zi8raf\\nUo9gmOmnitsWegKIy1Ri6okX9JDPjXbxM+Ev7qDp3m9ioPNcwYJL4mE4vD5uCj8V\\ngc3HcPGKm7kUSlfZidLVUXa0oH8LlHTh+1FbCCv2KXyVFF5f9YfcZ8eYet+zWEtA\\ng/IueYChpHq0L5f0ZckLc47yeDbxJWs/voIKxwR9JFXMglHloHIwuYha2nKdXIza\\nKUoFFR5AwCzCqY/qnEE5yRDzCLitoXsCu4vcpwTJynBRTtEdgMwfmQxnJ3KJ0dJx\\npT8NlVkLznKwmfIjr32Qnb2etsZEbD1jRzAirS88v85sA47X\\n-----END CERTIFICATE-----\\n\"\n  ],\n  \"enabled\": true,\n  \"jit\": true,\n  \"defaultRole\": \"member\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso"],"variable":[{"key":"id","value":""}]},"description":"Owner only. The provider’s fields are sent whole; the switches are left as they are when absent. `enabled` needs a verified domain; `enforced` needs it enabled and the owner’s own sign-in through the provider (409 `sso_test_login_required` until then): the owner keeps the email link as an audited emergency way in, and the application owner can switch the requirement off. Changing the provider’s entity id or address lifts the requirement. Every change is a row of the company’s log. 20 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Add a domain to prove","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"domain\": \"acme.example\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/domains","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","domains"],"variable":[{"key":"id","value":""}]},"description":"Owner only. Returns the DNS TXT record to create (`_plune-sso.<domain>`, value `plune-sso-verification=<token>`). A mail provider's domain (gmail.com, outlook.com and the like) is refused: a company cannot prove it. A domain another company has already verified is 409. At most 20 a company. 20 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Remove a domain","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/domains/:domain","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","domains",":domain"],"variable":[{"key":"id","value":""},{"key":"domain","value":""}]},"description":"Owner only. Removing the last verified domain switches single sign-on off (and the requirement with it). 20 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Verify a domain by its DNS record","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"domain\": \"acme.example\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/domains/verify","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","domains","verify"],"variable":[{"key":"id","value":""}]},"description":"Owner only. Reads the TXT record and, if it holds the value, marks the domain verified (the server asks again once a day; a record that is gone takes the verification, and with it the requirement, away). 409 `sso_record_not_found` while the record is not there yet; 502 when DNS could not be read. 10 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Revoke every API token of one person on a verified domain","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/members/:userId/tokens","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","members",":userId","tokens"],"variable":[{"key":"id","value":""},{"key":"userId","value":""}]},"description":"Owner and admin (#1610). 404 for a person whose address is not on a verified domain of the company; `revoked: 0` for one who holds no live token. One row of the company’s log. 20 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"API tokens of the people on the verified domains","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/tokens?limit=50&offset=0","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","tokens"],"query":[{"key":"limit","value":"50","disabled":true},{"key":"offset","value":"0","disabled":true}],"variable":[{"key":"id","value":""}]},"description":"Owner and admin (#1610). The live tokens of every person whose address is on a VERIFIED domain of the company (the domain matched as the sign-in matches it: lower case, punycode form), whether or not they are members: who, which project, when made and when it ends - never the token or its hash. Newest first; `limit` (default 50, at most 200) and `offset`. `affectedProjects` and `unboundTokens` cover ALL of the domain’s tokens, not the page, so a confirmation can say which pipelines may break. Anyone else, and a stranger, is told 404. The session only.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Revoke every API token of the verified domains","request":{"method":"DELETE","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": 0\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/tokens","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","tokens"],"variable":[{"key":"id","value":""}]},"description":"Owner and admin (#1610). `confirm` must equal the number of tokens that would be revoked; when the list changed since it was read the answer is 409 `sso_confirm_mismatch` with the fresh `count`, and nothing is revoked. A revoked token answers 401 on its next request. One row of the company’s log (counts only). CI pipelines that use these tokens will fail: see `affectedProjects`. 20 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Revoke one API token of a person on a verified domain","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/companies/:id/sso/tokens/:tokenId","host":["{{baseUrl}}"],"path":["v1","companies",":id","sso","tokens",":tokenId"],"variable":[{"key":"id","value":""},{"key":"tokenId","value":""}]},"description":"Owner and admin (#1610). A token of anyone else, an unknown, expired or already revoked one is 404. One row of the company’s log. 20 a minute a person.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]}]},{"name":"runs","description":"CLI run ingest + read (bearer-guarded)","item":[{"name":"The bytes of a result’s file","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/files/:id","host":["{{baseUrl}}"],"path":["v1","files",":id"],"variable":[{"key":"id","value":""}]},"description":"Served with the type it was uploaded with, `nosniff` and a `sandbox` CSP, so a picture is only ever a picture (ADR 0040). A text — JSON too — is served as `text/plain; charset=utf-8`, whatever it looks like: never as HTML, and never as a JSON document a browser would open (#928); the stored type is `ResultFile.contentType`. Immutable under its id. A file of a run in the Bin reads as not found until the run is restored. Only for a file Plune keeps (`storage: db`). A file in the project’s bucket (`storage: bucket`) has no bytes here: 409 with `code: in_bucket`. A file still waiting for `complete` is not a file yet, and reads as not found."},"response":[]},{"name":"The bytes are in the bucket — step 3 of 3 of sending a file to it","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/files/:id/complete","host":["{{baseUrl}}"],"path":["v1","files",":id","complete"],"variable":[{"key":"id","value":"","description":"The `id` of the entry that `uploads` answered with."}]},"description":"After `POST /v1/results/{id}/files/uploads` handed out the link and the client `PUT` the bytes to it. Plune asks the bucket (`HEAD`, through its guarded request) for the object: if it is there with the size and the type that were announced, the file is finished — from now on it is in the result’s `files`; if it is not, or it differs, the object is deleted (if there is one), the file is forgotten and the answer is 422: announce it again to try again. No body. Calling it again for a finished file answers 200 with the same body and does not ask the bucket again, so a client may repeat a call whose answer it did not hear. A bucket Plune cannot reach answers 503 `bucket_unreachable` and leaves the file waiting, so the same call can be repeated; a file nobody finishes is removed after an hour, and a call made after that hour is answered 410 `upload_expired`: announce the file again. On AWS S3 a key without `s3:ListBucket` is answered 403, not 404, for an object that is not there: `complete` takes that for a bucket it cannot read and answers 503. Someone else’s file is a 404, the same as an invented one. A reader of the project cannot finish a file. 3000 requests a minute a person."},"response":[]},{"name":"A link to read a result’s file from the project’s bucket","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/files/:id/link","host":["{{baseUrl}}"],"path":["v1","files",":id","link"],"variable":[{"key":"id","value":""}]},"description":"For a file with `storage: bucket` (ADR 0041): Plune looks in the bucket (`HEAD`, through its guarded request) and, if the object is there, signs a `GET` link that lives 10 minutes. The link carries the stored type and name (`inline` for the types a browser may show in place, `attachment` for anything else). If the bucket cannot be reached (a network error, a timeout of 2 seconds) the link is given all the same: only a 404 or a 403 from the bucket is a verdict. The answer is `Cache-Control: no-store`, and Plune writes the link to no log; do not write it to yours. Someone else’s file, an invented id and a file still waiting for `complete` are the same 404. A file Plune keeps itself (`storage: db`) has no link: 409 `not_in_bucket`, read it with `GET /v1/files/{id}`."},"response":[]},{"name":"The bytes are in the bucket for many files — step 3 of 3, up to 100 files a request","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/complete-batch","host":["{{baseUrl}}"],"path":["v1","files","complete-batch"]},"description":"`POST /v1/files/{id}/complete` for many files: `{ \"ids\": [\"…\"] }`, at most 100 (ADR 0051, #1476). 200 with one entry per id, in order: `status: \"ready\"` (the file is finished, or was — the call is repeatable) or `\"refused\"` with the `code` and `httpStatus` of the single route: `not_found` (another project’s file and an invented id alike), `file_missing` / `file_mismatch` (the file is forgotten: announce it again), `upload_expired`, `bucket_unreachable` (with `retryable: true`), `not_connected`. Plune asks the customer’s bucket for each file, eight at a time. One id refused costs the others nothing; an id twice in one request is `duplicate` for the second and the bucket is asked once. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it."},"response":[{"name":"200 — The bytes are there","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/complete-batch","host":["{{baseUrl}}"],"path":["v1","files","complete-batch"]},"description":"`POST /v1/files/{id}/complete` for many files: `{ \"ids\": [\"…\"] }`, at most 100 (ADR 0051, #1476). 200 with one entry per id, in order: `status: \"ready\"` (the file is finished, or was — the call is repeatable) or `\"refused\"` with the `code` and `httpStatus` of the single route: `not_found` (another project’s file and an invented id alike), `file_missing` / `file_mismatch` (the file is forgotten: announce it again), `upload_expired`, `bucket_unreachable` (with `retryable: true`), `not_connected`. Plune asks the customer’s bucket for each file, eight at a time. One id refused costs the others nothing; an id twice in one request is `duplicate` for the second and the bucket is asked once. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it."},"status":"One entry per id. Shown: a file that is finished (`ready`), and an id that is not a file of the caller’s project (`unknown`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"index\": 0,\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\",\n      \"status\": \"ready\",\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213,\n      \"storage\": \"bucket\"\n    }\n  ]\n}"},{"name":"200 — Someone else’s file and an invented id read the same","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/complete-batch","host":["{{baseUrl}}"],"path":["v1","files","complete-batch"]},"description":"`POST /v1/files/{id}/complete` for many files: `{ \"ids\": [\"…\"] }`, at most 100 (ADR 0051, #1476). 200 with one entry per id, in order: `status: \"ready\"` (the file is finished, or was — the call is repeatable) or `\"refused\"` with the `code` and `httpStatus` of the single route: `not_found` (another project’s file and an invented id alike), `file_missing` / `file_mismatch` (the file is forgotten: announce it again), `upload_expired`, `bucket_unreachable` (with `retryable: true`), `not_connected`. Plune asks the customer’s bucket for each file, eight at a time. One id refused costs the others nothing; an id twice in one request is `duplicate` for the second and the bucket is asked once. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it."},"status":"One entry per id. Shown: a file that is finished (`ready`), and an id that is not a file of the caller’s project (`unknown`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"index\": 0,\n      \"status\": \"refused\",\n      \"code\": \"not_found\",\n      \"error\": \"file '3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13' not found\",\n      \"httpStatus\": 404\n    }\n  ]\n}"}]},{"name":"Send the screenshots and texts of many results at once, for a project without a bucket","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"batch-state-1.png\",\n      \"contentType\": \"image/png\",\n      \"data\": \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==\"\n    },\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"batch-console.txt\",\n      \"contentType\": \"text/plain\",\n      \"data\": \"aGVsbG8gZnJvbSB0aGUgcnVubmVy\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/direct-batch","host":["{{baseUrl}}"],"path":["v1","files","direct-batch"]},"description":"`POST /v1/results/{id}/files` for many results: `{ \"items\": [{ \"resultId\", \"name\", \"contentType\", \"data\" }] }`, `data` being the bytes in base64 (ADR 0051, #1476). At most 100 items and **6 MiB of files together** (decoded; the body is a third larger) — over it the whole request is 413 `batch_too_large`, before anything is decoded. Each item is judged alone by the rules of the single route (a picture up to 2 MiB, a text up to 512 KiB once cleaned of credentials, UTF-8 only, 20 pictures and 10 texts a result, the run open, the result the caller’s project’s) and answered `stored`, `exists` (the result already holds a file of that name and content type with the same SHA-256 — a retried batch stores nothing twice. The server computes the SHA-256 of what it keeps, a text once cleaned; a different file of the same name, type and size is stored as another file) or `refused` with a `code` and `httpStatus`; a file larger than the project’s own storage budget (it differs by tariff) is `over_budget`. An item may carry `sha256`; if it is not the SHA-256 of the file as kept, the item is `invalid_file`. The same name and bytes twice in one request is `duplicate` for the second. The project’s budget is made room for once, for everything stored: its oldest files go, as for the single route, in the same transaction; `evicted` says how many and how many bytes. No audit entry, as for a reporter’s single upload. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it."},"response":[{"name":"200 — One entry per item; `evicted` when the budget sent files away","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"batch-state-1.png\",\n      \"contentType\": \"image/png\",\n      \"data\": \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==\"\n    },\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"batch-console.txt\",\n      \"contentType\": \"text/plain\",\n      \"data\": \"aGVsbG8gZnJvbSB0aGUgcnVubmVy\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/direct-batch","host":["{{baseUrl}}"],"path":["v1","files","direct-batch"]},"description":"`POST /v1/results/{id}/files` for many results: `{ \"items\": [{ \"resultId\", \"name\", \"contentType\", \"data\" }] }`, `data` being the bytes in base64 (ADR 0051, #1476). At most 100 items and **6 MiB of files together** (decoded; the body is a third larger) — over it the whole request is 413 `batch_too_large`, before anything is decoded. Each item is judged alone by the rules of the single route (a picture up to 2 MiB, a text up to 512 KiB once cleaned of credentials, UTF-8 only, 20 pictures and 10 texts a result, the run open, the result the caller’s project’s) and answered `stored`, `exists` (the result already holds a file of that name and content type with the same SHA-256 — a retried batch stores nothing twice. The server computes the SHA-256 of what it keeps, a text once cleaned; a different file of the same name, type and size is stored as another file) or `refused` with a `code` and `httpStatus`; a file larger than the project’s own storage budget (it differs by tariff) is `over_budget`. An item may carry `sha256`; if it is not the SHA-256 of the file as kept, the item is `invalid_file`. The same name and bytes twice in one request is `duplicate` for the second. The project’s budget is made room for once, for everything stored: its oldest files go, as for the single route, in the same transaction; `evicted` says how many and how many bytes. No audit entry, as for a reporter’s single upload. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it."},"status":"One entry per item; `evicted` when the budget sent files away","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"index\": 0,\n      \"status\": \"stored\",\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\",\n      \"name\": \"batch-state-1.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 70\n    },\n    {\n      \"index\": 1,\n      \"status\": \"stored\",\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c14\",\n      \"name\": \"batch-console.txt\",\n      \"contentType\": \"text/plain\",\n      \"size\": 21\n    }\n  ]\n}"}]},{"name":"Announce the files of many results at once — step 1 of 3, up to 100 files a request","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/uploads-batch","host":["{{baseUrl}}"],"path":["v1","files","uploads-batch"]},"description":"`POST /v1/results/{id}/files/uploads` for a whole report: `{ \"items\": [{ \"resultId\", \"name\", \"contentType\", \"size\" }] }`, at most 100 items, each for any result of the caller’s project (ADR 0051, #1476). The answer is 200 with one entry per item, in order, whatever the items said: `status: \"upload\"` (a link, as the single route gives it — `PUT` the bytes, then `complete`), `\"direct\"` (the project has no bucket and the file fits the built-in storage: send it by `POST /v1/files/direct-batch`), `\"exists\"` (the result already has this file, finished) or `\"refused\"` with a `code` and the `httpStatus` the single route would have answered with. One item refused costs the others nothing, and nothing is reserved for it. Someone else’s result, an invented id and a result in the Bin are the same `not_found` for that item. What a retry is: an item may carry `sha256` (64 hex characters, of the bytes it will `PUT`; Plune cannot read the bucket’s bytes, so it is the client’s word). **With** it, a finished file of the result with the same name, content type and `sha256` answers `exists` — nothing to send — and the same pair twice in one request is `duplicate` for the second. **Without** it, a finished file is never taken for this one: two files of one name, type and size are two files. A file announced and not finished is given a fresh link — `upload` with `resumed: true`, the same `id` — only to the user who announced it, only while a new link is still worth a round trip (the first 45 minutes: `complete` refuses a file after an hour, and a link lives 15 minutes), and only when the item carries a `sha256` equal to the row’s and the name, type and size are the same (an item without one always gets a row of its own; a retry of such an item can leave a stale waiting row, which the nightly sweep of unfinished uploads removes); otherwise the item gets a row and a link of its own. A result takes 30 files and a file the ceilings of the bucket, as for the single route; each item is judged alone, in order, so the files that fit are taken. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it. The single routes stay as they are. A platform that has this route says so with the header `X-Plune-Files-Batch: 1` on the answers of `POST /v1/results/{id}/files/uploads`, `POST /v1/results/{id}/files` and `POST /v1/files/{id}/complete`; an older one answers this route 404."},"response":[{"name":"200 — The project has a bucket","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/uploads-batch","host":["{{baseUrl}}"],"path":["v1","files","uploads-batch"]},"description":"`POST /v1/results/{id}/files/uploads` for a whole report: `{ \"items\": [{ \"resultId\", \"name\", \"contentType\", \"size\" }] }`, at most 100 items, each for any result of the caller’s project (ADR 0051, #1476). The answer is 200 with one entry per item, in order, whatever the items said: `status: \"upload\"` (a link, as the single route gives it — `PUT` the bytes, then `complete`), `\"direct\"` (the project has no bucket and the file fits the built-in storage: send it by `POST /v1/files/direct-batch`), `\"exists\"` (the result already has this file, finished) or `\"refused\"` with a `code` and the `httpStatus` the single route would have answered with. One item refused costs the others nothing, and nothing is reserved for it. Someone else’s result, an invented id and a result in the Bin are the same `not_found` for that item. What a retry is: an item may carry `sha256` (64 hex characters, of the bytes it will `PUT`; Plune cannot read the bucket’s bytes, so it is the client’s word). **With** it, a finished file of the result with the same name, content type and `sha256` answers `exists` — nothing to send — and the same pair twice in one request is `duplicate` for the second. **Without** it, a finished file is never taken for this one: two files of one name, type and size are two files. A file announced and not finished is given a fresh link — `upload` with `resumed: true`, the same `id` — only to the user who announced it, only while a new link is still worth a round trip (the first 45 minutes: `complete` refuses a file after an hour, and a link lives 15 minutes), and only when the item carries a `sha256` equal to the row’s and the name, type and size are the same (an item without one always gets a row of its own; a retry of such an item can leave a stale waiting row, which the nightly sweep of unfinished uploads removes); otherwise the item gets a row and a link of its own. A result takes 30 files and a file the ceilings of the bucket, as for the single route; each item is judged alone, in order, so the files that fit are taken. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it. The single routes stay as they are. A platform that has this route says so with the header `X-Plune-Files-Batch: 1` on the answers of `POST /v1/results/{id}/files/uploads`, `POST /v1/results/{id}/files` and `POST /v1/files/{id}/complete`; an older one answers this route 404."},"status":"One entry per item. Shown: the two files of the request, for a project with a bucket (`bucket`) and without one (`noBucket`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"index\": 0,\n      \"status\": \"upload\",\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\",\n      \"name\": \"signed-in.png\",\n      \"url\": \"https://s3.eu-central-1.amazonaws.com/acme-results/plune/…?X-Amz-Signature=…\",\n      \"method\": \"PUT\",\n      \"headers\": {\n        \"content-type\": \"image/png\",\n        \"content-length\": \"48213\"\n      },\n      \"expiresAt\": \"2026-10-15T09:15:00.000Z\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"refused\",\n      \"code\": \"file_too_large\",\n      \"error\": \"'trace.zip' is 209715200 bytes; a trace file may be at most 100 MiB\",\n      \"httpStatus\": 413\n    }\n  ]\n}"},{"name":"200 — The project has none: the picture goes to `direct-batch`, the trace has nowhere to go","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/files/uploads-batch","host":["{{baseUrl}}"],"path":["v1","files","uploads-batch"]},"description":"`POST /v1/results/{id}/files/uploads` for a whole report: `{ \"items\": [{ \"resultId\", \"name\", \"contentType\", \"size\" }] }`, at most 100 items, each for any result of the caller’s project (ADR 0051, #1476). The answer is 200 with one entry per item, in order, whatever the items said: `status: \"upload\"` (a link, as the single route gives it — `PUT` the bytes, then `complete`), `\"direct\"` (the project has no bucket and the file fits the built-in storage: send it by `POST /v1/files/direct-batch`), `\"exists\"` (the result already has this file, finished) or `\"refused\"` with a `code` and the `httpStatus` the single route would have answered with. One item refused costs the others nothing, and nothing is reserved for it. Someone else’s result, an invented id and a result in the Bin are the same `not_found` for that item. What a retry is: an item may carry `sha256` (64 hex characters, of the bytes it will `PUT`; Plune cannot read the bucket’s bytes, so it is the client’s word). **With** it, a finished file of the result with the same name, content type and `sha256` answers `exists` — nothing to send — and the same pair twice in one request is `duplicate` for the second. **Without** it, a finished file is never taken for this one: two files of one name, type and size are two files. A file announced and not finished is given a fresh link — `upload` with `resumed: true`, the same `id` — only to the user who announced it, only while a new link is still worth a round trip (the first 45 minutes: `complete` refuses a file after an hour, and a link lives 15 minutes), and only when the item carries a `sha256` equal to the row’s and the name, type and size are the same (an item without one always gets a row of its own; a retry of such an item can leave a stale waiting row, which the nightly sweep of unfinished uploads removes); otherwise the item gets a row and a link of its own. A result takes 30 files and a file the ceilings of the bucket, as for the single route; each item is judged alone, in order, so the files that fit are taken. Counted with the other two batch routes: 6000 items a minute a person and 120 requests a minute, each per server instance (429 `rate_limited` with `Retry-After`, nothing taken). A reader of the project cannot use it. The single routes stay as they are. A platform that has this route says so with the header `X-Plune-Files-Batch: 1` on the answers of `POST /v1/results/{id}/files/uploads`, `POST /v1/results/{id}/files` and `POST /v1/files/{id}/complete`; an older one answers this route 404."},"status":"One entry per item. Shown: the two files of the request, for a project with a bucket (`bucket`) and without one (`noBucket`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"index\": 0,\n      \"status\": \"direct\",\n      \"name\": \"signed-in.png\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"refused\",\n      \"code\": \"no_storage\",\n      \"error\": \"the project has no bucket, and this file does not fit the built-in storage\",\n      \"httpStatus\": 422\n    }\n  ]\n}"}]},{"name":"Change a manual mark","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"passed\",\n  \"errorContext\": null,\n  \"steps\": [\n    {\n      \"index\": 0,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 2,\n      \"status\": \"passed\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id","host":["{{baseUrl}}"],"path":["v1","results",":id"],"variable":[{"key":"id","value":""}]},"description":"The «To run» section of the run page (D34, `api.ts` → `patchResult`): a person who marked a case changes the mark — its status, its message, its steps — in place, while the run is open. Only a result with `source: manual` answers: a reporter’s result is evidence, and a run of 40 cases must not count 41 because one mark was corrected. Audited under the run. A patch never sets or changes `failure`; the message is sanitized like a submission’s. The sub-status (#1231) is set here too, after the status — the status button is the submit, and the sub-status follows it in a request of its own."},"response":[{"name":"200 — The result as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"passed\",\n  \"errorContext\": null,\n  \"steps\": [\n    {\n      \"index\": 0,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 2,\n      \"status\": \"passed\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id","host":["{{baseUrl}}"],"path":["v1","results",":id"],"variable":[{"key":"id","value":""}]},"description":"The «To run» section of the run page (D34, `api.ts` → `patchResult`): a person who marked a case changes the mark — its status, its message, its steps — in place, while the run is open. Only a result with `source: manual` answers: a reporter’s result is evidence, and a run of 40 cases must not count 41 because one mark was corrected. Audited under the run. A patch never sets or changes `failure`; the message is sanitized like a submission’s. The sub-status (#1231) is set here too, after the status — the status button is the submit, and the sub-status follows it in a request of its own."},"status":"The result as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"resultKey\": \"manual:6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"signature\": \"7f974c61f8f10065cd8ae6993cc069e4f85b1db1ed008b965885fb6f945a28c9\",\n  \"status\": \"passed\",\n  \"rawStatus\": null,\n  \"source\": \"manual\",\n  \"expectedStatus\": \"passed\",\n  \"outcome\": \"expected\",\n  \"execution\": null,\n  \"durationMs\": null,\n  \"caseVersion\": 1,\n  \"muted\": false,\n  \"errorContext\": null,\n  \"errorContextMeta\": null,\n  \"assertions\": [],\n  \"steps\": [\n    {\n      \"index\": 0,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 2,\n      \"status\": \"passed\"\n    }\n  ],\n  \"failure\": null,\n  \"executedBy\": null,\n  \"subStatus\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"400 — invalid body, nothing to change, or a sub-status the status does not offer — the message names the ones it does","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"passed\",\n  \"errorContext\": null,\n  \"steps\": [\n    {\n      \"index\": 0,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 2,\n      \"status\": \"passed\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id","host":["{{baseUrl}}"],"path":["v1","results",":id"],"variable":[{"key":"id","value":""}]},"description":"The «To run» section of the run page (D34, `api.ts` → `patchResult`): a person who marked a case changes the mark — its status, its message, its steps — in place, while the run is open. Only a result with `source: manual` answers: a reporter’s result is evidence, and a run of 40 cases must not count 41 because one mark was corrected. Audited under the run. A patch never sets or changes `failure`; the message is sanitized like a submission’s. The sub-status (#1231) is set here too, after the status — the status button is the submit, and the sub-status follows it in a request of its own."},"status":"invalid body, nothing to change, or a sub-status the status does not offer — the message names the ones it does","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"'Nope' is not a sub-status of failed — it has Product bug, Environment issue\"\n}"},{"name":"409 — the run is closed","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"passed\",\n  \"errorContext\": null,\n  \"steps\": [\n    {\n      \"index\": 0,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 1,\n      \"status\": \"passed\"\n    },\n    {\n      \"index\": 2,\n      \"status\": \"passed\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id","host":["{{baseUrl}}"],"path":["v1","results",":id"],"variable":[{"key":"id","value":""}]},"description":"The «To run» section of the run page (D34, `api.ts` → `patchResult`): a person who marked a case changes the mark — its status, its message, its steps — in place, while the run is open. Only a result with `source: manual` answers: a reporter’s result is evidence, and a run of 40 cases must not count 41 because one mark was corrected. Audited under the run. A patch never sets or changes `failure`; the message is sanitized like a submission’s. The sub-status (#1231) is set here too, after the status — the status button is the submit, and the sub-status follows it in a request of its own."},"status":"the run is closed","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the run is closed — its results stand\"\n}"}]},{"name":"Add a defect link to a failed result","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/defects","host":["{{baseUrl}}"],"path":["v1","results",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a result. Only a result that failed or broke takes one: any other answers 409 `result_not_failed`, and a closed run is no obstacle (a ticket is filed after the run is over). The body is judged before the owner is looked up. Audited on the run as `run/event`, the result and the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"response":[{"name":"201 — Added","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/defects","host":["{{baseUrl}}"],"path":["v1","results",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a result. Only a result that failed or broke takes one: any other answers 409 `result_not_failed`, and a closed run is no obstacle (a ticket is filed after the run is over). The body is judged before the owner is looked up. Audited on the run as `run/event`, the result and the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"Added","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a7\",\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\",\n  \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n  \"createdAt\": \"2026-09-16T10:30:00.000Z\"\n}"},{"name":"400 — the body is not JSON","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/defects","host":["{{baseUrl}}"],"path":["v1","results",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a result. Only a result that failed or broke takes one: any other answers 409 `result_not_failed`, and a closed run is no obstacle (a ticket is filed after the run is over). The body is judged before the owner is looked up. Audited on the run as `run/event`, the result and the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"the body is not JSON","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"invalid JSON body\"\n}"},{"name":"403 — a reader asked","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/defects","host":["{{baseUrl}}"],"path":["v1","results",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a result. Only a result that failed or broke takes one: any other answers 409 `result_not_failed`, and a closed run is no obstacle (a ticket is filed after the run is over). The body is judged before the owner is looked up. Audited on the run as `run/event`, the result and the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"409 — the result already holds 20 links (`too_many_defects`), or it did not fail (`result_not_failed`)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/defects","host":["{{baseUrl}}"],"path":["v1","results",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a result. Only a result that failed or broke takes one: any other answers 409 `result_not_failed`, and a closed run is no obstacle (a ticket is filed after the run is over). The body is judged before the owner is looked up. Audited on the run as `run/event`, the result and the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"the result already holds 20 links (`too_many_defects`), or it did not fail (`result_not_failed`)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"result '6f1c2b3a-0000-4000-8000-0000000000e1' already has 20 defect links: take one off first\",\n  \"code\": \"too_many_defects\"\n}"},{"name":"422 — the address or the label is refused; `code` says which","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/defects","host":["{{baseUrl}}"],"path":["v1","results",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a result. Only a result that failed or broke takes one: any other answers 409 `result_not_failed`, and a closed run is no obstacle (a ticket is filed after the run is over). The body is judged before the owner is looked up. Audited on the run as `run/event`, the result and the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"the address or the label is refused; `code` says which","code":422,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the address must start with https:// — nothing else is accepted\",\n  \"code\": \"defect_url_not_https\"\n}"}]},{"name":"Take a defect link off a result","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/results/:id/defects/:defectId","host":["{{baseUrl}}"],"path":["v1","results",":id","defects",":defectId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"defectId","value":"","description":"Format: uuid."}]},"description":"Any owner or member of the project may take off any link of the result. Audited on the run as `run/event`, the result and the address as `before`. A link of a case, or of another result, is a 404 here."},"response":[{"name":"403 — a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/results/:id/defects/:defectId","host":["{{baseUrl}}"],"path":["v1","results",":id","defects",":defectId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"defectId","value":"","description":"Format: uuid."}]},"description":"Any owner or member of the project may take off any link of the result. Audited on the run as `run/event`, the result and the address as `before`. A link of a case, or of another result, is a 404 here."},"status":"a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"404 — no such link on a result of this project","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/results/:id/defects/:defectId","host":["{{baseUrl}}"],"path":["v1","results",":id","defects",":defectId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"defectId","value":"","description":"Format: uuid."}]},"description":"Any owner or member of the project may take off any link of the result. Audited on the run as `run/event`, the result and the address as `before`. A link of a case, or of another result, is a 404 here."},"status":"no such link on a result of this project","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"defect link '6f1c2b3a-0000-4000-8000-0000000000a7' not found\"\n}"}]},{"name":"Add a screenshot or a text file to a result","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"orderId\": \"ord_1042\",\n  \"status\": \"paid\",\n  \"total\": 4820\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/files?name=","host":["{{baseUrl}}"],"path":["v1","results",":id","files"],"query":[{"key":"name","value":"","disabled":true,"description":"What the file is called; only the last segment of a path is kept, and it is cleaned of credentials. At most 200 characters once cleaned."}],"variable":[{"key":"id","value":"","description":"The result — `items[].id` of the batch that stored it."}]},"description":"The request body IS the file, and `Content-Type` says which kind (ADR 0040). A screenshot: PNG, JPEG or WebP, at most 2 MiB, up to 20 per result. A text (the amendment, #928): `application/json` or `text/plain` — a log is `text/plain`, whatever its name (`.log`, `stdout`, `stderr`) — as UTF-8 only, at most 512 KiB, up to 10 per result, counted apart from the screenshots. A text is cleaned of credentials before it is kept, by the pass a failure’s text goes through: colour codes go, and so do tokens, keys, cookies and `Authorization` values. What is kept is the cleaned text, its `size` is that, and it can be longer than what was sent — the 512 KiB ceiling is on both. The server does not parse JSON; cleaning can leave a JSON file that no longer parses, and a client reads it as text then. Trace and video are not accepted here. Into a run that is still open: a reporter uploads after each batch of results and before it closes the run, using the ids the batch answered with. Each project keeps its files, pictures and texts together, within a budget of 1 GiB: an upload that would pass it first deletes the project’s oldest files — the results stay, their files go. Only the files kept in the database count toward the budget and the allowances above; a file in the project’s bucket (`POST /v1/results/{id}/files/uploads`) counts toward neither, and is never one of the oldest that go. A project with a bucket sends everything there instead; this route stays for a project without one."},"response":[{"name":"201 — Stored. `evicted` is there only when the budget sent files away for it (#1234)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"orderId\": \"ord_1042\",\n  \"status\": \"paid\",\n  \"total\": 4820\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/files?name=","host":["{{baseUrl}}"],"path":["v1","results",":id","files"],"query":[{"key":"name","value":"","disabled":true,"description":"What the file is called; only the last segment of a path is kept, and it is cleaned of credentials. At most 200 characters once cleaned."}],"variable":[{"key":"id","value":"","description":"The result — `items[].id` of the batch that stored it."}]},"description":"The request body IS the file, and `Content-Type` says which kind (ADR 0040). A screenshot: PNG, JPEG or WebP, at most 2 MiB, up to 20 per result. A text (the amendment, #928): `application/json` or `text/plain` — a log is `text/plain`, whatever its name (`.log`, `stdout`, `stderr`) — as UTF-8 only, at most 512 KiB, up to 10 per result, counted apart from the screenshots. A text is cleaned of credentials before it is kept, by the pass a failure’s text goes through: colour codes go, and so do tokens, keys, cookies and `Authorization` values. What is kept is the cleaned text, its `size` is that, and it can be longer than what was sent — the 512 KiB ceiling is on both. The server does not parse JSON; cleaning can leave a JSON file that no longer parses, and a client reads it as text then. Trace and video are not accepted here. Into a run that is still open: a reporter uploads after each batch of results and before it closes the run, using the ids the batch answered with. Each project keeps its files, pictures and texts together, within a budget of 1 GiB: an upload that would pass it first deletes the project’s oldest files — the results stay, their files go. Only the files kept in the database count toward the budget and the allowances above; a file in the project’s bucket (`POST /v1/results/{id}/files/uploads`) counts toward neither, and is never one of the oldest that go. A project with a bucket sends everything there instead; this route stays for a project without one."},"status":"Stored. `evicted` is there only when the budget sent files away for it (#1234)","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\",\n  \"name\": \"signed-in.png\",\n  \"contentType\": \"image/png\",\n  \"size\": 48213,\n  \"storage\": \"db\"\n}"}]},{"name":"Announce files for a result — step 1 of 3 of sending them to the project’s bucket","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/files/uploads","host":["{{baseUrl}}"],"path":["v1","results",":id","files","uploads"],"variable":[{"key":"id","value":"","description":"The result — `items[].id` of the batch that stored it."}]},"description":"Files that are not screenshots or small texts — a trace, a video, a log of megabytes — go straight to the bucket the project connected (ADR 0041); Plune never carries their bytes. Three HTTP calls are all a reporter needs, in any language, with no S3 library and no key of the bucket:\n\n1. `POST /v1/results/{id}/files/uploads` (this call) with `{ \"files\": [{ \"name\", \"contentType\", \"size\" }] }`, `size` being the exact number of bytes. The answer has one entry per file, in the same order: `{ id, name, url, method: \"PUT\", headers, expiresAt }` for a file that goes to the bucket (`ResultFileUploadLink`); `{ name, direct: true }` for a picture or a text of a project **without** a bucket, which goes to `POST /v1/results/{id}/files?name=` as before; `{ name, skipped: \"no_storage\" }` for anything else of a project without a bucket — nothing to send, and not an error.\n2. `PUT` the bytes to `url`, to the bucket and not to Plune: with exactly `headers` (`content-type` and `content-length`) and **no** `Authorization` header, before `expiresAt` — 15 minutes. A `200` means the bucket took it; anything else means the file is not there, and a new link is a new call to this route. The link is signed for that type and that size: a different body is refused by the bucket.\n3. `POST /v1/files/{id}/complete` with the `id` of the entry, no body. Plune looks in the bucket, and only a file that is there with the size and type announced becomes part of the result.\n\nThe ceilings of a file in the bucket, which are checked here by the size you announce and again in step 3 by the size found: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB. The kind is read from `contentType` and the name (an image type; JSON, `.log`, `stdout`, `stderr`, plain text or `.md`; a video type or `.webm`/`.mp4`; `trace` or `trace*.zip`; anything else). A result takes 30 files, the database and the bucket together. Nothing is shown until step 3: a file that was announced and never completed is not in any list, and is removed with its object after an hour. Into a run that is still open, for a result of the caller’s project: someone else’s result is a 404, the same as an invented one. A reader of the project cannot announce files. 3000 requests a minute a person, for each of the three routes a result’s files go through. A link is never written to any log by Plune; do not write it to yours."},"response":[{"name":"200 — The project has a bucket","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/files/uploads","host":["{{baseUrl}}"],"path":["v1","results",":id","files","uploads"],"variable":[{"key":"id","value":"","description":"The result — `items[].id` of the batch that stored it."}]},"description":"Files that are not screenshots or small texts — a trace, a video, a log of megabytes — go straight to the bucket the project connected (ADR 0041); Plune never carries their bytes. Three HTTP calls are all a reporter needs, in any language, with no S3 library and no key of the bucket:\n\n1. `POST /v1/results/{id}/files/uploads` (this call) with `{ \"files\": [{ \"name\", \"contentType\", \"size\" }] }`, `size` being the exact number of bytes. The answer has one entry per file, in the same order: `{ id, name, url, method: \"PUT\", headers, expiresAt }` for a file that goes to the bucket (`ResultFileUploadLink`); `{ name, direct: true }` for a picture or a text of a project **without** a bucket, which goes to `POST /v1/results/{id}/files?name=` as before; `{ name, skipped: \"no_storage\" }` for anything else of a project without a bucket — nothing to send, and not an error.\n2. `PUT` the bytes to `url`, to the bucket and not to Plune: with exactly `headers` (`content-type` and `content-length`) and **no** `Authorization` header, before `expiresAt` — 15 minutes. A `200` means the bucket took it; anything else means the file is not there, and a new link is a new call to this route. The link is signed for that type and that size: a different body is refused by the bucket.\n3. `POST /v1/files/{id}/complete` with the `id` of the entry, no body. Plune looks in the bucket, and only a file that is there with the size and type announced becomes part of the result.\n\nThe ceilings of a file in the bucket, which are checked here by the size you announce and again in step 3 by the size found: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB. The kind is read from `contentType` and the name (an image type; JSON, `.log`, `stdout`, `stderr`, plain text or `.md`; a video type or `.webm`/`.mp4`; `trace` or `trace*.zip`; anything else). A result takes 30 files, the database and the bucket together. Nothing is shown until step 3: a file that was announced and never completed is not in any list, and is removed with its object after an hour. Into a run that is still open, for a result of the caller’s project: someone else’s result is a 404, the same as an invented one. A reader of the project cannot announce files. 3000 requests a minute a person, for each of the three routes a result’s files go through. A link is never written to any log by Plune; do not write it to yours."},"status":"One entry per file. Shown: the two files of the request, for a project with a bucket (`bucket`) and without one (`noBucket`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"files\": [\n    {\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13\",\n      \"name\": \"signed-in.png\",\n      \"url\": \"https://s3.eu-central-1.amazonaws.com/acme-results/plune/u-shop%3A%3Adefault/run/result/3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c13-signed-in.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA...&X-Amz-Date=20261010T090000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost&X-Amz-Signature=…\",\n      \"method\": \"PUT\",\n      \"headers\": {\n        \"content-type\": \"image/png\",\n        \"content-length\": \"48213\"\n      },\n      \"expiresAt\": \"2026-10-10T09:15:00.000Z\"\n    },\n    {\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c14\",\n      \"name\": \"trace.zip\",\n      \"url\": \"https://s3.eu-central-1.amazonaws.com/acme-results/plune/…?X-Amz-Signature=…\",\n      \"method\": \"PUT\",\n      \"headers\": {\n        \"content-type\": \"application/zip\",\n        \"content-length\": \"3145728\"\n      },\n      \"expiresAt\": \"2026-10-10T09:15:00.000Z\"\n    }\n  ]\n}"},{"name":"200 — The project has none: the picture goes to the route of old, the trace has nowhere to go","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/results/:id/files/uploads","host":["{{baseUrl}}"],"path":["v1","results",":id","files","uploads"],"variable":[{"key":"id","value":"","description":"The result — `items[].id` of the batch that stored it."}]},"description":"Files that are not screenshots or small texts — a trace, a video, a log of megabytes — go straight to the bucket the project connected (ADR 0041); Plune never carries their bytes. Three HTTP calls are all a reporter needs, in any language, with no S3 library and no key of the bucket:\n\n1. `POST /v1/results/{id}/files/uploads` (this call) with `{ \"files\": [{ \"name\", \"contentType\", \"size\" }] }`, `size` being the exact number of bytes. The answer has one entry per file, in the same order: `{ id, name, url, method: \"PUT\", headers, expiresAt }` for a file that goes to the bucket (`ResultFileUploadLink`); `{ name, direct: true }` for a picture or a text of a project **without** a bucket, which goes to `POST /v1/results/{id}/files?name=` as before; `{ name, skipped: \"no_storage\" }` for anything else of a project without a bucket — nothing to send, and not an error.\n2. `PUT` the bytes to `url`, to the bucket and not to Plune: with exactly `headers` (`content-type` and `content-length`) and **no** `Authorization` header, before `expiresAt` — 15 minutes. A `200` means the bucket took it; anything else means the file is not there, and a new link is a new call to this route. The link is signed for that type and that size: a different body is refused by the bucket.\n3. `POST /v1/files/{id}/complete` with the `id` of the entry, no body. Plune looks in the bucket, and only a file that is there with the size and type announced becomes part of the result.\n\nThe ceilings of a file in the bucket, which are checked here by the size you announce and again in step 3 by the size found: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB. The kind is read from `contentType` and the name (an image type; JSON, `.log`, `stdout`, `stderr`, plain text or `.md`; a video type or `.webm`/`.mp4`; `trace` or `trace*.zip`; anything else). A result takes 30 files, the database and the bucket together. Nothing is shown until step 3: a file that was announced and never completed is not in any list, and is removed with its object after an hour. Into a run that is still open, for a result of the caller’s project: someone else’s result is a 404, the same as an invented one. A reader of the project cannot announce files. 3000 requests a minute a person, for each of the three routes a result’s files go through. A link is never written to any log by Plune; do not write it to yours."},"status":"One entry per file. Shown: the two files of the request, for a project with a bucket (`bucket`) and without one (`noBucket`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"direct\": true\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"skipped\": \"no_storage\"\n    }\n  ]\n}"}]},{"name":"One test across the runs it appeared in","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/results/:id/history","host":["{{baseUrl}}"],"path":["v1","results",":id","history"],"variable":[{"key":"id","value":""}]},"description":"Grouped by `signature`, not by test case id: the same case run with different `params` is a different test, and a history that mixed them would put a green and a red side by side with nothing to explain the difference. The rows are read like a run’s results: each carries `failure` and a sanitized `errorContext`."},"response":[]},{"name":"The finished file of a large run’s export","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/run-exports/:id","host":["{{baseUrl}}"],"path":["v1","run-exports",":id"],"variable":[{"key":"id","value":""}]},"description":"The file `GET /v1/runs/{id}/export` promised with its 202 (#1265), by the `fileId` it gave. Kept 24 hours after it is ready; then 410. Another project’s id and an invented one are the same 404. The project’s owner only."},"response":[]},{"name":"List your runs (newest first)","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs?limit=50&offset=0&environment=&planId=&q=&key=&custom=","host":["{{baseUrl}}"],"path":["v1","runs"],"query":[{"key":"limit","value":"50","disabled":true},{"key":"offset","value":"0","disabled":true},{"key":"environment","value":"","disabled":true,"description":"Only runs that declared this environment. Blank or absent means every run — a caller building the query from an unset CI variable asks for everything, not for the empty string."},{"key":"planId","value":"","disabled":true,"description":"Only runs launched from this plan (`meta.planId`, D33) — the «Runs of this plan» list. Blank or absent means every run."},{"key":"q","value":"","disabled":true,"description":"Only runs whose title contains this text, in any case — the search palette (#866). Text, not a pattern: `%` and `_` match themselves, as in the cases list. A run without a title is not found by one. Blank or absent means every run."},{"key":"key","value":"","disabled":true,"description":"Only the run that carries this `externalKey`, whole — the key a reporter joins by (`PLUNE_RUN=<key>`), so `plune run grep <key>` finds its run (plune-ai/cli#85). At most one: a key belongs to one live run of the project. Blank or absent means every run."},{"key":"custom","value":"","disabled":true,"description":"`<fieldId>:<value>` (#1766, ADR 0048): only the runs whose custom field holds the value — the text of a string field, the option id of a list (a `multiple` list matches when it holds it), the number of a number field. Repeat for one field to OR its values; different fields are AND-ed. A field or a value nobody holds matches nothing; a pair without a field or a value is a 400 that names the form."}]},"description":"The Runs screen (`api.ts` → `listRuns`) and the first call to make with a fresh token. Summaries, not full runs: identity, the headline counts, cost and duration, the eval score with its movement against the previous finished run of the same environment. Offset paging — a run list is short enough for it; the audit feed, which is not, pages by cursor."},"response":[{"name":"200 — Your runs","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs?limit=50&offset=0&environment=&planId=&q=&key=&custom=","host":["{{baseUrl}}"],"path":["v1","runs"],"query":[{"key":"limit","value":"50","disabled":true},{"key":"offset","value":"0","disabled":true},{"key":"environment","value":"","disabled":true,"description":"Only runs that declared this environment. Blank or absent means every run — a caller building the query from an unset CI variable asks for everything, not for the empty string."},{"key":"planId","value":"","disabled":true,"description":"Only runs launched from this plan (`meta.planId`, D33) — the «Runs of this plan» list. Blank or absent means every run."},{"key":"q","value":"","disabled":true,"description":"Only runs whose title contains this text, in any case — the search palette (#866). Text, not a pattern: `%` and `_` match themselves, as in the cases list. A run without a title is not found by one. Blank or absent means every run."},{"key":"key","value":"","disabled":true,"description":"Only the run that carries this `externalKey`, whole — the key a reporter joins by (`PLUNE_RUN=<key>`), so `plune run grep <key>` finds its run (plune-ai/cli#85). At most one: a key belongs to one live run of the project. Blank or absent means every run."},{"key":"custom","value":"","disabled":true,"description":"`<fieldId>:<value>` (#1766, ADR 0048): only the runs whose custom field holds the value — the text of a string field, the option id of a list (a `multiple` list matches when it holds it), the number of a number field. Repeat for one field to OR its values; different fields are AND-ed. A field or a value nobody holds matches nothing; a pair without a field or a value is a 400 that names the form."}]},"description":"The Runs screen (`api.ts` → `listRuns`) and the first call to make with a fresh token. Summaries, not full runs: identity, the headline counts, cost and duration, the eval score with its movement against the previous finished run of the same environment. Offset paging — a run list is short enough for it; the audit feed, which is not, pages by cursor."},"status":"Your runs","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"runs\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"kind\": \"automated\",\n      \"source\": \"cli\",\n      \"title\": \"plune · 2026-09-16 09:12 · ci\",\n      \"environment\": \"staging\",\n      \"labels\": [\n        \"plune\",\n        \"vitest\",\n        \"ci\"\n      ],\n      \"total\": 2,\n      \"passed\": 1,\n      \"failed\": 1,\n      \"broken\": 0,\n      \"blocked\": 0,\n      \"skipped\": 0,\n      \"muted\": 0,\n      \"score\": null,\n      \"scoreDelta\": null,\n      \"costUsd\": 0,\n      \"durationMs\": 4931,\n      \"measured\": false,\n      \"running\": false\n    }\n  ],\n  \"total\": 1\n}"}]},{"name":"Start a run, or ingest a finished eval run — schemaVersion 2 — open (or join) a run before its results arrive","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 2,\n  \"kind\": \"automated\",\n  \"externalKey\": \"gh-35003177826\",\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\"\n  ],\n  \"configuration\": {\n    \"expected\": [\n      {\n        \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n      },\n      {\n        \"externalKey\": {\n          \"kind\": \"path-title\",\n          \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n        }\n      }\n    ],\n    \"full\": true\n  },\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs","host":["{{baseUrl}}"],"path":["v1","runs"]},"description":"One route, two bodies, told apart by `schemaVersion`. **2** — `RunStart`: the reporter (`@plune-ai/playwright`, `plune run start`, `plune run import`) opens a run or joins the live one that already carries this `externalKey`, then streams results to `POST /v1/runs/{id}/results` and closes it with `POST /v1/runs/{id}/events`. **1** — `RunResult`: `plune sync` uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. `meta.ciUrl` must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of `meta`. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, `plune sync`, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its `externalKey` opens none, spends nothing and is never refused for it. **Retrying a schemaVersion 1 upload** (#1534, ADR 0053): send an `Idempotency-Key` header (1 to 128 of letters, digits and `. _ : ~ -`; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (`id`, `linked`, `unlinked`) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its `externalKey` is the same idea."},"response":[{"name":"200 — Joined — a run already carried this `externalKey`; `joined: true`, and the shard reports into it. Or, for schemaVersion 1, a retry under an `Idempotency-Key` that already made its run: `RunCreated` as first answered","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 2,\n  \"kind\": \"automated\",\n  \"externalKey\": \"gh-35003177826\",\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\"\n  ],\n  \"configuration\": {\n    \"expected\": [\n      {\n        \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n      },\n      {\n        \"externalKey\": {\n          \"kind\": \"path-title\",\n          \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n        }\n      }\n    ],\n    \"full\": true\n  },\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs","host":["{{baseUrl}}"],"path":["v1","runs"]},"description":"One route, two bodies, told apart by `schemaVersion`. **2** — `RunStart`: the reporter (`@plune-ai/playwright`, `plune run start`, `plune run import`) opens a run or joins the live one that already carries this `externalKey`, then streams results to `POST /v1/runs/{id}/results` and closes it with `POST /v1/runs/{id}/events`. **1** — `RunResult`: `plune sync` uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. `meta.ciUrl` must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of `meta`. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, `plune sync`, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its `externalKey` opens none, spends nothing and is never refused for it. **Retrying a schemaVersion 1 upload** (#1534, ADR 0053): send an `Idempotency-Key` header (1 to 128 of letters, digits and `. _ : ~ -`; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (`id`, `linked`, `unlinked`) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its `externalKey` is the same idea."},"status":"Joined — a run already carried this `externalKey`; `joined: true`, and the shard reports into it. Or, for schemaVersion 1, a retry under an `Idempotency-Key` that already made its run: `RunCreated` as first answered","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"run\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"projectId\": \"u-shop::default\",\n    \"kind\": \"automated\",\n    \"externalKey\": \"gh-35003177826\",\n    \"statusEvent\": \"launched\",\n    \"configuration\": {\n      \"full\": true,\n      \"expected\": [\n        {\n          \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n        },\n        {\n          \"externalKey\": {\n            \"kind\": \"path-title\",\n            \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n          }\n        }\n      ]\n    },\n    \"meta\": {\n      \"sha\": \"3e31591\",\n      \"branch\": \"master\",\n      \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n      \"runner\": \"playwright\"\n    },\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": null,\n    \"terminateReason\": null,\n    \"summary\": null,\n    \"title\": \"plune · 2026-09-16 09:12 · ci\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"plune\",\n      \"vitest\",\n      \"ci\"\n    ],\n    \"description\": null,\n    \"createdBy\": \"u-shop\"\n  },\n  \"joined\": true\n}"},{"name":"201 — Created — the run this shard opened (`RunStarted`, schemaVersion 2) or the eval run as recorded (`RunCreated`, schemaVersion 1)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 2,\n  \"kind\": \"automated\",\n  \"externalKey\": \"gh-35003177826\",\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\"\n  ],\n  \"configuration\": {\n    \"expected\": [\n      {\n        \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n      },\n      {\n        \"externalKey\": {\n          \"kind\": \"path-title\",\n          \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n        }\n      }\n    ],\n    \"full\": true\n  },\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs","host":["{{baseUrl}}"],"path":["v1","runs"]},"description":"One route, two bodies, told apart by `schemaVersion`. **2** — `RunStart`: the reporter (`@plune-ai/playwright`, `plune run start`, `plune run import`) opens a run or joins the live one that already carries this `externalKey`, then streams results to `POST /v1/runs/{id}/results` and closes it with `POST /v1/runs/{id}/events`. **1** — `RunResult`: `plune sync` uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. `meta.ciUrl` must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of `meta`. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, `plune sync`, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its `externalKey` opens none, spends nothing and is never refused for it. **Retrying a schemaVersion 1 upload** (#1534, ADR 0053): send an `Idempotency-Key` header (1 to 128 of letters, digits and `. _ : ~ -`; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (`id`, `linked`, `unlinked`) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its `externalKey` is the same idea."},"status":"Created — the run this shard opened (`RunStarted`, schemaVersion 2) or the eval run as recorded (`RunCreated`, schemaVersion 1)","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"run\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"projectId\": \"u-shop::default\",\n    \"kind\": \"automated\",\n    \"externalKey\": \"gh-35003177826\",\n    \"statusEvent\": \"launched\",\n    \"configuration\": {\n      \"full\": true,\n      \"expected\": [\n        {\n          \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n        },\n        {\n          \"externalKey\": {\n            \"kind\": \"path-title\",\n            \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n          }\n        }\n      ]\n    },\n    \"meta\": {\n      \"sha\": \"3e31591\",\n      \"branch\": \"master\",\n      \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n      \"runner\": \"playwright\"\n    },\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": null,\n    \"terminateReason\": null,\n    \"summary\": null,\n    \"title\": \"plune · 2026-09-16 09:12 · ci\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"plune\",\n      \"vitest\",\n      \"ci\"\n    ],\n    \"description\": null,\n    \"createdBy\": \"u-shop\"\n  },\n  \"joined\": false\n}"},{"name":"400 — The link of the run is not a web address","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 2,\n  \"kind\": \"automated\",\n  \"externalKey\": \"gh-35003177826\",\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\"\n  ],\n  \"configuration\": {\n    \"expected\": [\n      {\n        \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n      },\n      {\n        \"externalKey\": {\n          \"kind\": \"path-title\",\n          \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n        }\n      }\n    ],\n    \"full\": true\n  },\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs","host":["{{baseUrl}}"],"path":["v1","runs"]},"description":"One route, two bodies, told apart by `schemaVersion`. **2** — `RunStart`: the reporter (`@plune-ai/playwright`, `plune run start`, `plune run import`) opens a run or joins the live one that already carries this `externalKey`, then streams results to `POST /v1/runs/{id}/results` and closes it with `POST /v1/runs/{id}/events`. **1** — `RunResult`: `plune sync` uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. `meta.ciUrl` must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of `meta`. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, `plune sync`, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its `externalKey` opens none, spends nothing and is never refused for it. **Retrying a schemaVersion 1 upload** (#1534, ADR 0053): send an `Idempotency-Key` header (1 to 128 of letters, digits and `. _ : ~ -`; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (`id`, `linked`, `unlinked`) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its `externalKey` is the same idea."},"status":"invalid body — field-level detail in `error`","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — meta.ciUrl: must be an http or https address\"\n}"}]},{"name":"Start a run, or ingest a finished eval run — schemaVersion 2 — a person’s run, handed out by each case’s assignee (B8-team T4)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 2,\n  \"kind\": \"manual\",\n  \"title\": \"Release regression · 2026-09-18\",\n  \"configuration\": {\n    \"expected\": [\n      {\n        \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n      }\n    ]\n  },\n  \"assignment\": \"prefer-assignee\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs","host":["{{baseUrl}}"],"path":["v1","runs"]},"description":"One route, two bodies, told apart by `schemaVersion`. **2** — `RunStart`: the reporter (`@plune-ai/playwright`, `plune run start`, `plune run import`) opens a run or joins the live one that already carries this `externalKey`, then streams results to `POST /v1/runs/{id}/results` and closes it with `POST /v1/runs/{id}/events`. **1** — `RunResult`: `plune sync` uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. `meta.ciUrl` must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of `meta`. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, `plune sync`, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its `externalKey` opens none, spends nothing and is never refused for it. **Retrying a schemaVersion 1 upload** (#1534, ADR 0053): send an `Idempotency-Key` header (1 to 128 of letters, digits and `. _ : ~ -`; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (`id`, `linked`, `unlinked`) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its `externalKey` is the same idea."},"response":[]},{"name":"Start a run, or ingest a finished eval run — schemaVersion 1 — a finished eval run, in one piece","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 1,\n  \"plune_version\": \"0.6.0\",\n  \"started_at\": \"2026-08-30T09:12:04.000Z\",\n  \"finished_at\": \"2026-08-30T09:12:41.522Z\",\n  \"config_hash\": \"a3f1c9\",\n  \"summary\": {\n    \"total\": 12,\n    \"passed\": 11,\n    \"failed\": 1,\n    \"errored\": 0,\n    \"cost_usd\": 0.0417,\n    \"duration_ms\": 37522\n  },\n  \"evals\": []\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs","host":["{{baseUrl}}"],"path":["v1","runs"]},"description":"One route, two bodies, told apart by `schemaVersion`. **2** — `RunStart`: the reporter (`@plune-ai/playwright`, `plune run start`, `plune run import`) opens a run or joins the live one that already carries this `externalKey`, then streams results to `POST /v1/runs/{id}/results` and closes it with `POST /v1/runs/{id}/events`. **1** — `RunResult`: `plune sync` uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. `meta.ciUrl` must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of `meta`. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, `plune sync`, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its `externalKey` opens none, spends nothing and is never refused for it. **Retrying a schemaVersion 1 upload** (#1534, ADR 0053): send an `Idempotency-Key` header (1 to 128 of letters, digits and `. _ : ~ -`; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (`id`, `linked`, `unlinked`) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its `externalKey` is the same idea."},"response":[]},{"name":"Read one of your runs in full","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id","host":["{{baseUrl}}"],"path":["v1","runs",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The stored `RunResult`, byte for byte — the shape `plune sync` uploaded, which is why nothing is ever added to it: that round trip is the CLI contract. The run’s own facts (title, environment, `notRun`, the frozen counts) live on `GET /v1/runs/{id}/summary`, and its failure groups on `GET /v1/runs/{id}/failure-groups`; the run page reads all three (`api.ts` → `getRun`)."},"response":[]},{"name":"Change what an open run declares, or what any run is for","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\",\n    \"nightly\"\n  ],\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id","host":["{{baseUrl}}"],"path":["v1","runs",":id"],"variable":[{"key":"id","value":""}]},"description":"Only while the run is open. Changing what a finished run EXPECTED would change its `notRun` after the fact, and the summary is frozen precisely so that cannot happen. The one exception is `description` (#1232): it moves no number, so a body that holds nothing else is taken by a finished or terminated run too. `custom` is not an exception (#1764): on a closed run it is a 409, alone or with `description`. An owner or a member writes it; a reader is refused with 403."},"response":[{"name":"200 — The updated run","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\",\n    \"nightly\"\n  ],\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id","host":["{{baseUrl}}"],"path":["v1","runs",":id"],"variable":[{"key":"id","value":""}]},"description":"Only while the run is open. Changing what a finished run EXPECTED would change its `notRun` after the fact, and the summary is frozen precisely so that cannot happen. The one exception is `description` (#1232): it moves no number, so a body that holds nothing else is taken by a finished or terminated run too. `custom` is not an exception (#1764): on a closed run it is a 409, alone or with `description`. An owner or a member writes it; a reader is refused with 403."},"status":"The updated run","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"run\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"projectId\": \"u-shop::default\",\n    \"kind\": \"automated\",\n    \"externalKey\": \"gh-35003177826\",\n    \"statusEvent\": \"launched\",\n    \"configuration\": {\n      \"full\": true,\n      \"expected\": [\n        {\n          \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n        },\n        {\n          \"externalKey\": {\n            \"kind\": \"path-title\",\n            \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n          }\n        }\n      ]\n    },\n    \"meta\": {\n      \"sha\": \"3e31591\",\n      \"branch\": \"master\",\n      \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n      \"runner\": \"playwright\"\n    },\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": null,\n    \"terminateReason\": null,\n    \"summary\": null,\n    \"title\": \"plune · 2026-09-16 09:12 · ci\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"plune\",\n      \"vitest\",\n      \"ci\",\n      \"nightly\"\n    ],\n    \"description\": null,\n    \"createdBy\": \"u-shop\"\n  }\n}"},{"name":"400 — unknown field, invalid configuration, or a `meta.ciUrl` that is not an http or https address","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"plune · 2026-09-16 09:12 · ci\",\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"plune\",\n    \"vitest\",\n    \"ci\",\n    \"nightly\"\n  ],\n  \"meta\": {\n    \"sha\": \"3e31591\",\n    \"branch\": \"master\",\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"runner\": \"playwright\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id","host":["{{baseUrl}}"],"path":["v1","runs",":id"],"variable":[{"key":"id","value":""}]},"description":"Only while the run is open. Changing what a finished run EXPECTED would change its `notRun` after the fact, and the summary is frozen precisely so that cannot happen. The one exception is `description` (#1232): it moves no number, so a body that holds nothing else is taken by a finished or terminated run too. `custom` is not an exception (#1764): on a closed run it is a 409, alone or with `description`. An owner or a member writes it; a reader is refused with 403."},"status":"unknown field, invalid configuration, or a `meta.ciUrl` that is not an http or https address","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — meta.ciUrl: must be an http or https address\"\n}"}]},{"name":"Delete a run, and everything it produced","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id","host":["{{baseUrl}}"],"path":["v1","runs",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Soft. The run and the review-queue entries it produced stop being readable at once; the rows are purged for good 30 days later. Results are not listed separately because they are only ever read through their run. Deleting is the account owner’s own business and takes no confirmation; putting it back is not theirs to do — see `POST /v1/runs/{id}/restore`. A second DELETE answers 404 rather than 204, so a typo in the id cannot read as a success. The run’s keys go free with it: the same Cairn report sent again, or a start with the same `externalKey`, makes a new run."},"response":[]},{"name":"The run’s cases, as a plan lists its own","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases?status=","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases"],"query":[{"key":"status","value":"","disabled":true,"description":"Only the cases whose latest result is counted under one of these — `passed`, `failed`, `broken`, `blocked`, `skipped` or `muted`: a comma-separated list, or the key repeated. None named is every case, run or not; a word that is not one of them is a 400."}],"variable":[{"key":"id","value":""}]},"description":"Every case the run expected (`configuration.expected`, cases added with «Add tests» among them) or holds a result of — in that order, no duplicates, cases in the Bin left out — each with its title, type and keys: the list `plune run grep <run>` joins into one `--grep`, as `plune plan grep` does a plan’s (plune-ai/cli#85). `status` keeps the cases whose latest result here is counted under one of the statuses named, as the run’s counters count: a failure of a muted case is `muted`, not `failed` (#1247), and an earlier failure followed by a pass is a pass. `failed,broken` is what «Re-run N failed» takes. A case nobody got to has no status, so a `status` filter leaves it out."},"response":[{"name":"200 — The cases","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases?status=","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases"],"query":[{"key":"status","value":"","disabled":true,"description":"Only the cases whose latest result is counted under one of these — `passed`, `failed`, `broken`, `blocked`, `skipped` or `muted`: a comma-separated list, or the key repeated. None named is every case, run or not; a word that is not one of them is a 400."}],"variable":[{"key":"id","value":""}]},"description":"Every case the run expected (`configuration.expected`, cases added with «Add tests» among them) or holds a result of — in that order, no duplicates, cases in the Bin left out — each with its title, type and keys: the list `plune run grep <run>` joins into one `--grep`, as `plune plan grep` does a plan’s (plune-ai/cli#85). `status` keeps the cases whose latest result here is counted under one of the statuses named, as the run’s counters count: a failure of a muted case is `muted`, not `failed` (#1247), and an earlier failure followed by a pass is a pass. `failed,broken` is what «Re-run N failed» takes. A case nobody got to has no status, so a `status` filter leaves it out."},"status":"The cases","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"cases\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"title\": \"Checkout — empty cart @smoke @checkout\",\n      \"type\": \"automated\",\n      \"externalKeys\": [\n        {\n          \"kind\": \"path-title\",\n          \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n        }\n      ]\n    }\n  ],\n  \"count\": 1\n}"}]},{"name":"Add cases to an open manual or mixed run","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"testCaseIds\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases"],"variable":[{"key":"id","value":""}]},"description":"«Add tests» on the run page (#1233): cases from the tree, or a plan’s — the page reads the plan once and sends its cases. The run must be open (`scheduled`, `launched` or `rerun`) and manual or mixed; its `configuration.expected` grows by the cases it does not hold yet, in the order sent. A case the run already holds — expected by it, or already marked — is skipped as `in_run`: one case, one result. A case that is not in the project, or is in the Bin, is skipped as `not_found`. Two people adding the same case at once add it once. When the launch copied who runs what (`assignment: prefer-assignee`), an added case brings its assignee along. Written to the audit log as an `update` of the run. A reader is refused (403)."},"response":[{"name":"200 — What was added, and what was skipped and why","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"testCaseIds\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases"],"variable":[{"key":"id","value":""}]},"description":"«Add tests» on the run page (#1233): cases from the tree, or a plan’s — the page reads the plan once and sends its cases. The run must be open (`scheduled`, `launched` or `rerun`) and manual or mixed; its `configuration.expected` grows by the cases it does not hold yet, in the order sent. A case the run already holds — expected by it, or already marked — is skipped as `in_run`: one case, one result. A case that is not in the project, or is in the Bin, is skipped as `not_found`. Two people adding the same case at once add it once. When the launch copied who runs what (`assignment: prefer-assignee`), an added case brings its assignee along. Written to the audit log as an `update` of the run. A reader is refused (403)."},"status":"What was added, and what was skipped and why","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"added\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"skipped\": [\n    {\n      \"id\": \"3f2b9d3e-9d0c-4d3e-8b55-0a7e3a1c2b10\",\n      \"reason\": \"in_run\"\n    }\n  ]\n}"}]},{"name":"A case as this run shows it","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/content","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","content"],"variable":[{"key":"id","value":""},{"key":"caseId","value":""}]},"description":"Plune #1267. While the run holds no result of the case, the case as it is now (`frozen: false`). From the first result on, the content the case had at the `caseVersion` of that result (`frozen: true`): a later edit or revert of the case, its move to the Bin and its restore leave it as it was, and `currentVersion` above `version` says the case has moved on. A case in the Bin shows the same way. `snapshot` is `null` only when the version tested was never kept (a result from before revisions, of a case edited since). 404: not your run, or it does not hold the case."},"response":[]},{"name":"A person opens a case of a run","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/open","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","open"],"variable":[{"key":"id","value":""},{"key":"caseId","value":""}]},"description":"E10 (#1253), the switch `autoAssignee` of the project’s `executionToggles`. With it on, the first person to open a case of the run that nobody holds becomes its executor (`configuration.assignees`) - one statement, so two people opening it at once cannot both win - and a held case is never rewritten. With it off nothing is written. `assignee` is who holds the case after this (null for nobody); `claimed` is whether this call made the caller its holder. A reader is refused (403)."},"response":[{"name":"200 — Who holds the case now","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/open","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","open"],"variable":[{"key":"id","value":""},{"key":"caseId","value":""}]},"description":"E10 (#1253), the switch `autoAssignee` of the project’s `executionToggles`. With it on, the first person to open a case of the run that nobody holds becomes its executor (`configuration.assignees`) - one statement, so two people opening it at once cannot both win - and a held case is never rewritten. With it off nothing is written. `assignee` is who holds the case after this (null for nobody); `claimed` is whether this call made the caller its holder. A reader is refused (403)."},"status":"Who holds the case now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"assignee\": null,\n  \"claimed\": false\n}"}]},{"name":"What people said about this run, oldest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/comments","host":["{{baseUrl}}"],"path":["v1","runs",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). The live comments on a run, the same shape and rules as a case's: anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A run in the bin, in another project or never there answers the same 404."},"response":[{"name":"200 — The comments","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/comments","host":["{{baseUrl}}"],"path":["v1","runs",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). The live comments on a run, the same shape and rules as a case's: anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A run in the bin, in another project or never there answers the same 404."},"status":"The comments","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"comments\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a4\",\n      \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n      \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\",\n      \"createdAt\": \"2026-09-18T10:12:00.000Z\"\n    }\n  ]\n}"},{"name":"404 — no such run in this project, or it is in the bin","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/comments","host":["{{baseUrl}}"],"path":["v1","runs",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). The live comments on a run, the same shape and rules as a case's: anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A run in the bin, in another project or never there answers the same 404."},"status":"no such run in this project, or it is in the bin","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"run '6f1c2b3a-0000-4000-8000-0000000000d1' not found\"\n}"}]},{"name":"Say something about this run","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/comments","host":["{{baseUrl}}"],"path":["v1","runs",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). Plain text with its line breaks, trimmed, never empty, at most 8192 bytes; no edit, no Markdown. Audited on the run as `run/comment`, the text in the diff, and never undone from the feed. A reader may comment (#1440), recorded as its author; it cannot take one back (`DELETE` is 403 `read-only member`). Taken back with `DELETE /v1/comments/{id}`."},"response":[]},{"name":"Launch, finish or terminate a run — Close the run and freeze its summary","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"event\": \"finish\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/events","host":["{{baseUrl}}"],"path":["v1","runs",":id","events"],"variable":[{"key":"id","value":""}]},"description":"The run lifecycle. `launch` starts a run saved as `scheduled` (#1233) and sets its `startedAt` to now — on a run under way it changes nothing, and a reporter joining a scheduled run by its key starts it too. `finish` freezes the summary and closes the run to new results; `terminate` closes it with a reason and can be reopened by joining the same key again. A repeated `finish` is an idempotent success — the CI step that sends it may itself be retried. Terminating an already finished run is the one refusal: its summary may already have gated a release.\n\nA sharded run (#1601): `finish` with `shardIndex` (1-based) and `shardsExpected` (N) says «shard k of N is done» and does not close the run by itself. The platform keeps the set of distinct indexes that reported, and the run closes — once — when it holds N; the response then is the one a plain `finish` gives. Fewer than N leaves the run open and answers with the counts. A repeated index counts once, a red shard counts as reported, a shard sent after the close changes nothing, and a different N than the first shard declared is 409 `shards_mismatch`. Without these two fields `finish` is exactly what it was."},"response":[{"name":"200 — The run, its summary if it just finished, and how many cases the finish detached (`configuration.full`)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"event\": \"finish\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/events","host":["{{baseUrl}}"],"path":["v1","runs",":id","events"],"variable":[{"key":"id","value":""}]},"description":"The run lifecycle. `launch` starts a run saved as `scheduled` (#1233) and sets its `startedAt` to now — on a run under way it changes nothing, and a reporter joining a scheduled run by its key starts it too. `finish` freezes the summary and closes the run to new results; `terminate` closes it with a reason and can be reopened by joining the same key again. A repeated `finish` is an idempotent success — the CI step that sends it may itself be retried. Terminating an already finished run is the one refusal: its summary may already have gated a release.\n\nA sharded run (#1601): `finish` with `shardIndex` (1-based) and `shardsExpected` (N) says «shard k of N is done» and does not close the run by itself. The platform keeps the set of distinct indexes that reported, and the run closes — once — when it holds N; the response then is the one a plain `finish` gives. Fewer than N leaves the run open and answers with the counts. A repeated index counts once, a red shard counts as reported, a shard sent after the close changes nothing, and a different N than the first shard declared is 409 `shards_mismatch`. Without these two fields `finish` is exactly what it was."},"status":"The run, its summary if it just finished, and how many cases the finish detached (`configuration.full`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"run\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"projectId\": \"u-shop::default\",\n    \"kind\": \"automated\",\n    \"externalKey\": \"gh-35003177826\",\n    \"statusEvent\": \"finished\",\n    \"configuration\": {\n      \"full\": true,\n      \"expected\": [\n        {\n          \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n        },\n        {\n          \"externalKey\": {\n            \"kind\": \"path-title\",\n            \"value\": \"e2e/checkout.spec.ts > Checkout — coupon applied\"\n          }\n        }\n      ]\n    },\n    \"meta\": {\n      \"sha\": \"3e31591\",\n      \"branch\": \"master\",\n      \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n      \"runner\": \"playwright\"\n    },\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": \"2026-09-16T09:12:09.082Z\",\n    \"terminateReason\": null,\n    \"summary\": {\n      \"total\": 2,\n      \"counts\": {\n        \"passed\": 1,\n        \"failed\": 1\n      },\n      \"notRun\": []\n    },\n    \"title\": \"plune · 2026-09-16 09:12 · ci\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"plune\",\n      \"vitest\",\n      \"ci\"\n    ],\n    \"description\": null,\n    \"createdBy\": \"u-shop\"\n  },\n  \"summary\": {\n    \"total\": 2,\n    \"counts\": {\n      \"passed\": 1,\n      \"failed\": 1\n    },\n    \"notRun\": []\n  },\n  \"detached\": 0\n}"}]},{"name":"Launch, finish or terminate a run — Shard 2 of 4 is done (#1601): the run closes when all four have reported","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"event\": \"finish\",\n  \"shardIndex\": 2,\n  \"shardsExpected\": 4\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/events","host":["{{baseUrl}}"],"path":["v1","runs",":id","events"],"variable":[{"key":"id","value":""}]},"description":"The run lifecycle. `launch` starts a run saved as `scheduled` (#1233) and sets its `startedAt` to now — on a run under way it changes nothing, and a reporter joining a scheduled run by its key starts it too. `finish` freezes the summary and closes the run to new results; `terminate` closes it with a reason and can be reopened by joining the same key again. A repeated `finish` is an idempotent success — the CI step that sends it may itself be retried. Terminating an already finished run is the one refusal: its summary may already have gated a release.\n\nA sharded run (#1601): `finish` with `shardIndex` (1-based) and `shardsExpected` (N) says «shard k of N is done» and does not close the run by itself. The platform keeps the set of distinct indexes that reported, and the run closes — once — when it holds N; the response then is the one a plain `finish` gives. Fewer than N leaves the run open and answers with the counts. A repeated index counts once, a red shard counts as reported, a shard sent after the close changes nothing, and a different N than the first shard declared is 409 `shards_mismatch`. Without these two fields `finish` is exactly what it was."},"response":[]},{"name":"Launch, finish or terminate a run — Close it without a summary, and say why","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"event\": \"terminate\",\n  \"reason\": \"CI job cancelled by a newer push\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/events","host":["{{baseUrl}}"],"path":["v1","runs",":id","events"],"variable":[{"key":"id","value":""}]},"description":"The run lifecycle. `launch` starts a run saved as `scheduled` (#1233) and sets its `startedAt` to now — on a run under way it changes nothing, and a reporter joining a scheduled run by its key starts it too. `finish` freezes the summary and closes the run to new results; `terminate` closes it with a reason and can be reopened by joining the same key again. A repeated `finish` is an idempotent success — the CI step that sends it may itself be retried. Terminating an already finished run is the one refusal: its summary may already have gated a release.\n\nA sharded run (#1601): `finish` with `shardIndex` (1-based) and `shardsExpected` (N) says «shard k of N is done» and does not close the run by itself. The platform keeps the set of distinct indexes that reported, and the run closes — once — when it holds N; the response then is the one a plain `finish` gives. Fewer than N leaves the run open and answers with the counts. A repeated index counts once, a red shard counts as reported, a shard sent after the close changes nothing, and a different N than the first shard declared is 409 `shards_mismatch`. Without these two fields `finish` is exactly what it was."},"response":[]},{"name":"Launch, finish or terminate a run — Start a run that was saved, not launched (#1233)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"event\": \"launch\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/events","host":["{{baseUrl}}"],"path":["v1","runs",":id","events"],"variable":[{"key":"id","value":""}]},"description":"The run lifecycle. `launch` starts a run saved as `scheduled` (#1233) and sets its `startedAt` to now — on a run under way it changes nothing, and a reporter joining a scheduled run by its key starts it too. `finish` freezes the summary and closes the run to new results; `terminate` closes it with a reason and can be reopened by joining the same key again. A repeated `finish` is an idempotent success — the CI step that sends it may itself be retried. Terminating an already finished run is the one refusal: its summary may already have gated a release.\n\nA sharded run (#1601): `finish` with `shardIndex` (1-based) and `shardsExpected` (N) says «shard k of N is done» and does not close the run by itself. The platform keeps the set of distinct indexes that reported, and the run closes — once — when it holds N; the response then is the one a plain `finish` gives. Fewer than N leaves the run open and answers with the counts. A repeated index counts once, a red shard counts as reported, a shard sent after the close changes nothing, and a different N than the first shard declared is 409 `shards_mismatch`. Without these two fields `finish` is exactly what it was."},"response":[]},{"name":"A run’s report as an XLSX or PDF file","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/export?format=","host":["{{baseUrl}}"],"path":["v1","runs",":id","export"],"query":[{"key":"format","value":"","disabled":true,"description":"One of: xlsx, pdf."}],"variable":[{"key":"id","value":""}]},"description":"The run’s report as a file (#1265): `?format=xlsx` or `?format=pdf`, and no other (CSV and JSON are not offered). The workbook has three sheets: `Summary` first (#1680: title, environment, branch and commit, times, the five statuses and muted, the share passed as n / N, the first failed cases with their errors, what the file holds, the attachment-link term), then `Results` with one row a result with every column of the report, then `Attachments`; the platform’s own XLSX import reads the sheet named `Results`. The PDF opens with the same summary. A result row has: test case, suite, status, whether it is muted, sub-status, time in ms, who executed it, the error (cut at 2000 characters), the attachments and when it was recorded. Text is written as text, so a title that begins with `=` is never a formula. The PDF is A4 landscape with the same columns wrapped inside their cells and the header row on every page, in a font that has Latin, Cyrillic and Greek (CJK and emoji come out as boxes). Attachments are never embedded: each is a line `name: address` in the workbook and a link in the PDF. A file the platform keeps is linked to the dashboard page of its result (`<dashboard>/runs/<run>#result-<id>`), which opens only for a signed-in member of the project, so the file holds no secret; the link does NOT expire (AC-05 asks for a term and this is not met: a presigned link would be dead in a saved file; operator decision pending). When the server does not know the dashboard’s address such a file is named and not linked. Links a reporter attached are included as given when they are `http(s)` addresses. Up to 5 000 results the answer is the file. The platform files of a result are listed as links, never embedded (AC-05, #1643): `GET /public/files/<token>`, signed, openable without a sign-in, valid 7 days from the moment the file is made (the report says until when, in UTC, in its Summary sheet / first PDF page); the file itself is kept 24 hours, so a link outlives it. Without a server secret and a public API address the links are the result pages, which need a sign-in. Past that (AC-03) the answer is 202 with the queue’s `job` (kind `run-export`) and `fileId`: the file is built in the background, the person may leave the page, and when the job is `completed` the file is `GET /v1/run-exports/<fileId>` for 24 hours. The same run in the same format already waiting or done answers with that job, not a second; a project holds at most 3 exports (and 256 MiB of finished ones) at a time, past which the answer is 429. A run with more than 100 000 results (XLSX) or 20 000 (PDF) is refused with 413. Every export is written to the audit log (entity `run`, action `event`, `export` with the format and the count). The project’s owner only."},"response":[]},{"name":"The few causes behind a run’s failures","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/failure-groups","host":["{{baseUrl}}"],"path":["v1","runs",":id","failure-groups"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A separate path rather than a field on the run: `GET /v1/runs/{id}` returns the CLI’s RunResult verbatim, and that round trip is the sync contract."},"response":[]},{"name":"Is the run’s public report on","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/public-report","host":["{{baseUrl}}"],"path":["v1","runs",":id","public-report"],"variable":[{"key":"id","value":""}]},"description":"The state of the run’s public report (#1263): `{ enabled: false }`, or `{ enabled: true, createdAt, createdBy, expiresAt, expired, passwordProtected }`. Never the token, the password or its hash, or any piece of it: the server keeps only a hash of it, and the link is shown once, in the answer to `PUT` that made it. Every member of the project reads this, a reader too."},"response":[]},{"name":"Turn the run’s public report on (the link is shown once)","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"password\": \"correct horse battery staple\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/public-report","host":["{{baseUrl}}"],"path":["v1","runs",":id","public-report"],"variable":[{"key":"id","value":""}]},"description":"Makes the public link of the run (#1263), `GET /public/report/<token>`, readable by anyone who holds it, with no account. The token is 256 random bits and is in the 201 answer ONCE: only its SHA-256 is stored, so nothing can show it again, and the owner must copy the link then. The body is optional: `{ expiresAt?, password? }` (#1264). A link always ends: `expiresAt` (ISO 8601 with an offset) is at least an hour and at most 30 days from now, and when it is left out the link lives 7 days. `password` is optional, 8 to 128 printable ASCII characters; it is checked on the server, and only a salted scrypt hash of it is stored. A run that already has a LIVE link answers 200 with the state only (`enabled`, `createdAt`, `createdBy`, `expiresAt`, `expired`, `passwordProtected`, `lockedUntil`) and never the token; the link in hand keeps working. Sending a date or a password with it is refused, 409 `link_exists`, not dropped: revoke first. The body is at most 4096 bytes (413 past that). A link that has EXPIRED is replaced: 201 with a new token. To change a date or a password, or to get a new link early, revoke (`DELETE`) and make one again: the old address is dead from the revoke on. Written to the audit log as `public_report` `create` (`update` when it replaced an expired link) with the date and whether a password is set, without the token, the password or any hash. The project’s owner only: a member is refused with 403 `project owner only`, a reader with 403 `read-only member`."},"response":[]},{"name":"Revoke the run’s public report","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/public-report","host":["{{baseUrl}}"],"path":["v1","runs",":id","public-report"],"variable":[{"key":"id","value":""}]},"description":"Deletes the link (#1263): from this call on the old address answers 404, exactly as an address that never existed does. This is also how an owner gets a new link: revoke, then `PUT` again. 204 whether there was a link or not. Written to the audit log as `public_report` `delete` when there was one. The project’s owner only."},"response":[]},{"name":"Launch a manual or mixed run again: its failures, or a copy","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"all\",\n  \"title\": \"Release 4.12 regression · copy\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/relaunch","host":["{{baseUrl}}"],"path":["v1","runs",":id","relaunch"],"variable":[{"key":"id","value":""}]},"description":"A new manual run made from this one (#1232) — «Re-run N failed» and «Launch a copy» on the run page. `cases: failed` takes the cases whose latest result here failed or broke, the whole run whatever a filter shows, each assigned to whoever marked it, else to the run’s own assignee. `cases: all` takes every case the run expected or holds a result of, assigned as the run was. `statuses` (#1768) clones the run with a filter instead: any combination of `passed`, `failed`, `broken`, `blocked`, `skipped` and `muted`, the cases whose latest result here is counted under one of them; the new run is called «… · clone». Give exactly one of `cases` and `statuses`. Cases in the Bin since are left out; no result comes along, so the new run starts unmarked. It is called `title` when the body names one, else after the source — «… · re-run» or «… · copy» — and keeps its environment, labels and custom field values (#1764, copied as they stand, so a field made required since does not stop it), not its description. Only a manual or a mixed run: an automated run is re-run where its tests live (`plune run grep <id> --failed` names its failures), so it is refused with 409, an eval run too. A new run spends the daily quota like every other way to create one (see `POST /v1/runs`). The source run’s history gets an `event` naming the run it became. A reader is refused (403)."},"response":[{"name":"201 — The new run — open it to walk its cases","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"all\",\n  \"title\": \"Release 4.12 regression · copy\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/relaunch","host":["{{baseUrl}}"],"path":["v1","runs",":id","relaunch"],"variable":[{"key":"id","value":""}]},"description":"A new manual run made from this one (#1232) — «Re-run N failed» and «Launch a copy» on the run page. `cases: failed` takes the cases whose latest result here failed or broke, the whole run whatever a filter shows, each assigned to whoever marked it, else to the run’s own assignee. `cases: all` takes every case the run expected or holds a result of, assigned as the run was. `statuses` (#1768) clones the run with a filter instead: any combination of `passed`, `failed`, `broken`, `blocked`, `skipped` and `muted`, the cases whose latest result here is counted under one of them; the new run is called «… · clone». Give exactly one of `cases` and `statuses`. Cases in the Bin since are left out; no result comes along, so the new run starts unmarked. It is called `title` when the body names one, else after the source — «… · re-run» or «… · copy» — and keeps its environment, labels and custom field values (#1764, copied as they stand, so a field made required since does not stop it), not its description. Only a manual or a mixed run: an automated run is re-run where its tests live (`plune run grep <id> --failed` names its failures), so it is refused with 409, an eval run too. A new run spends the daily quota like every other way to create one (see `POST /v1/runs`). The source run’s history gets an `event` naming the run it became. A reader is refused (403)."},"status":"The new run — open it to walk its cases","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"run\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"projectId\": \"u-shop::default\",\n    \"kind\": \"manual\",\n    \"externalKey\": null,\n    \"statusEvent\": \"launched\",\n    \"configuration\": {\n      \"expected\": [\n        {\n          \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n        }\n      ],\n      \"assignees\": {\n        \"6f1c2b3a-0000-4000-8000-0000000000c1\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n      }\n    },\n    \"meta\": null,\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": null,\n    \"terminateReason\": null,\n    \"summary\": null,\n    \"title\": \"Release 4.12 regression · copy\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"release\"\n    ],\n    \"description\": null,\n    \"createdBy\": \"u-shop\"\n  }\n}"},{"name":"409 — an automated or eval run, or nothing to launch: no case failed or broke, no case stands in the `statuses` asked for, or the run holds no case","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"cases\": \"all\",\n  \"title\": \"Release 4.12 regression · copy\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/relaunch","host":["{{baseUrl}}"],"path":["v1","runs",":id","relaunch"],"variable":[{"key":"id","value":""}]},"description":"A new manual run made from this one (#1232) — «Re-run N failed» and «Launch a copy» on the run page. `cases: failed` takes the cases whose latest result here failed or broke, the whole run whatever a filter shows, each assigned to whoever marked it, else to the run’s own assignee. `cases: all` takes every case the run expected or holds a result of, assigned as the run was. `statuses` (#1768) clones the run with a filter instead: any combination of `passed`, `failed`, `broken`, `blocked`, `skipped` and `muted`, the cases whose latest result here is counted under one of them; the new run is called «… · clone». Give exactly one of `cases` and `statuses`. Cases in the Bin since are left out; no result comes along, so the new run starts unmarked. It is called `title` when the body names one, else after the source — «… · re-run» or «… · copy» — and keeps its environment, labels and custom field values (#1764, copied as they stand, so a field made required since does not stop it), not its description. Only a manual or a mixed run: an automated run is re-run where its tests live (`plune run grep <id> --failed` names its failures), so it is refused with 409, an eval run too. A new run spends the daily quota like every other way to create one (see `POST /v1/runs`). The source run’s history gets an `event` naming the run it became. A reader is refused (403)."},"status":"an automated or eval run, or nothing to launch: no case failed or broke, no case stands in the `statuses` asked for, or the run holds no case","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"nothing failed or broke in run '6f1c2b3a-0000-4000-8000-0000000000d1' — nothing to re-run\"\n}"}]},{"name":"Put a deleted run back","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/restore","host":["{{baseUrl}}"],"path":["v1","runs",":id","restore"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Not the same thing as `POST /v1/audit/{id}/restore`, which rolls back an EDIT to a record that never went away. This resurrects a deleted run together with the queue entries removed with it, and exactly those — anything deleted afterwards stays deleted. For the account that owns the run (Settings → Bin is their undo) and for the owner of the deployment in any project; everyone else gets 404, the same 404 an unknown id gets. A run’s Cairn report key and `externalKey` are unique among live runs: when the report was sent again, or the job started again, after this run was deleted, a live run holds the key now, and this one is not put back beside it — 409 naming that run; delete it first, then restore this one. `GET /v1/bin` lists what can be brought back."},"response":[]},{"name":"The results of a run, newest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/results?limit=100&before=&defects=&status=&muted=&q=&subStatus=","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"query":[{"key":"limit","value":"100","disabled":true,"description":"At most this many results in the answer: 100 when absent, 1000 at the most (#1380)."},{"key":"before","value":"","disabled":true,"description":"The `next` of a previous answer: continue after it."},{"key":"defects","value":"","disabled":true,"description":"`1`: only the results that carry at least one defect link (#1064). Anything else is the whole run. One of: 1."},{"key":"status","value":"","disabled":true,"description":"Only the results with one of these statuses (#1232): a comma-separated list, or the key repeated. None named is every status; a word that is not a status is a 400."},{"key":"muted","value":"","disabled":true,"description":"Reads `status` the way the run’s counters do (#1247): a muted failure — the `failed` or `broken` result of a case that was muted when it was written (`muted: true`) — is counted as `muted`, not under its status. `true`: the muted failures, beside the statuses named, or alone when none is; `false`: the statuses named without them, or every result but them. Without it, `status` is the result’s own and finds a muted failure too. Anything but `true` or `false` is a 400. One of: true, false."},{"key":"q","value":"","disabled":true,"description":"Only the results whose case’s name holds this text (#1232) — any part of it, in any case; `%` and `_` are letters here. Trimmed; blank is no filter."},{"key":"subStatus","value":"","disabled":true,"description":"Only the results marked with one of these sub-statuses (#1231), by the name a result keeps — the key repeated for several, never a comma (a name may hold one). A name the settings no longer offer still finds the results marked with it. Narrows `status` as the other filters do."}],"variable":[{"key":"id","value":""}]},"description":"The result list of the run page (`api.ts` → `listRunResults`), a page at a time: the results the run holds, with their status, outcome, execution and error text, whatever the state of the case they point at — a result is evidence of the run and stays listed when its case is later deleted. Paged (#1380): without `limit` the answer is the newest 100 results, and `next` while more remain — the `before` of the call for the page after it. `limit` asks for another size, up to 1000; past that it is a 400. A page also stops early, with `next`, once its error text and failure details pass 64 Mi characters (#814). A caller that wants the whole run follows `next` until it is absent — the length of one answer is never the size of the run, which `GET /v1/runs/{id}/summary` counts. Newest first throughout. Each result carries its `failure` in this same answer, so the page opens the detail without another request, and every `errorContext` is sanitized on the way out — one stored before the rules existed included. The run report (#1232) narrows the list here, on the server: `status`, `q` and `defects` each narrow what the others left, and every result carries its case’s `suitePath`, which its Suites view groups by."},"response":[{"name":"200 — Results of the run","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/results?limit=100&before=&defects=&status=&muted=&q=&subStatus=","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"query":[{"key":"limit","value":"100","disabled":true,"description":"At most this many results in the answer: 100 when absent, 1000 at the most (#1380)."},{"key":"before","value":"","disabled":true,"description":"The `next` of a previous answer: continue after it."},{"key":"defects","value":"","disabled":true,"description":"`1`: only the results that carry at least one defect link (#1064). Anything else is the whole run. One of: 1."},{"key":"status","value":"","disabled":true,"description":"Only the results with one of these statuses (#1232): a comma-separated list, or the key repeated. None named is every status; a word that is not a status is a 400."},{"key":"muted","value":"","disabled":true,"description":"Reads `status` the way the run’s counters do (#1247): a muted failure — the `failed` or `broken` result of a case that was muted when it was written (`muted: true`) — is counted as `muted`, not under its status. `true`: the muted failures, beside the statuses named, or alone when none is; `false`: the statuses named without them, or every result but them. Without it, `status` is the result’s own and finds a muted failure too. Anything but `true` or `false` is a 400. One of: true, false."},{"key":"q","value":"","disabled":true,"description":"Only the results whose case’s name holds this text (#1232) — any part of it, in any case; `%` and `_` are letters here. Trimmed; blank is no filter."},{"key":"subStatus","value":"","disabled":true,"description":"Only the results marked with one of these sub-statuses (#1231), by the name a result keeps — the key repeated for several, never a comma (a name may hold one). A name the settings no longer offer still finds the results marked with it. Narrows `status` as the other filters do."}],"variable":[{"key":"id","value":""}]},"description":"The result list of the run page (`api.ts` → `listRunResults`), a page at a time: the results the run holds, with their status, outcome, execution and error text, whatever the state of the case they point at — a result is evidence of the run and stays listed when its case is later deleted. Paged (#1380): without `limit` the answer is the newest 100 results, and `next` while more remain — the `before` of the call for the page after it. `limit` asks for another size, up to 1000; past that it is a 400. A page also stops early, with `next`, once its error text and failure details pass 64 Mi characters (#814). A caller that wants the whole run follows `next` until it is absent — the length of one answer is never the size of the run, which `GET /v1/runs/{id}/summary` counts. Newest first throughout. Each result carries its `failure` in this same answer, so the page opens the detail without another request, and every `errorContext` is sanitized on the way out — one stored before the rules existed included. The run report (#1232) narrows the list here, on the server: `status`, `q` and `defects` each narrow what the others left, and every result carries its case’s `suitePath`, which its Suites view groups by."},"status":"Results of the run","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"results\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e2\",\n      \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"signature\": \"2c624232cdd221771294dfbb310aca000a0df6ac8b66b696d90ef06fdefb64a3\",\n      \"status\": \"failed\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"expectedStatus\": \"passed\",\n      \"outcome\": \"unexpected\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"durationMs\": 3621,\n      \"caseVersion\": 1,\n      \"muted\": false,\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"errorContextMeta\": {\n        \"redacted\": 0,\n        \"truncated\": false\n      },\n      \"assertions\": [],\n      \"steps\": null,\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      },\n      \"executedBy\": null,\n      \"subStatus\": null,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"defects\": [\n        {\n          \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a8\",\n          \"url\": \"https://tracker.example.com/browse/PLN-482\",\n          \"label\": \"Coupon is not applied on an empty cart\",\n          \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n          \"createdAt\": \"2026-09-16T10:30:00.000Z\"\n        }\n      ],\n      \"suitePath\": [\n        \"e2e/checkout.spec.ts\"\n      ]\n    },\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"signature\": \"7f974c61f8f10065cd8ae6993cc069e4f85b1db1ed008b965885fb6f945a28c9\",\n      \"status\": \"passed\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"expectedStatus\": \"passed\",\n      \"outcome\": \"expected\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"durationMs\": 1310,\n      \"caseVersion\": 1,\n      \"muted\": false,\n      \"errorContext\": null,\n      \"errorContextMeta\": null,\n      \"assertions\": [],\n      \"steps\": null,\n      \"failure\": null,\n      \"executedBy\": null,\n      \"subStatus\": null,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"suitePath\": [\n        \"e2e/checkout.spec.ts\"\n      ]\n    }\n  ]\n}"},{"name":"400 — `limit` is not an integer from 1 to 1000, `before` is not a `next` of this list, or `status` names a word that is not a status","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/results?limit=100&before=&defects=&status=&muted=&q=&subStatus=","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"query":[{"key":"limit","value":"100","disabled":true,"description":"At most this many results in the answer: 100 when absent, 1000 at the most (#1380)."},{"key":"before","value":"","disabled":true,"description":"The `next` of a previous answer: continue after it."},{"key":"defects","value":"","disabled":true,"description":"`1`: only the results that carry at least one defect link (#1064). Anything else is the whole run. One of: 1."},{"key":"status","value":"","disabled":true,"description":"Only the results with one of these statuses (#1232): a comma-separated list, or the key repeated. None named is every status; a word that is not a status is a 400."},{"key":"muted","value":"","disabled":true,"description":"Reads `status` the way the run’s counters do (#1247): a muted failure — the `failed` or `broken` result of a case that was muted when it was written (`muted: true`) — is counted as `muted`, not under its status. `true`: the muted failures, beside the statuses named, or alone when none is; `false`: the statuses named without them, or every result but them. Without it, `status` is the result’s own and finds a muted failure too. Anything but `true` or `false` is a 400. One of: true, false."},{"key":"q","value":"","disabled":true,"description":"Only the results whose case’s name holds this text (#1232) — any part of it, in any case; `%` and `_` are letters here. Trimmed; blank is no filter."},{"key":"subStatus","value":"","disabled":true,"description":"Only the results marked with one of these sub-statuses (#1231), by the name a result keeps — the key repeated for several, never a comma (a name may hold one). A name the settings no longer offer still finds the results marked with it. Narrows `status` as the other filters do."}],"variable":[{"key":"id","value":""}]},"description":"The result list of the run page (`api.ts` → `listRunResults`), a page at a time: the results the run holds, with their status, outcome, execution and error text, whatever the state of the case they point at — a result is evidence of the run and stays listed when its case is later deleted. Paged (#1380): without `limit` the answer is the newest 100 results, and `next` while more remain — the `before` of the call for the page after it. `limit` asks for another size, up to 1000; past that it is a 400. A page also stops early, with `next`, once its error text and failure details pass 64 Mi characters (#814). A caller that wants the whole run follows `next` until it is absent — the length of one answer is never the size of the run, which `GET /v1/runs/{id}/summary` counts. Newest first throughout. Each result carries its `failure` in this same answer, so the page opens the detail without another request, and every `errorContext` is sanitized on the way out — one stored before the rules existed included. The run report (#1232) narrows the list here, on the server: `status`, `q` and `defects` each narrow what the others left, and every result carries its case’s `suitePath`, which its Suites view groups by."},"status":"`limit` is not an integer from 1 to 1000, `before` is not a `next` of this list, or `status` names a word that is not a status","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"`limit` must be an integer from 1 to 1000\"\n}"}]},{"name":"Submit a batch of results to a run — A reporter’s batch — a pass, and a failure with its detail","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"params\": {\n        \"project\": \"chromium\"\n      }\n    },\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"params\": {\n        \"project\": \"firefox\"\n      },\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      }\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"response":[{"name":"200 — Per-element verdicts and counts","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"params\": {\n        \"project\": \"chromium\"\n      }\n    },\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"params\": {\n        \"project\": \"firefox\"\n      },\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      }\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"status":"Per-element verdicts and counts","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"index\": 0,\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"status\": \"accepted\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e1\"\n    },\n    {\n      \"index\": 1,\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"status\": \"accepted\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e2\"\n    }\n  ],\n  \"counts\": {\n    \"accepted\": 2,\n    \"duplicate\": 0,\n    \"conflict\": 0,\n    \"rejected\": 0\n  }\n}"},{"name":"400 — The line of the place is not a positive integer","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"params\": {\n        \"project\": \"chromium\"\n      }\n    },\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"params\": {\n        \"project\": \"firefox\"\n      },\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      }\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"status":"body is not a batch of submittable results — the whole batch is refused and nothing of it is stored","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — results.1.failure.location.line: Too small: expected number to be >0\"\n}"},{"name":"400 — The file is a path of the machine, not from the repository root","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"params\": {\n        \"project\": \"chromium\"\n      }\n    },\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"params\": {\n        \"project\": \"firefox\"\n      },\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      }\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"status":"body is not a batch of submittable results — the whole batch is refused and nothing of it is stored","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — results.1.failure.location.file: must be a path from the repository root — no absolute path, drive, scheme, ~ or ..\"\n}"},{"name":"400 — The link to the artifacts is not a web address","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"params\": {\n        \"project\": \"chromium\"\n      }\n    },\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"params\": {\n        \"project\": \"firefox\"\n      },\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      }\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"status":"body is not a batch of submittable results — the whole batch is refused and nothing of it is stored","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — results.1.failure.ciUrl: must be an http or https address\"\n}"},{"name":"413 — over 10485760 bytes — refused before anything reads the body","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — empty cart::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"passed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:04.000Z\",\n        \"finishedAt\": \"2026-09-16T09:12:05.310Z\",\n        \"durationMs\": 1310,\n        \"retry\": 0,\n        \"worker\": \"chromium-1\"\n      },\n      \"params\": {\n        \"project\": \"chromium\"\n      }\n    },\n    {\n      \"resultKey\": \"e2e/checkout.spec.ts::Checkout — coupon applied::chromium\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"rawStatus\": \"failed\",\n      \"source\": \"playwright\",\n      \"execution\": {\n        \"startedAt\": \"2026-09-16T09:12:05.400Z\",\n        \"finishedAt\": \"2026-09-16T09:12:09.021Z\",\n        \"durationMs\": 3621,\n        \"retry\": 1,\n        \"worker\": \"chromium-1\"\n      },\n      \"errorContext\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\\n  Expected: \\\"−10 %\\\"\\n  Received: \\\"0 %\\\"\\n    at e2e/checkout.spec.ts:41:31\",\n      \"params\": {\n        \"project\": \"firefox\"\n      },\n      \"failure\": {\n        \"headline\": \"Error: expect(locator).toHaveText(\\\"−10 %\\\")\",\n        \"steps\": [\n          \"Pay\",\n          \"Apply the coupon\"\n        ],\n        \"location\": {\n          \"file\": \"e2e/checkout.spec.ts\",\n          \"line\": 41,\n          \"column\": 31\n        },\n        \"artifacts\": [\n          {\n            \"name\": \"screenshot\",\n            \"contentType\": \"image/png\"\n          },\n          {\n            \"name\": \"error-context\",\n            \"contentType\": \"text/markdown\"\n          },\n          {\n            \"name\": \"trace\",\n            \"contentType\": \"application/zip\"\n          }\n        ],\n        \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\"\n      }\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"status":"over 10485760 bytes — refused before anything reads the body","code":413,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"request body too large — this route accepts at most 10485760 bytes\"\n}"}]},{"name":"Submit a batch of results to a run — A person’s mark (D34) — one case, step by step","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"results\": [\n    {\n      \"resultKey\": \"manual:6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"status\": \"failed\",\n      \"source\": \"manual\",\n      \"errorContext\": \"Step 3: the coupon field accepted an expired code.\",\n      \"steps\": [\n        {\n          \"index\": 0,\n          \"status\": \"passed\"\n        },\n        {\n          \"index\": 1,\n          \"status\": \"passed\"\n        },\n        {\n          \"index\": 2,\n          \"status\": \"failed\"\n        }\n      ]\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/results","host":["{{baseUrl}}"],"path":["v1","runs",":id","results"],"variable":[{"key":"id","value":""}]},"description":"Idempotent by `resultKey`, which is unique within the run. A repeated batch answers `duplicate` for every element and changes nothing; an element whose key is already held with DIFFERENT content answers `conflict` and names the result that holds it — a different `failure` included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.\n\nA failed attempt may carry `failure` (`FailureDetailSubmission`). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of `failure` are stored without terminal colour codes, with credentials replaced by `[redacted:<kind>]` markers, and only then shortened (`FailureDetail`). The route takes up to 10485760 bytes, like `POST /v1/runs`, so a batch of long failures arrives whole."},"response":[]},{"name":"Retest cases in place: reset their result, keep the original in history","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"testCaseIds\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/retest","host":["{{baseUrl}}"],"path":["v1","runs",":id","retest"],"variable":[{"key":"id","value":""}]},"description":"Retest inside a run (#1767). The current results of the named cases in this run are replaced: each old result keeps its row, so it stays in the case’s history (`GET /v1/results/{id}/history`), and drops out of the run’s counters and result list. Until a new result arrives the case is «awaiting retest» — a state of its own: it is in no counter (not `passed`, not `failed`, not `muted`), not in `total`, and not in `notRun` (it did run); `GET /v1/runs/{id}/summary` lists it under `awaitingRetest`. The run is not finished until every such case has a new result: `finish` answers 409 `retest_pending` naming them, while `terminate` still works. The result key of the replaced result is freed, so a reporter that sends the same key again, or a person who marks the case again, writes a new result. A case with no current result in the run is skipped with `no_result`, one already waiting with `awaiting`. Only a run under way: a finished, terminated or scheduled run and an eval run are 409. A reader is refused (403)."},"response":[{"name":"200 — The cases now awaiting retest, and the ones left out with why","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"testCaseIds\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/retest","host":["{{baseUrl}}"],"path":["v1","runs",":id","retest"],"variable":[{"key":"id","value":""}]},"description":"Retest inside a run (#1767). The current results of the named cases in this run are replaced: each old result keeps its row, so it stays in the case’s history (`GET /v1/results/{id}/history`), and drops out of the run’s counters and result list. Until a new result arrives the case is «awaiting retest» — a state of its own: it is in no counter (not `passed`, not `failed`, not `muted`), not in `total`, and not in `notRun` (it did run); `GET /v1/runs/{id}/summary` lists it under `awaitingRetest`. The run is not finished until every such case has a new result: `finish` answers 409 `retest_pending` naming them, while `terminate` still works. The result key of the replaced result is freed, so a reporter that sends the same key again, or a person who marks the case again, writes a new result. A case with no current result in the run is skipped with `no_result`, one already waiting with `awaiting`. Only a run under way: a finished, terminated or scheduled run and an eval run are 409. A reader is refused (403)."},"status":"The cases now awaiting retest, and the ones left out with why","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"retested\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"skipped\": []\n}"}]},{"name":"The five status counters of one run","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/summary","host":["{{baseUrl}}"],"path":["v1","runs",":id","summary"],"variable":[{"key":"id","value":""}]},"description":"Every status appears, including the ones at zero. \"unmapped\" counts results whose raw status the project map does not cover TODAY — the stored status of a result never changes, but whether the map has a hole in it does. \"discovered\" counts the tests this run turned up that have no case here yet and are still pending in the review queue: on a project's FIRST run every result is diverted there instead of being written, so five zeros beside a non-zero \"discovered\" is a healthy first run, not an empty one. \"waiting\" counts the same pending tests in the whole project's queue, whichever run found them: a later run that sends the same tests finds them already there, so its own \"discovered\" is zero while \"waiting\" is not. \"counts.muted\" sits beside the five: A failed or broken result of a muted case is counted as `muted`, not as a failure; a finished run never changes (#1247)."},"response":[{"name":"200 — Counters, the results nobody has mapped, and the tests still waiting in review","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/summary","host":["{{baseUrl}}"],"path":["v1","runs",":id","summary"],"variable":[{"key":"id","value":""}]},"description":"Every status appears, including the ones at zero. \"unmapped\" counts results whose raw status the project map does not cover TODAY — the stored status of a result never changes, but whether the map has a hole in it does. \"discovered\" counts the tests this run turned up that have no case here yet and are still pending in the review queue: on a project's FIRST run every result is diverted there instead of being written, so five zeros beside a non-zero \"discovered\" is a healthy first run, not an empty one. \"waiting\" counts the same pending tests in the whole project's queue, whichever run found them: a later run that sends the same tests finds them already there, so its own \"discovered\" is zero while \"waiting\" is not. \"counts.muted\" sits beside the five: A failed or broken result of a muted case is counted as `muted`, not as a failure; a finished run never changes (#1247)."},"status":"Counters, the results nobody has mapped, and the tests still waiting in review","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"counts\": {\n    \"passed\": 1,\n    \"failed\": 1,\n    \"broken\": 0,\n    \"blocked\": 0,\n    \"skipped\": 0,\n    \"muted\": 0\n  },\n  \"unmapped\": 0,\n  \"discovered\": 0,\n  \"hasBody\": false,\n  \"withDefect\": 1,\n  \"subStatuses\": [\n    {\n      \"status\": \"failed\",\n      \"name\": \"Product bug\",\n      \"count\": 1\n    }\n  ],\n  \"run\": {\n    \"kind\": \"automated\",\n    \"statusEvent\": \"finished\",\n    \"running\": false,\n    \"externalKey\": \"gh-35003177826\",\n    \"title\": \"plune · 2026-09-16 09:12 · ci\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"plune\",\n      \"vitest\",\n      \"ci\"\n    ],\n    \"description\": null,\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": \"2026-09-16T09:12:09.082Z\",\n    \"terminateReason\": null,\n    \"createdBy\": \"u-shop\",\n    \"assignees\": null,\n    \"ciUrl\": \"https://github.com/plune-ai/plune/actions/runs/35003177826\",\n    \"branch\": \"master\",\n    \"sha\": \"3e31591\",\n    \"runner\": \"playwright\",\n    \"planId\": null\n  },\n  \"notRun\": [],\n  \"awaitingRetest\": []\n}"}]},{"name":"Delete several runs at once","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n  ],\n  \"sync\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/bulk-delete","host":["{{baseUrl}}"],"path":["v1","runs","bulk-delete"]},"description":"#1423: the server deletes the runs as a job. The answer is 202 with the job (`kind: runs-delete`) — `progress` counts the runs named, `total` how many — and the job goes on whether or not anybody waits for it, ten runs at a time on the worker: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. A run that still takes results stays, as below, and is counted in the audit entry that closes the job; the job ends `completed` when the list is gone through, or `failed` with the reason (the caller may no longer delete here, the database kept failing). A Retry of a failed job cannot go on — the list is not kept — so it ends again at once: delete the runs again, what went is not deleted twice. With `sync: true` the call is the one it was, below: it answers when every run has been taken, which for runs of a million results can be past the server’s limit for a statement. The same soft delete as `DELETE /v1/runs/{id}` — the run and what it produced go to the Bin for 30 days — for one to 200 ids in a call (#1100, the Runs list’s «Delete» on a selection). Not all or nothing: each id is its own delete, in the order given, and the answer says which went and which stayed. A run that is still under way (open, or reopened by a rerun) stays — its reporter is still sending results — and is answered `running`; anything else that was not deleted is `not_found`: no such run, another project’s, or already in the Bin, never told apart. An id named twice is deleted once. Repeating a call is safe: whatever went is `not_found` the second time. A reader is refused at the boundary like every write (403). Every run taken leaves the `delete` entry the single delete leaves. `DELETE /v1/runs/{id}` itself still takes a run that is under way."},"response":[{"name":"200 — With `sync`: which runs went to the Bin, and which stayed and why","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n  ],\n  \"sync\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/bulk-delete","host":["{{baseUrl}}"],"path":["v1","runs","bulk-delete"]},"description":"#1423: the server deletes the runs as a job. The answer is 202 with the job (`kind: runs-delete`) — `progress` counts the runs named, `total` how many — and the job goes on whether or not anybody waits for it, ten runs at a time on the worker: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. A run that still takes results stays, as below, and is counted in the audit entry that closes the job; the job ends `completed` when the list is gone through, or `failed` with the reason (the caller may no longer delete here, the database kept failing). A Retry of a failed job cannot go on — the list is not kept — so it ends again at once: delete the runs again, what went is not deleted twice. With `sync: true` the call is the one it was, below: it answers when every run has been taken, which for runs of a million results can be past the server’s limit for a statement. The same soft delete as `DELETE /v1/runs/{id}` — the run and what it produced go to the Bin for 30 days — for one to 200 ids in a call (#1100, the Runs list’s «Delete» on a selection). Not all or nothing: each id is its own delete, in the order given, and the answer says which went and which stayed. A run that is still under way (open, or reopened by a rerun) stays — its reporter is still sending results — and is answered `running`; anything else that was not deleted is `not_found`: no such run, another project’s, or already in the Bin, never told apart. An id named twice is deleted once. Repeating a call is safe: whatever went is `not_found` the second time. A reader is refused at the boundary like every write (403). Every run taken leaves the `delete` entry the single delete leaves. `DELETE /v1/runs/{id}` itself still takes a run that is under way."},"status":"With `sync`: which runs went to the Bin, and which stayed and why","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"deleted\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n  ],\n  \"skipped\": [\n    {\n      \"id\": \"3f2b9d3e-9d0c-4d3e-8b55-0a7e3a1c2b10\",\n      \"reason\": \"running\"\n    }\n  ]\n}"},{"name":"202 — The job that deletes the runs, opened now and queued","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n  ],\n  \"sync\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/bulk-delete","host":["{{baseUrl}}"],"path":["v1","runs","bulk-delete"]},"description":"#1423: the server deletes the runs as a job. The answer is 202 with the job (`kind: runs-delete`) — `progress` counts the runs named, `total` how many — and the job goes on whether or not anybody waits for it, ten runs at a time on the worker: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. A run that still takes results stays, as below, and is counted in the audit entry that closes the job; the job ends `completed` when the list is gone through, or `failed` with the reason (the caller may no longer delete here, the database kept failing). A Retry of a failed job cannot go on — the list is not kept — so it ends again at once: delete the runs again, what went is not deleted twice. With `sync: true` the call is the one it was, below: it answers when every run has been taken, which for runs of a million results can be past the server’s limit for a statement. The same soft delete as `DELETE /v1/runs/{id}` — the run and what it produced go to the Bin for 30 days — for one to 200 ids in a call (#1100, the Runs list’s «Delete» on a selection). Not all or nothing: each id is its own delete, in the order given, and the answer says which went and which stayed. A run that is still under way (open, or reopened by a rerun) stays — its reporter is still sending results — and is answered `running`; anything else that was not deleted is `not_found`: no such run, another project’s, or already in the Bin, never told apart. An id named twice is deleted once. Repeating a call is safe: whatever went is `not_found` the second time. A reader is refused at the boundary like every write (403). Every run taken leaves the `delete` entry the single delete leaves. `DELETE /v1/runs/{id}` itself still takes a run that is under way."},"status":"The job that deletes the runs, opened now and queued","code":202,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n  \"kind\": \"runs-delete\",\n  \"state\": \"in_progress\",\n  \"subjectId\": null,\n  \"subjectName\": null,\n  \"progress\": {\n    \"done\": 0,\n    \"total\": 150\n  },\n  \"reason\": null,\n  \"cursor\": \"0\",\n  \"attempt\": 1,\n  \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n  \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n  \"finishedAt\": null,\n  \"updatedAt\": \"2026-09-16T10:02:00.000Z\"\n}"},{"name":"400 — noIds","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n  ],\n  \"sync\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/bulk-delete","host":["{{baseUrl}}"],"path":["v1","runs","bulk-delete"]},"description":"#1423: the server deletes the runs as a job. The answer is 202 with the job (`kind: runs-delete`) — `progress` counts the runs named, `total` how many — and the job goes on whether or not anybody waits for it, ten runs at a time on the worker: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. A run that still takes results stays, as below, and is counted in the audit entry that closes the job; the job ends `completed` when the list is gone through, or `failed` with the reason (the caller may no longer delete here, the database kept failing). A Retry of a failed job cannot go on — the list is not kept — so it ends again at once: delete the runs again, what went is not deleted twice. With `sync: true` the call is the one it was, below: it answers when every run has been taken, which for runs of a million results can be past the server’s limit for a statement. The same soft delete as `DELETE /v1/runs/{id}` — the run and what it produced go to the Bin for 30 days — for one to 200 ids in a call (#1100, the Runs list’s «Delete» on a selection). Not all or nothing: each id is its own delete, in the order given, and the answer says which went and which stayed. A run that is still under way (open, or reopened by a rerun) stays — its reporter is still sending results — and is answered `running`; anything else that was not deleted is `not_found`: no such run, another project’s, or already in the Bin, never told apart. An id named twice is deleted once. Repeating a call is safe: whatever went is `not_found` the second time. A reader is refused at the boundary like every write (403). Every run taken leaves the `delete` entry the single delete leaves. `DELETE /v1/runs/{id}` itself still takes a run that is under way."},"status":"invalid body — no id, more than 200, an id that is not text, or not JSON","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — ids: Too small: expected array to have >=1 items\"\n}"}]},{"name":"Compare 2 to 10 runs","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/compare?ids=","host":["{{baseUrl}}"],"path":["v1","runs","compare"],"query":[{"key":"ids","value":"","disabled":true,"description":"Run ids, comma separated; 2 to 10."}]},"description":"T14 (#1275). The runs are put in the order they were created; every verdict is between the newest and the one before it. `regressed`: passed before, failed or broke now. `improved`: the opposite. `degraded`: passed in both and took 30 % or more longer. A muted failure is not a failure. `flaky` is the case’s own flag, beside the verdict. `notMeasured` is never absent and says what was not judged (a case missing from one of the two runs, a passed case without a duration, cost: a result carries none), as codes with numbers the client words in the reader’s language: `no_verdict` (`count` of `total`), `no_duration` (`count`), `cost_not_compared`. The dashboard draws this same answer."},"response":[]},{"name":"Issue a run key for a launch dialog","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/keys","host":["{{baseUrl}}"],"path":["v1","runs","keys"]},"description":"A fresh run key (#1233): `rk_` and ten lowercase hex, held by no live run of the project. The launch dialog asks for one when it opens, so the command it shows — `PLUNE_RUN=<key> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` — is the real one; Save or Launch sends it as `runKey` (`POST /v1/plans/{id}/launch`) and the run keeps it as its `externalKey`, the key a reporter joins by. Nothing is stored: a dialog closed without Save or Launch just drops it. A reader is refused (403), as on every write."},"response":[{"name":"200 — The key","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/keys","host":["{{baseUrl}}"],"path":["v1","runs","keys"]},"description":"A fresh run key (#1233): `rk_` and ten lowercase hex, held by no live run of the project. The launch dialog asks for one when it opens, so the command it shows — `PLUNE_RUN=<key> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` — is the real one; Save or Launch sends it as `runKey` (`POST /v1/plans/{id}/launch`) and the run keeps it as its `externalKey`, the key a reporter joins by. Nothing is stored: a dialog closed without Save or Launch just drops it. A reader is refused (403), as on every write."},"status":"The key","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"key\": \"rk_4f7c2a9e1b\"\n}"}]}]},{"name":"suites","description":"TMS suite tree — folders that organise test cases","item":[{"name":"List your suites as a tree","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/suites","host":["{{baseUrl}}"],"path":["v1","suites"]},"description":"Roots at the top level, each with its `children` nested in `position` order and its `caseCount` (a folder sums its subtree). Only your project’s nodes."},"response":[{"name":"200 — Your suite forest","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/suites","host":["{{baseUrl}}"],"path":["v1","suites"]},"description":"Roots at the top level, each with its `children` nested in `position` order and its `caseCount` (a folder sums its subtree). Only your project’s nodes."},"status":"Your suite forest","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"suites\": [\n    {\n      \"schemaVersion\": 1,\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n      \"name\": \"checkout.spec.ts\",\n      \"projectId\": \"u-shop::default\",\n      \"kind\": \"suite\",\n      \"position\": 0,\n      \"description\": \"Cart and checkout flows of the web shop.\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"children\": [],\n      \"caseCount\": 1\n    }\n  ]\n}"}]},{"name":"Create a suite","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"checkout.spec.ts\",\n  \"kind\": \"suite\",\n  \"description\": \"Cart and checkout flows of the web shop.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/suites","host":["{{baseUrl}}"],"path":["v1","suites"]},"description":"“New suite” on the Suites screen (`api.ts` → `createSuite`). The tree is a filesystem: a `folder` holds folders and suites, a `suite` holds cases, so `parentId` must name a folder. Nodes a person makes have no `file`; the nodes filing derives from an automated case’s `specRef` do, and an import finds a suite by that `file` before it looks at names."},"response":[{"name":"201 — Created","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"checkout.spec.ts\",\n  \"kind\": \"suite\",\n  \"description\": \"Cart and checkout flows of the web shop.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/suites","host":["{{baseUrl}}"],"path":["v1","suites"]},"description":"“New suite” on the Suites screen (`api.ts` → `createSuite`). The tree is a filesystem: a `folder` holds folders and suites, a `suite` holds cases, so `parentId` must name a folder. Nodes a person makes have no `file`; the nodes filing derives from an automated case’s `specRef` do, and an import finds a suite by that `file` before it looks at names."},"status":"Created","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 1,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"name\": \"checkout.spec.ts\",\n  \"projectId\": \"u-shop::default\",\n  \"kind\": \"suite\",\n  \"position\": 0,\n  \"description\": \"Cart and checkout flows of the web shop.\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Rename, re-parent, reorder or re-kind a node","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"checkout — cart.spec.ts\",\n  \"position\": 0\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/suites/:id","host":["{{baseUrl}}"],"path":["v1","suites",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Every structural edit of the Suites screen goes through here (`api.ts` → `updateSuite`): inline rename, “Move to…”, the ↑/↓ that reorder siblings, and the change of kind. Send only what changes; `parentId: null` moves a node back to the root. Siblings are renumbered densely after every placement, so `position` is always 0-based and gap-free when read back."},"response":[{"name":"200 — Updated","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"checkout — cart.spec.ts\",\n  \"position\": 0\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/suites/:id","host":["{{baseUrl}}"],"path":["v1","suites",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Every structural edit of the Suites screen goes through here (`api.ts` → `updateSuite`): inline rename, “Move to…”, the ↑/↓ that reorder siblings, and the change of kind. Send only what changes; `parentId: null` moves a node back to the root. Siblings are renumbered densely after every placement, so `position` is always 0-based and gap-free when read back."},"status":"Updated","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 1,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"name\": \"checkout — cart.spec.ts\",\n  \"projectId\": \"u-shop::default\",\n  \"kind\": \"suite\",\n  \"position\": 0,\n  \"description\": \"Cart and checkout flows of the web shop.\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Delete a suite with everything beneath it","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id","host":["{{baseUrl}}"],"path":["v1","suites",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Cascades, and is reversible (D21): the node, every descendant and every case in them leave every read together and keep their results, keys, tags and history. `POST /v1/suites/{id}/restore` puts exactly that set back for 30 days; after that the purge removes it for good. Never refuses for being non-empty — the screen asks with the numbers before it calls this."},"response":[]},{"name":"What people said about this suite, oldest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/comments","host":["{{baseUrl}}"],"path":["v1","suites",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). The live comments on a suite, the same shape and rules as a case's: anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A suite in the bin, in another project or never there answers the same 404."},"response":[{"name":"200 — The comments","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/comments","host":["{{baseUrl}}"],"path":["v1","suites",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). The live comments on a suite, the same shape and rules as a case's: anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A suite in the bin, in another project or never there answers the same 404."},"status":"The comments","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"comments\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a4\",\n      \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n      \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\",\n      \"createdAt\": \"2026-09-18T10:12:00.000Z\"\n    }\n  ]\n}"},{"name":"404 — no such suite in this project, or it is in the bin","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/comments","host":["{{baseUrl}}"],"path":["v1","suites",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). The live comments on a suite, the same shape and rules as a case's: anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A suite in the bin, in another project or never there answers the same 404."},"status":"no such suite in this project, or it is in the bin","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"suite '6f1c2b3a-0000-4000-8000-0000000000a1' not found\"\n}"}]},{"name":"Say something about this suite","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/suites/:id/comments","host":["{{baseUrl}}"],"path":["v1","suites",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"M6 (#1256). Plain text with its line breaks, trimmed, never empty, at most 8192 bytes; no edit, no Markdown. Audited on the suite as `suite/comment`, the text in the diff, and never undone from the feed. A reader may comment (#1440), recorded as its author; it cannot take one back (`DELETE` is 403 `read-only member`). Taken back with `DELETE /v1/comments/{id}`."},"response":[]},{"name":"Turn a suite into a folder, moving its cases into a child suite of the same name","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/convert-to-folder","host":["{{baseUrl}}"],"path":["v1","suites",":id","convert-to-folder"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The one way to convert a suite that holds cases (ADR 0031, AC-04). In a single transaction the node's `kind` becomes `folder` — same id, same position — a child suite of the same name is inserted at `position: 0`, and every case moves into it; a `file` the node carried moves to the child, so the next case from that spec is filed beside its siblings. Two audit entries are written under one session, so the operation reads as one in D8 (send `X-Plune-Session` as elsewhere). Nothing is lost: the cases keep their ids, versions and history. Works on an empty suite too — the child is then empty, though `PATCH { kind }` is the lighter way for that. The tree the dashboard shows afterwards is `GET /v1/suites`."},"response":[{"name":"200 — Converted — the folder (same id) and the child suite that now holds the cases","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/convert-to-folder","host":["{{baseUrl}}"],"path":["v1","suites",":id","convert-to-folder"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The one way to convert a suite that holds cases (ADR 0031, AC-04). In a single transaction the node's `kind` becomes `folder` — same id, same position — a child suite of the same name is inserted at `position: 0`, and every case moves into it; a `file` the node carried moves to the child, so the next case from that spec is filed beside its siblings. Two audit entries are written under one session, so the operation reads as one in D8 (send `X-Plune-Session` as elsewhere). Nothing is lost: the cases keep their ids, versions and history. Works on an empty suite too — the child is then empty, though `PATCH { kind }` is the lighter way for that. The tree the dashboard shows afterwards is `GET /v1/suites`."},"status":"Converted — the folder (same id) and the child suite that now holds the cases","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"folder\": {\n    \"schemaVersion\": 1,\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n    \"projectId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"name\": \"Checkout\",\n    \"kind\": \"folder\",\n    \"position\": 1,\n    \"createdAt\": \"2026-09-01T10:00:00.000Z\",\n    \"updatedAt\": \"2026-09-11T10:09:00.000Z\"\n  },\n  \"child\": {\n    \"schemaVersion\": 1,\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000e9\",\n    \"projectId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"parentId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n    \"name\": \"Checkout\",\n    \"kind\": \"suite\",\n    \"position\": 0,\n    \"createdAt\": \"2026-09-11T10:09:00.000Z\",\n    \"updatedAt\": \"2026-09-11T10:09:00.000Z\"\n  }\n}"},{"name":"404 — not found or not yours","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/convert-to-folder","host":["{{baseUrl}}"],"path":["v1","suites",":id","convert-to-folder"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The one way to convert a suite that holds cases (ADR 0031, AC-04). In a single transaction the node's `kind` becomes `folder` — same id, same position — a child suite of the same name is inserted at `position: 0`, and every case moves into it; a `file` the node carried moves to the child, so the next case from that spec is filed beside its siblings. Two audit entries are written under one session, so the operation reads as one in D8 (send `X-Plune-Session` as elsewhere). Nothing is lost: the cases keep their ids, versions and history. Works on an empty suite too — the child is then empty, though `PATCH { kind }` is the lighter way for that. The tree the dashboard shows afterwards is `GET /v1/suites`."},"status":"not found or not yours","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"suite '6f1c2b3a-0000-4000-8000-0000000000e1' not found\"\n}"},{"name":"409 — the node is already a folder (AC-03)","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/convert-to-folder","host":["{{baseUrl}}"],"path":["v1","suites",":id","convert-to-folder"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The one way to convert a suite that holds cases (ADR 0031, AC-04). In a single transaction the node's `kind` becomes `folder` — same id, same position — a child suite of the same name is inserted at `position: 0`, and every case moves into it; a `file` the node carried moves to the child, so the next case from that spec is filed beside its siblings. Two audit entries are written under one session, so the operation reads as one in D8 (send `X-Plune-Session` as elsewhere). Nothing is lost: the cases keep their ids, versions and history. Works on an empty suite too — the child is then empty, though `PATCH { kind }` is the lighter way for that. The tree the dashboard shows afterwards is `GET /v1/suites`."},"status":"the node is already a folder (AC-03)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"suite '6f1c2b3a-0000-4000-8000-0000000000f1' is already a folder\"\n}"}]},{"name":"A node’s subtree as Markdown","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/markdown","host":["{{baseUrl}}"],"path":["v1","suites",":id","markdown"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The same document as `GET /v1/test-cases/markdown?suiteId=` — a folder gives every suite under it, a suite gives itself (D9)."},"response":[]},{"name":"Put a deleted suite back with everything that went with it","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/suites/:id/restore","host":["{{baseUrl}}"],"path":["v1","suites",":id","restore"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The reverse of `DELETE /v1/suites/{id}`: the node and exactly the descendants and cases deleted with it (same timestamp) come back to their places; anything deleted separately stays deleted. For the account that owns the suite (Settings → Bin) and for the owner of the deployment; everyone else gets 404, the same 404 an unknown id gets. `GET /v1/bin` shows the suite as one entry with what went along. The cases that come back are live cases again, so the project ceiling (`testCasesPerProject`) holds for them: a restore that would cross it answers 429 `Cannot restore: you reached the limit …` and changes nothing: the suite comes back whole or not at all."},"response":[]}]},{"name":"test-cases","description":"TMS test cases — manual, automated and eval","item":[{"name":"Take a comment back","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/comments/:id","host":["{{baseUrl}}"],"path":["v1","comments",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `deleteComment`, «Delete» on the row). The row is deleted at once (#1078): the text stays in the audit entry that names it, the list stops showing it. The author may take back their own; an owner of the project may take back anyone’s. Somebody else’s comment is answered 404 to everybody else — the same answer an id nobody has gets, on purpose. Audited on the case, suite or run it was on (`test_case`, `suite` or `run` / `comment`), the text as `before`. Works for a comment on a suite or a run too (M6, #1256)."},"response":[{"name":"403 — a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/comments/:id","host":["{{baseUrl}}"],"path":["v1","comments",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `deleteComment`, «Delete» on the row). The row is deleted at once (#1078): the text stays in the audit entry that names it, the list stops showing it. The author may take back their own; an owner of the project may take back anyone’s. Somebody else’s comment is answered 404 to everybody else — the same answer an id nobody has gets, on purpose. Audited on the case, suite or run it was on (`test_case`, `suite` or `run` / `comment`), the text as `before`. Works for a comment on a suite or a run too (M6, #1256)."},"status":"a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"404 — not yours to take back, already gone, or never there","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/comments/:id","host":["{{baseUrl}}"],"path":["v1","comments",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `deleteComment`, «Delete» on the row). The row is deleted at once (#1078): the text stays in the audit entry that names it, the list stops showing it. The author may take back their own; an owner of the project may take back anyone’s. Somebody else’s comment is answered 404 to everybody else — the same answer an id nobody has gets, on purpose. Audited on the case, suite or run it was on (`test_case`, `suite` or `run` / `comment`), the text as `before`. Works for a comment on a suite or a run too (M6, #1256)."},"status":"not yours to take back, already gone, or never there","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"comment '6f1c2b3a-0000-4000-8000-0000000000a4' not found\"\n}"}]},{"name":"Delete a file a person added","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/files/:id","host":["{{baseUrl}}"],"path":["v1","files",":id"],"variable":[{"key":"id","value":""}]},"description":"✕ on a file of a case of its own, or on one waiting on the case being marked in a manual run (#1234). It goes for good — there is no Bin for a file — and, on a case, leaves an entry on the case’s feed. A file in the project’s bucket goes from Plune’s lists; its object is left to the bucket’s lifecycle rule. A file of a result stays with the result it was marked with: 409 `result_file`. A reader of the project cannot delete one; someone else’s file is a 404. 120 requests a minute a person."},"response":[]},{"name":"The files waiting on the case being marked","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/files","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","files"],"variable":[{"key":"id","value":"","description":"The manual run, still open."},{"key":"caseId","value":"","description":"The case being marked in it."}]},"description":"The finished files of the case being marked in a manual run, before its mark (#1234): the mark takes them with `results[].files`. One left unmarked is removed a week after it was added, or once the run is closed, and what the project’s 1 GiB budget would send away for one more picture (`budget.evicts`) — what the dashboard warns about before an upload. Someone else’s is a 404."},"response":[{"name":"200 — The files and the budget","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/files","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","files"],"variable":[{"key":"id","value":"","description":"The manual run, still open."},{"key":"caseId","value":"","description":"The case being marked in it."}]},"description":"The finished files of the case being marked in a manual run, before its mark (#1234): the mark takes them with `results[].files`. One left unmarked is removed a week after it was added, or once the run is closed, and what the project’s 1 GiB budget would send away for one more picture (`budget.evicts`) — what the dashboard warns about before an upload. Someone else’s is a 404."},"status":"The files and the budget","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"files\": [\n    {\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c15\",\n      \"name\": \"price-list-eur.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213,\n      \"storage\": \"db\",\n      \"createdAt\": \"2026-10-13T09:00:00.000Z\",\n      \"createdBy\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n    }\n  ],\n  \"budget\": {\n    \"bucket\": false,\n    \"usedBytes\": 48213,\n    \"limitBytes\": 1073741824,\n    \"evicts\": null\n  }\n}"}]},{"name":"Add a file to the case being marked","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"currency\": \"EUR\",\n  \"rate\": 1.08\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/files?name=","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","files"],"query":[{"key":"name","value":"","disabled":true,"description":"What the file is called; only the last segment of a path is kept, cleaned of credentials."}],"variable":[{"key":"id","value":"","description":"The manual run, still open."},{"key":"caseId","value":"","description":"The case being marked in it."}]},"description":"A file onto the case being marked in a manual run, before its mark (#1234): the mark takes them with `results[].files`. One left unmarked is removed a week after it was added, or once the run is closed. The body IS the file, as for a result’s (`POST /v1/results/{id}/files`): a picture (PNG, JPEG, WebP) at most 2 MiB, or a text (`application/json`, `text/plain`) at most 512 KiB as UTF-8, cleaned of credentials before it is kept. Up to 30 files, 20 pictures and 10 texts of them in the database. The project’s files share one budget of 1 GiB: an upload that would pass it first deletes the project’s oldest files kept in the database — a person’s as much as a reporter’s — and its answer says what went (`evicted`). The list of the same files says beforehand what one more would cost (`budget.evicts`). Another file — a video, an archive — needs the project’s own bucket: `…/files/uploads`, then `PUT`, then `POST /v1/files/{id}/complete`. A reader of the project cannot add one; someone else’s case or run is a 404, the same as an invented one. 600 requests a minute a person."},"response":[{"name":"201 — Stored. `evicted` is there only when the budget sent files away for it","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"currency\": \"EUR\",\n  \"rate\": 1.08\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/files?name=","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","files"],"query":[{"key":"name","value":"","disabled":true,"description":"What the file is called; only the last segment of a path is kept, cleaned of credentials."}],"variable":[{"key":"id","value":"","description":"The manual run, still open."},{"key":"caseId","value":"","description":"The case being marked in it."}]},"description":"A file onto the case being marked in a manual run, before its mark (#1234): the mark takes them with `results[].files`. One left unmarked is removed a week after it was added, or once the run is closed. The body IS the file, as for a result’s (`POST /v1/results/{id}/files`): a picture (PNG, JPEG, WebP) at most 2 MiB, or a text (`application/json`, `text/plain`) at most 512 KiB as UTF-8, cleaned of credentials before it is kept. Up to 30 files, 20 pictures and 10 texts of them in the database. The project’s files share one budget of 1 GiB: an upload that would pass it first deletes the project’s oldest files kept in the database — a person’s as much as a reporter’s — and its answer says what went (`evicted`). The list of the same files says beforehand what one more would cost (`budget.evicts`). Another file — a video, an archive — needs the project’s own bucket: `…/files/uploads`, then `PUT`, then `POST /v1/files/{id}/complete`. A reader of the project cannot add one; someone else’s case or run is a 404, the same as an invented one. 600 requests a minute a person."},"status":"Stored. `evicted` is there only when the budget sent files away for it","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c15\",\n  \"name\": \"price-list-eur.png\",\n  \"contentType\": \"image/png\",\n  \"size\": 48213,\n  \"storage\": \"db\",\n  \"createdAt\": \"2026-10-13T09:00:00.000Z\",\n  \"createdBy\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}"}]},{"name":"Announce files for the case being marked — step 1 of 3 of sending them to the project’s bucket","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/files/uploads","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","files","uploads"],"variable":[{"key":"id","value":"","description":"The manual run, still open."},{"key":"caseId","value":"","description":"The case being marked in it."}]},"description":"The same three steps as `POST /v1/results/{id}/files/uploads` (ADR 0041), for the case being marked in a manual run, before its mark (#1234): the mark takes them with `results[].files`. One left unmarked is removed a week after it was added, or once the run is closed. A project without a bucket is told which files go to `/v1/runs/{id}/cases/{caseId}/files?name=` instead (`direct`) and which have nowhere to go (`skipped`). The ceilings in the bucket: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB; 30 files, the database and the bucket together. A file in the bucket costs the budget nothing. For a browser, the bucket needs a CORS rule that lets the dashboard’s address `PUT`. A reader cannot announce files; someone else’s is a 404. 600 requests a minute a person."},"response":[{"name":"200 — The project has no bucket","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/runs/:id/cases/:caseId/files/uploads","host":["{{baseUrl}}"],"path":["v1","runs",":id","cases",":caseId","files","uploads"],"variable":[{"key":"id","value":"","description":"The manual run, still open."},{"key":"caseId","value":"","description":"The case being marked in it."}]},"description":"The same three steps as `POST /v1/results/{id}/files/uploads` (ADR 0041), for the case being marked in a manual run, before its mark (#1234): the mark takes them with `results[].files`. One left unmarked is removed a week after it was added, or once the run is closed. A project without a bucket is told which files go to `/v1/runs/{id}/cases/{caseId}/files?name=` instead (`direct`) and which have nowhere to go (`skipped`). The ceilings in the bucket: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB; 30 files, the database and the bucket together. A file in the bucket costs the budget nothing. For a browser, the bucket needs a CORS rule that lets the dashboard’s address `PUT`. A reader cannot announce files; someone else’s is a 404. 600 requests a minute a person."},"status":"One entry per file, in the order asked","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"direct\": true\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"skipped\": \"no_storage\"\n    }\n  ]\n}"}]},{"name":"List your test cases","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases?limit=200&cursor=&stability=&suiteId=&tag=&priority=&priorityMin=&type=&state=&assignee=&q=&defects=&flaky=&muted=&requirement=&covered=&starred=&health=&notRunDays=30&estimate=&custom=","host":["{{baseUrl}}"],"path":["v1","test-cases"],"query":[{"key":"limit","value":"200","disabled":true,"description":"Page size; clamped into the range, the maximum when absent."},{"key":"cursor","value":"","disabled":true,"description":"`nextCursor` from the previous page. Omit for the first page."},{"key":"stability","value":"","disabled":true,"description":"`0` or `false`: skip the stability query and answer `stability: {}` — for a caller that wants the cases or a count, not the badges. Anything else is the default. One of: 0, false."},{"key":"suiteId","value":"","disabled":true,"description":"A suite: its cases. A folder: the cases of every suite beneath it. Repeat for several."},{"key":"tag","value":"","disabled":true,"description":"Verbatim — `Smoke` is not `smoke`. Repeat for any-of."},{"key":"priority","value":"","disabled":true},{"key":"priorityMin","value":"","disabled":true,"description":"This rank and above — `high` means `high` and `critical`. One of: low, normal, important, high, critical."},{"key":"type","value":"","disabled":true},{"key":"state","value":"","disabled":true},{"key":"assignee","value":"","disabled":true,"description":"Who is to run it (B8-team T4): `me` — the caller; `none` — nobody; otherwise a member’s `userId`. Repeat for OR. An id nobody holds matches nothing."},{"key":"q","value":"","disabled":true,"description":"Looked for in the title, the description, the precondition, every step (action and expected result), the case’s own expected result and the parameters table (names and cells), case-insensitively (#1255). Text, not a pattern."},{"key":"defects","value":"","disabled":true,"description":"`1`: only the cases that carry at least one defect link (#1064), together with the other filters. Anything else is no filter. One of: 1."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"requirement","value":"","disabled":true,"description":"Only the cases linked to this requirement (#1268); repeat for OR. An id nobody holds matches nothing."},{"key":"covered","value":"","disabled":true,"description":"`1` or `true`: only the cases linked to at least one requirement that is not archived (#1268); `0` or `false`: only the cases linked to none — cases need no requirement, so this works with only some of them linked. Together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"starred","value":"","disabled":true,"description":"`1` or `true`: only the cases in a starred suite or under a starred folder (#1255); `0` or `false`: only the others, a case with no suite included. Together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"health","value":"","disabled":true,"description":"The list a health widget on Analytics leads to (#1260); repeat for OR, together with the other filters. `never_run`: live manual and automated cases (not evals) with no result in a live run for `notRunDays` days, or no result at all. `ever_failing`: cases whose history holds at least 3 verdicts and every one is a failure (`failed` or `broken`; `skipped` and `blocked` are no verdict). `flaky`: the cases Analytics counts in `kpis.flaky` — flaky as measured over the history, not the `isFlaky` flag. Any other word is a 400 that names the values."},{"key":"notRunDays","value":"30","disabled":true,"description":"How many days back `health=never_run` looks for a result (#1260); 30 when absent. A whole number outside 1..365 is a 400 that names the parameter."},{"key":"estimate","value":"","disabled":true,"description":"Only the cases whose estimate (#1881) is in one of these buckets (#1905); repeat the key or separate with commas for OR, together with the other filters. `none`: nobody estimated the case (no estimate, never a zero one). `lt5`: under 5 minutes (0..299 s). `5to30`: 5 to 30 minutes (300..1800 s). `gt30`: over 30 minutes (over 1800 s). A case with no estimate is in no numeric bucket. Any other word is a 400 that names the values."},{"key":"custom","value":"","disabled":true,"description":"`<fieldId>:<value>` (#1766, ADR 0048): only the cases whose custom field holds the value — the text of a string field, the option id of a list (a `multiple` list matches when it holds it), the number of a number field. Repeat for one field to OR its values; different fields are AND-ed. A field or a value nobody holds matches nothing; a pair without a field or a value is a 400 that names the form."}]},"description":"The query string is the filter (B-7 AC-11): a repeated parameter is OR inside its dimension, different parameters are AND. Absent means every case. The order is the tree as the dashboard draws it: suite by suite down the tree, a folder’s child nodes by position before the node’s own cases, each suite’s cases by position, then creation, then id; unfiled cases last. A page is therefore the top of the tree, and the suite at its bottom may be cut by the page edge.\n\nPAGED as of #1391 (D4): a response carries at most `limit` cases (200 at most, and 200 when none is sent) and a `nextCursor`; a client that read `testCases` and never looked at `nextCursor` now sees only the first page. The filter is applied before the page; `matching` counts what it admits, `total` every case. A `cursor` that is not one is REFUSED with 400 rather than answered with the start of the list.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the project, counted from the start of the request; `rest` is the page of cases and their counts; `stability` is the badges of that page — the read of its cases’ histories and their scoring, asked once the page is known. `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"response":[{"name":"400 — a priority, type or state that does not exist, an `estimate` that is not one of the four buckets, or a `flaky`/`muted` value other than 1, true, 0 or false (the message names the allowed ones, AC-12), or a `cursor` that is not one","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases?limit=200&cursor=&stability=&suiteId=&tag=&priority=&priorityMin=&type=&state=&assignee=&q=&defects=&flaky=&muted=&requirement=&covered=&starred=&health=&notRunDays=30&estimate=&custom=","host":["{{baseUrl}}"],"path":["v1","test-cases"],"query":[{"key":"limit","value":"200","disabled":true,"description":"Page size; clamped into the range, the maximum when absent."},{"key":"cursor","value":"","disabled":true,"description":"`nextCursor` from the previous page. Omit for the first page."},{"key":"stability","value":"","disabled":true,"description":"`0` or `false`: skip the stability query and answer `stability: {}` — for a caller that wants the cases or a count, not the badges. Anything else is the default. One of: 0, false."},{"key":"suiteId","value":"","disabled":true,"description":"A suite: its cases. A folder: the cases of every suite beneath it. Repeat for several."},{"key":"tag","value":"","disabled":true,"description":"Verbatim — `Smoke` is not `smoke`. Repeat for any-of."},{"key":"priority","value":"","disabled":true},{"key":"priorityMin","value":"","disabled":true,"description":"This rank and above — `high` means `high` and `critical`. One of: low, normal, important, high, critical."},{"key":"type","value":"","disabled":true},{"key":"state","value":"","disabled":true},{"key":"assignee","value":"","disabled":true,"description":"Who is to run it (B8-team T4): `me` — the caller; `none` — nobody; otherwise a member’s `userId`. Repeat for OR. An id nobody holds matches nothing."},{"key":"q","value":"","disabled":true,"description":"Looked for in the title, the description, the precondition, every step (action and expected result), the case’s own expected result and the parameters table (names and cells), case-insensitively (#1255). Text, not a pattern."},{"key":"defects","value":"","disabled":true,"description":"`1`: only the cases that carry at least one defect link (#1064), together with the other filters. Anything else is no filter. One of: 1."},{"key":"flaky","value":"","disabled":true,"description":"`1` or `true`: only the cases marked flaky (`isFlaky`, #1246); `0` or `false`: only the others — together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"muted","value":"","disabled":true,"description":"`1` or `true`: only the muted cases (`isMuted`, #1246); `0` or `false`: only the ones that count — together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"requirement","value":"","disabled":true,"description":"Only the cases linked to this requirement (#1268); repeat for OR. An id nobody holds matches nothing."},{"key":"covered","value":"","disabled":true,"description":"`1` or `true`: only the cases linked to at least one requirement that is not archived (#1268); `0` or `false`: only the cases linked to none — cases need no requirement, so this works with only some of them linked. Together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"starred","value":"","disabled":true,"description":"`1` or `true`: only the cases in a starred suite or under a starred folder (#1255); `0` or `false`: only the others, a case with no suite included. Together with the other filters. Any other value is a 400 that names the parameter. One of: 1, true, 0, false."},{"key":"health","value":"","disabled":true,"description":"The list a health widget on Analytics leads to (#1260); repeat for OR, together with the other filters. `never_run`: live manual and automated cases (not evals) with no result in a live run for `notRunDays` days, or no result at all. `ever_failing`: cases whose history holds at least 3 verdicts and every one is a failure (`failed` or `broken`; `skipped` and `blocked` are no verdict). `flaky`: the cases Analytics counts in `kpis.flaky` — flaky as measured over the history, not the `isFlaky` flag. Any other word is a 400 that names the values."},{"key":"notRunDays","value":"30","disabled":true,"description":"How many days back `health=never_run` looks for a result (#1260); 30 when absent. A whole number outside 1..365 is a 400 that names the parameter."},{"key":"estimate","value":"","disabled":true,"description":"Only the cases whose estimate (#1881) is in one of these buckets (#1905); repeat the key or separate with commas for OR, together with the other filters. `none`: nobody estimated the case (no estimate, never a zero one). `lt5`: under 5 minutes (0..299 s). `5to30`: 5 to 30 minutes (300..1800 s). `gt30`: over 30 minutes (over 1800 s). A case with no estimate is in no numeric bucket. Any other word is a 400 that names the values."},{"key":"custom","value":"","disabled":true,"description":"`<fieldId>:<value>` (#1766, ADR 0048): only the cases whose custom field holds the value — the text of a string field, the option id of a list (a `multiple` list matches when it holds it), the number of a number field. Repeat for one field to OR its values; different fields are AND-ed. A field or a value nobody holds matches nothing; a pair without a field or a value is a 400 that names the form."}]},"description":"The query string is the filter (B-7 AC-11): a repeated parameter is OR inside its dimension, different parameters are AND. Absent means every case. The order is the tree as the dashboard draws it: suite by suite down the tree, a folder’s child nodes by position before the node’s own cases, each suite’s cases by position, then creation, then id; unfiled cases last. A page is therefore the top of the tree, and the suite at its bottom may be cut by the page edge.\n\nPAGED as of #1391 (D4): a response carries at most `limit` cases (200 at most, and 200 when none is sent) and a `nextCursor`; a client that read `testCases` and never looked at `nextCursor` now sees only the first page. The filter is applied before the page; `matching` counts what it admits, `total` every case. A `cursor` that is not one is REFUSED with 400 rather than answered with the start of the list.\n\nSends a `Server-Timing` header (W3C): where the server spent the time, in milliseconds to a tenth — DevTools lists it under Network → Timing. `auth` is the token or session and the project, counted from the start of the request; `rest` is the page of cases and their counts; `stability` is the badges of that page — the read of its cases’ histories and their scoring, asked once the page is known. `total` is the whole answer, before it is sent. The names are fixed and carry nothing of yours."},"status":"a priority, type or state that does not exist, an `estimate` that is not one of the four buckets, or a `flaky`/`muted` value other than 1, true, 0 or false (the message names the allowed ones, AC-12), or a `cursor` that is not one","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"unknown priority \\\"urgent\\\" — priorities are low, normal, important, high, critical\"\n}"}]},{"name":"Create a test case — An automated case, filed into a suite, with the key its reporter resolves it by","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"priority\": \"high\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"response":[{"name":"201 — Created — for an automated case without a suite, already filed under the suite its specRef names","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"priority\": \"high\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"status":"Created — for an automated case without a suite, already filed under the suite its specRef names","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 2,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"state\": \"draft\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"priority\": \"high\",\n  \"position\": 0,\n  \"isFlaky\": false,\n  \"isMuted\": false,\n  \"createdBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ],\n  \"status\": \"draft\",\n  \"tags\": [\n    \"smoke\",\n    \"checkout\"\n  ]\n}"},{"name":"400 — AC-03 — the target is a folder","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"priority\": \"high\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"status":"invalid body, a suite that is missing / not yours, a suite that is a folder, a title made of tags only, an invalid tag, or an unknown priority — each refusal names what it refused","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"suite '6f1c2b3a-0000-4000-8000-0000000000f1' is a folder — cases go in suites\"\n}"},{"name":"400 — AC-06 — nothing is left of the title once the tags are removed","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"priority\": \"high\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"status":"invalid body, a suite that is missing / not yours, a suite that is a folder, a title made of tags only, an invalid tag, or an unknown priority — each refusal names what it refused","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — title: a title cannot consist of tags only\"\n}"},{"name":"400 — AC-06 — the refusal names the tag","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"priority\": \"high\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"status":"invalid body, a suite that is missing / not yours, a suite that is a folder, a title made of tags only, an invalid tag, or an unknown priority — each refusal names what it refused","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — tags.1: invalid tag 'smoke test' — a tag is 1 to 119 characters from \\\\w = - _ ( ) . : &\"\n}"},{"name":"400 — AC-06 — the five levels are listed","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"priority\": \"high\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"status":"invalid body, a suite that is missing / not yours, a suite that is a folder, a title made of tags only, an invalid tag, or an unknown priority — each refusal names what it refused","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — priority: must be one of low, normal, important, high, critical\"\n}"}]},{"name":"Create a test case — A manual case at the root, with steps and the expected outcome","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Refund — partial amount @manual @payments\",\n  \"type\": \"manual\",\n  \"priority\": \"normal\",\n  \"precondition\": \"An order paid by card, delivered, within the 14-day window.\",\n  \"steps\": [\n    {\n      \"action\": \"Open the order and choose “Refund”.\"\n    },\n    {\n      \"action\": \"Enter ${Amount} and confirm.\",\n      \"expected\": \"The refund is listed as ${Outcome} with the entered amount.\"\n    }\n  ],\n  \"expected\": \"The customer receives the partial refund; the order stays delivered.\",\n  \"examples\": {\n    \"params\": [\n      \"Amount\",\n      \"Outcome\"\n    ],\n    \"rows\": [\n      [\n        \"10.00\",\n        \"pending\"\n      ],\n      [\n        \"0.00\",\n        \"refused\"\n      ]\n    ]\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases","host":["{{baseUrl}}"],"path":["v1","test-cases"]},"description":"The case form (`api.ts` → `createTestCase`), and the one road into a case that does not pass through the review queue — the other two are an approved proposal and a trusted discovery source. The body is the authoring half of the chosen `type`: steps for `manual`, a `specRef` for `automated`, a prompt with assertions for `eval`. Tags may ride in the title as `@tag` or in `tags`; the title is the source of truth for them either way (ADR 0032). `externalKeys` are the identifiers the reporter will resolve the case by — set them here and the first run matches without `PLUNE_CREATE`."},"response":[]},{"name":"Read one of your test cases","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id","host":["{{baseUrl}}"],"path":["v1","test-cases",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The case page (`api.ts` → `getTestCase`): the stored record with its state, tags and keys, plus `allowedStates` — the moves the lifecycle matrix lets THIS caller make from here (a token and a session get different lists, D22), so the screen offers exactly those and never copies the matrix - and `defects`, its defect links (#1064). A deleted case answers 404 like a foreign one."},"response":[{"name":"200 — The test case","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id","host":["{{baseUrl}}"],"path":["v1","test-cases",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The case page (`api.ts` → `getTestCase`): the stored record with its state, tags and keys, plus `allowedStates` — the moves the lifecycle matrix lets THIS caller make from here (a token and a session get different lists, D22), so the screen offers exactly those and never copies the matrix - and `defects`, its defect links (#1064). A deleted case answers 404 like a foreign one."},"status":"The test case","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 2,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Checkout — empty cart @smoke @checkout\",\n  \"state\": \"draft\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"priority\": \"high\",\n  \"position\": 0,\n  \"isFlaky\": false,\n  \"isMuted\": false,\n  \"createdBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ],\n  \"status\": \"draft\",\n  \"tags\": [\n    \"smoke\",\n    \"checkout\"\n  ],\n  \"allowedStates\": [\n    \"in_review\",\n    \"active\"\n  ],\n  \"defects\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a7\",\n      \"url\": \"https://tracker.example.com/browse/PLN-482\",\n      \"label\": \"Coupon is not applied on an empty cart\",\n      \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n      \"createdAt\": \"2026-09-16T10:30:00.000Z\"\n    }\n  ]\n}"}]},{"name":"Update a test case — Rank, tags and a precondition","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"priority\": \"critical\",\n  \"tags\": [\n    \"smoke\",\n    \"checkout\",\n    \"regression\"\n  ],\n  \"precondition\": \"A signed-in customer with an empty cart.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id","host":["{{baseUrl}}"],"path":["v1","test-cases",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The case form on save, “Move to…” and the lifecycle buttons (`api.ts` → `updateTestCase`): any subset of the writable fields, merged and re-validated whole. `state` moves the case through its lifecycle and is refused with the allowed moves when the matrix does not permit it for this caller — a token cannot retire a case, a person can. `stateReason` is kept on the audit entry, so the “why” survives the move."},"response":[{"name":"200 — Updated","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"priority\": \"critical\",\n  \"tags\": [\n    \"smoke\",\n    \"checkout\",\n    \"regression\"\n  ],\n  \"precondition\": \"A signed-in customer with an empty cart.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id","host":["{{baseUrl}}"],"path":["v1","test-cases",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The case form on save, “Move to…” and the lifecycle buttons (`api.ts` → `updateTestCase`): any subset of the writable fields, merged and re-validated whole. `state` moves the case through its lifecycle and is refused with the allowed moves when the matrix does not permit it for this caller — a token cannot retire a case, a person can. `stateReason` is kept on the audit entry, so the “why” survives the move."},"status":"Updated","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 2,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"type\": \"automated\",\n  \"specRef\": \"e2e/checkout.spec.ts#empty-cart\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Checkout — empty cart @smoke @checkout @regression\",\n  \"state\": \"draft\",\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"description\": \"An empty cart must not reach the payment step.\",\n  \"priority\": \"critical\",\n  \"position\": 0,\n  \"isFlaky\": false,\n  \"isMuted\": false,\n  \"createdBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"externalKeys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    }\n  ],\n  \"status\": \"draft\",\n  \"tags\": [\n    \"smoke\",\n    \"checkout\",\n    \"regression\"\n  ],\n  \"precondition\": \"A signed-in customer with an empty cart.\"\n}"}]},{"name":"Update a test case — Hand the case to a member (B8-team T4); `null` takes it back","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"assigneeId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id","host":["{{baseUrl}}"],"path":["v1","test-cases",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The case form on save, “Move to…” and the lifecycle buttons (`api.ts` → `updateTestCase`): any subset of the writable fields, merged and re-validated whole. `state` moves the case through its lifecycle and is refused with the allowed moves when the matrix does not permit it for this caller — a token cannot retire a case, a person can. `stateReason` is kept on the audit entry, so the “why” survives the move."},"response":[]},{"name":"Delete a test case","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id","host":["{{baseUrl}}"],"path":["v1","test-cases",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Reversible (D21): the case leaves every read — the list, the tree, the counters, `resolve` — and keeps its results, keys, tags and history; a result reported for it afterwards is refused like one for a foreign case. `POST /v1/test-cases/{id}/restore` puts it back for 30 days; after that the purge removes it with its results. A queue entry that created the case is unlinked and keeps its verdict."},"response":[]},{"name":"What people said about this case, oldest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/comments","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `listComments`, the «Comments · N» section of the case page). The live comments — a deleted one is gone from here for good. Anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A case in the bin, or one that is not in this project, has no comments to show: the same 404 the case itself answers."},"response":[{"name":"200 — The comments","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/comments","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `listComments`, the «Comments · N» section of the case page). The live comments — a deleted one is gone from here for good. Anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A case in the bin, or one that is not in this project, has no comments to show: the same 404 the case itself answers."},"status":"The comments","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"comments\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a4\",\n      \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n      \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\",\n      \"createdAt\": \"2026-09-18T10:12:00.000Z\"\n    }\n  ]\n}"},{"name":"404 — no such case in this project, or it is in the bin","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/comments","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `listComments`, the «Comments · N» section of the case page). The live comments — a deleted one is gone from here for good. Anyone in the project reads them, a reader too; the author is a `userId` to be named from `GET /v1/members`. A case in the bin, or one that is not in this project, has no comments to show: the same 404 the case itself answers."},"status":"no such case in this project, or it is in the bin","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"test case '6f1c2b3a-0000-4000-8000-0000000000c1' not found\"\n}"}]},{"name":"Say something about this case","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/comments","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `addComment`, the «Write a comment…» form). Plain text with its line breaks; trimmed, never empty, at most 8192 bytes. No edit afterwards — a changed mind is a second comment — and no Markdown (OQ-2). Audited on the case as `test_case/comment`, the text in the diff. A reader may comment (#1440) and is recorded as the author; a `read` API token may not (403 `read-only token`)."},"response":[{"name":"400 — empty, or over the byte cap","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/comments","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `addComment`, the «Write a comment…» form). Plain text with its line breaks; trimmed, never empty, at most 8192 bytes. No edit afterwards — a changed mind is a second comment — and no Markdown (OQ-2). Audited on the case as `test_case/comment`, the text in the diff. A reader may comment (#1440) and is recorded as the author; a `read` API token may not (403 `read-only token`)."},"status":"empty, or over the byte cap","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — body: a comment needs some text\"\n}"},{"name":"403 — a `read` API token","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"body\": \"Step 3 is stale since the checkout redesign — the coupon field moved into the order summary.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/comments","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","comments"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"B8-team T5 (`api.ts` → `addComment`, the «Write a comment…» form). Plain text with its line breaks; trimmed, never empty, at most 8192 bytes. No edit afterwards — a changed mind is a second comment — and no Markdown (OQ-2). Audited on the case as `test_case/comment`, the text in the diff. A reader may comment (#1440) and is recorded as the author; a `read` API token may not (403 `read-only token`)."},"status":"a `read` API token","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only token\"\n}"}]},{"name":"Add a defect link to a case","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a case. No condition on the case: any case in the project takes one. The body is judged before the owner is looked up. Audited on the case as `test_case/event`, the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"response":[{"name":"201 — Added","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a case. No condition on the case: any case in the project takes one. The body is judged before the owner is looked up. Audited on the case as `test_case/event`, the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"Added","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a7\",\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\",\n  \"authorId\": \"6f1c2b3a-0000-4000-8000-0000000000a2\",\n  \"createdAt\": \"2026-09-16T10:30:00.000Z\"\n}"},{"name":"400 — the body is not JSON","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a case. No condition on the case: any case in the project takes one. The body is judged before the owner is looked up. Audited on the case as `test_case/event`, the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"the body is not JSON","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"invalid JSON body\"\n}"},{"name":"403 — a reader asked","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a case. No condition on the case: any case in the project takes one. The body is judged before the owner is looked up. Audited on the case as `test_case/event`, the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"409 — the case already holds 20 links: `too_many_defects`","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a case. No condition on the case: any case in the project takes one. The body is judged before the owner is looked up. Audited on the case as `test_case/event`, the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"the case already holds 20 links: `too_many_defects`","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"test case '6f1c2b3a-0000-4000-8000-0000000000c1' already has 20 defect links: take one off first\",\n  \"code\": \"too_many_defects\"\n}"},{"name":"422 — the address or the label is refused; `code` says which","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://tracker.example.com/browse/PLN-482\",\n  \"label\": \"Coupon is not applied on an empty cart\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The address of a ticket somebody already filed (#1064). `https` only, at most 2 000 characters, with an optional one-line label of text; at most 20 links on a case. No condition on the case: any case in the project takes one. The body is judged before the owner is looked up. Audited on the case as `test_case/event`, the address in the diff; not undone from the feed. A reader is refused at the boundary with 403 `read-only member`."},"status":"the address or the label is refused; `code` says which","code":422,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the address must start with https:// — nothing else is accepted\",\n  \"code\": \"defect_url_not_https\"\n}"}]},{"name":"Take a defect link off a case","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects/:defectId","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects",":defectId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"defectId","value":"","description":"Format: uuid."}]},"description":"Any owner or member of the project may take off any link of the case. Audited on the case as `test_case/event`, the address as `before`. A link of another case, or of a result, is a 404 here."},"response":[{"name":"403 — a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects/:defectId","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects",":defectId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"defectId","value":"","description":"Format: uuid."}]},"description":"Any owner or member of the project may take off any link of the case. Audited on the case as `test_case/event`, the address as `before`. A link of another case, or of a result, is a 404 here."},"status":"a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"404 — no such link on a case of this project","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/defects/:defectId","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","defects",":defectId"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"defectId","value":"","description":"Format: uuid."}]},"description":"Any owner or member of the project may take off any link of the case. Audited on the case as `test_case/event`, the address as `before`. A link of another case, or of a result, is a 404 here."},"status":"no such link on a case of this project","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"defect link '6f1c2b3a-0000-4000-8000-0000000000a7' not found\"\n}"}]},{"name":"Copy a case beside the original","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n  \"position\": 0\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/duplicate","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","duplicate"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A new case with the same texts, priority and suite, titled «Copy of …» (its @tags come along), as a draft, right under its original (D30, D31). External keys and `evalId` stay with the original — they are what makes a case itself — and an automated case has no copy: its identity is the spec, so the test is duplicated in code. The body is optional and says where the copy goes instead — the dashboard sends it when a selection is dropped with Alt held."},"response":[]},{"name":"The files of a test case","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/files","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","files"],"variable":[{"key":"id","value":"","description":"The test case."}]},"description":"The finished files of a test case of its own (the case page’s **Files**, #1234), and what the project’s 1 GiB budget would send away for one more picture (`budget.evicts`) — what the dashboard warns about before an upload. Someone else’s is a 404."},"response":[{"name":"200 — The files and the budget","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/files","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","files"],"variable":[{"key":"id","value":"","description":"The test case."}]},"description":"The finished files of a test case of its own (the case page’s **Files**, #1234), and what the project’s 1 GiB budget would send away for one more picture (`budget.evicts`) — what the dashboard warns about before an upload. Someone else’s is a 404."},"status":"The files and the budget","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"files\": [\n    {\n      \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c15\",\n      \"name\": \"price-list-eur.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213,\n      \"storage\": \"db\",\n      \"createdAt\": \"2026-10-13T09:00:00.000Z\",\n      \"createdBy\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n    }\n  ],\n  \"budget\": {\n    \"bucket\": false,\n    \"usedBytes\": 48213,\n    \"limitBytes\": 1073741824,\n    \"evicts\": null\n  }\n}"}]},{"name":"Add a file to a test case","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"currency\": \"EUR\",\n  \"rate\": 1.08\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/files?name=","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","files"],"query":[{"key":"name","value":"","disabled":true,"description":"What the file is called; only the last segment of a path is kept, cleaned of credentials."}],"variable":[{"key":"id","value":"","description":"The test case."}]},"description":"A file onto a test case of its own (the case page’s **Files**, #1234). The body IS the file, as for a result’s (`POST /v1/results/{id}/files`): a picture (PNG, JPEG, WebP) at most 2 MiB, or a text (`application/json`, `text/plain`) at most 512 KiB as UTF-8, cleaned of credentials before it is kept. Up to 30 files, 20 pictures and 10 texts of them in the database. The project’s files share one budget of 1 GiB: an upload that would pass it first deletes the project’s oldest files kept in the database — a person’s as much as a reporter’s — and its answer says what went (`evicted`). The list of the same files says beforehand what one more would cost (`budget.evicts`). Another file — a video, an archive — needs the project’s own bucket: `…/files/uploads`, then `PUT`, then `POST /v1/files/{id}/complete`. A reader of the project cannot add one; someone else’s case or run is a 404, the same as an invented one. 600 requests a minute a person."},"response":[{"name":"201 — Stored. `evicted` is there only when the budget sent files away for it","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"currency\": \"EUR\",\n  \"rate\": 1.08\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/files?name=","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","files"],"query":[{"key":"name","value":"","disabled":true,"description":"What the file is called; only the last segment of a path is kept, cleaned of credentials."}],"variable":[{"key":"id","value":"","description":"The test case."}]},"description":"A file onto a test case of its own (the case page’s **Files**, #1234). The body IS the file, as for a result’s (`POST /v1/results/{id}/files`): a picture (PNG, JPEG, WebP) at most 2 MiB, or a text (`application/json`, `text/plain`) at most 512 KiB as UTF-8, cleaned of credentials before it is kept. Up to 30 files, 20 pictures and 10 texts of them in the database. The project’s files share one budget of 1 GiB: an upload that would pass it first deletes the project’s oldest files kept in the database — a person’s as much as a reporter’s — and its answer says what went (`evicted`). The list of the same files says beforehand what one more would cost (`budget.evicts`). Another file — a video, an archive — needs the project’s own bucket: `…/files/uploads`, then `PUT`, then `POST /v1/files/{id}/complete`. A reader of the project cannot add one; someone else’s case or run is a 404, the same as an invented one. 600 requests a minute a person."},"status":"Stored. `evicted` is there only when the budget sent files away for it","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"3f0c2a7e-1b5d-4c8e-9a61-2d7f4e8b0c15\",\n  \"name\": \"price-list-eur.png\",\n  \"contentType\": \"image/png\",\n  \"size\": 48213,\n  \"storage\": \"db\",\n  \"createdAt\": \"2026-10-13T09:00:00.000Z\",\n  \"createdBy\": \"6f1c2b3a-0000-4000-8000-0000000000a2\"\n}"}]},{"name":"Announce files for a test case — step 1 of 3 of sending them to the project’s bucket","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/files/uploads","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","files","uploads"],"variable":[{"key":"id","value":"","description":"The test case."}]},"description":"The same three steps as `POST /v1/results/{id}/files/uploads` (ADR 0041), for a test case of its own (the case page’s **Files**, #1234). A project without a bucket is told which files go to `/v1/test-cases/{id}/files?name=` instead (`direct`) and which have nowhere to go (`skipped`). The ceilings in the bucket: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB; 30 files, the database and the bucket together. A file in the bucket costs the budget nothing. For a browser, the bucket needs a CORS rule that lets the dashboard’s address `PUT`. A reader cannot announce files; someone else’s is a 404. 600 requests a minute a person."},"response":[{"name":"200 — The project has no bucket","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"contentType\": \"image/png\",\n      \"size\": 48213\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"contentType\": \"application/zip\",\n      \"size\": 3145728\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/files/uploads","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","files","uploads"],"variable":[{"key":"id","value":"","description":"The test case."}]},"description":"The same three steps as `POST /v1/results/{id}/files/uploads` (ADR 0041), for a test case of its own (the case page’s **Files**, #1234). A project without a bucket is told which files go to `/v1/test-cases/{id}/files?name=` instead (`direct`) and which have nowhere to go (`skipped`). The ceilings in the bucket: image 10 MiB, text 5 MiB, video 100 MiB, trace 100 MiB, other 50 MiB; 30 files, the database and the bucket together. A file in the bucket costs the budget nothing. For a browser, the bucket needs a CORS rule that lets the dashboard’s address `PUT`. A reader cannot announce files; someone else’s is a 404. 600 requests a minute a person."},"status":"One entry per file, in the order asked","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"files\": [\n    {\n      \"name\": \"signed-in.png\",\n      \"direct\": true\n    },\n    {\n      \"name\": \"trace.zip\",\n      \"skipped\": \"no_storage\"\n    }\n  ]\n}"}]},{"name":"One case as its Markdown block","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/markdown","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","markdown"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The case’s `<!-- test -->` block alone — no suite block around it, so pasting it into `POST /v1/test-cases/markdown` edits the case where it is (D9). What «Copy as Markdown» copies."},"response":[]},{"name":"Put a deleted test case back","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/restore","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","restore"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The reverse of `DELETE /v1/test-cases/{id}` — for the account that owns the case (Settings → Bin) and for the owner of the deployment; everyone else gets 404, the same 404 an unknown id gets. A case that went with its suite comes back with the suite (`POST /v1/suites/{id}/restore`), not on its own: asking for it alone answers 409 and names the suite. A restored case is a live case again, so the project ceiling (`testCasesPerProject`) holds for it: a restore that would cross it answers 429 `Cannot restore: you reached the limit …` and changes nothing."},"response":[]},{"name":"This case’s past evaluations, newest run first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/results","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","results"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Two ways a Result reaches here. A CLI run attaches one when it carries an eval whose id matches the case’s `evalId` (ADR 0010); approving a Cairn proposal attaches the verdict the proposing run already measured, so a case born that way has history from the start."},"response":[]},{"name":"Put the content of a case back to a revision","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"version\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/revert","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","revert"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The title, description, precondition, parameters and body go back to what the revision held (#1255); the state, the place, the priority, the assignee, the flags, the keys, the custom fields and every result stay. It is a new version of the case, not a rewind: the revision it came from, and every result written against an earlier version, are left as they were, so a run still shows the version it tested. Audited as an edit of the case. 409 when the case already has that content."},"response":[]},{"name":"The revisions of this case’s content, newest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/revisions","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","revisions"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The title, description, precondition, parameters and body the case had at each of its versions (#1255) - kept whenever the version moves (the version moves exactly when that content does). A case from before the feature starts at the version it had then. At most the newest 100, without the content: `changes` says what a revert to each would change."},"response":[]},{"name":"One revision with its content","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/revisions/:version","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","revisions",":version"],"variable":[{"key":"id","value":"","description":"Format: uuid."},{"key":"version","value":""}]},"description":"The content the case had at that version - also what a result with that `caseVersion` tested."},"response":[]},{"name":"One action over many cases — Move three cases into a suite","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"move\",\n    \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"response":[{"name":"200 — What was done and what was refused","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"move\",\n    \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"status":"What was done and what was refused","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"done\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"refused\": []\n}"},{"name":"400 — invalid body, or a move target every case would be refused for","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"move\",\n    \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"status":"invalid body, or a move target every case would be refused for","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"suite '6f1c2b3a-0000-4000-8000-0000000000a2' is a folder — cases go in suites\"\n}"}]},{"name":"One action over many cases — Drop them first in the suite, in this order","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"move\",\n    \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"position\": 0\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"response":[]},{"name":"One action over many cases — Mark them critical","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"priority\",\n    \"priority\": \"critical\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"response":[]},{"name":"One action over many cases — Retire them with one reason","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"state\",\n    \"state\": \"deprecated\",\n    \"stateReason\": \"the old checkout flow is gone\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"response":[]},{"name":"One action over many cases — Delete them — into the bin, 30 days","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n  ],\n  \"action\": {\n    \"kind\": \"delete\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/bulk","host":["{{baseUrl}}"],"path":["v1","test-cases","bulk"]},"description":"The dashboard’s selection bar (D30) and an agent’s batch: move, set the priority, change the state or delete up to 200 cases in one call. Every id walks the same road as the single `PATCH`/`DELETE` — same actor rule for states (a bearer token is an agent, a session a person), same suite rule, same words when refused — and the answer is always 200 with `done` and `refused` by id: a refusal of one case is a result, not an error of the call. Sequential, not atomic: what was done before a refusal stays done, and each case gets its own audit entry under one session, plus one summary entry on the project with the counts (#1280). An agent (a bearer token with `X-Plune-Session`) is held: the first call changes nothing and answers `{ preview: { action, total, sample, confirm, expiresInSeconds } }`; the same request with `?confirm=` does it, once, within five minutes, and `delete` is refused 403 `bulk_delete_needs_person`. A person with a session cookie is not held. A `move` target that would refuse every case (a folder, a stranger’s suite) is answered 400 before anything moves."},"response":[]},{"name":"Cases as an XLSX — the tree, nodes or cases","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/export?suiteId=&caseId=","host":["{{baseUrl}}"],"path":["v1","test-cases","export"],"query":[{"key":"suiteId","value":"","disabled":true,"description":"A folder or suite — its subtree is in the file. Repeat for more than one. Format: uuid."},{"key":"caseId","value":"","disabled":true,"description":"A case to put in the file. Repeat for more than one. Format: uuid."}]},"description":"The caller’s cases as one Excel workbook (#1235), in Testomat’s columns — `ID, Title, Status, Folder, Priority, Description, Examples` — which is what `POST /v1/test-cases/import` reads back, so the file imports to the same cases. `ID` is the case’s Testomat key when it came from Testomat, else empty; `Title` keeps its tags; `Status` is the type; `Folder` is `/folder/suite` (`folder > a/b` when a name has a slash); `Description` holds the texts in the Markdown of `GET /v1/test-cases/markdown` (`## Preconditions`, `## Steps`, `## Expected result`). Cases in no suite first, then each suite in tree order with its cases by position; the bin is not in it. With no parameter, every case; `suiteId` and `caseId`, each repeatable, keep the subtrees of those nodes and those cases — each case once. Automated and eval cases are written too; the import brings back manual ones."},"response":[]},{"name":"Cases from a CSV or an XLSX — Testomat, TestRail, Qase, Zephyr or columns of your own","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/import?dryRun=&source=&columns=&file=&folder=&folderId=","host":["{{baseUrl}}"],"path":["v1","test-cases","import"],"query":[{"key":"dryRun","value":"","disabled":true,"description":"Report only; nothing is written, not even the folders."},{"key":"source","value":"","disabled":true,"description":"The tool the file came from. Absent, the header says; `custom` has no key. One of: testomat, testrail, qase, zephyr, custom."},{"key":"columns","value":"","disabled":true,"description":"What each column of the file becomes, comma-separated, in its order — one of `skip`, `title`, `key`, `suitePath`, `suiteId`, `suiteParent`, `priority`, `description`, `precondition`, `step`, `stepExpected`, `expected`, `tags`, `isFlaky`, `estimate`. `title`, `key`, `suitePath`, `suiteId`, `suiteParent`, `priority` and `isFlaky` come from one column at most; a title is required. `isFlaky` (Qase’s `is_flaky` column, #1246) only sets the flag: yes, true or 1 sets it; no, false, 0, an empty cell or any other word leaves the case’s flag as it is — a file has no base version to clear it by. `estimate` (TestRail’s `Estimate`, Zephyr’s `Estimated Time`, #1881) is the case’s time estimate: `30s`, `5m`, `1h 30m`, a bare number is minutes; an empty cell leaves the case’s estimate as it is, and a cell that is not an estimate is a warning on its row — the case is imported without it. `estimate` comes from one column at most."},{"key":"file","value":"","disabled":true,"description":"The file’s name — the default folder and the suite for rows with no path are named after it."},{"key":"folder","value":"","disabled":true,"description":"A folder at the top of the tree to import into, made if there is none."},{"key":"folderId","value":"","disabled":true,"description":"A folder of the tree to import into. Format: uuid."}]},"description":"The body is the file (#1235): RFC 4180, comma-separated (a header split by `;` or a tab is read by that), a UTF-8 BOM dropped, up to 20 000 rows and 200 columns. Cells are text — a formula is not evaluated. Or an Excel workbook, sent as `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`: its first visible sheet is read into the same header and rows — each cell as the file stores it (a formula’s last value, a number’s digits, a boolean as `TRUE`/`FALSE`), at most 100 MB of XML once unpacked — and goes the same way from there. Each column becomes a case field (`columns`, else the preset of `source`, else the field of its own name): a row with a title starts a case, a row with none that has a step or an expected result goes on with the case above it (one step per row, as TestRail and Zephyr write them). Steps pair with expected results by their numbers (`1. …`), else by position; a description in Testomat’s Markdown gives its `## Preconditions`, `## Steps` and `## Expected result`. Tags join the title. Suite paths (`A > B`, `/A/B`, Qase’s suite ids) become folders, then the suite, under the target: `folderId`, else a folder at the top named `folder`, else after `file` — made if missing; a row with no path goes into a suite named after the file, and a node that holds cases and other nodes becomes a folder with its own cases in a suite of its name. A case the tool’s key already names (`source`’s namespace) is updated — title, texts, priority, steps, its suite — so the same file again adds nothing twice; any other is created as a draft with the key. Nothing is deleted; type and state never change. A row is refused, with its reason, when it has no title, a priority Plune has no place for, a path with an empty part, a key twice in the file, a key a case in the Bin holds, a key on a case that is not manual, or no step or expectation. Each write is its own: what was done before a refusal stays. `?dryRun=true` gives the same report and writes nothing. The one refusal before any write: a file whose new cases would take the project past its case limit is answered `429`, nothing written; the dry run reports the numbers in `ceiling` instead. With `Accept: application/x-ndjson` a write answers one JSON per line — `{\"progress\":{\"done\",\"total\"}}` as cases are written, then `{\"report\":…}` (or `{\"error\":…}` if it stopped)."},"response":[{"name":"200 — The report; refusals of single rows are inside it. With `Accept: application/x-ndjson` on a write, lines of progress and then the report.","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/import?dryRun=&source=&columns=&file=&folder=&folderId=","host":["{{baseUrl}}"],"path":["v1","test-cases","import"],"query":[{"key":"dryRun","value":"","disabled":true,"description":"Report only; nothing is written, not even the folders."},{"key":"source","value":"","disabled":true,"description":"The tool the file came from. Absent, the header says; `custom` has no key. One of: testomat, testrail, qase, zephyr, custom."},{"key":"columns","value":"","disabled":true,"description":"What each column of the file becomes, comma-separated, in its order — one of `skip`, `title`, `key`, `suitePath`, `suiteId`, `suiteParent`, `priority`, `description`, `precondition`, `step`, `stepExpected`, `expected`, `tags`, `isFlaky`, `estimate`. `title`, `key`, `suitePath`, `suiteId`, `suiteParent`, `priority` and `isFlaky` come from one column at most; a title is required. `isFlaky` (Qase’s `is_flaky` column, #1246) only sets the flag: yes, true or 1 sets it; no, false, 0, an empty cell or any other word leaves the case’s flag as it is — a file has no base version to clear it by. `estimate` (TestRail’s `Estimate`, Zephyr’s `Estimated Time`, #1881) is the case’s time estimate: `30s`, `5m`, `1h 30m`, a bare number is minutes; an empty cell leaves the case’s estimate as it is, and a cell that is not an estimate is a warning on its row — the case is imported without it. `estimate` comes from one column at most."},{"key":"file","value":"","disabled":true,"description":"The file’s name — the default folder and the suite for rows with no path are named after it."},{"key":"folder","value":"","disabled":true,"description":"A folder at the top of the tree to import into, made if there is none."},{"key":"folderId","value":"","disabled":true,"description":"A folder of the tree to import into. Format: uuid."}]},"description":"The body is the file (#1235): RFC 4180, comma-separated (a header split by `;` or a tab is read by that), a UTF-8 BOM dropped, up to 20 000 rows and 200 columns. Cells are text — a formula is not evaluated. Or an Excel workbook, sent as `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`: its first visible sheet is read into the same header and rows — each cell as the file stores it (a formula’s last value, a number’s digits, a boolean as `TRUE`/`FALSE`), at most 100 MB of XML once unpacked — and goes the same way from there. Each column becomes a case field (`columns`, else the preset of `source`, else the field of its own name): a row with a title starts a case, a row with none that has a step or an expected result goes on with the case above it (one step per row, as TestRail and Zephyr write them). Steps pair with expected results by their numbers (`1. …`), else by position; a description in Testomat’s Markdown gives its `## Preconditions`, `## Steps` and `## Expected result`. Tags join the title. Suite paths (`A > B`, `/A/B`, Qase’s suite ids) become folders, then the suite, under the target: `folderId`, else a folder at the top named `folder`, else after `file` — made if missing; a row with no path goes into a suite named after the file, and a node that holds cases and other nodes becomes a folder with its own cases in a suite of its name. A case the tool’s key already names (`source`’s namespace) is updated — title, texts, priority, steps, its suite — so the same file again adds nothing twice; any other is created as a draft with the key. Nothing is deleted; type and state never change. A row is refused, with its reason, when it has no title, a priority Plune has no place for, a path with an empty part, a key twice in the file, a key a case in the Bin holds, a key on a case that is not manual, or no step or expectation. Each write is its own: what was done before a refusal stays. `?dryRun=true` gives the same report and writes nothing. The one refusal before any write: a file whose new cases would take the project past its case limit is answered `429`, nothing written; the dry run reports the numbers in `ceiling` instead. With `Accept: application/x-ndjson` a write answers one JSON per line — `{\"progress\":{\"done\",\"total\"}}` as cases are written, then `{\"report\":…}` (or `{\"error\":…}` if it stopped)."},"status":"The report; refusals of single rows are inside it. With `Accept: application/x-ndjson` on a write, lines of progress and then the report.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": false,\n  \"source\": \"testrail\",\n  \"keyKind\": \"testrail\",\n  \"folder\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"name\": \"testrail-cases\"\n  },\n  \"rows\": 1,\n  \"created\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d4\",\n      \"title\": \"Apply a 10% coupon at checkout\",\n      \"row\": 2\n    }\n  ],\n  \"updated\": [],\n  \"unchanged\": [],\n  \"refused\": [],\n  \"suites\": {\n    \"created\": [\n      {\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n        \"path\": \"testrail-cases\",\n        \"row\": 2\n      },\n      {\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d2\",\n        \"path\": \"testrail-cases > Checkout\",\n        \"row\": 2\n      },\n      {\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d3\",\n        \"path\": \"testrail-cases > Checkout > Coupons\",\n        \"row\": 2\n      }\n    ]\n  },\n  \"warnings\": [],\n  \"ceiling\": {\n    \"now\": 3,\n    \"after\": 4,\n    \"limit\": 20000\n  }\n}"},{"name":"400 — a file that is neither a CSV nor a workbook Plune reads, is empty or past a cap, or columns that do not fit it","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/import?dryRun=&source=&columns=&file=&folder=&folderId=","host":["{{baseUrl}}"],"path":["v1","test-cases","import"],"query":[{"key":"dryRun","value":"","disabled":true,"description":"Report only; nothing is written, not even the folders."},{"key":"source","value":"","disabled":true,"description":"The tool the file came from. Absent, the header says; `custom` has no key. One of: testomat, testrail, qase, zephyr, custom."},{"key":"columns","value":"","disabled":true,"description":"What each column of the file becomes, comma-separated, in its order — one of `skip`, `title`, `key`, `suitePath`, `suiteId`, `suiteParent`, `priority`, `description`, `precondition`, `step`, `stepExpected`, `expected`, `tags`, `isFlaky`, `estimate`. `title`, `key`, `suitePath`, `suiteId`, `suiteParent`, `priority` and `isFlaky` come from one column at most; a title is required. `isFlaky` (Qase’s `is_flaky` column, #1246) only sets the flag: yes, true or 1 sets it; no, false, 0, an empty cell or any other word leaves the case’s flag as it is — a file has no base version to clear it by. `estimate` (TestRail’s `Estimate`, Zephyr’s `Estimated Time`, #1881) is the case’s time estimate: `30s`, `5m`, `1h 30m`, a bare number is minutes; an empty cell leaves the case’s estimate as it is, and a cell that is not an estimate is a warning on its row — the case is imported without it. `estimate` comes from one column at most."},{"key":"file","value":"","disabled":true,"description":"The file’s name — the default folder and the suite for rows with no path are named after it."},{"key":"folder","value":"","disabled":true,"description":"A folder at the top of the tree to import into, made if there is none."},{"key":"folderId","value":"","disabled":true,"description":"A folder of the tree to import into. Format: uuid."}]},"description":"The body is the file (#1235): RFC 4180, comma-separated (a header split by `;` or a tab is read by that), a UTF-8 BOM dropped, up to 20 000 rows and 200 columns. Cells are text — a formula is not evaluated. Or an Excel workbook, sent as `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`: its first visible sheet is read into the same header and rows — each cell as the file stores it (a formula’s last value, a number’s digits, a boolean as `TRUE`/`FALSE`), at most 100 MB of XML once unpacked — and goes the same way from there. Each column becomes a case field (`columns`, else the preset of `source`, else the field of its own name): a row with a title starts a case, a row with none that has a step or an expected result goes on with the case above it (one step per row, as TestRail and Zephyr write them). Steps pair with expected results by their numbers (`1. …`), else by position; a description in Testomat’s Markdown gives its `## Preconditions`, `## Steps` and `## Expected result`. Tags join the title. Suite paths (`A > B`, `/A/B`, Qase’s suite ids) become folders, then the suite, under the target: `folderId`, else a folder at the top named `folder`, else after `file` — made if missing; a row with no path goes into a suite named after the file, and a node that holds cases and other nodes becomes a folder with its own cases in a suite of its name. A case the tool’s key already names (`source`’s namespace) is updated — title, texts, priority, steps, its suite — so the same file again adds nothing twice; any other is created as a draft with the key. Nothing is deleted; type and state never change. A row is refused, with its reason, when it has no title, a priority Plune has no place for, a path with an empty part, a key twice in the file, a key a case in the Bin holds, a key on a case that is not manual, or no step or expectation. Each write is its own: what was done before a refusal stays. `?dryRun=true` gives the same report and writes nothing. The one refusal before any write: a file whose new cases would take the project past its case limit is answered `429`, nothing written; the dry run reports the numbers in `ceiling` instead. With `Accept: application/x-ndjson` a write answers one JSON per line — `{\"progress\":{\"done\",\"total\"}}` as cases are written, then `{\"report\":…}` (or `{\"error\":…}` if it stopped)."},"status":"a file that is neither a CSV nor a workbook Plune reads, is empty or past a cap, or columns that do not fit it","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no column becomes the title — every case needs one\"\n}"}]},{"name":"The tree as one Markdown document","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/markdown?suiteId=&state=","host":["{{baseUrl}}"],"path":["v1","test-cases","markdown"],"query":[{"key":"suiteId","value":"","disabled":true,"description":"A folder or suite — the document is its subtree. Format: uuid."},{"key":"state","value":"","disabled":true,"description":"Repeat for more than one. One of: draft, in_review, active, detached, deprecated."}]},"description":"Every case the caller owns, as `text/markdown` (D9): cases in no suite first, then each suite in tree order with its cases by position. The bin is not in it. `suiteId` keeps one node’s subtree (and drops the unfiled cases — they are nobody’s subtree); `state`, repeatable, keeps cases of those states. Read the document with `POST /v1/test-cases/markdown` after editing it anywhere; `plune pull` writes it to a file.\n\n**Format.** Testomat’s Classical Tests Markdown Format, verbatim, plus Plune’s columns: `<!-- suite -->` / `<!-- test -->` blocks with `key: value` metadata, the title as the first `#`/`##` heading after the block (its `@tags` are the tags), `## Steps` with `*Expected*:` under each step, `<!-- example -->` tables. Cases before the first suite block are in no suite; a nested suite is its path in the title, `# apps > web > checkout.spec.ts`. Plune’s columns: `id` is the record’s UUID, `updatedAt` is that case’s (or suite’s) `updatedAt` when the document was written (the base version a push holds it to), `type` may be `eval`, `state` is written for the eye and only read back as a warning, `spec` carries an automated case’s specRef, `flaky: true` and `muted: true` are the case’s flags (written only when set; a block that leaves one out keeps the case’s, `false` clears it, and anything but `true`/`false` refuses the block), `estimate: 1h 30m` is the case’s time estimate (#1881; written only when set; a block that leaves it out keeps the case’s, one that is not an estimate refuses the block); `## Preconditions`, `## Expected result`, `## Prompt` and `## Assertions` (a JSON array) are the body. The full reference: https://docs.plune.ai/platform/cases/markdown/."},"response":[]},{"name":"A Markdown document back — create, update, report","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/markdown?dryRun=","host":["{{baseUrl}}"],"path":["v1","test-cases","markdown"],"query":[{"key":"dryRun","value":"","disabled":true,"description":"Report only; nothing is written, not even the folders of a new path."}]},"description":"The same document, edited anywhere, read back (D9). Each `<!-- test -->` block is matched by its `id`: known — the case is updated (title, texts, priority, body, and its suite when the document has suite blocks); absent — a new draft in the suite of the block it sits under, last; unknown — refused by line. Nothing is deleted: a case or suite the document does not name is not touched. A case’s `type` never changes this way, and `state:` is only read back as a warning — states move through their own action. A block (case or suite) with an `id` and an `updatedAt` is written only if it still has that `updatedAt`: one saved by someone since the document was pulled is refused by line (`changed since this document was pulled — pull again and reapply your edit`), with `?dryRun=true` too, and nothing of it is written; a block the case already agrees with is `unchanged` whatever its `updatedAt`. A block with an `id` and no `updatedAt` (a document written by hand, or pulled before this existed) is written over the case or suite as it stands, and a warning says so; an `updatedAt` that is not a time is refused as `updatedAt is not a timestamp — pull again`. A case or suite named by more than one block is judged at the first; the others are refused (`the same case appears more than once in this document`), in a dry run too. A document with no suite block moves nothing, so a single case pasted back keeps its suite. Suite blocks are matched by `id`, else by their path — missing segments are created (folders, then the suite), each with a warning. Each write is its own transaction, as in `bulk`: what was done before a refusal stays done, and the report says which was which. `?dryRun=true` gives the same report and writes nothing — `plune push` shows it before writing. The one refusal that comes before any write: a document whose blocks with no `id` would take the project past its case limit is answered `429` — the message a create gives a full project, then how many cases the document makes — and nothing is written, no folder or suite either; `?dryRun=true` is refused the same.\n\n**Format.** Testomat’s Classical Tests Markdown Format, verbatim, plus Plune’s columns: `<!-- suite -->` / `<!-- test -->` blocks with `key: value` metadata, the title as the first `#`/`##` heading after the block (its `@tags` are the tags), `## Steps` with `*Expected*:` under each step, `<!-- example -->` tables. Cases before the first suite block are in no suite; a nested suite is its path in the title, `# apps > web > checkout.spec.ts`. Plune’s columns: `id` is the record’s UUID, `updatedAt` is that case’s (or suite’s) `updatedAt` when the document was written (the base version a push holds it to), `type` may be `eval`, `state` is written for the eye and only read back as a warning, `spec` carries an automated case’s specRef, `flaky: true` and `muted: true` are the case’s flags (written only when set; a block that leaves one out keeps the case’s, `false` clears it, and anything but `true`/`false` refuses the block), `estimate: 1h 30m` is the case’s time estimate (#1881; written only when set; a block that leaves it out keeps the case’s, one that is not an estimate refuses the block); `## Preconditions`, `## Expected result`, `## Prompt` and `## Assertions` (a JSON array) are the body. The full reference: https://docs.plune.ai/platform/cases/markdown/."},"response":[{"name":"200 — The report, when the body is a document and the project has room for its new cases; refusals of single blocks are inside it.","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/markdown?dryRun=","host":["{{baseUrl}}"],"path":["v1","test-cases","markdown"],"query":[{"key":"dryRun","value":"","disabled":true,"description":"Report only; nothing is written, not even the folders of a new path."}]},"description":"The same document, edited anywhere, read back (D9). Each `<!-- test -->` block is matched by its `id`: known — the case is updated (title, texts, priority, body, and its suite when the document has suite blocks); absent — a new draft in the suite of the block it sits under, last; unknown — refused by line. Nothing is deleted: a case or suite the document does not name is not touched. A case’s `type` never changes this way, and `state:` is only read back as a warning — states move through their own action. A block (case or suite) with an `id` and an `updatedAt` is written only if it still has that `updatedAt`: one saved by someone since the document was pulled is refused by line (`changed since this document was pulled — pull again and reapply your edit`), with `?dryRun=true` too, and nothing of it is written; a block the case already agrees with is `unchanged` whatever its `updatedAt`. A block with an `id` and no `updatedAt` (a document written by hand, or pulled before this existed) is written over the case or suite as it stands, and a warning says so; an `updatedAt` that is not a time is refused as `updatedAt is not a timestamp — pull again`. A case or suite named by more than one block is judged at the first; the others are refused (`the same case appears more than once in this document`), in a dry run too. A document with no suite block moves nothing, so a single case pasted back keeps its suite. Suite blocks are matched by `id`, else by their path — missing segments are created (folders, then the suite), each with a warning. Each write is its own transaction, as in `bulk`: what was done before a refusal stays done, and the report says which was which. `?dryRun=true` gives the same report and writes nothing — `plune push` shows it before writing. The one refusal that comes before any write: a document whose blocks with no `id` would take the project past its case limit is answered `429` — the message a create gives a full project, then how many cases the document makes — and nothing is written, no folder or suite either; `?dryRun=true` is refused the same.\n\n**Format.** Testomat’s Classical Tests Markdown Format, verbatim, plus Plune’s columns: `<!-- suite -->` / `<!-- test -->` blocks with `key: value` metadata, the title as the first `#`/`##` heading after the block (its `@tags` are the tags), `## Steps` with `*Expected*:` under each step, `<!-- example -->` tables. Cases before the first suite block are in no suite; a nested suite is its path in the title, `# apps > web > checkout.spec.ts`. Plune’s columns: `id` is the record’s UUID, `updatedAt` is that case’s (or suite’s) `updatedAt` when the document was written (the base version a push holds it to), `type` may be `eval`, `state` is written for the eye and only read back as a warning, `spec` carries an automated case’s specRef, `flaky: true` and `muted: true` are the case’s flags (written only when set; a block that leaves one out keeps the case’s, `false` clears it, and anything but `true`/`false` refuses the block), `estimate: 1h 30m` is the case’s time estimate (#1881; written only when set; a block that leaves it out keeps the case’s, one that is not an estimate refuses the block); `## Preconditions`, `## Expected result`, `## Prompt` and `## Assertions` (a JSON array) are the body. The full reference: https://docs.plune.ai/platform/cases/markdown/."},"status":"The report, when the body is a document and the project has room for its new cases; refusals of single blocks are inside it.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": false,\n  \"created\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c2\",\n      \"title\": \"empty cart says so @cart\",\n      \"line\": 12\n    }\n  ],\n  \"updated\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"title\": \"search by sku, the exact match first @smoke @catalog\",\n      \"line\": 1\n    }\n  ],\n  \"unchanged\": [],\n  \"refused\": [],\n  \"suites\": {\n    \"created\": [],\n    \"updated\": []\n  },\n  \"warnings\": [\n    {\n      \"line\": 1,\n      \"message\": \"the block has no base version (`updatedAt:`) — its fields replace the ones the case or suite has now, whatever was saved since the pull; pull again to get one\"\n    }\n  ]\n}"}]},{"name":"Find a case by an identifier another tool uses","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/resolve?value=&kind=","host":["{{baseUrl}}"],"path":["v1","test-cases","resolve"],"query":[{"key":"value","value":"","disabled":true},{"key":"kind","value":"","disabled":true,"description":"One of: playwright-id, path-title, allure-history, cairn-stable, eval-id, qase, testrail, testomat, zephyr."}]},"description":"Without `kind`, the value is matched across every namespace and the highest-ranking one wins; the answer says which it matched. Nothing is created when there is no match — whether a case should exist is for the client to decide (ADR 0010)."},"response":[]},{"name":"Resolve up to 500 identifiers in one request","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    },\n    {\n      \"value\": \"3a9f2c1d7b4e6f80\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/resolve","host":["{{baseUrl}}"],"path":["v1","test-cases","resolve"]},"description":"What a reporter calls once for a whole run rather than once per test. Answers 200 with a null id for every key that matches nothing — a list of questions, each of which may be answered \"no\"."},"response":[{"name":"200 — One answer per key, in the order they were asked","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"keys\": [\n    {\n      \"kind\": \"path-title\",\n      \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n    },\n    {\n      \"value\": \"3a9f2c1d7b4e6f80\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/resolve","host":["{{baseUrl}}"],"path":["v1","test-cases","resolve"]},"description":"What a reporter calls once for a whole run rather than once per test. Answers 200 with a null id for every key that matches nothing — a list of questions, each of which may be answered \"no\"."},"status":"One answer per key, in the order they were asked","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"results\": [\n    {\n      \"key\": {\n        \"kind\": \"path-title\",\n        \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n      },\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"matchedKind\": \"path-title\"\n    },\n    {\n      \"key\": {\n        \"value\": \"3a9f2c1d7b4e6f80\"\n      },\n      \"testCaseId\": null,\n      \"matchedKind\": null\n    }\n  ]\n}"}]}]},{"name":"review","description":"Human-in-the-loop review queue: disputed results and proposed cases","item":[{"name":"One page of the review queue, newest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/review-items?state=&limit=50&cursor=","host":["{{baseUrl}}"],"path":["v1","review-items"],"query":[{"key":"state","value":"","disabled":true,"description":"Narrow to one state; omit for everything. One of: pending, changes_requested, approved, rejected."},{"key":"limit","value":"50","disabled":true,"description":"Page size. The maximum equals the ceiling on items per decision, so a list built from one visible page can never exceed what a single decision accepts."},{"key":"cursor","value":"","disabled":true,"description":"`nextCursor` from the previous page. Omit for the first page."}]},"description":"BREAKING as of R3: this read is always paged. A client that read `items` and never looked at `nextCursor` now sees only the first page. A `limit` outside the range is clamped; an invalid `cursor` is REFUSED rather than answered with the start of the queue -- a silent restart reads as \"nothing changed\", which is how the middle of a queue disappears unnoticed."},"response":[{"name":"200 — One page of your queue","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/review-items?state=&limit=50&cursor=","host":["{{baseUrl}}"],"path":["v1","review-items"],"query":[{"key":"state","value":"","disabled":true,"description":"Narrow to one state; omit for everything. One of: pending, changes_requested, approved, rejected."},{"key":"limit","value":"50","disabled":true,"description":"Page size. The maximum equals the ceiling on items per decision, so a list built from one visible page can never exceed what a single decision accepts."},{"key":"cursor","value":"","disabled":true,"description":"`nextCursor` from the previous page. Omit for the first page."}]},"description":"BREAKING as of R3: this read is always paged. A client that read `items` and never looked at `nextCursor` now sees only the first page. A `limit` outside the range is clamped; an invalid `cursor` is REFUSED rather than answered with the start of the queue -- a silent restart reads as \"nothing changed\", which is how the middle of a queue disappears unnoticed."},"status":"One page of your queue","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"items\": [\n    {\n      \"schemaVersion\": 1,\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b1\",\n      \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n      \"state\": \"pending\",\n      \"note\": \"Passed only on the retry — a flaky selector or a real race?\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"testCaseTitle\": \"Checkout — empty cart @smoke @checkout\",\n      \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"runAt\": \"2026-09-16T09:12:04.151Z\",\n      \"status\": \"passed\",\n      \"outcome\": \"expected\",\n      \"passed\": 0,\n      \"failed\": 0\n    }\n  ],\n  \"nextCursor\": null\n}"}]},{"name":"Queue a result for human review","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n  \"note\": \"Passed only on the retry — a flaky selector or a real race?\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items","host":["{{baseUrl}}"],"path":["v1","review-items"]},"description":"A person queues a disputed result here. The Cairn ingest also queues entries, but they are PROPOSED CASES rather than results (#10, ADR-CI-03) and arrive through /v1/ingest/cairn. One item per result. A reader may propose here (#1440): the item is created `pending` and counts in the pending ceiling like any other; a reader whose body names another `state` or a `rejectionCategory` is refused 403, so a proposal can never be a verdict. Deciding stays with a paid seat."},"response":[{"name":"201 — Queued, `pending`","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n  \"note\": \"Passed only on the retry — a flaky selector or a real race?\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items","host":["{{baseUrl}}"],"path":["v1","review-items"]},"description":"A person queues a disputed result here. The Cairn ingest also queues entries, but they are PROPOSED CASES rather than results (#10, ADR-CI-03) and arrive through /v1/ingest/cairn. One item per result. A reader may propose here (#1440): the item is created `pending` and counts in the pending ceiling like any other; a reader whose body names another `state` or a `rejectionCategory` is refused 403, so a proposal can never be a verdict. Deciding stays with a paid seat."},"status":"Queued, `pending`","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 1,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b1\",\n  \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n  \"state\": \"pending\",\n  \"note\": \"Passed only on the retry — a flaky selector or a real race?\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"One queue entry, with its comparisons","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/review-items/:id","host":["{{baseUrl}}"],"path":["v1","review-items",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The same object the listing returns, assembled by the same code -- so a client holding an id never sees a different shape from one reading the page. An item of another project and an id that was never issued get the same refusal, word for word."},"response":[{"name":"200 — The entry","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/review-items/:id","host":["{{baseUrl}}"],"path":["v1","review-items",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The same object the listing returns, assembled by the same code -- so a client holding an id never sees a different shape from one reading the page. An item of another project and an id that was never issued get the same refusal, word for word."},"status":"The entry","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"schemaVersion\": 1,\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b1\",\n  \"resultId\": \"6f1c2b3a-0000-4000-8000-0000000000e1\",\n  \"state\": \"pending\",\n  \"note\": \"Passed only on the retry — a flaky selector or a real race?\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"testCaseTitle\": \"Checkout — empty cart @smoke @checkout\",\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"runAt\": \"2026-09-16T09:12:04.151Z\",\n  \"status\": \"passed\",\n  \"outcome\": \"expected\",\n  \"passed\": 0,\n  \"failed\": 0\n}"}]},{"name":"Approve or reject an item, or correct a rejection reason — Reject with the full pair","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"rejected\",\n  \"rejectionCategory\": \"duplicate\",\n  \"note\": \"Цей самий сценарій уже покриває кейс «Оформлення замовлення з порожнім кошиком».\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/:id","host":["{{baseUrl}}"],"path":["v1","review-items",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The verdict on one entry — the review item page (`api.ts` → `reviewItem`); a verdict over a whole page goes to `POST /v1/review-items/decisions`. Approving a proposed case is the one road by which a machine-made case becomes a real one (ADR 0010); approving a discovered test creates its case, or puts back the deleted case that still holds its key. A rejection must say why, in a category the agent can act on plus a note, and a corrected reason is refused half-done. An approval that would make a case past the project's ceiling is refused whole with a 429: the item stays as it was (waiting, or rejected if it had been) and nothing is written. This holds for the case that comes back from the bin as well as for a new one."},"response":[]},{"name":"Approve or reject an item, or correct a rejection reason — Correct the reason of an already rejected item","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"rejectionCategory\": \"out-of-scope\",\n  \"note\": \"Перечитав: сценарій не про дублікат, а про звіти, яких у продукті поки немає.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/:id","host":["{{baseUrl}}"],"path":["v1","review-items",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The verdict on one entry — the review item page (`api.ts` → `reviewItem`); a verdict over a whole page goes to `POST /v1/review-items/decisions`. Approving a proposed case is the one road by which a machine-made case becomes a real one (ADR 0010); approving a discovered test creates its case, or puts back the deleted case that still holds its key. A rejection must say why, in a category the agent can act on plus a note, and a corrected reason is refused half-done. An approval that would make a case past the project's ceiling is refused whole with a 429: the item stays as it was (waiting, or rejected if it had been) and nothing is written. This holds for the case that comes back from the bin as well as for a new one."},"response":[]},{"name":"Approve or reject an item, or correct a rejection reason — Approve — the note stays an optional remark","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"approved\",\n  \"note\": \"Крок 2 переписаний під нову форму\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/:id","host":["{{baseUrl}}"],"path":["v1","review-items",":id"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The verdict on one entry — the review item page (`api.ts` → `reviewItem`); a verdict over a whole page goes to `POST /v1/review-items/decisions`. Approving a proposed case is the one road by which a machine-made case becomes a real one (ADR 0010); approving a discovered test creates its case, or puts back the deleted case that still holds its key. A rejection must say why, in a category the agent can act on plus a note, and a corrected reason is refused half-done. An approval that would make a case past the project's ceiling is refused whole with a 429: the item stays as it was (waiting, or rejected if it had been) and nothing is written. This holds for the case that comes back from the bin as well as for a new one."},"response":[]},{"name":"Approve the current revision of an item","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/review-items/:id/approvals","host":["{{baseUrl}}"],"path":["v1","review-items",":id","approvals"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A reviewer (#1276). One approval per person for the item’s current revision; asking twice is one. The verdict that approves (`PATCH /v1/review-items/{id}`) asks for `approvalsRequired` of them when the project’s policy says so (409 with the count when short). The author cannot approve their own item as a reviewer (409). When the project’s auto-merge is on and the approvals asked for stand on the current version with nothing blocking, this approval is also the verdict: the item is approved as the person who gave it, and the log and the author’s notification say it was automatic (#1277)."},"response":[]},{"name":"Ask the author for changes","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"note\": \"The steps do not say which card to use.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/:id/changes-requested","host":["{{baseUrl}}"],"path":["v1","review-items",":id","changes-requested"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A reviewer (#1276). The item’s state becomes `changes_requested` and the words go on it as `changeRequest`; the author’s re-submission of the same proposal puts it back to `pending` and clears the request, and an approval on the old text is gone. While it stands the item cannot be approved (409). A person cannot be the reviewer of the item they put in the queue (409), only a pending item can be asked about (409), and the project’s review policy must be on."},"response":[]},{"name":"Set the reviewers of an item","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"reviewers\": []\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/:id/reviewers","host":["{{baseUrl}}"],"path":["v1","review-items",":id","reviewers"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Add or take away reviewers after the item was made (#1277). The list replaces the one on the item. Only members who may review can be added, and never the person who put the item in the queue (409); a reviewer who is already on the list stays and can be taken away. Only an item that waits (pending, or changes requested) has its reviewers changed."},"response":[]},{"name":"Decide one verdict over an explicit list of queue items — Approve a list of proposals","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"itemIds\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000b1\"\n  ],\n  \"state\": \"approved\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/decisions","host":["{{baseUrl}}"],"path":["v1","review-items","decisions"]},"description":"Applies one verdict to every item in a list. Each item is applied on its own: an approved proposal creates its test case, a disputed result only changes state, and each application is audited separately. Items that cannot change are left untouched and named in the report with a reason, so repeating the decision over just those is the normal way to finish a batch rather than an error path. Partial application is a SUCCESS -- the batch is not a transaction, and one stale item does not undo nineteen sound decisions. A test case is still born only from an approval; the batch is a cheaper way to say the same thing, never a second way in."},"response":[]},{"name":"Decide one verdict over an explicit list of queue items — Reject a list with one reason","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"itemIds\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000b1\"\n  ],\n  \"state\": \"rejected\",\n  \"rejectionCategory\": \"incorrect\",\n  \"note\": \"Очікуваний результат застарів — потік змінився в червневому релізі.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/decisions","host":["{{baseUrl}}"],"path":["v1","review-items","decisions"]},"description":"Applies one verdict to every item in a list. Each item is applied on its own: an approved proposal creates its test case, a disputed result only changes state, and each application is audited separately. Items that cannot change are left untouched and named in the report with a reason, so repeating the decision over just those is the normal way to finish a batch rather than an error path. Partial application is a SUCCESS -- the batch is not a transaction, and one stale item does not undo nineteen sound decisions. A test case is still born only from an approval; the batch is a cheaper way to say the same thing, never a second way in."},"response":[]},{"name":"Offer tests this project has no case for","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"runId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"discovered\": [\n    {\n      \"keys\": [\n        {\n          \"kind\": \"playwright-id\",\n          \"value\": \"pw-cart\"\n        }\n      ],\n      \"title\": \"cart adds an item\",\n      \"source\": \"playwright\",\n      \"specRef\": \"e2e/checkout.spec.ts:12\",\n      \"rawStatus\": \"passed\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/review-items/discovered","host":["{{baseUrl}}"],"path":["v1","review-items","discovered"]},"description":"A runner reports the tests it ran that resolved to no case. Each becomes a queue entry of a third kind -- one that testifies a case is missing rather than describing one -- and a reviewer decides whether the project tracks it. Approving creates an `automated` case and attaches the keys, which is what makes the next run resolve instead of offering the same test again. A source the project trusts (`trustedDiscoverySources`, ADR 0030) is the exception: its tests become cases at once, with no queue entry. A request that would take the project past its case ceiling, or the queue past its own, with what it adds is refused whole with a 429 before anything is written; when another request uses the room in between, the 429 can come after some of the trusted cases were made, and sending the request again writes the rest (a test with a case is `known`)."},"response":[]}]},{"name":"ingest","description":"Cairn run ingest — generated cases arrive as review proposals","item":[{"name":"Ingest a Cairn run","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"schemaVersion\": 1,\n  \"run\": {\n    \"runId\": \"cairn-2026-08-30-0001\",\n    \"mode\": \"api\",\n    \"target\": \"https://shop.example/api\"\n  },\n  \"cases\": [\n    {\n      \"stableId\": \"9f2a4c1e\",\n      \"title\": \"health check answers 200\",\n      \"execution\": \"auto\",\n      \"steps\": [\n        \"GET /health\"\n      ],\n      \"expected\": \"HTTP 200\",\n      \"verdict\": \"passed\",\n      \"technique\": \"happy-path\"\n    },\n    {\n      \"stableId\": \"3b7d0e55\",\n      \"title\": \"order with a negative quantity is refused\",\n      \"execution\": \"auto\",\n      \"steps\": [\n        \"POST /orders\"\n      ],\n      \"expected\": \"HTTP 422\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/ingest/cairn","host":["{{baseUrl}}"],"path":["v1","ingest","cairn"]},"description":"Where `plune ingest <dir>` sends a Cairn run after reading it off disk (ADR-CI-01) — the client is `@plune-ai/cli`, and the dashboard never calls this surface. Generated cases NEVER become test cases here — they are queued as proposals for a human (ADR-CI-03), so ADR 0010 holds. Idempotent per project on `run.runId`: a repeat writes nothing and replays the same counters while that run is live; sent again after it was deleted, the report makes a new run (D33)."},"response":[]}]},{"name":"settings","description":"Project settings — status mapping, trusted discovery sources, the tags of the project, the Steps Database","item":[{"name":"Read this project's settings","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings","host":["{{baseUrl}}"],"path":["v1","settings"]},"description":"No id in the path: routes are user-scoped and the project is the implicit default. A project that has never been configured reads as an empty object, never as absent. The `ETag` names this version of the document: send it back as `If-Match` on `PATCH` to be refused (409) rather than overwrite a save made since."},"response":[{"name":"200 — The settings","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings","host":["{{baseUrl}}"],"path":["v1","settings"]},"description":"No id in the path: routes are user-scoped and the project is the implicit default. A project that has never been configured reads as an empty object, never as absent. The `ETag` names this version of the document: send it back as `If-Match` on `PATCH` to be refused (409) rather than overwrite a save made since."},"status":"The settings","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"statusMapping\": {\n    \"junit\": {\n      \"error\": \"broken\",\n      \"skipped\": \"skipped\"\n    }\n  },\n  \"trustedDiscoverySources\": [\n    \"junit\"\n  ]\n}"}]},{"name":"Replace the settings","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"statusMapping\": {\n    \"junit\": {\n      \"error\": \"broken\",\n      \"skipped\": \"skipped\"\n    }\n  },\n  \"trustedDiscoverySources\": [\n    \"junit\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings","host":["{{baseUrl}}"],"path":["v1","settings"]},"description":"Replace, not merge: a key left out is one the owner removed. Every target of a status mapping must be one of the five statuses. `subStatuses` is the exception: it is the owner’s, written by `PUT /v1/settings/sub-statuses/{status}`, and kept as stored whatever is sent here. The read, the replace and the audit entry are one step under the project’s lock, so two saves queue. `If-Match` (the `ETag` of `GET /v1/settings` or of the last save) is optional: sent, and the document has changed since, nothing is written and the answer is 409 - read again and repeat the change; left out (or `*`), the last save wins, as before."},"response":[{"name":"200 — The settings as stored","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"statusMapping\": {\n    \"junit\": {\n      \"error\": \"broken\",\n      \"skipped\": \"skipped\"\n    }\n  },\n  \"trustedDiscoverySources\": [\n    \"junit\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings","host":["{{baseUrl}}"],"path":["v1","settings"]},"description":"Replace, not merge: a key left out is one the owner removed. Every target of a status mapping must be one of the five statuses. `subStatuses` is the exception: it is the owner’s, written by `PUT /v1/settings/sub-statuses/{status}`, and kept as stored whatever is sent here. The read, the replace and the audit entry are one step under the project’s lock, so two saves queue. `If-Match` (the `ETag` of `GET /v1/settings` or of the last save) is optional: sent, and the document has changed since, nothing is written and the answer is 409 - read again and repeat the change; left out (or `*`), the last save wins, as before."},"status":"The settings as stored","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"statusMapping\": {\n    \"junit\": {\n      \"error\": \"broken\",\n      \"skipped\": \"skipped\"\n    }\n  },\n  \"trustedDiscoverySources\": [\n    \"junit\"\n  ]\n}"}]},{"name":"The project's AI provider settings — shaped by the caller's role","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"An owner reads everything but the credentials themselves (AC-12): provider, model, language, when and by whom credentials were saved, the last provider issue if any, and the current calendar month’s usage (AC-15, UTC). A member reads only whether AI is enabled and which provider/model (AC-06) — the same shape as the \"AI enabled, provider X\" line on SCR-01’s explanation. A reader gets 403: this section does not exist for them at all (AC-06), unlike the draft-availability check, which a reader may still read."},"response":[{"name":"200 — Owner view — connected and enabled","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"An owner reads everything but the credentials themselves (AC-12): provider, model, language, when and by whom credentials were saved, the last provider issue if any, and the current calendar month’s usage (AC-15, UTC). A member reads only whether AI is enabled and which provider/model (AC-06) — the same shape as the \"AI enabled, provider X\" line on SCR-01’s explanation. A reader gets 403: this section does not exist for them at all (AC-06), unlike the draft-availability check, which a reader may still read."},"status":"Settings, role-shaped","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"enabled\": true,\n  \"provider\": \"anthropic\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"credentialsSavedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"credentialsSavedBy\": \"u-owner1\",\n  \"recommendedModels\": {\n    \"anthropic\": \"claude-sonnet-4-5\",\n    \"openrouter\": \"anthropic/claude-haiku-4-5\"\n  },\n  \"models\": {\n    \"anthropic\": [\n      {\n        \"id\": \"claude-sonnet-4-5\",\n        \"inputUsdPerMillion\": 3,\n        \"outputUsdPerMillion\": 15\n      },\n      {\n        \"id\": \"claude-haiku-4-5\",\n        \"inputUsdPerMillion\": 1,\n        \"outputUsdPerMillion\": 5\n      }\n    ],\n    \"openrouter\": [\n      {\n        \"id\": \"anthropic/claude-haiku-4-5\",\n        \"inputUsdPerMillion\": 1.1,\n        \"outputUsdPerMillion\": 5.5\n      },\n      {\n        \"id\": \"deepseek/deepseek-v4.1-flash\",\n        \"inputUsdPerMillion\": 0.035,\n        \"outputUsdPerMillion\": 0.29\n      }\n    ]\n  }\n}"},{"name":"200 — Owner view — nothing connected yet (AC-07’s \"provider not connected\")","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"An owner reads everything but the credentials themselves (AC-12): provider, model, language, when and by whom credentials were saved, the last provider issue if any, and the current calendar month’s usage (AC-15, UTC). A member reads only whether AI is enabled and which provider/model (AC-06) — the same shape as the \"AI enabled, provider X\" line on SCR-01’s explanation. A reader gets 403: this section does not exist for them at all (AC-06), unlike the draft-availability check, which a reader may still read."},"status":"Settings, role-shaped","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": false,\n  \"enabled\": false,\n  \"recommendedModels\": {\n    \"anthropic\": \"claude-sonnet-4-5\",\n    \"openrouter\": \"anthropic/claude-haiku-4-5\"\n  },\n  \"models\": {\n    \"anthropic\": [\n      {\n        \"id\": \"claude-sonnet-4-5\",\n        \"inputUsdPerMillion\": 3,\n        \"outputUsdPerMillion\": 15\n      },\n      {\n        \"id\": \"claude-haiku-4-5\",\n        \"inputUsdPerMillion\": 1,\n        \"outputUsdPerMillion\": 5\n      }\n    ],\n    \"openrouter\": [\n      {\n        \"id\": \"anthropic/claude-haiku-4-5\",\n        \"inputUsdPerMillion\": 1.1,\n        \"outputUsdPerMillion\": 5.5\n      },\n      {\n        \"id\": \"deepseek/deepseek-v4.1-flash\",\n        \"inputUsdPerMillion\": 0.035,\n        \"outputUsdPerMillion\": 0.29\n      }\n    ]\n  }\n}"},{"name":"200 — Member view — state, provider and model only (AC-06)","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"An owner reads everything but the credentials themselves (AC-12): provider, model, language, when and by whom credentials were saved, the last provider issue if any, and the current calendar month’s usage (AC-15, UTC). A member reads only whether AI is enabled and which provider/model (AC-06) — the same shape as the \"AI enabled, provider X\" line on SCR-01’s explanation. A reader gets 403: this section does not exist for them at all (AC-06), unlike the draft-availability check, which a reader may still read."},"status":"Settings, role-shaped","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"configured\": true,\n  \"enabled\": true,\n  \"provider\": \"anthropic\",\n  \"model\": \"claude-sonnet-5\"\n}"},{"name":"403 — a reader asked (AC-06)","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"An owner reads everything but the credentials themselves (AC-12): provider, model, language, when and by whom credentials were saved, the last provider issue if any, and the current calendar month’s usage (AC-15, UTC). A member reads only whether AI is enabled and which provider/model (AC-06) — the same shape as the \"AI enabled, provider X\" line on SCR-01’s explanation. A reader gets 403: this section does not exist for them at all (AC-06), unlike the draft-availability check, which a reader may still read."},"status":"a reader asked (AC-06)","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI settings are not visible to a reader\"\n}"}]},{"name":"Enable or disable AI, or change the model or draft language, without touching credentials — disable","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"enabled\": false\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only. Disabling keeps the credentials for a later re-enable (AC-20); a draft already `awaiting_decision` is unaffected either way (AC-20) — new requests are what gets refused while AI is off (AC-07). Re-enabling with no prior connect (no row yet) answers 409: `PUT` connects first. `model` and `language` change independently of credentials, per SCR-03’s \"choose model\" being its own action, separate from \"replace credentials\"."},"response":[{"name":"403 — a member or a reader asked","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"enabled\": false\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only. Disabling keeps the credentials for a later re-enable (AC-20); a draft already `awaiting_decision` is unaffected either way (AC-20) — new requests are what gets refused while AI is off (AC-07). Re-enabling with no prior connect (no row yet) answers 409: `PUT` connects first. `model` and `language` change independently of credentials, per SCR-03’s \"choose model\" being its own action, separate from \"replace credentials\"."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — no provider connected yet — `PUT` first","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"enabled\": false\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only. Disabling keeps the credentials for a later re-enable (AC-20); a draft already `awaiting_decision` is unaffected either way (AC-20) — new requests are what gets refused while AI is off (AC-07). Re-enabling with no prior connect (no row yet) answers 409: `PUT` connects first. `model` and `language` change independently of credentials, per SCR-03’s \"choose model\" being its own action, separate from \"replace credentials\"."},"status":"no provider connected yet — `PUT` first","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI is not connected yet — connect a provider first\"\n}"}]},{"name":"Enable or disable AI, or change the model or draft language, without touching credentials — changeModel","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"model\": \"claude-opus-5-5\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only. Disabling keeps the credentials for a later re-enable (AC-20); a draft already `awaiting_decision` is unaffected either way (AC-20) — new requests are what gets refused while AI is off (AC-07). Re-enabling with no prior connect (no row yet) answers 409: `PUT` connects first. `model` and `language` change independently of credentials, per SCR-03’s \"choose model\" being its own action, separate from \"replace credentials\"."},"response":[]},{"name":"Connect the project's AI provider, or replace its provider, model or credentials — AC-05 — connect a provider from the list","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"response":[{"name":"400 — missingAddress","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"status":"Invalid body, or a rejected address (AC-22) — the message never names the resolved address","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — customAddress: required when provider is custom\"\n}"},{"name":"400 — notAccepted","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"status":"Invalid body, or a rejected address (AC-22) — the message never names the resolved address","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — acceptedDisclaimer: must be true\"\n}"},{"name":"400 — AC-22","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"status":"Invalid body, or a rejected address (AC-22) — the message never names the resolved address","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"address rejected — must be an https address\"\n}"},{"name":"400 — AC-22 — resolves to a private, loopback or link-local address, now or later","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"status":"Invalid body, or a rejected address (AC-22) — the message never names the resolved address","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"address rejected — must resolve to a public address\"\n}"},{"name":"403 — a member or a reader asked","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"503 — SAD §7/§11 — no server encryption key; nothing is saved","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"anthropic\",\n  \"apiKey\": \"sk-ant-example-not-a-real-key\",\n  \"model\": \"claude-sonnet-5\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"status":"SAD §7/§11 — no server encryption key; nothing is saved","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI platform not configured — contact your operator\"\n}"}]},{"name":"Connect the project's AI provider, or replace its provider, model or credentials — AC-05, AC-22 — a self-hosted OpenAI-compatible address","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"custom\",\n  \"customAddress\": \"https://models.example.test/v1\",\n  \"apiKey\": \"example-not-a-real-key\",\n  \"model\": \"local-llama-70b\",\n  \"language\": \"en\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai","host":["{{baseUrl}}"],"path":["v1","settings","ai"]},"description":"Owner only (AC-05). Encrypts the credentials with the server’s application key, bound to this project (ADR-0003) — the plaintext is never stored and never echoed back. `customAddress` is required exactly when `provider` is `custom` and is checked for being a public https address both here and on every later call (ADR-0005, AC-22); rejected here, the response never says which internal address it resolved to. `acceptedDisclaimer` must be sent `true` — the \"code goes to this provider on every request\" acknowledgement (AC-05), not stored beyond this check. A call that replaces an existing connection keeps `enabled` as it already was unless the caller changes it in the same call; a first connect always sets `enabled: true`."},"response":[]},{"name":"Remove the stored credentials permanently and disable AI","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai/credentials","host":["{{baseUrl}}"],"path":["v1","settings","ai","credentials"]},"description":"Owner only (AC-20). The ciphertext is deleted without a recovery path. AI is left disabled; connecting again (`PUT`) starts over from AC-05. A draft already `awaiting_decision` is unaffected (AC-20)."},"response":[{"name":"403 — a member or a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai/credentials","host":["{{baseUrl}}"],"path":["v1","settings","ai","credentials"]},"description":"Owner only (AC-20). The ciphertext is deleted without a recovery path. AI is left disabled; connecting again (`PUT`) starts over from AC-05. A draft already `awaiting_decision` is unaffected (AC-20)."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no provider connected — nothing to remove","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai/credentials","host":["{{baseUrl}}"],"path":["v1","settings","ai","credentials"]},"description":"Owner only (AC-20). The ciphertext is deleted without a recovery path. AI is left disabled; connecting again (`PUT`) starts over from AC-05. A draft already `awaiting_decision` is unaffected (AC-20)."},"status":"no provider connected — nothing to remove","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI is not connected\"\n}"}]},{"name":"Change the fallback provider’s model","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"model\": \"gpt-5-mini\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The model only - the provider and the key are replaced with `PUT`. 404 when no fallback is set."},"response":[{"name":"403 — a member or a reader asked","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"model\": \"gpt-5-mini\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The model only - the provider and the key are replaced with `PUT`. 404 when no fallback is set."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no fallback is set","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"model\": \"gpt-5-mini\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The model only - the provider and the key are replaced with `PUT`. 404 when no fallback is set."},"status":"no fallback is set","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"No fallback is set\"\n}"},{"name":"409 — the primary is not connected yet - `PUT /v1/settings/ai` first","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"model\": \"gpt-5-mini\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The model only - the provider and the key are replaced with `PUT`. 404 when no fallback is set."},"status":"the primary is not connected yet - `PUT /v1/settings/ai` first","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI is not connected yet — connect a provider first\"\n}"}]},{"name":"Connect or replace the fallback AI provider","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"response":[{"name":"400 — notAccepted","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"status":"Invalid body, a rejected address, or the same as the primary","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — acceptedDisclaimer: must be true\"\n}"},{"name":"400 — notHttps","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"status":"Invalid body, a rejected address, or the same as the primary","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"address rejected — must be an https address\"\n}"},{"name":"400 — sameAsPrimary","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"status":"Invalid body, a rejected address, or the same as the primary","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"The fallback cannot be the same as the primary: the same provider, address and model\"\n}"},{"name":"403 — a member or a reader asked","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — the primary is not connected yet - `PUT /v1/settings/ai` first","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"status":"the primary is not connected yet - `PUT /v1/settings/ai` first","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI is not connected yet — connect a provider first\"\n}"},{"name":"503 — no server encryption key; nothing is saved","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"provider\": \"openrouter\",\n  \"apiKey\": \"sk-example-not-a-real-key\",\n  \"model\": \"gpt-5\",\n  \"acceptedDisclaimer\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829 (ADR 0052): owner only. An optional second provider with its own key; the code goes to it only when the primary fails. The primary must be connected first (409). The fallback cannot be the same as the primary: the same provider, address and model (400). A `custom` address goes through the same guard as the primary’s (https, public address) before anything is saved. The key is write-only."},"status":"no server encryption key; nothing is saved","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI platform not configured — contact your operator\"\n}"}]},{"name":"Remove the fallback provider and its key","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The fallback’s ciphertext is deleted without a recovery path; the primary stays as it was."},"response":[{"name":"403 — a member or a reader asked","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The fallback’s ciphertext is deleted without a recovery path; the primary stays as it was."},"status":"a member or a reader asked","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"404 — no fallback is set","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/ai/fallback","host":["{{baseUrl}}"],"path":["v1","settings","ai","fallback"]},"description":"#829: owner only. The fallback’s ciphertext is deleted without a recovery path; the primary stays as it was."},"status":"no fallback is set","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"No fallback is set\"\n}"}]},{"name":"The Steps Database — every step of the project with its usage count","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps?q=","host":["{{baseUrl}}"],"path":["v1","settings","steps"],"query":[{"key":"q","value":"","disabled":true,"description":"A prefix of the step text. Blank or absent means every step."}]},"description":"Testomat’s Steps Database, as a list: every `*` line under `## Steps` of a live manual case is a row here, written on the case’s save (D10), plus the steps made by hand. `count` is the live cases carrying the step — a soft-deleted case (D21) drops out of it, a restored one comes back. Ordered case-insensitively by text, then verbatim. `q` narrows to steps that START with it, case-insensitively — the autocomplete of the case form asks this way."},"response":[{"name":"200 — The steps","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps?q=","host":["{{baseUrl}}"],"path":["v1","settings","steps"],"query":[{"key":"q","value":"","disabled":true,"description":"A prefix of the step text. Blank or absent means every step."}]},"description":"Testomat’s Steps Database, as a list: every `*` line under `## Steps` of a live manual case is a row here, written on the case’s save (D10), plus the steps made by hand. `count` is the live cases carrying the step — a soft-deleted case (D21) drops out of it, a restored one comes back. Ordered case-insensitively by text, then verbatim. `q` narrows to steps that START with it, case-insensitively — the autocomplete of the case form asks this way."},"status":"The steps","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"steps\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f0\",\n      \"step\": \"Enter ${Amount} and confirm.\",\n      \"count\": 1\n    },\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f1\",\n      \"step\": \"Sign in as the shop owner.\",\n      \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\",\n      \"count\": 0\n    }\n  ]\n}"}]},{"name":"Add a step by hand","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"step\": \"Log in as the shop owner.\",\n  \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps","host":["{{baseUrl}}"],"path":["v1","settings","steps"]},"description":"A row no case carries yet — `count: 0` — so it is offered by the autocomplete before anyone writes it into a case. The text is trimmed; the same text again is a 409 that carries the existing row, so a form can jump to it."},"response":[{"name":"201 — Created","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"step\": \"Log in as the shop owner.\",\n  \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps","host":["{{baseUrl}}"],"path":["v1","settings","steps"]},"description":"A row no case carries yet — `count: 0` — so it is offered by the autocomplete before anyone writes it into a case. The text is trimmed; the same text again is a 409 that carries the existing row, so a form can jump to it."},"status":"Created","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f1\",\n  \"step\": \"Log in as the shop owner.\",\n  \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\",\n  \"count\": 0\n}"},{"name":"409 — the project already has that step — the answer carries it","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"step\": \"Log in as the shop owner.\",\n  \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps","host":["{{baseUrl}}"],"path":["v1","settings","steps"]},"description":"A row no case carries yet — `count: 0` — so it is offered by the autocomplete before anyone writes it into a case. The text is trimmed; the same text again is a 409 that carries the existing row, so a form can jump to it."},"status":"the project already has that step — the answer carries it","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"step 'Log in as the shop owner.' already exists\",\n  \"step\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f1\",\n    \"step\": \"Log in as the shop owner.\",\n    \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\",\n    \"count\": 0\n  }\n}"}]},{"name":"Delete every step no case carries","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps?unused=","host":["{{baseUrl}}"],"path":["v1","settings","steps"],"query":[{"key":"unused","value":"","disabled":true,"description":"One of: true."}]},"description":"«Delete unused»: the rows with `count: 0` go — steps made by hand and never used, and steps orphaned when a case was edited. `?unused=true` is the only form; without it the request is refused (400): a bulk delete of USED steps would be a rewrite of cases nobody asked for."},"response":[{"name":"200 — How many rows went","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps?unused=","host":["{{baseUrl}}"],"path":["v1","settings","steps"],"query":[{"key":"unused","value":"","disabled":true,"description":"One of: true."}]},"description":"«Delete unused»: the rows with `count: 0` go — steps made by hand and never used, and steps orphaned when a case was edited. `?unused=true` is the only form; without it the request is refused (400): a bulk delete of USED steps would be a rewrite of cases nobody asked for."},"status":"How many rows went","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"deleted\": 2\n}"},{"name":"400 — `unused=true` is missing","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps?unused=","host":["{{baseUrl}}"],"path":["v1","settings","steps"],"query":[{"key":"unused","value":"","disabled":true,"description":"One of: true."}]},"description":"«Delete unused»: the rows with `count: 0` go — steps made by hand and never used, and steps orphaned when a case was edited. `?unused=true` is the only form; without it the request is refused (400): a bulk delete of USED steps would be a rewrite of cases nobody asked for."},"status":"`unused=true` is missing","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"pass ?unused=true — only unused steps are deleted in bulk\"\n}"}]},{"name":"Rename a step in every case that carries it, or change its note — The text — every carrier follows","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"step\": \"Sign in as the shop owner.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps/:id","host":["{{baseUrl}}"],"path":["v1","settings","steps",":id"],"variable":[{"key":"id","value":""}]},"description":"A new `step` is written into the action of every live case carrying the old one — one transaction, each case gets its own audit entry with the reason and a version bump (D10). When the new text already exists the rename is a merge into that row: `merged: true` and `step.id` is the survivor’s. A `description` alone touches only the row. A case edited between the read and this write keeps what its form saved and is skipped; its link is rebuilt on its next save. A rename of a step that more than 1 000 cases carry is a 413, refused before anything is written; a `description` alone touches no case and is never refused for it."},"response":[{"name":"200 — The step as it is now, and how many cases followed","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"step\": \"Sign in as the shop owner.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps/:id","host":["{{baseUrl}}"],"path":["v1","settings","steps",":id"],"variable":[{"key":"id","value":""}]},"description":"A new `step` is written into the action of every live case carrying the old one — one transaction, each case gets its own audit entry with the reason and a version bump (D10). When the new text already exists the rename is a merge into that row: `merged: true` and `step.id` is the survivor’s. A `description` alone touches only the row. A case edited between the read and this write keeps what its form saved and is skipped; its link is rebuilt on its next save. A rename of a step that more than 1 000 cases carry is a 413, refused before anything is written; a `description` alone touches no case and is never refused for it."},"status":"The step as it is now, and how many cases followed","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"step\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f1\",\n    \"step\": \"Sign in as the shop owner.\",\n    \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\",\n    \"count\": 0\n  },\n  \"affected\": 0\n}"}]},{"name":"Rename a step in every case that carries it, or change its note — The note — no case is touched","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"description\": \"The owner account of the demo shop; its credentials are in the team vault.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps/:id","host":["{{baseUrl}}"],"path":["v1","settings","steps",":id"],"variable":[{"key":"id","value":""}]},"description":"A new `step` is written into the action of every live case carrying the old one — one transaction, each case gets its own audit entry with the reason and a version bump (D10). When the new text already exists the rename is a merge into that row: `merged: true` and `step.id` is the survivor’s. A `description` alone touches only the row. A case edited between the read and this write keeps what its form saved and is skipped; its link is rebuilt on its next save. A rename of a step that more than 1 000 cases carry is a 413, refused before anything is written; a `description` alone touches no case and is never refused for it."},"response":[]},{"name":"Delete one step nobody carries","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps/:id","host":["{{baseUrl}}"],"path":["v1","settings","steps",":id"],"variable":[{"key":"id","value":""}]},"description":"Only a step with `count: 0`. One that cases carry is refused (409) — rename or merge it instead; deleting it would mean rewriting those cases."},"response":[{"name":"409 — cases carry the step","originalRequest":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/steps/:id","host":["{{baseUrl}}"],"path":["v1","settings","steps",":id"],"variable":[{"key":"id","value":""}]},"description":"Only a step with `count: 0`. One that cases carry is refused (409) — rename or merge it instead; deleting it would mean rewriting those cases."},"status":"cases carry the step","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the step is used by cases — rename or merge it instead\"\n}"}]},{"name":"Fold several steps into one text","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000f1\"\n  ],\n  \"into\": \"Open the order and choose “Refund”.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps/merge","host":["{{baseUrl}}"],"path":["v1","settings","steps","merge"]},"description":"Every live case carrying any step in `ids` has that action rewritten to `into` — the same per-case write as a rename, one transaction, one audit entry per case (D10). `into` need not exist yet: an existing row of that text is the survivor and keeps its note; else the first of `ids` is renamed in place. The other rows go with their links. An unknown id is a 404 before anything is written. One call rewrites at most 1 000 cases — counted once each, however many of the steps in `ids` it carries: more is a 413, refused before anything is written. The custom field values (#1764) of the rows that go are added to the survivor: its own value wins a field both hold, and between the others the earlier in `ids` wins. When the combination is over the 16 KiB limit the merge is a 409 and nothing is written."},"response":[{"name":"200 — The surviving step and how many cases were rewritten","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000f1\"\n  ],\n  \"into\": \"Open the order and choose “Refund”.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/steps/merge","host":["{{baseUrl}}"],"path":["v1","settings","steps","merge"]},"description":"Every live case carrying any step in `ids` has that action rewritten to `into` — the same per-case write as a rename, one transaction, one audit entry per case (D10). `into` need not exist yet: an existing row of that text is the survivor and keeps its note; else the first of `ids` is renamed in place. The other rows go with their links. An unknown id is a 404 before anything is written. One call rewrites at most 1 000 cases — counted once each, however many of the steps in `ids` it carries: more is a 413, refused before anything is written. The custom field values (#1764) of the rows that go are added to the survivor: its own value wins a field both hold, and between the others the earlier in `ids` wins. When the combination is over the 16 KiB limit the merge is a 409 and nothing is written."},"status":"The surviving step and how many cases were rewritten","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"step\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f3\",\n    \"step\": \"Open the order and choose “Refund”.\",\n    \"count\": 1\n  },\n  \"affected\": 0\n}"}]},{"name":"The project's sub-statuses, with the results carrying each","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/sub-statuses","host":["{{baseUrl}}"],"path":["v1","settings","sub-statuses"]},"description":"Settings → Statuses (#1231): the words a result can carry under its status — why a case failed, what blocked it. A person picks one when marking a result in a manual run (`PATCH /v1/results/{id}`), and the run report filters by them (`GET /v1/runs/{id}/results?subStatus=`). Four come with every project and cannot be renamed or removed — passed: Minor issue; failed: Product bug, Environment issue; blocked: Waiting for a fix. Every role reads them."},"response":[{"name":"200 — Per status, the built-ins and then the project’s own","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/sub-statuses","host":["{{baseUrl}}"],"path":["v1","settings","sub-statuses"]},"description":"Settings → Statuses (#1231): the words a result can carry under its status — why a case failed, what blocked it. A person picks one when marking a result in a manual run (`PATCH /v1/results/{id}`), and the run report filters by them (`GET /v1/runs/{id}/results?subStatus=`). Four come with every project and cannot be renamed or removed — passed: Minor issue; failed: Product bug, Environment issue; blocked: Waiting for a fix. Every role reads them."},"status":"Per status, the built-ins and then the project’s own","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"subStatuses\": [\n    {\n      \"status\": \"passed\",\n      \"name\": \"Minor issue\",\n      \"builtIn\": true,\n      \"results\": 6\n    },\n    {\n      \"status\": \"failed\",\n      \"name\": \"Product bug\",\n      \"builtIn\": true,\n      \"results\": 9\n    },\n    {\n      \"status\": \"failed\",\n      \"name\": \"Environment issue\",\n      \"builtIn\": true,\n      \"results\": 14\n    },\n    {\n      \"status\": \"failed\",\n      \"name\": \"Test is outdated\",\n      \"builtIn\": false,\n      \"results\": 4\n    },\n    {\n      \"status\": \"blocked\",\n      \"name\": \"Waiting for a fix\",\n      \"builtIn\": true,\n      \"results\": 5\n    }\n  ]\n}"}]},{"name":"Replace the project's own sub-statuses under one status","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"names\": [\n    \"Test is outdated\",\n    \"Flaky test\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/sub-statuses/:status","host":["{{baseUrl}}"],"path":["v1","settings","sub-statuses",":status"],"variable":[{"key":"status","value":"","description":"One of: passed, failed, broken, blocked, skipped."}]},"description":"Add, rename and remove are this one write: the list is what the owner wants under the status now, and an empty one removes them all. A result keeps the name it was marked with, so nothing is carried over and no past run changes. At most 10 of the project’s own per status — the built-ins are on top — each 1–40 characters after trimming; none twice and none a built-in’s, both in any case. The project owner’s alone (403 for a member and a reader). One audit entry, the settings before and after. The CLI and the reporters set no sub-status."},"response":[{"name":"400 — not one of the five statuses, or a list the settings cannot hold — too long a name, too many, one twice, a built-in’s","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"names\": [\n    \"Test is outdated\",\n    \"Flaky test\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/sub-statuses/:status","host":["{{baseUrl}}"],"path":["v1","settings","sub-statuses",":status"],"variable":[{"key":"status","value":"","description":"One of: passed, failed, broken, blocked, skipped."}]},"description":"Add, rename and remove are this one write: the list is what the owner wants under the status now, and an empty one removes them all. A result keeps the name it was marked with, so nothing is carried over and no past run changes. At most 10 of the project’s own per status — the built-ins are on top — each 1–40 characters after trimming; none twice and none a built-in’s, both in any case. The project owner’s alone (403 for a member and a reader). One audit entry, the settings before and after. The CLI and the reporters set no sub-status."},"status":"not one of the five statuses, or a list the settings cannot hold — too long a name, too many, one twice, a built-in’s","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — failed.1: 'flaky' is there twice\"\n}"},{"name":"403 — not the project’s owner","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"names\": [\n    \"Test is outdated\",\n    \"Flaky test\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/sub-statuses/:status","host":["{{baseUrl}}"],"path":["v1","settings","sub-statuses",":status"],"variable":[{"key":"status","value":"","description":"One of: passed, failed, broken, blocked, skipped."}]},"description":"Add, rename and remove are this one write: the list is what the owner wants under the status now, and an empty one removes them all. A result keeps the name it was marked with, so nothing is carried over and no past run changes. At most 10 of the project’s own per status — the built-ins are on top — each 1–40 characters after trimming; none twice and none a built-in’s, both in any case. The project owner’s alone (403 for a member and a reader). One audit entry, the settings before and after. The CLI and the reporters set no sub-status."},"status":"not the project’s owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"}]},{"name":"The project's tags with usage counts","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/tags","host":["{{baseUrl}}"],"path":["v1","settings","tags"]},"description":"A separate route, not a field of `GET /v1/settings`: the counters are a query over the cases, the settings are one row (B-7 AC-10). A tag exists only while a case carries it — there is no tag with a count of 0 and no \"delete tag\": the last case losing it, or a merge, is what removes it. Ordered case-insensitively by tag, then verbatim, so `Smoke` and `smoke` sit next to each other — the pair the owner came here to merge. An empty project answers with an empty list."},"response":[{"name":"200 — The tags","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/settings/tags","host":["{{baseUrl}}"],"path":["v1","settings","tags"]},"description":"A separate route, not a field of `GET /v1/settings`: the counters are a query over the cases, the settings are one row (B-7 AC-10). A tag exists only while a case carries it — there is no tag with a count of 0 and no \"delete tag\": the last case losing it, or a merge, is what removes it. Ordered case-insensitively by tag, then verbatim, so `Smoke` and `smoke` sit next to each other — the pair the owner came here to merge. An empty project answers with an empty list."},"status":"The tags","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"tags\": [\n    {\n      \"tag\": \"api\",\n      \"count\": 41\n    },\n    {\n      \"tag\": \"Smoke\",\n      \"count\": 3\n    },\n    {\n      \"tag\": \"smoke\",\n      \"count\": 118\n    }\n  ]\n}"}]},{"name":"Fold several tags into one that stays","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"from\": [\n    \"smoke-test\",\n    \"smoketest\"\n  ],\n  \"into\": \"smoke\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/tags/merge","host":["{{baseUrl}}"],"path":["v1","settings","tags","merge"]},"description":"\"Keep this one\": every case carrying any tag in `from` has those tags replaced by `into` in its title — one occurrence, however many it carried — through the same per-case write as rename (AC-10, AC-23). `into` need not exist yet; it is checked against the tag rule (400). `from` must not contain `into` (400). One transaction; each case gets its own audit entry; text versions do not move. One call rewrites at most 1 000 cases — counted once each, however many of the tags in `from` it carries: more is a 413, refused before anything is written."},"response":[{"name":"400 — `into` is not a valid tag, `from` contains `into`, or the body is invalid","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"from\": [\n    \"smoke-test\",\n    \"smoketest\"\n  ],\n  \"into\": \"smoke\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/tags/merge","host":["{{baseUrl}}"],"path":["v1","settings","tags","merge"]},"description":"\"Keep this one\": every case carrying any tag in `from` has those tags replaced by `into` in its title — one occurrence, however many it carried — through the same per-case write as rename (AC-10, AC-23). `into` need not exist yet; it is checked against the tag rule (400). `from` must not contain `into` (400). One transaction; each case gets its own audit entry; text versions do not move. One call rewrites at most 1 000 cases — counted once each, however many of the tags in `from` it carries: more is a 413, refused before anything is written."},"status":"`into` is not a valid tag, `from` contains `into`, or the body is invalid","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — from: must not contain the tag kept by 'into'\"\n}"}]},{"name":"Rename a tag in every case that carries it","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"from\": \"Smoke\",\n  \"to\": \"smoke\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/tags/rename","host":["{{baseUrl}}"],"path":["v1","settings","tags","rename"]},"description":"Every case carrying `from` has its title rewritten with `to` in place of `from` — the clean title, then the tags, each once, as the case form writes it — so each case gets its own audit entry with a diff and its text version does not move (AC-09, AC-10). One transaction for all of them. `to` is checked against the tag rule first (400). If `to` already exists the outcome is a merge of `from` into `to`: a case carrying both ends up with one. A `from` nobody carries affects nothing and is not an error. For an `automated` case the title in the code stays as it was — the dashboard warns before confirming. One call rewrites at most 1 000 cases: more is a 413, refused before anything is written."},"response":[{"name":"400 — the new name is not a valid tag, or the body is invalid","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"from\": \"Smoke\",\n  \"to\": \"smoke\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/settings/tags/rename","host":["{{baseUrl}}"],"path":["v1","settings","tags","rename"]},"description":"Every case carrying `from` has its title rewritten with `to` in place of `from` — the clean title, then the tags, each once, as the case form writes it — so each case gets its own audit entry with a diff and its text version does not move (AC-09, AC-10). One transaction for all of them. `to` is checked against the tag rule first (400). If `to` already exists the outcome is a merge of `from` into `to`: a case carrying both ends up with one. A `from` nobody carries affects nothing and is not an error. For an `automated` case the title in the code stays as it was — the dashboard warns before confirming. One call rewrites at most 1 000 cases: more is a 413, refused before anything is written."},"status":"the new name is not a valid tag, or the body is invalid","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"invalid tag 'regression suite' — a tag is 1 to 119 characters from \\\\w = - _ ( ) . : &\"\n}"}]}]},{"name":"quotas","description":"What a project may hold and what is left of it: runs in the last 24 hours, test cases, items waiting in the review queue","item":[{"name":"The project’s quotas and what is left","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/quotas","host":["{{baseUrl}}"],"path":["v1","quotas"]},"description":"Each ceiling of the project with `limit`, `used`, `remaining` and `resetsAt` — the numbers a write is judged by, so a refusal is never a surprise. The run quota is a rolling 24 hours (`resetsAt` is when its oldest run ages out); the case and review-queue quotas are working sets that only an action shrinks (`resetsAt` is `null`). A reader of the project may call it: a project over a quota can still see why."},"response":[]}]},{"name":"refs","description":"The `@T…` / `@S…` references written in a case’s text — which of them name a case or a suite of the project","item":[{"name":"Which references name a case or a suite","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/refs?ref=","host":["{{baseUrl}}"],"path":["v1","refs"],"query":[{"key":"ref","value":"","disabled":true}]},"description":"A reference is `@T` (a case) or `@S` (a suite) and the first eight characters of the id, e.g. `@T1a2b3c4d`; the app shows one in a description as a link. Ask with `ref` repeated (up to 50). Only the references that name a live case or suite of the project come back; a reference nothing answers to, one in the bin, another project’s, or one that two ids start with is left out, and the screen shows it as an error."},"response":[{"name":"200 — The references that resolve","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/refs?ref=","host":["{{baseUrl}}"],"path":["v1","refs"],"query":[{"key":"ref","value":"","disabled":true}]},"description":"A reference is `@T` (a case) or `@S` (a suite) and the first eight characters of the id, e.g. `@T1a2b3c4d`; the app shows one in a description as a link. Ask with `ref` repeated (up to 50). Only the references that name a live case or suite of the project come back; a reference nothing answers to, one in the bin, another project’s, or one that two ids start with is left out, and the screen shows it as an error."},"status":"The references that resolve","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"refs\": [\n    {\n      \"ref\": \"@T1a2b3c4d\",\n      \"kind\": \"case\",\n      \"id\": \"1a2b3c4d-0000-4000-8000-000000000001\",\n      \"title\": \"Pay with a saved card\"\n    }\n  ]\n}"}]}]},{"name":"widgets","description":"Widgets of the project’s dashboard (ADR 0063): a stored recipe of up to five entity blocks over one time axis, and the numbers behind it, each with its denominator","item":[{"name":"The project’s widgets","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets","host":["{{baseUrl}}"],"path":["v1","widgets"]},"description":"The active widgets of the project, in dashboard order. Every member sees them all; a reader reads and cannot write."},"response":[{"name":"200 — The widgets","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets","host":["{{baseUrl}}"],"path":["v1","widgets"]},"description":"The active widgets of the project, in dashboard order. Every member sees them all; a reader reads and cannot write."},"status":"The widgets","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"widgets\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b4\",\n      \"projectId\": \"u-shop::default\",\n      \"title\": \"Automated runs and failures\",\n      \"config\": {\n        \"schemaVersion\": 1,\n        \"render\": \"line\",\n        \"bucket\": \"day\",\n        \"accumulated\": false,\n        \"blocks\": [\n          {\n            \"entity\": \"run\",\n            \"metric\": \"count\",\n            \"filters\": [\n              {\n                \"property\": \"kind\",\n                \"values\": [\n                  \"automated\"\n                ]\n              }\n            ]\n          },\n          {\n            \"entity\": \"run_result\",\n            \"metric\": \"count\",\n            \"filters\": [\n              {\n                \"property\": \"status\",\n                \"values\": [\n                  \"failed\",\n                  \"broken\"\n                ]\n              }\n            ]\n          }\n        ],\n        \"segment\": {\n          \"by\": \"label\",\n          \"values\": [\n            \"nightly\"\n          ]\n        }\n      },\n      \"position\": 0,\n      \"createdBy\": \"u-shop\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"}]},{"name":"Create a widget","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Automated runs and failures\",\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/widgets","host":["{{baseUrl}}"],"path":["v1","widgets"]},"description":"Saves a recipe in the project. The recipe is judged whole (400 with the field that is wrong) and its static cost against a 90-day reference (400 `widget_too_expensive`, with the numbers). At most 30 active widgets a project (409). Owner or member; a reader gets 403."},"response":[{"name":"201 — Created","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Automated runs and failures\",\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/widgets","host":["{{baseUrl}}"],"path":["v1","widgets"]},"description":"Saves a recipe in the project. The recipe is judged whole (400 with the field that is wrong) and its static cost against a 90-day reference (400 `widget_too_expensive`, with the numbers). At most 30 active widgets a project (409). Owner or member; a reader gets 403."},"status":"Created","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b4\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Automated runs and failures\",\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  },\n  \"position\": 0,\n  \"createdBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"One widget","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets/:id","host":["{{baseUrl}}"],"path":["v1","widgets",":id"],"variable":[{"key":"id","value":""}]},"description":"By id, from the caller’s project only — another project’s widget reads as 404, never as 403."},"response":[{"name":"200 — The widget","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets/:id","host":["{{baseUrl}}"],"path":["v1","widgets",":id"],"variable":[{"key":"id","value":""}]},"description":"By id, from the caller’s project only — another project’s widget reads as 404, never as 403."},"status":"The widget","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b4\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Automated runs and failures\",\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  },\n  \"position\": 0,\n  \"createdBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Change a widget","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Runs and failures\",\n  \"position\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/widgets/:id","host":["{{baseUrl}}"],"path":["v1","widgets",":id"],"variable":[{"key":"id","value":""}]},"description":"Title, recipe or place (`position`). The recipe is judged and costed as on creation."},"response":[{"name":"200 — The widget as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Runs and failures\",\n  \"position\": 1\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/widgets/:id","host":["{{baseUrl}}"],"path":["v1","widgets",":id"],"variable":[{"key":"id","value":""}]},"description":"Title, recipe or place (`position`). The recipe is judged and costed as on creation."},"status":"The widget as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b4\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Runs and failures\",\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  },\n  \"position\": 1,\n  \"createdBy\": \"u-shop\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Archive a widget","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets/:id","host":["{{baseUrl}}"],"path":["v1","widgets",":id"],"variable":[{"key":"id","value":""}]},"description":"The widget leaves the dashboard and frees its place among the 30; the row stays, and the audit feed says who archived it."},"response":[]},{"name":"The numbers of a saved widget","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets/:id/data?from=&to=&tz=","host":["{{baseUrl}}"],"path":["v1","widgets",":id","data"],"query":[{"key":"from","value":"","disabled":true,"description":"Format: date-time."},{"key":"to","value":"","disabled":true,"description":"Format: date-time."},{"key":"tz","value":"","disabled":true}],"variable":[{"key":"id","value":""}]},"description":"The widget’s recipe asked of the data for a period: one shared time axis for every block, series capped at 50 (a table at 500 rows) with `truncated` and the codes in `notMeasured`, groups past 20 folded into «Other», a denominator on every block. The cost is checked before any query (400 `widget_too_expensive`); a block that runs out of time (5 s) is reported as `block_timeout` and the others are answered. A recipe that no longer matches the catalogue is a 422 `config_stale`."},"response":[{"name":"200 — The numbers","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/widgets/:id/data?from=&to=&tz=","host":["{{baseUrl}}"],"path":["v1","widgets",":id","data"],"query":[{"key":"from","value":"","disabled":true,"description":"Format: date-time."},{"key":"to","value":"","disabled":true,"description":"Format: date-time."},{"key":"tz","value":"","disabled":true}],"variable":[{"key":"id","value":""}]},"description":"The widget’s recipe asked of the data for a period: one shared time axis for every block, series capped at 50 (a table at 500 rows) with `truncated` and the codes in `notMeasured`, groups past 20 folded into «Other», a denominator on every block. The cost is checked before any query (400 `widget_too_expensive`); a block that runs out of time (5 s) is reported as `block_timeout` and the others are answered. A recipe that no longer matches the catalogue is a 422 `config_stale`."},"status":"The numbers","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"render\": \"line\",\n  \"bucket\": \"day\",\n  \"from\": \"2026-10-01T00:00:00.000Z\",\n  \"to\": \"2026-10-03T23:59:59.000Z\",\n  \"axis\": [\n    \"2026-10-01\",\n    \"2026-10-02\",\n    \"2026-10-03\"\n  ],\n  \"series\": [\n    {\n      \"block\": 0,\n      \"entity\": \"run\",\n      \"group\": null,\n      \"segment\": \"nightly\",\n      \"total\": 2,\n      \"points\": [\n        {\n          \"t\": \"2026-10-01\",\n          \"v\": 1\n        },\n        {\n          \"t\": \"2026-10-02\",\n          \"v\": 1\n        },\n        {\n          \"t\": \"2026-10-03\",\n          \"v\": 0\n        }\n      ]\n    },\n    {\n      \"block\": 1,\n      \"entity\": \"run_result\",\n      \"group\": null,\n      \"segment\": \"nightly\",\n      \"total\": 3,\n      \"points\": [\n        {\n          \"t\": \"2026-10-01\",\n          \"v\": 0\n        },\n        {\n          \"t\": \"2026-10-02\",\n          \"v\": 3\n        },\n        {\n          \"t\": \"2026-10-03\",\n          \"v\": 0\n        }\n      ]\n    }\n  ],\n  \"denominators\": [\n    {\n      \"block\": 0,\n      \"entity\": \"run\",\n      \"denominator\": 5,\n      \"matched\": 2\n    },\n    {\n      \"block\": 1,\n      \"entity\": \"run_result\",\n      \"denominator\": 120,\n      \"matched\": 3\n    }\n  ],\n  \"truncated\": false,\n  \"notMeasured\": []\n}"}]},{"name":"The numbers of a draft widget","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  },\n  \"from\": \"2026-10-01T00:00:00Z\",\n  \"to\": \"2026-10-03T23:59:59Z\",\n  \"tz\": \"Europe/Kyiv\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/widgets/data","host":["{{baseUrl}}"],"path":["v1","widgets","data"]},"description":"The numbers of a recipe that is not saved, so the builder can redraw as the person configures. Judged exactly as a saved widget: the closed catalogue, the caps, the cost guard BEFORE any query (400 `widget_too_expensive`), a denominator on every block. Nothing is stored. It is a POST because the recipe is the body."},"response":[{"name":"200 — The numbers","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"config\": {\n    \"schemaVersion\": 1,\n    \"render\": \"line\",\n    \"bucket\": \"day\",\n    \"accumulated\": false,\n    \"blocks\": [\n      {\n        \"entity\": \"run\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"kind\",\n            \"values\": [\n              \"automated\"\n            ]\n          }\n        ]\n      },\n      {\n        \"entity\": \"run_result\",\n        \"metric\": \"count\",\n        \"filters\": [\n          {\n            \"property\": \"status\",\n            \"values\": [\n              \"failed\",\n              \"broken\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"segment\": {\n      \"by\": \"label\",\n      \"values\": [\n        \"nightly\"\n      ]\n    }\n  },\n  \"from\": \"2026-10-01T00:00:00Z\",\n  \"to\": \"2026-10-03T23:59:59Z\",\n  \"tz\": \"Europe/Kyiv\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/widgets/data","host":["{{baseUrl}}"],"path":["v1","widgets","data"]},"description":"The numbers of a recipe that is not saved, so the builder can redraw as the person configures. Judged exactly as a saved widget: the closed catalogue, the caps, the cost guard BEFORE any query (400 `widget_too_expensive`), a denominator on every block. Nothing is stored. It is a POST because the recipe is the body."},"status":"The numbers","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"render\": \"line\",\n  \"bucket\": \"day\",\n  \"from\": \"2026-10-01T00:00:00.000Z\",\n  \"to\": \"2026-10-03T23:59:59.000Z\",\n  \"axis\": [\n    \"2026-10-01\",\n    \"2026-10-02\",\n    \"2026-10-03\"\n  ],\n  \"series\": [\n    {\n      \"block\": 0,\n      \"entity\": \"run\",\n      \"group\": null,\n      \"segment\": \"nightly\",\n      \"total\": 2,\n      \"points\": [\n        {\n          \"t\": \"2026-10-01\",\n          \"v\": 1\n        },\n        {\n          \"t\": \"2026-10-02\",\n          \"v\": 1\n        },\n        {\n          \"t\": \"2026-10-03\",\n          \"v\": 0\n        }\n      ]\n    },\n    {\n      \"block\": 1,\n      \"entity\": \"run_result\",\n      \"group\": null,\n      \"segment\": \"nightly\",\n      \"total\": 3,\n      \"points\": [\n        {\n          \"t\": \"2026-10-01\",\n          \"v\": 0\n        },\n        {\n          \"t\": \"2026-10-02\",\n          \"v\": 3\n        },\n        {\n          \"t\": \"2026-10-03\",\n          \"v\": 0\n        }\n      ]\n    }\n  ],\n  \"denominators\": [\n    {\n      \"block\": 0,\n      \"entity\": \"run\",\n      \"denominator\": 5,\n      \"matched\": 2\n    },\n    {\n      \"block\": 1,\n      \"entity\": \"run_result\",\n      \"denominator\": 120,\n      \"matched\": 3\n    }\n  ],\n  \"truncated\": false,\n  \"notMeasured\": []\n}"}]}]},{"name":"snippets","description":"Titled pieces of Markdown a case is written from — inserted as a copy; the ones flagged `template` are what a new case may start from","item":[{"name":"The project’s snippets","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/snippets","host":["{{baseUrl}}"],"path":["v1","snippets"]},"description":"Every snippet, ordered case-insensitively by title. The case form’s «Insert snippet» menu is this list; «Start from» on a new case is the ones with `template: true`."},"response":[{"name":"200 — The snippets","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/snippets","host":["{{baseUrl}}"],"path":["v1","snippets"]},"description":"Every snippet, ordered case-insensitively by title. The case form’s «Insert snippet» menu is this list; «Start from» on a new case is the ones with `template: true`."},"status":"The snippets","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"snippets\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f2\",\n      \"projectId\": \"u-shop::default\",\n      \"title\": \"Refund — checklist\",\n      \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n      \"template\": true,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"}]},{"name":"Create a snippet","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Refund — checklist\",\n  \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n  \"template\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/snippets","host":["{{baseUrl}}"],"path":["v1","snippets"]},"description":"A titled piece of Markdown. The title is unique within the project (409 otherwise); the text is up to 16 384 bytes. `template` marks it as a starting point for a new case."},"response":[{"name":"201 — Created","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Refund — checklist\",\n  \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n  \"template\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/snippets","host":["{{baseUrl}}"],"path":["v1","snippets"]},"description":"A titled piece of Markdown. The title is unique within the project (409 otherwise); the text is up to 16 384 bytes. `template` marks it as a starting point for a new case."},"status":"Created","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f2\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Refund — checklist\",\n  \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n  \"template\": true,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"409 — a snippet of that title exists","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Refund — checklist\",\n  \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n  \"template\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/snippets","host":["{{baseUrl}}"],"path":["v1","snippets"]},"description":"A titled piece of Markdown. The title is unique within the project (409 otherwise); the text is up to 16 384 bytes. `template` marks it as a starting point for a new case."},"status":"a snippet of that title exists","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a snippet titled 'Refund — checklist' already exists\"\n}"}]},{"name":"One snippet","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/snippets/:id","host":["{{baseUrl}}"],"path":["v1","snippets",":id"],"variable":[{"key":"id","value":""}]},"description":"By id, from your projects only — another tenant’s reads as 404, never as 403."},"response":[{"name":"200 — The snippet","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/snippets/:id","host":["{{baseUrl}}"],"path":["v1","snippets",":id"],"variable":[{"key":"id","value":""}]},"description":"By id, from your projects only — another tenant’s reads as 404, never as 403."},"status":"The snippet","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f2\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Refund — checklist\",\n  \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n  \"template\": true,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Change a snippet","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"template\": false\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/snippets/:id","host":["{{baseUrl}}"],"path":["v1","snippets",":id"],"variable":[{"key":"id","value":""}]},"description":"Title, text or the `template` flag. Cases that already inserted the snippet keep their copy — a snippet is not a link."},"response":[{"name":"200 — The snippet as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"template\": false\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/snippets/:id","host":["{{baseUrl}}"],"path":["v1","snippets",":id"],"variable":[{"key":"id","value":""}]},"description":"Title, text or the `template` flag. Cases that already inserted the snippet keep their copy — a snippet is not a link."},"status":"The snippet as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f2\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Refund — checklist\",\n  \"text\": \"## Steps\\n* Open the order and choose “Refund”.\\n* Enter the amount and confirm.\\n\\n## Expected result\\nThe refund is listed with the entered amount.\",\n  \"template\": false,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Delete a snippet","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/snippets/:id","host":["{{baseUrl}}"],"path":["v1","snippets",":id"],"variable":[{"key":"id","value":""}]},"description":"Gone for good — no bin: a snippet is an editor’s tool, not a record anyone reports against. The cases written from it are untouched."},"response":[]}]},{"name":"custom-fields","description":"Custom fields (#1250, ADR 0048) — the project’s field definitions; the values ride on the entity as `custom`. A field is archived, never deleted","item":[{"name":"The project’s custom fields","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields?entity=&archived=","host":["{{baseUrl}}"],"path":["v1","custom-fields"],"query":[{"key":"entity","value":"","disabled":true,"description":"One of: case, suite, run, plan, step."},{"key":"archived","value":"","disabled":true,"description":"One of: true, false."}]},"description":"The live fields in their order; `?entity=case` narrows to one entity, `?archived=true` adds the archived ones after them. Any member reads, a reader too — the fields a case carries are how its `custom` is read."},"response":[{"name":"200 — The fields","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields?entity=&archived=","host":["{{baseUrl}}"],"path":["v1","custom-fields"],"query":[{"key":"entity","value":"","disabled":true,"description":"One of: case, suite, run, plan, step."},{"key":"archived","value":"","disabled":true,"description":"One of: true, false."}]},"description":"The live fields in their order; `?entity=case` narrows to one entity, `?archived=true` adds the archived ones after them. Any member reads, a reader too — the fields a case carries are how its `custom` is read."},"status":"The fields","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"fields\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f4\",\n      \"projectId\": \"u-shop::default\",\n      \"entity\": \"case\",\n      \"name\": \"Browser\",\n      \"type\": \"list\",\n      \"multiple\": true,\n      \"options\": [\n        {\n          \"id\": \"opt_chrome0001\",\n          \"label\": \"Chrome\"\n        },\n        {\n          \"id\": \"opt_firefox001\",\n          \"label\": \"Firefox\"\n        }\n      ],\n      \"required\": false,\n      \"defaultValue\": null,\n      \"showInList\": true,\n      \"showInFilter\": false,\n      \"position\": 0,\n      \"archivedAt\": null,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"},{"name":"400 — invalid body, a value the field does not take, or a rule broken","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields?entity=&archived=","host":["{{baseUrl}}"],"path":["v1","custom-fields"],"query":[{"key":"entity","value":"","disabled":true,"description":"One of: case, suite, run, plan, step."},{"key":"archived","value":"","disabled":true,"description":"One of: true, false."}]},"description":"The live fields in their order; `?entity=case` narrows to one entity, `?archived=true` adds the archived ones after them. Any member reads, a reader too — the fields a case carries are how its `custom` is read."},"status":"invalid body, a value the field does not take, or a rule broken","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — name: a field needs a name\"\n}"}]},{"name":"Define a custom field","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"label\": \"Chrome\"\n    },\n    {\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"showInList\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields","host":["{{baseUrl}}"],"path":["v1","custom-fields"]},"description":"The project owner’s. Needs no migration of the cases that exist: a case without a value just does not have one. At most 50 live fields per entity (429 after that; archive one), a name unique per entity (409). Only `entity: case` for now (400 for the others)."},"response":[{"name":"201 — Created, at the end of the order","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"label\": \"Chrome\"\n    },\n    {\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"showInList\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields","host":["{{baseUrl}}"],"path":["v1","custom-fields"]},"description":"The project owner’s. Needs no migration of the cases that exist: a case without a value just does not have one. At most 50 live fields per entity (429 after that; archive one), a name unique per entity (409). Only `entity: case` for now (400 for the others)."},"status":"Created, at the end of the order","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f4\",\n  \"projectId\": \"u-shop::default\",\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"id\": \"opt_chrome0001\",\n      \"label\": \"Chrome\"\n    },\n    {\n      \"id\": \"opt_firefox001\",\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"required\": false,\n  \"defaultValue\": null,\n  \"showInList\": true,\n  \"showInFilter\": false,\n  \"position\": 0,\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"400 — invalid body, a value the field does not take, or a rule broken","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"label\": \"Chrome\"\n    },\n    {\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"showInList\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields","host":["{{baseUrl}}"],"path":["v1","custom-fields"]},"description":"The project owner’s. Needs no migration of the cases that exist: a case without a value just does not have one. At most 50 live fields per entity (429 after that; archive one), a name unique per entity (409). Only `entity: case` for now (400 for the others)."},"status":"invalid body, a value the field does not take, or a rule broken","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — name: a field needs a name\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"label\": \"Chrome\"\n    },\n    {\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"showInList\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields","host":["{{baseUrl}}"],"path":["v1","custom-fields"]},"description":"The project owner’s. Needs no migration of the cases that exist: a case without a value just does not have one. At most 50 live fields per entity (429 after that; archive one), a name unique per entity (409). Only `entity: case` for now (400 for the others)."},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — a field of that name exists for the entity (an archived one counts)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"label\": \"Chrome\"\n    },\n    {\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"showInList\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields","host":["{{baseUrl}}"],"path":["v1","custom-fields"]},"description":"The project owner’s. Needs no migration of the cases that exist: a case without a value just does not have one. At most 50 live fields per entity (429 after that; archive one), a name unique per entity (409). Only `entity: case` for now (400 for the others)."},"status":"a field of that name exists for the entity (an archived one counts)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a field named 'Browser' already exists for this entity (an archived one counts)\"\n}"}]},{"name":"Change a custom field","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"showInFilter\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Name, options (the whole list; ids keep an option under a new label), `required`, `defaultValue` and the two toggles. Type, `multiple` and entity are fixed (400). Cases that hold an option taken off the list keep it."},"response":[{"name":"200 — The field as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"showInFilter\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Name, options (the whole list; ids keep an option under a new label), `required`, `defaultValue` and the two toggles. Type, `multiple` and entity are fixed (400). Cases that hold an option taken off the list keep it."},"status":"The field as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f4\",\n  \"projectId\": \"u-shop::default\",\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"id\": \"opt_chrome0001\",\n      \"label\": \"Chrome\"\n    },\n    {\n      \"id\": \"opt_firefox001\",\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"required\": false,\n  \"defaultValue\": null,\n  \"showInList\": true,\n  \"showInFilter\": true,\n  \"position\": 0,\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"400 — invalid body, a value the field does not take, or a rule broken","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"showInFilter\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Name, options (the whole list; ids keep an option under a new label), `required`, `defaultValue` and the two toggles. Type, `multiple` and entity are fixed (400). Cases that hold an option taken off the list keep it."},"status":"invalid body, a value the field does not take, or a rule broken","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — name: a field needs a name\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"showInFilter\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Name, options (the whole list; ids keep an option under a new label), `required`, `defaultValue` and the two toggles. Type, `multiple` and entity are fixed (400). Cases that hold an option taken off the list keep it."},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"},{"name":"409 — a field of that name exists for the entity (an archived one counts)","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"showInFilter\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Name, options (the whole list; ids keep an option under a new label), `required`, `defaultValue` and the two toggles. Type, `multiple` and entity are fixed (400). Cases that hold an option taken off the list keep it."},"status":"a field of that name exists for the entity (an archived one counts)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a field named 'Browser' already exists for this entity (an archived one counts)\"\n}"}]},{"name":"Archive a custom field","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id/archive","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id","archive"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. The field leaves the forms and takes no new value; the values on the cases stay, and a restore brings the field back with them. The live fields close the gap in the order. Archiving an archived field changes nothing."},"response":[{"name":"200 — The archived field","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id/archive","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id","archive"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. The field leaves the forms and takes no new value; the values on the cases stay, and a restore brings the field back with them. The live fields close the gap in the order. Archiving an archived field changes nothing."},"status":"The archived field","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f4\",\n  \"projectId\": \"u-shop::default\",\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"id\": \"opt_chrome0001\",\n      \"label\": \"Chrome\"\n    },\n    {\n      \"id\": \"opt_firefox001\",\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"required\": false,\n  \"defaultValue\": null,\n  \"showInList\": true,\n  \"showInFilter\": false,\n  \"position\": 0,\n  \"archivedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id/archive","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id","archive"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. The field leaves the forms and takes no new value; the values on the cases stay, and a restore brings the field back with them. The live fields close the gap in the order. Archiving an archived field changes nothing."},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"}]},{"name":"Restore an archived custom field","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id/restore","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id","restore"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Back at the end of the order, with the values the cases kept. Refused with 429 when the entity already has 50 live fields."},"response":[{"name":"200 — The live field","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id/restore","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id","restore"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Back at the end of the order, with the values the cases kept. Refused with 429 when the entity already has 50 live fields."},"status":"The live field","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f4\",\n  \"projectId\": \"u-shop::default\",\n  \"entity\": \"case\",\n  \"name\": \"Browser\",\n  \"type\": \"list\",\n  \"multiple\": true,\n  \"options\": [\n    {\n      \"id\": \"opt_chrome0001\",\n      \"label\": \"Chrome\"\n    },\n    {\n      \"id\": \"opt_firefox001\",\n      \"label\": \"Firefox\"\n    }\n  ],\n  \"required\": false,\n  \"defaultValue\": null,\n  \"showInList\": true,\n  \"showInFilter\": false,\n  \"position\": 0,\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/custom-fields/:id/restore","host":["{{baseUrl}}"],"path":["v1","custom-fields",":id","restore"],"variable":[{"key":"id","value":""}]},"description":"The project owner’s. Back at the end of the order, with the values the cases kept. Refused with 429 when the entity already has 50 live fields."},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"}]},{"name":"Reorder the fields of an entity","request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000f4\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/order","host":["{{baseUrl}}"],"path":["v1","custom-fields","order"]},"description":"The project owner’s. `ids` is every live field of the entity in the order wanted — a missing, repeated or foreign id is a 400 and nothing moves. One audit entry per field that changed place."},"response":[{"name":"200 — The live fields of the entity as they are now","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000f4\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/order","host":["{{baseUrl}}"],"path":["v1","custom-fields","order"]},"description":"The project owner’s. `ids` is every live field of the entity in the order wanted — a missing, repeated or foreign id is a 400 and nothing moves. One audit entry per field that changed place."},"status":"The live fields of the entity as they are now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"fields\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f4\",\n      \"projectId\": \"u-shop::default\",\n      \"entity\": \"case\",\n      \"name\": \"Browser\",\n      \"type\": \"list\",\n      \"multiple\": true,\n      \"options\": [\n        {\n          \"id\": \"opt_chrome0001\",\n          \"label\": \"Chrome\"\n        },\n        {\n          \"id\": \"opt_firefox001\",\n          \"label\": \"Firefox\"\n        }\n      ],\n      \"required\": false,\n      \"defaultValue\": null,\n      \"showInList\": true,\n      \"showInFilter\": false,\n      \"position\": 0,\n      \"archivedAt\": null,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"},{"name":"400 — invalid body, a value the field does not take, or a rule broken","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000f4\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/order","host":["{{baseUrl}}"],"path":["v1","custom-fields","order"]},"description":"The project owner’s. `ids` is every live field of the entity in the order wanted — a missing, repeated or foreign id is a 400 and nothing moves. One audit entry per field that changed place."},"status":"invalid body, a value the field does not take, or a rule broken","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — name: a field needs a name\"\n}"},{"name":"403 — you are in the project, but not as its owner","originalRequest":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entity\": \"case\",\n  \"ids\": [\n    \"6f1c2b3a-0000-4000-8000-0000000000f4\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/custom-fields/order","host":["{{baseUrl}}"],"path":["v1","custom-fields","order"]},"description":"The project owner’s. `ids` is every live field of the entity in the order wanted — a missing, repeated or foreign id is a 400 and nothing moves. One audit entry per field that changed place."},"status":"you are in the project, but not as its owner","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"project owner only\"\n}"}]}]},{"name":"requirements","description":"Requirements (#1268) — a user story, a feature or an epic of the project, linked many-to-many to its cases; the coverage is counted from the cases’ results. Archived, never deleted","item":[{"name":"The saved versions of the traceability matrix","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/matrix-versions","host":["{{baseUrl}}"],"path":["v1","matrix-versions"]},"description":"A version is a SNAPSHOT (#1269): the requirements of the project with their cases and coverage over a period, frozen when somebody saved it. Newest first, without the content (`GET /v1/matrix-versions/{id}` has it). Any member reads, a reader too."},"response":[{"name":"200 — The versions","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/matrix-versions","host":["{{baseUrl}}"],"path":["v1","matrix-versions"]},"description":"A version is a SNAPSHOT (#1269): the requirements of the project with their cases and coverage over a period, frozen when somebody saved it. Newest first, without the content (`GET /v1/matrix-versions/{id}` has it). Any member reads, a reader too."},"status":"The versions","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"versions\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b3\",\n      \"number\": 3,\n      \"periodFrom\": \"2026-09-16T00:00:00.000Z\",\n      \"periodTo\": \"2026-10-15T23:59:59.999Z\",\n      \"builtAt\": \"2026-09-16T09:12:04.151Z\",\n      \"authorName\": \"Dana\"\n    }\n  ]\n}"}]},{"name":"Save a version of the matrix","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"from\": \"2026-09-16T00:00:00+03:00\",\n  \"to\": \"2026-10-15T23:59:59+03:00\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/matrix-versions","host":["{{baseUrl}}"],"path":["v1","matrix-versions"]},"description":"An owner’s or a member’s. Counts the matrix over `from`–`to` the way `GET /v1/requirements?cases=true` does, and freezes it as the next number of the project, with the date and the author; the open matrix stays live. The text of the requirements is not kept. A snapshot over 262144 bytes of JSON is a 413 naming the limit: choose a shorter period. Nothing in it changes afterwards."},"response":[{"name":"201 — Saved","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"from\": \"2026-09-16T00:00:00+03:00\",\n  \"to\": \"2026-10-15T23:59:59+03:00\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/matrix-versions","host":["{{baseUrl}}"],"path":["v1","matrix-versions"]},"description":"An owner’s or a member’s. Counts the matrix over `from`–`to` the way `GET /v1/requirements?cases=true` does, and freezes it as the next number of the project, with the date and the author; the open matrix stays live. The text of the requirements is not kept. A snapshot over 262144 bytes of JSON is a 413 naming the limit: choose a shorter period. Nothing in it changes afterwards."},"status":"Saved","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b3\",\n  \"number\": 3,\n  \"periodFrom\": \"2026-09-16T00:00:00.000Z\",\n  \"periodTo\": \"2026-10-15T23:59:59.999Z\",\n  \"builtAt\": \"2026-09-16T09:12:04.151Z\",\n  \"authorName\": \"Dana\"\n}"}]},{"name":"One saved version of the matrix, with its content","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/matrix-versions/:id","host":["{{baseUrl}}"],"path":["v1","matrix-versions",":id"],"variable":[{"key":"id","value":""}]},"description":"The snapshot as it was saved: `requirements`, each with its `cases` and `coverage`. It never changes, so an address built from the id keeps showing the same matrix."},"response":[{"name":"200 — The version","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/matrix-versions/:id","host":["{{baseUrl}}"],"path":["v1","matrix-versions",":id"],"variable":[{"key":"id","value":""}]},"description":"The snapshot as it was saved: `requirements`, each with its `cases` and `coverage`. It never changes, so an address built from the id keeps showing the same matrix."},"status":"The version","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000b3\",\n  \"number\": 3,\n  \"periodFrom\": \"2026-09-16T00:00:00.000Z\",\n  \"periodTo\": \"2026-10-15T23:59:59.999Z\",\n  \"builtAt\": \"2026-09-16T09:12:04.151Z\",\n  \"authorName\": \"Dana\",\n  \"requirements\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n      \"key\": \"REQ-214\",\n      \"type\": \"user_story\",\n      \"status\": \"active\",\n      \"title\": \"A buyer pays by card\",\n      \"priority\": \"Must-have\",\n      \"category\": \"functional\",\n      \"coverage\": {\n        \"total\": 3,\n        \"passed\": 2,\n        \"failed\": 1,\n        \"broken\": 0,\n        \"blocked\": 0,\n        \"skipped\": 0,\n        \"muted\": 0,\n        \"notRun\": 0,\n        \"automated\": 0\n      },\n      \"cases\": [],\n      \"archivedAt\": null\n    }\n  ]\n}"}]},{"name":"Import requirements from a Markdown file (a dry run by default) — A table with an id column, as a dry run","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Billing SRS\\n\\n## 3. Functional requirements\\n\\n| ID | Requirement | Priority |\\n|----|-------------|----------|\\n| REQ-FN-001 | The system **shall** let a user sign in with an e-mail link. | Critical |\\n| REQ-FN-002 | The system shall export an invoice as a PDF file. | High |\\n\",\n  \"mode\": \"table\",\n  \"table\": 0,\n  \"keyColumn\": \"ID\",\n  \"textColumn\": \"Requirement\",\n  \"priorityColumn\": \"Priority\",\n  \"priorityMap\": {\n    \"Critical\": \"Must-have\",\n    \"High\": \"Should-have\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Reads the table (`mode: table`) or the section (`mode: sections`) the person picked after `.../import/discover`, with the columns they mapped, and answers what it does with each row: `create`, `update`, `unchanged` or `skip`, each with a reason and the line in the file. **`dryRun` is `true` unless the body says `false`**: a dry run writes nothing. `dryRun: false` applies the same plan in ONE transaction (a failure leaves nothing behind) and answers the same report with `applied: true` and the id of each requirement written. A row with a key matches an existing requirement by that key (any case); a row without one matches by exact title (the reason says `matched by title`) and a new one gets `REQ-<n>`. A re-import of the same file changes nothing: every row is `unchanged`. A field is updated only when the file gives a value and it differs; an empty cell leaves the requirement as it is. Tables and sections not picked are listed in `notImported`, never dropped silently. Checklist marks (`[x]`) are never read: coverage is counted from results. A bad row (ragged, a key that is invalid or repeated in the file, a status that is not draft, active or done, a text over the description limit) is skipped with its line; the rest goes on. Refusals: more than 2000 requirements or a file over 1048576 bytes is a 413 naming the limit; an unknown column or table is a 400 naming the headers or the count. `caseTables` (#1624) chooses Given / When / Then tables as manual test cases: unticked by default, so a table not listed there creates nothing and stays in `notImported`; the cases are written in the same transaction as the requirements, each with its own audit entry, and a project over its case ceiling is a 429 with nothing written. An applied import is ONE audit entry with the counts, never the text. An owner or a member (a reader gets 403); a bearer token only in the project it was minted in."},"response":[{"name":"200 — The report - the same for a dry run and for an applied import","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Billing SRS\\n\\n## 3. Functional requirements\\n\\n| ID | Requirement | Priority |\\n|----|-------------|----------|\\n| REQ-FN-001 | The system **shall** let a user sign in with an e-mail link. | Critical |\\n| REQ-FN-002 | The system shall export an invoice as a PDF file. | High |\\n\",\n  \"mode\": \"table\",\n  \"table\": 0,\n  \"keyColumn\": \"ID\",\n  \"textColumn\": \"Requirement\",\n  \"priorityColumn\": \"Priority\",\n  \"priorityMap\": {\n    \"Critical\": \"Must-have\",\n    \"High\": \"Should-have\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Reads the table (`mode: table`) or the section (`mode: sections`) the person picked after `.../import/discover`, with the columns they mapped, and answers what it does with each row: `create`, `update`, `unchanged` or `skip`, each with a reason and the line in the file. **`dryRun` is `true` unless the body says `false`**: a dry run writes nothing. `dryRun: false` applies the same plan in ONE transaction (a failure leaves nothing behind) and answers the same report with `applied: true` and the id of each requirement written. A row with a key matches an existing requirement by that key (any case); a row without one matches by exact title (the reason says `matched by title`) and a new one gets `REQ-<n>`. A re-import of the same file changes nothing: every row is `unchanged`. A field is updated only when the file gives a value and it differs; an empty cell leaves the requirement as it is. Tables and sections not picked are listed in `notImported`, never dropped silently. Checklist marks (`[x]`) are never read: coverage is counted from results. A bad row (ragged, a key that is invalid or repeated in the file, a status that is not draft, active or done, a text over the description limit) is skipped with its line; the rest goes on. Refusals: more than 2000 requirements or a file over 1048576 bytes is a 413 naming the limit; an unknown column or table is a 400 naming the headers or the count. `caseTables` (#1624) chooses Given / When / Then tables as manual test cases: unticked by default, so a table not listed there creates nothing and stays in `notImported`; the cases are written in the same transaction as the requirements, each with its own audit entry, and a project over its case ceiling is a 429 with nothing written. An applied import is ONE audit entry with the counts, never the text. An owner or a member (a reader gets 403); a bearer token only in the project it was minted in."},"status":"The report - the same for a dry run and for an applied import","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": true,\n  \"applied\": false,\n  \"mode\": \"table\",\n  \"summary\": {\n    \"create\": 1,\n    \"update\": 0,\n    \"unchanged\": 0,\n    \"skip\": 1\n  },\n  \"items\": [\n    {\n      \"line\": 7,\n      \"action\": \"create\",\n      \"key\": \"REQ-FN-001\",\n      \"title\": \"The system **shall** let a user sign in with an e-mail link.\",\n      \"reason\": \"new\"\n    },\n    {\n      \"line\": 8,\n      \"action\": \"skip\",\n      \"key\": \"REQ-FN-002\",\n      \"title\": \"The system shall export an invoice as a PDF file.\",\n      \"reason\": \"status 'maybe' is not one of draft, active, done\"\n    }\n  ],\n  \"values\": {\n    \"priority\": [\n      {\n        \"value\": \"Critical\",\n        \"count\": 1,\n        \"mappedTo\": \"Must-have\"\n      },\n      {\n        \"value\": \"High\",\n        \"count\": 1,\n        \"mappedTo\": \"Should-have\"\n      }\n    ],\n    \"category\": []\n  },\n  \"notImported\": {\n    \"frontmatterKeys\": [],\n    \"tables\": [],\n    \"sections\": [\n      {\n        \"index\": 0,\n        \"heading\": \"Billing SRS\",\n        \"level\": 1,\n        \"bulletCount\": 0\n      },\n      {\n        \"index\": 1,\n        \"heading\": \"3. Functional requirements\",\n        \"level\": 2,\n        \"bulletCount\": 0\n      }\n    ]\n  }\n}"}]},{"name":"Import requirements from a Markdown file (a dry run by default) — Bullets under a heading, as a dry run","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"---\\ntitle: Invoice reminders\\n---\\n\\n### Requirements\\n\\n- The system sends a reminder three days before the due date.\\n- A customer can switch the reminders off.\\n\\n### Risks\\n\\n- Mails land in spam\\n\",\n  \"mode\": \"sections\",\n  \"section\": \"Requirements\",\n  \"unit\": \"bullets\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Reads the table (`mode: table`) or the section (`mode: sections`) the person picked after `.../import/discover`, with the columns they mapped, and answers what it does with each row: `create`, `update`, `unchanged` or `skip`, each with a reason and the line in the file. **`dryRun` is `true` unless the body says `false`**: a dry run writes nothing. `dryRun: false` applies the same plan in ONE transaction (a failure leaves nothing behind) and answers the same report with `applied: true` and the id of each requirement written. A row with a key matches an existing requirement by that key (any case); a row without one matches by exact title (the reason says `matched by title`) and a new one gets `REQ-<n>`. A re-import of the same file changes nothing: every row is `unchanged`. A field is updated only when the file gives a value and it differs; an empty cell leaves the requirement as it is. Tables and sections not picked are listed in `notImported`, never dropped silently. Checklist marks (`[x]`) are never read: coverage is counted from results. A bad row (ragged, a key that is invalid or repeated in the file, a status that is not draft, active or done, a text over the description limit) is skipped with its line; the rest goes on. Refusals: more than 2000 requirements or a file over 1048576 bytes is a 413 naming the limit; an unknown column or table is a 400 naming the headers or the count. `caseTables` (#1624) chooses Given / When / Then tables as manual test cases: unticked by default, so a table not listed there creates nothing and stays in `notImported`; the cases are written in the same transaction as the requirements, each with its own audit entry, and a project over its case ceiling is a 429 with nothing written. An applied import is ONE audit entry with the counts, never the text. An owner or a member (a reader gets 403); a bearer token only in the project it was minted in."},"response":[]},{"name":"Import requirements from a Markdown file (a dry run by default) — The same table, written","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Billing SRS\\n\\n## 3. Functional requirements\\n\\n| ID | Requirement | Priority |\\n|----|-------------|----------|\\n| REQ-FN-001 | The system **shall** let a user sign in with an e-mail link. | Critical |\\n| REQ-FN-002 | The system shall export an invoice as a PDF file. | High |\\n\",\n  \"mode\": \"table\",\n  \"table\": 0,\n  \"keyColumn\": \"ID\",\n  \"textColumn\": \"Requirement\",\n  \"priorityColumn\": \"Priority\",\n  \"priorityMap\": {\n    \"Critical\": \"Must-have\",\n    \"High\": \"Should-have\"\n  },\n  \"dryRun\": false\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"Reads the table (`mode: table`) or the section (`mode: sections`) the person picked after `.../import/discover`, with the columns they mapped, and answers what it does with each row: `create`, `update`, `unchanged` or `skip`, each with a reason and the line in the file. **`dryRun` is `true` unless the body says `false`**: a dry run writes nothing. `dryRun: false` applies the same plan in ONE transaction (a failure leaves nothing behind) and answers the same report with `applied: true` and the id of each requirement written. A row with a key matches an existing requirement by that key (any case); a row without one matches by exact title (the reason says `matched by title`) and a new one gets `REQ-<n>`. A re-import of the same file changes nothing: every row is `unchanged`. A field is updated only when the file gives a value and it differs; an empty cell leaves the requirement as it is. Tables and sections not picked are listed in `notImported`, never dropped silently. Checklist marks (`[x]`) are never read: coverage is counted from results. A bad row (ragged, a key that is invalid or repeated in the file, a status that is not draft, active or done, a text over the description limit) is skipped with its line; the rest goes on. Refusals: more than 2000 requirements or a file over 1048576 bytes is a 413 naming the limit; an unknown column or table is a 400 naming the headers or the count. `caseTables` (#1624) chooses Given / When / Then tables as manual test cases: unticked by default, so a table not listed there creates nothing and stays in `notImported`; the cases are written in the same transaction as the requirements, each with its own audit entry, and a project over its case ceiling is a 429 with nothing written. An applied import is ONE audit entry with the counts, never the text. An owner or a member (a reader gets 403); a bearer token only in the project it was minted in."},"response":[]},{"name":"What a Markdown file holds, for the mapping step of an import","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Billing SRS\\n\\n## 3. Functional requirements\\n\\n| ID | Requirement | Priority |\\n|----|-------------|----------|\\n| REQ-FN-001 | The system **shall** let a user sign in with an e-mail link. | Critical |\\n| REQ-FN-002 | The system shall export an invoice as a PDF file. | High |\\n\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import/discover","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import","discover"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"The first step of importing requirements from a `.md` file (#1605, epic #1594): the structure found, so a client can ask the person which table or section holds requirements. The frontmatter keys, every heading with its level and the number of list items under it, every table with its nearest heading, its header cells, its row count and its first three rows, and a guess (`looksLikeRequirements`) for the tables that look like requirements. Nothing is stored. An owner or a member of the project in the path (a reader gets 403); a bearer token only in the project it was minted in. A file over 1048576 bytes is a 413 naming the limit."},"response":[{"name":"200 — What the file holds","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Billing SRS\\n\\n## 3. Functional requirements\\n\\n| ID | Requirement | Priority |\\n|----|-------------|----------|\\n| REQ-FN-001 | The system **shall** let a user sign in with an e-mail link. | Critical |\\n| REQ-FN-002 | The system shall export an invoice as a PDF file. | High |\\n\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import/discover","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import","discover"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"The first step of importing requirements from a `.md` file (#1605, epic #1594): the structure found, so a client can ask the person which table or section holds requirements. The frontmatter keys, every heading with its level and the number of list items under it, every table with its nearest heading, its header cells, its row count and its first three rows, and a guess (`looksLikeRequirements`) for the tables that look like requirements. Nothing is stored. An owner or a member of the project in the path (a reader gets 403); a bearer token only in the project it was minted in. A file over 1048576 bytes is a 413 naming the limit."},"status":"What the file holds","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"frontmatter\": {\n    \"keys\": [],\n    \"title\": null\n  },\n  \"sections\": [\n    {\n      \"index\": 0,\n      \"heading\": \"Billing SRS\",\n      \"level\": 1,\n      \"line\": 1,\n      \"bulletCount\": 0,\n      \"subheadingCount\": 1\n    },\n    {\n      \"index\": 1,\n      \"heading\": \"3. Functional requirements\",\n      \"level\": 2,\n      \"line\": 3,\n      \"bulletCount\": 0,\n      \"subheadingCount\": 0\n    }\n  ],\n  \"tables\": [\n    {\n      \"index\": 0,\n      \"line\": 5,\n      \"heading\": \"3. Functional requirements\",\n      \"headers\": [\n        \"ID\",\n        \"Requirement\",\n        \"Priority\"\n      ],\n      \"rowCount\": 2,\n      \"firstRows\": [\n        [\n          \"REQ-FN-001\",\n          \"The system **shall** let a user sign in with an e-mail link.\",\n          \"Critical\"\n        ],\n        [\n          \"REQ-FN-002\",\n          \"The system shall export an invoice as a PDF file.\",\n          \"High\"\n        ]\n      ],\n      \"looksLikeRequirements\": true,\n      \"looksLikeCases\": false\n    }\n  ],\n  \"truncated\": false,\n  \"limits\": {\n    \"markdownBytes\": 1048576,\n    \"rows\": 2000,\n    \"columns\": 200,\n    \"mapEntries\": 200\n  }\n}"}]},{"name":"Import a traceability matrix as links (a dry run by default)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Traceability\\n\\n| Requirement | Test |\\n|---|---|\\n| REQ-FN-001 | Sign in with an e-mail link |\\n| REQ-FN-002, REQ-FN-003 | Export an invoice as PDF |\\n\",\n  \"table\": 0,\n  \"requirementColumn\": \"Requirement\",\n  \"testColumn\": \"Test\",\n  \"requirementSeparator\": \",\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import/links","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import","links"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"A table of a Markdown file with a column of requirement keys and a column of tests (#1608, epic #1594). A requirement is matched by its `key` (any case); a test by the exact title of a live case of the project or, for an automated case, by its `specRef` path. A row that matches nothing, or more than one case, is listed as `unmatched` with the reason: nothing is guessed. A pair already linked is `unchanged`, so a second import changes nothing. A requirement cell may hold several keys, split by `requirementSeparator` (default `,`). **`dryRun` is `true` unless the body says `false`**: a dry run writes nothing; `dryRun: false` adds the new links in ONE transaction and answers the same report with `applied: true`. Links never override the computed coverage: it still comes from each case’s last result. An applied import is ONE audit entry with the counts. An owner or a member (a reader gets 403); a bearer token only in the project it was minted in. A file over 1 MiB, or a table over 2000 rows, or a report over 2000 rows once the cells are split, is a 413; an unknown table or column is a 400 naming the headers."},"response":[{"name":"200 — The report - the same for a dry run and for an applied import","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"markdown\": \"# Traceability\\n\\n| Requirement | Test |\\n|---|---|\\n| REQ-FN-001 | Sign in with an e-mail link |\\n| REQ-FN-002, REQ-FN-003 | Export an invoice as PDF |\\n\",\n  \"table\": 0,\n  \"requirementColumn\": \"Requirement\",\n  \"testColumn\": \"Test\",\n  \"requirementSeparator\": \",\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/projects/:id/requirements/import/links","host":["{{baseUrl}}"],"path":["v1","projects",":id","requirements","import","links"],"variable":[{"key":"id","value":"","description":"An id from `GET /v1/projects`"}]},"description":"A table of a Markdown file with a column of requirement keys and a column of tests (#1608, epic #1594). A requirement is matched by its `key` (any case); a test by the exact title of a live case of the project or, for an automated case, by its `specRef` path. A row that matches nothing, or more than one case, is listed as `unmatched` with the reason: nothing is guessed. A pair already linked is `unchanged`, so a second import changes nothing. A requirement cell may hold several keys, split by `requirementSeparator` (default `,`). **`dryRun` is `true` unless the body says `false`**: a dry run writes nothing; `dryRun: false` adds the new links in ONE transaction and answers the same report with `applied: true`. Links never override the computed coverage: it still comes from each case’s last result. An applied import is ONE audit entry with the counts. An owner or a member (a reader gets 403); a bearer token only in the project it was minted in. A file over 1 MiB, or a table over 2000 rows, or a report over 2000 rows once the cells are split, is a 413; an unknown table or column is a 400 naming the headers."},"status":"The report - the same for a dry run and for an applied import","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": true,\n  \"applied\": false,\n  \"summary\": {\n    \"link\": 1,\n    \"unchanged\": 0,\n    \"unmatched\": 1\n  },\n  \"items\": [\n    {\n      \"line\": 5,\n      \"action\": \"link\",\n      \"key\": \"REQ-FN-001\",\n      \"test\": \"Sign in with an e-mail link\",\n      \"reason\": \"new\",\n      \"requirementId\": \"req_1\",\n      \"caseId\": \"case_1\"\n    },\n    {\n      \"line\": 6,\n      \"action\": \"unmatched\",\n      \"key\": \"REQ-FN-002\",\n      \"test\": \"Export an invoice as PDF\",\n      \"reason\": \"no case with this exact title or source path\",\n      \"requirementId\": \"req_2\"\n    }\n  ]\n}"}]},{"name":"The project’s requirements","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements?type=&status=&category=&priority=&noPriority=&noCategory=&key=&q=&archived=&from=&to=&cases=","host":["{{baseUrl}}"],"path":["v1","requirements"],"query":[{"key":"type","value":"","disabled":true,"description":"One of: user_story, feature, epic."},{"key":"status","value":"","disabled":true,"description":"One of: draft, active, done."},{"key":"category","value":"","disabled":true,"description":"One of: functional, non-functional, other."},{"key":"priority","value":"","disabled":true},{"key":"noPriority","value":"","disabled":true,"description":"`true` - only the requirements with no priority (“not set”). One of: true, false."},{"key":"noCategory","value":"","disabled":true,"description":"`true` - only the requirements with no category (“not set”). One of: true, false."},{"key":"key","value":"","disabled":true},{"key":"q","value":"","disabled":true},{"key":"archived","value":"","disabled":true,"description":"One of: true, false."},{"key":"from","value":"","disabled":true,"description":"Format: date-time."},{"key":"to","value":"","disabled":true,"description":"Format: date-time."},{"key":"cases","value":"","disabled":true,"description":"One of: true, false."}]},"description":"The live requirements in the order they were made, each with its coverage counted from the results of its cases. `?type=`, `?status=`, `?category=`, `?priority=` (the exact label, case-insensitive), `?key=` (the exact key, case-insensitive) and `?q=` (a part of the title or of the key, case-insensitive) narrow; `?archived=true` adds the archived ones. `?from=` and `?to=` (ISO 8601 date-times with a zone, both ends included) count the coverage only over the runs that started in that period; a case with no result there is not run. `?cases=true` puts the linked cases, each with its newest result and the run it is in, on every row: the traceability matrix reads that. Any member reads, a reader too."},"response":[{"name":"200 — The requirements","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements?type=&status=&category=&priority=&noPriority=&noCategory=&key=&q=&archived=&from=&to=&cases=","host":["{{baseUrl}}"],"path":["v1","requirements"],"query":[{"key":"type","value":"","disabled":true,"description":"One of: user_story, feature, epic."},{"key":"status","value":"","disabled":true,"description":"One of: draft, active, done."},{"key":"category","value":"","disabled":true,"description":"One of: functional, non-functional, other."},{"key":"priority","value":"","disabled":true},{"key":"noPriority","value":"","disabled":true,"description":"`true` - only the requirements with no priority (“not set”). One of: true, false."},{"key":"noCategory","value":"","disabled":true,"description":"`true` - only the requirements with no category (“not set”). One of: true, false."},{"key":"key","value":"","disabled":true},{"key":"q","value":"","disabled":true},{"key":"archived","value":"","disabled":true,"description":"One of: true, false."},{"key":"from","value":"","disabled":true,"description":"Format: date-time."},{"key":"to","value":"","disabled":true,"description":"Format: date-time."},{"key":"cases","value":"","disabled":true,"description":"One of: true, false."}]},"description":"The live requirements in the order they were made, each with its coverage counted from the results of its cases. `?type=`, `?status=`, `?category=`, `?priority=` (the exact label, case-insensitive), `?key=` (the exact key, case-insensitive) and `?q=` (a part of the title or of the key, case-insensitive) narrow; `?archived=true` adds the archived ones. `?from=` and `?to=` (ISO 8601 date-times with a zone, both ends included) count the coverage only over the runs that started in that period; a case with no result there is not run. `?cases=true` puts the linked cases, each with its newest result and the run it is in, on every row: the traceability matrix reads that. Any member reads, a reader too."},"status":"The requirements","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"requirements\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n      \"key\": \"REQ-214\",\n      \"projectId\": \"u-shop::default\",\n      \"type\": \"user_story\",\n      \"status\": \"active\",\n      \"title\": \"A buyer pays by card\",\n      \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n      \"priority\": \"Must-have\",\n      \"acceptanceCriteria\": \"- the card is charged once\\n- a declined card shows the bank reason\",\n      \"category\": \"functional\",\n      \"coverage\": {\n        \"total\": 3,\n        \"passed\": 2,\n        \"failed\": 1,\n        \"broken\": 0,\n        \"blocked\": 0,\n        \"skipped\": 0,\n        \"muted\": 0,\n        \"notRun\": 0,\n        \"automated\": 0\n      },\n      \"archivedAt\": null,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"}]},{"name":"Make a requirement","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"type\": \"user_story\",\n  \"status\": \"active\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\",\n  \"category\": \"functional\",\n  \"key\": \"REQ-214\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/requirements","host":["{{baseUrl}}"],"path":["v1","requirements"]},"description":"An owner’s or a member’s (a reader gets 403). A new requirement has no cases; link them with `PUT /v1/requirements/{id}/cases/{caseId}`. Without a `key` it gets the next `REQ-<n>` of the project; with one, that key is kept (a clash in the project is 409; the same key in another project is fine). Built in: nothing here depends on an external tracker."},"response":[{"name":"201 — Created","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"type\": \"user_story\",\n  \"status\": \"active\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\",\n  \"category\": \"functional\",\n  \"key\": \"REQ-214\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/requirements","host":["{{baseUrl}}"],"path":["v1","requirements"]},"description":"An owner’s or a member’s (a reader gets 403). A new requirement has no cases; link them with `PUT /v1/requirements/{id}/cases/{caseId}`. Without a `key` it gets the next `REQ-<n>` of the project; with one, that key is kept (a clash in the project is 409; the same key in another project is fine). Built in: nothing here depends on an external tracker."},"status":"Created","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n  \"key\": \"REQ-214\",\n  \"projectId\": \"u-shop::default\",\n  \"type\": \"user_story\",\n  \"status\": \"active\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\\n- a declined card shows the bank reason\",\n  \"category\": \"functional\",\n  \"coverage\": {\n    \"total\": 0,\n    \"passed\": 0,\n    \"failed\": 0,\n    \"broken\": 0,\n    \"blocked\": 0,\n    \"skipped\": 0,\n    \"muted\": 0,\n    \"notRun\": 0,\n    \"automated\": 0\n  },\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"One requirement with its cases","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id","host":["{{baseUrl}}"],"path":["v1","requirements",":id"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"The requirement, the live cases linked to it (each with the status its coverage counted it under) and the coverage: «N of M cases passed» is `coverage.passed` of `coverage.total`."},"response":[{"name":"200 — The requirement","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id","host":["{{baseUrl}}"],"path":["v1","requirements",":id"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"The requirement, the live cases linked to it (each with the status its coverage counted it under) and the coverage: «N of M cases passed» is `coverage.passed` of `coverage.total`."},"status":"The requirement","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n  \"key\": \"REQ-214\",\n  \"projectId\": \"u-shop::default\",\n  \"type\": \"user_story\",\n  \"status\": \"active\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\\n- a declined card shows the bank reason\",\n  \"category\": \"functional\",\n  \"coverage\": {\n    \"total\": 3,\n    \"passed\": 2,\n    \"failed\": 1,\n    \"broken\": 0,\n    \"blocked\": 0,\n    \"skipped\": 0,\n    \"muted\": 0,\n    \"notRun\": 0,\n    \"automated\": 0\n  },\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"cases\": []\n}"}]},{"name":"Change a requirement","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"done\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/requirements/:id","host":["{{baseUrl}}"],"path":["v1","requirements",":id"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"An owner’s or a member’s. Type, status, title, key, description, priority, acceptance criteria and category (`null` clears the optional ones); an unchanged save leaves no audit entry. A changed key is an audit entry (its text is not secret)."},"response":[{"name":"200 — The requirement as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"done\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/requirements/:id","host":["{{baseUrl}}"],"path":["v1","requirements",":id"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"An owner’s or a member’s. Type, status, title, key, description, priority, acceptance criteria and category (`null` clears the optional ones); an unchanged save leaves no audit entry. A changed key is an audit entry (its text is not secret)."},"status":"The requirement as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n  \"key\": \"REQ-214\",\n  \"projectId\": \"u-shop::default\",\n  \"type\": \"user_story\",\n  \"status\": \"done\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\\n- a declined card shows the bank reason\",\n  \"category\": \"functional\",\n  \"coverage\": {\n    \"total\": 3,\n    \"passed\": 2,\n    \"failed\": 1,\n    \"broken\": 0,\n    \"blocked\": 0,\n    \"skipped\": 0,\n    \"muted\": 0,\n    \"notRun\": 0,\n    \"automated\": 0\n  },\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Archive a requirement","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id/archive","host":["{{baseUrl}}"],"path":["v1","requirements",":id","archive"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"An owner’s or a member’s. The requirement leaves the default list and every coverage filter and takes no new case; its links stay, and a restore brings it back whole. Archiving an archived requirement changes nothing."},"response":[{"name":"200 — The archived requirement","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id/archive","host":["{{baseUrl}}"],"path":["v1","requirements",":id","archive"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"An owner’s or a member’s. The requirement leaves the default list and every coverage filter and takes no new case; its links stay, and a restore brings it back whole. Archiving an archived requirement changes nothing."},"status":"The archived requirement","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n  \"key\": \"REQ-214\",\n  \"projectId\": \"u-shop::default\",\n  \"type\": \"user_story\",\n  \"status\": \"active\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\\n- a declined card shows the bank reason\",\n  \"category\": \"functional\",\n  \"coverage\": {\n    \"total\": 3,\n    \"passed\": 2,\n    \"failed\": 1,\n    \"broken\": 0,\n    \"blocked\": 0,\n    \"skipped\": 0,\n    \"muted\": 0,\n    \"notRun\": 0,\n    \"automated\": 0\n  },\n  \"archivedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Link a case to a requirement","request":{"method":"PUT","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id/cases/:caseId","host":["{{baseUrl}}"],"path":["v1","requirements",":id","cases",":caseId"],"variable":[{"key":"id","value":""},{"key":"caseId","value":""}]},"description":"An owner’s or a member’s. Many-to-many: a requirement has many cases and a case many requirements; the link is seen from both sides (`GET /v1/requirements/{id}`, `GET /v1/test-cases/{id}/requirements`). Linking twice is one link (200 the second time, 201 the first). Both must be in the same project; a case in the Bin is not a case here. An archived requirement takes no new case (409)."},"response":[]},{"name":"Unlink a case from a requirement","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id/cases/:caseId","host":["{{baseUrl}}"],"path":["v1","requirements",":id","cases",":caseId"],"variable":[{"key":"id","value":""},{"key":"caseId","value":""}]},"description":"An owner’s or a member’s. The case and the requirement stay; 404 when they were not linked."},"response":[]},{"name":"Restore an archived requirement","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id/restore","host":["{{baseUrl}}"],"path":["v1","requirements",":id","restore"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"An owner’s or a member’s. Back in the list, with the cases it had."},"response":[{"name":"200 — The live requirement","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/:id/restore","host":["{{baseUrl}}"],"path":["v1","requirements",":id","restore"],"variable":[{"key":"id","value":"","description":"The requirement's id, or its key (`REQ-214`, any case): a key is never only digits and never looks like an id, so the two cannot be confused."}]},"description":"An owner’s or a member’s. Back in the list, with the cases it had."},"status":"The live requirement","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f5\",\n  \"key\": \"REQ-214\",\n  \"projectId\": \"u-shop::default\",\n  \"type\": \"user_story\",\n  \"status\": \"active\",\n  \"title\": \"A buyer pays by card\",\n  \"description\": \"As a buyer I pay for the order by card, so that I do not wait for an invoice.\",\n  \"priority\": \"Must-have\",\n  \"acceptanceCriteria\": \"- the card is charged once\\n- a declined card shows the bank reason\",\n  \"category\": \"functional\",\n  \"coverage\": {\n    \"total\": 3,\n    \"passed\": 2,\n    \"failed\": 1,\n    \"broken\": 0,\n    \"blocked\": 0,\n    \"skipped\": 0,\n    \"muted\": 0,\n    \"notRun\": 0,\n    \"automated\": 0\n  },\n  \"archivedAt\": null,\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"The priority labels in use","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/priorities?q=","host":["{{baseUrl}}"],"path":["v1","requirements","priorities"],"query":[{"key":"q","value":"","disabled":true}]},"description":"The distinct priority labels of the project’s live requirements with how many requirements carry each, the most used first, at most 20. Labels that differ only in case are one (the spelling shown is the first in alphabetical order). `?q=` keeps the labels containing that text (case-insensitive). For the Priority filter and the suggestions in the requirement form. Any member reads, a reader too."},"response":[{"name":"200 — The labels","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/requirements/priorities?q=","host":["{{baseUrl}}"],"path":["v1","requirements","priorities"],"query":[{"key":"q","value":"","disabled":true}]},"description":"The distinct priority labels of the project’s live requirements with how many requirements carry each, the most used first, at most 20. Labels that differ only in case are one (the spelling shown is the first in alphabetical order). `?q=` keeps the labels containing that text (case-insensitive). For the Priority filter and the suggestions in the requirement form. Any member reads, a reader too."},"status":"The labels","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"priorities\": [\n    {\n      \"label\": \"Must-have\",\n      \"count\": 12\n    },\n    {\n      \"label\": \"Should-have\",\n      \"count\": 5\n    }\n  ]\n}"}]},{"name":"The requirements a case covers","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/requirements","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","requirements"],"variable":[{"key":"id","value":""}]},"description":"The other side of the link: the requirements of the project that are not archived and have this case linked. A case with none answers an empty list — it is not an error."},"response":[]},{"name":"Link a requirement to a case","request":{"method":"PUT","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/requirements/:requirementId","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","requirements",":requirementId"],"variable":[{"key":"id","value":""},{"key":"requirementId","value":""}]},"description":"The case page’s side of `PUT /v1/requirements/{id}/cases/{caseId}`: the same link, the same audit entry on the requirement. 201 when made, 200 when it was there. An archived requirement takes no new case (409)."},"response":[]},{"name":"Take a requirement off a case","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/requirements/:requirementId","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","requirements",":requirementId"],"variable":[{"key":"id","value":""},{"key":"requirementId","value":""}]},"description":"The case page’s side of `DELETE /v1/requirements/{id}/cases/{caseId}`. 404 when they were not linked."},"response":[]}]},{"name":"plans","description":"Test plans — collections of case filters a run is started from; resolved when asked, so tomorrow’s cases are in tomorrow’s run","item":[{"name":"The project’s test plans","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/plans","host":["{{baseUrl}}"],"path":["v1","plans"]},"description":"Every plan, ordered case-insensitively by title, each with how many cases it collects right now — resolved on the way out, so the count is today’s, not the count at the last edit."},"response":[{"name":"200 — The plans","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/plans","host":["{{baseUrl}}"],"path":["v1","plans"]},"description":"Every plan, ordered case-insensitively by title, each with how many cases it collects right now — resolved on the way out, so the count is today’s, not the count at the last edit."},"status":"The plans","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"plans\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f3\",\n      \"projectId\": \"u-shop::default\",\n      \"title\": \"Release regression\",\n      \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n      \"kind\": \"mixed\",\n      \"asManual\": false,\n      \"collections\": [\n        \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n        \"priority=critical\"\n      ],\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"updatedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"count\": 12,\n      \"detachedSkipped\": 0\n    }\n  ]\n}"}]},{"name":"Create a test plan","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"mixed\",\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans","host":["{{baseUrl}}"],"path":["v1","plans"]},"description":"A title, a kind and one to twenty collections. Each collection is a case filter as a query string; it is read through the filter contract, so an unknown priority or state is refused with the filter’s own sentence (400), and written back canonical. The title is unique within the project (409 otherwise)."},"response":[{"name":"201 — Created","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"mixed\",\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans","host":["{{baseUrl}}"],"path":["v1","plans"]},"description":"A title, a kind and one to twenty collections. Each collection is a case filter as a query string; it is read through the filter contract, so an unknown priority or state is refused with the filter’s own sentence (400), and written back canonical. The title is unique within the project (409 otherwise)."},"status":"Created","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f3\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"mixed\",\n  \"asManual\": false,\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ],\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"},{"name":"400 — invalid body — a missing title, no collections, or a filter value outside its vocabulary","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"mixed\",\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans","host":["{{baseUrl}}"],"path":["v1","plans"]},"description":"A title, a kind and one to twenty collections. Each collection is a case filter as a query string; it is read through the filter contract, so an unknown priority or state is refused with the filter’s own sentence (400), and written back canonical. The title is unique within the project (409 otherwise)."},"status":"invalid body — a missing title, no collections, or a filter value outside its vocabulary","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — collections.1: unknown priority \\\"urgent\\\" — priorities are low, normal, important, high, critical\"\n}"},{"name":"409 — a plan of that title exists","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"mixed\",\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans","host":["{{baseUrl}}"],"path":["v1","plans"]},"description":"A title, a kind and one to twenty collections. Each collection is a case filter as a query string; it is read through the filter contract, so an unknown priority or state is refused with the filter’s own sentence (400), and written back canonical. The title is unique within the project (409 otherwise)."},"status":"a plan of that title exists","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a plan titled 'Release regression' already exists\"\n}"}]},{"name":"One test plan","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/plans/:id","host":["{{baseUrl}}"],"path":["v1","plans",":id"],"variable":[{"key":"id","value":""}]},"description":"By id, from your projects only — another tenant’s reads as 404, never as 403. The plan itself, not what it collects: that is `/cases`."},"response":[{"name":"200 — The plan","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/plans/:id","host":["{{baseUrl}}"],"path":["v1","plans",":id"],"variable":[{"key":"id","value":""}]},"description":"By id, from your projects only — another tenant’s reads as 404, never as 403. The plan itself, not what it collects: that is `/cases`."},"status":"The plan","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f3\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"mixed\",\n  \"asManual\": false,\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ],\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Change a test plan","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"kind\": \"manual\",\n  \"asManual\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id","host":["{{baseUrl}}"],"path":["v1","plans",":id"],"variable":[{"key":"id","value":""}]},"description":"Title, description, kind, `asManual` or the collections — the last replaces the whole list. Runs already launched from the plan keep their own `expected`: a plan is resolved at launch, not linked."},"response":[{"name":"200 — The plan as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"kind\": \"manual\",\n  \"asManual\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id","host":["{{baseUrl}}"],"path":["v1","plans",":id"],"variable":[{"key":"id","value":""}]},"description":"Title, description, kind, `asManual` or the collections — the last replaces the whole list. Runs already launched from the plan keep their own `expected`: a plan is resolved at launch, not linked."},"status":"The plan as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000f3\",\n  \"projectId\": \"u-shop::default\",\n  \"title\": \"Release regression\",\n  \"description\": \"Everything under checkout, plus every critical case wherever it lives.\",\n  \"kind\": \"manual\",\n  \"asManual\": true,\n  \"collections\": [\n    \"suiteId=6f1c2b3a-0000-4000-8000-0000000000a1\",\n    \"priority=critical\"\n  ],\n  \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n  \"updatedAt\": \"2026-09-16T09:12:04.151Z\"\n}"}]},{"name":"Delete a test plan","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/plans/:id","host":["{{baseUrl}}"],"path":["v1","plans",":id"],"variable":[{"key":"id","value":""}]},"description":"Gone for good — no bin: the plan is a saved query, and the runs launched from it stay, with `meta.planId` pointing at an id that no longer answers."},"response":[]},{"name":"What the plan collects right now","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/plans/:id/cases","host":["{{baseUrl}}"],"path":["v1","plans",":id","cases"],"variable":[{"key":"id","value":""}]},"description":"The union of the plan’s collections, then the kind’s rule: a `manual` plan keeps manual cases (and automated ones when `asManual`), `automated` keeps automated, `mixed` both; evals never. A `detached` case (its test left the code) is not listed and not launched; `detachedSkipped` counts them (#1291). Tree order, no duplicates, deleted cases excluded. This is the list a launch turns into `expected`, and the list `plune plan grep` joins into a reporter filter."},"response":[{"name":"200 — The cases","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/plans/:id/cases","host":["{{baseUrl}}"],"path":["v1","plans",":id","cases"],"variable":[{"key":"id","value":""}]},"description":"The union of the plan’s collections, then the kind’s rule: a `manual` plan keeps manual cases (and automated ones when `asManual`), `automated` keeps automated, `mixed` both; evals never. A `detached` case (its test left the code) is not listed and not launched; `detachedSkipped` counts them (#1291). Tree order, no duplicates, deleted cases excluded. This is the list a launch turns into `expected`, and the list `plune plan grep` joins into a reporter filter."},"status":"The cases","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"cases\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"title\": \"Checkout — empty cart @smoke @checkout\",\n      \"type\": \"automated\",\n      \"priority\": \"high\",\n      \"state\": \"draft\",\n      \"suiteId\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n      \"externalKeys\": [\n        {\n          \"kind\": \"path-title\",\n          \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n        }\n      ]\n    }\n  ],\n  \"count\": 1,\n  \"detachedSkipped\": 0\n}"}]},{"name":"Start a run from the plan — Launch now, under a key issued by the launch","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"release\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id/launch","host":["{{baseUrl}}"],"path":["v1","plans",":id","launch"],"variable":[{"key":"id","value":""}]},"description":"The same road a reporter takes (`POST /v1/runs`), with the plan as the caller that knows what to expect: a run of the plan’s kind, `configuration.expected` set to what the plan collects at this moment (each with its first external key, so a reporter joining the run matches by it), `meta.planId` for provenance, the plan’s title unless the body names another. The body is optional. A plan that collects nothing is not launched (409) — a run of nothing would finish green. A launch always opens a run of its own, so it counts against the daily quota like every other way to create one (see `POST /v1/runs`): on a full day it answers 429 and opens nothing, whatever the plan collects. The run carries a key a reporter joins by (#1233): `runKey`, the one the dialog was issued by `POST /v1/runs/keys`, or one issued now — `PLUNE_RUN=<externalKey> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` reports into it. `schedule: true` is Save: the run waits, `scheduled`, for Launch or for that reporter."},"response":[{"name":"201 — The run, launched — open it to walk the manual cases or point a reporter at it","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"release\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id/launch","host":["{{baseUrl}}"],"path":["v1","plans",":id","launch"],"variable":[{"key":"id","value":""}]},"description":"The same road a reporter takes (`POST /v1/runs`), with the plan as the caller that knows what to expect: a run of the plan’s kind, `configuration.expected` set to what the plan collects at this moment (each with its first external key, so a reporter joining the run matches by it), `meta.planId` for provenance, the plan’s title unless the body names another. The body is optional. A plan that collects nothing is not launched (409) — a run of nothing would finish green. A launch always opens a run of its own, so it counts against the daily quota like every other way to create one (see `POST /v1/runs`): on a full day it answers 429 and opens nothing, whatever the plan collects. The run carries a key a reporter joins by (#1233): `runKey`, the one the dialog was issued by `POST /v1/runs/keys`, or one issued now — `PLUNE_RUN=<externalKey> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` reports into it. `schedule: true` is Save: the run waits, `scheduled`, for Launch or for that reporter."},"status":"The run, launched — open it to walk the manual cases or point a reporter at it","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"run\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n    \"projectId\": \"u-shop::default\",\n    \"kind\": \"mixed\",\n    \"externalKey\": \"rk_4f7c2a9e1b\",\n    \"statusEvent\": \"launched\",\n    \"configuration\": {\n      \"expected\": [\n        {\n          \"testCaseId\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n          \"externalKey\": {\n            \"kind\": \"path-title\",\n            \"value\": \"e2e/checkout.spec.ts > Checkout — empty cart\"\n          }\n        }\n      ]\n    },\n    \"meta\": {\n      \"planId\": \"6f1c2b3a-0000-4000-8000-0000000000f3\"\n    },\n    \"startedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"finishedAt\": null,\n    \"terminateReason\": null,\n    \"summary\": null,\n    \"title\": \"Release regression\",\n    \"environment\": \"staging\",\n    \"labels\": [\n      \"release\"\n    ],\n    \"description\": null,\n    \"createdBy\": \"u-shop\"\n  },\n  \"detachedSkipped\": 0\n}"},{"name":"409 — nothing","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"release\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id/launch","host":["{{baseUrl}}"],"path":["v1","plans",":id","launch"],"variable":[{"key":"id","value":""}]},"description":"The same road a reporter takes (`POST /v1/runs`), with the plan as the caller that knows what to expect: a run of the plan’s kind, `configuration.expected` set to what the plan collects at this moment (each with its first external key, so a reporter joining the run matches by it), `meta.planId` for provenance, the plan’s title unless the body names another. The body is optional. A plan that collects nothing is not launched (409) — a run of nothing would finish green. A launch always opens a run of its own, so it counts against the daily quota like every other way to create one (see `POST /v1/runs`): on a full day it answers 429 and opens nothing, whatever the plan collects. The run carries a key a reporter joins by (#1233): `runKey`, the one the dialog was issued by `POST /v1/runs/keys`, or one issued now — `PLUNE_RUN=<externalKey> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` reports into it. `schedule: true` is Save: the run waits, `scheduled`, for Launch or for that reporter."},"status":"the plan collects no case right now, or the `runKey` sent is already a live run’s (`code: run_key_taken` — ask for a new one)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the plan matches no case — nothing to run\"\n}"},{"name":"409 — keyTaken","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"environment\": \"staging\",\n  \"labels\": [\n    \"release\"\n  ]\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id/launch","host":["{{baseUrl}}"],"path":["v1","plans",":id","launch"],"variable":[{"key":"id","value":""}]},"description":"The same road a reporter takes (`POST /v1/runs`), with the plan as the caller that knows what to expect: a run of the plan’s kind, `configuration.expected` set to what the plan collects at this moment (each with its first external key, so a reporter joining the run matches by it), `meta.planId` for provenance, the plan’s title unless the body names another. The body is optional. A plan that collects nothing is not launched (409) — a run of nothing would finish green. A launch always opens a run of its own, so it counts against the daily quota like every other way to create one (see `POST /v1/runs`): on a full day it answers 429 and opens nothing, whatever the plan collects. The run carries a key a reporter joins by (#1233): `runKey`, the one the dialog was issued by `POST /v1/runs/keys`, or one issued now — `PLUNE_RUN=<externalKey> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` reports into it. `schedule: true` is Save: the run waits, `scheduled`, for Launch or for that reporter."},"status":"the plan collects no case right now, or the `runKey` sent is already a live run’s (`code: run_key_taken` — ask for a new one)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the run key is already a live run’s — ask for a new one\",\n  \"code\": \"run_key_taken\"\n}"}]},{"name":"Start a run from the plan — Save under the key the dialog was issued — the run waits for Launch or for the reporter (#1233)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"environment\": \"staging\",\n  \"schedule\": true,\n  \"runKey\": \"rk_4f7c2a9e1b\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/plans/:id/launch","host":["{{baseUrl}}"],"path":["v1","plans",":id","launch"],"variable":[{"key":"id","value":""}]},"description":"The same road a reporter takes (`POST /v1/runs`), with the plan as the caller that knows what to expect: a run of the plan’s kind, `configuration.expected` set to what the plan collects at this moment (each with its first external key, so a reporter joining the run matches by it), `meta.planId` for provenance, the plan’s title unless the body names another. The body is optional. A plan that collects nothing is not launched (409) — a run of nothing would finish green. A launch always opens a run of its own, so it counts against the daily quota like every other way to create one (see `POST /v1/runs`): on a full day it answers 429 and opens nothing, whatever the plan collects. The run carries a key a reporter joins by (#1233): `runKey`, the one the dialog was issued by `POST /v1/runs/keys`, or one issued now — `PLUNE_RUN=<externalKey> npx playwright test --grep \"$(plune plan grep <plan-id>)\"` reports into it. `schedule: true` is Save: the run waits, `scheduled`, for Launch or for that reporter."},"response":[]}]},{"name":"audit","description":"Who changed what — the project feed, one record’s history, undo of one change or a whole session","item":[{"name":"Who changed what, newest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/audit?entity=&actorType=&since=&limit=50&cursor=&groupBySession=","host":["{{baseUrl}}"],"path":["v1","audit"],"query":[{"key":"entity","value":"","disabled":true,"description":"One of: test_case, suite, run, review_item, project, snippet, plan, ai_settings, storage_settings, job, ci_integration, custom_field, public_report, requirement, api_token, widget, testrail_connection."},{"key":"actorType","value":"","disabled":true,"description":"One of: user, token, agent."},{"key":"since","value":"","disabled":true,"description":"ISO timestamp; entries at or after it."},{"key":"limit","value":"50","disabled":true},{"key":"cursor","value":"","disabled":true,"description":"nextCursor from the previous page."},{"key":"groupBySession","value":"","disabled":true,"description":"One of: true."}]},"description":"The project feed. Paging is keyset, not offset: an agent session writes dozens of entries inside one second, so a timestamp alone does not order them, and an offset page would shift as new entries arrive at the head. Pass the previous page cursor to continue. With groupBySession=true the answer is one row per session instead — forty changes shown as forty rows is a wall nobody reads."},"response":[]},{"name":"Everything that ever happened to one record","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/audit/:entity/:entityId","host":["{{baseUrl}}"],"path":["v1","audit",":entity",":entityId"],"variable":[{"key":"entity","value":"","description":"One of: test_case, suite, run, review_item, project, snippet, plan, ai_settings, storage_settings, job, ci_integration, custom_field, public_report, requirement, api_token, widget, testrail_connection."},{"key":"entityId","value":""}]},"description":"The “History” of a case, a suite or a run on its own page (`api.ts` → the detail screens): every entry that names this record, newest first, each with `before`/`after` and who did it. The entity kind is in the path because ids are not unique across tables; an unknown kind is refused with the five that exist. A record that was deleted still has its history — that is where the deletion is."},"response":[]},{"name":"Put one record back the way it was","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/audit/:id/restore","host":["{{baseUrl}}"],"path":["v1","audit",":id","restore"],"variable":[{"key":"id","value":""}]},"description":"A restore is not an edit to history — it is a new change, applied through the ordinary store, so every rule still holds and the restore itself is audited with restoredFrom naming the entry it undid (`meta.restoredFrom` on the new entry)."},"response":[]},{"name":"Every change of one session, oldest first","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/audit/sessions/:hash","host":["{{baseUrl}}"],"path":["v1","audit","sessions",":hash"],"variable":[{"key":"hash","value":""}]},"description":"A session is one agent run — every write it made carries the same `sessionHash`. The Activity screen opens a grouped row into this list; it is also the preview of what `POST /v1/audit/sessions/{hash}/restore` would undo, in the order it was done. An unknown hash is an empty list, not a 404: a session that wrote nothing and one that never existed look the same, and neither is an error."},"response":[]},{"name":"Undo a whole session (a project owner, in person)","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/audit/sessions/:hash/restore","host":["{{baseUrl}}"],"path":["v1","audit","sessions",":hash","restore"],"variable":[{"key":"hash","value":""}]},"description":"Only the project owner may roll a session back, and only in person: a token gets 403 `person_only` whoever owns it (an agent does not roll back), anyone else 403 `owner_only`; either refusal is an entry of the project. `GET /v1/audit/sessions/{hash}/restore-preview` answers first what would happen and writes nothing. The real call walks the session backwards — the changes were applied forwards, so the state before the FIRST change is what the earliest entry holds. Whatever the preview names as changed by people or not possible is LEFT AS IT IS and named in `skipped`; nothing somebody else saved is overwritten. A record that keeps being saved by others through all three attempts of the write is skipped too. Each restore writes its own entry with `meta.restoredFrom` naming the entry it undid, and the rollback itself leaves one `event` entry on the project. Idempotent: an entry a former run put back is counted in `alreadyReverted` and not touched again, so calling it again after a failure finishes the rest. Always 200 with a report, never a stop at the first refusal. A `create` is undone by deleting the record — to the bin, reversibly (D21), so a case with results goes too and keeps them. A node that still holds something stays, because the ordinary delete cascades and an undo must not take another actor’s work along. That is what takes a filed case’s derived folders and suite with it (B-7) — and leaves a suite someone has filled since, named in the report."},"response":[]},{"name":"What undoing a session would do, written nowhere","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/audit/sessions/:hash/restore-preview","host":["{{baseUrl}}"],"path":["v1","audit","sessions",":hash","restore-preview"],"variable":[{"key":"hash","value":""}]},"description":"The preview of `POST /v1/audit/sessions/{hash}/restore` (#1282), a read: every member of the project may ask, a reader included. How many entries can be put back now (`revertable` of `total`), how many an earlier rollback already put back (`alreadyReverted`), the ones a person saved over after the session (`changedByPeople`) and the ones that cannot be put back (`cannotRevert`, with a reason code). The last two groups are what the rollback leaves as they are. An unknown hash is a preview of nothing: zeros and empty lists. Whether a folder that holds only what the session made can go is an estimate; the rollback itself is the judge."},"response":[]}]},{"name":"bin","description":"What was deleted and can still come back — the list behind Settings → Bin; the restores live on the entities — and the two ways out of it for good: selected entries, and emptying it","item":[{"name":"What was deleted and can still come back","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/bin","host":["{{baseUrl}}"],"path":["v1","bin"]},"description":"The list behind Settings → Bin, for the caller’s project: suites, test cases and runs that carry `deleted_at`, newest deletion first, each with the date the purge removes it for good. One entry per deletion — a suite deleted with its subtree is a single entry counting its `nodes` and `cases`, and a case that went with it is not repeated below. The way back is the entity’s own restore (`POST /v1/suites/{id}/restore`, `POST /v1/test-cases/{id}/restore`, `POST /v1/runs/{id}/restore`), open to the account owner since D29."},"response":[{"name":"200 — The bin, possibly empty in every group","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/bin","host":["{{baseUrl}}"],"path":["v1","bin"]},"description":"The list behind Settings → Bin, for the caller’s project: suites, test cases and runs that carry `deleted_at`, newest deletion first, each with the date the purge removes it for good. One entry per deletion — a suite deleted with its subtree is a single entry counting its `nodes` and `cases`, and a case that went with it is not repeated below. The way back is the entity’s own restore (`POST /v1/suites/{id}/restore`, `POST /v1/test-cases/{id}/restore`, `POST /v1/runs/{id}/restore`), open to the account owner since D29."},"status":"The bin, possibly empty in every group","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"suites\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\",\n      \"name\": \"Checkout\",\n      \"kind\": \"folder\",\n      \"nodes\": 2,\n      \"cases\": 11,\n      \"deletedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"purgeAt\": \"2026-10-16T09:12:04.151Z\"\n    }\n  ],\n  \"testCases\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n      \"title\": \"Refund — partial amount @manual @payments\",\n      \"type\": \"manual\",\n      \"suite\": null,\n      \"deletedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"purgeAt\": \"2026-10-16T09:12:04.151Z\"\n    }\n  ],\n  \"runs\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"title\": \"plune · 2026-09-16 09:12 · ci\",\n      \"source\": \"cli\",\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\",\n      \"deletedAt\": \"2026-09-16T09:12:04.151Z\",\n      \"purgeAt\": \"2026-10-16T09:12:04.151Z\"\n    }\n  ]\n}"}]},{"name":"Empty the Bin — for good, in the background","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": \"default\",\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/empty","host":["{{baseUrl}}"],"path":["v1","bin","empty"]},"description":"#1388: the server empties the Bin as a job. The answer is 202 with the job (`kind: bin-empty`) — `progress` counts entries of the Bin, `total` what it held — and the job goes on whether or not anybody waits for it: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. One a project: while it runs, another call answers that job. It ends `completed` when the Bin is empty, or `failed` with the reason — a suite that has to stay, or the caller no longer an owner. The deletions are the ones below, made one piece at a time and audited as the caller; so are the job’s start and end. With `sync: true`, or `dryRun`, the call is the one it was: it deletes forever the entries of the caller’s project’s Bin a piece at a time: the cases deleted on their own first, then the runs, then the suites, the oldest first within each kind. A call takes up to `limit` root items (500, also the default) from the front of that order — so a Bin bigger than that loses its cases before its runs and its runs before its suites — in transactions of at most 200, and answers how many are left of each kind. It also stops once the runs it took hold 100 000 results, taking the first run whatever it holds, so a Bin of big runs takes more calls of a few seconds each. A big Bin is emptied by calling again while `remaining` is not zero; a suite entry that had to stay (`skipped`) is still counted there, so a client stops when a call deleted nothing. `confirm` must be the name of the project, exactly — anything else is 400 and nothing is deleted. Who may, and what is and is not deleted, are as for `POST /v1/bin/purge`; so is `dryRun`, except that here it is one transaction over every entry the call would take — up to 500 root items — and holds the real locks until it rolls back, where the real call works in transactions of at most 200."},"response":[{"name":"200 — With `sync` or `dryRun`: what was deleted — or, on a dry run, what would be — and what is left","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": \"default\",\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/empty","host":["{{baseUrl}}"],"path":["v1","bin","empty"]},"description":"#1388: the server empties the Bin as a job. The answer is 202 with the job (`kind: bin-empty`) — `progress` counts entries of the Bin, `total` what it held — and the job goes on whether or not anybody waits for it: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. One a project: while it runs, another call answers that job. It ends `completed` when the Bin is empty, or `failed` with the reason — a suite that has to stay, or the caller no longer an owner. The deletions are the ones below, made one piece at a time and audited as the caller; so are the job’s start and end. With `sync: true`, or `dryRun`, the call is the one it was: it deletes forever the entries of the caller’s project’s Bin a piece at a time: the cases deleted on their own first, then the runs, then the suites, the oldest first within each kind. A call takes up to `limit` root items (500, also the default) from the front of that order — so a Bin bigger than that loses its cases before its runs and its runs before its suites — in transactions of at most 200, and answers how many are left of each kind. It also stops once the runs it took hold 100 000 results, taking the first run whatever it holds, so a Bin of big runs takes more calls of a few seconds each. A big Bin is emptied by calling again while `remaining` is not zero; a suite entry that had to stay (`skipped`) is still counted there, so a client stops when a call deleted nothing. `confirm` must be the name of the project, exactly — anything else is 400 and nothing is deleted. Who may, and what is and is not deleted, are as for `POST /v1/bin/purge`; so is `dryRun`, except that here it is one transaction over every entry the call would take — up to 500 root items — and holds the real locks until it rolls back, where the real call works in transactions of at most 200."},"status":"With `sync` or `dryRun`: what was deleted — or, on a dry run, what would be — and what is left","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": true,\n  \"purged\": {\n    \"suites\": 3,\n    \"testCases\": 12,\n    \"runs\": 1,\n    \"results\": 48,\n    \"reviewItems\": 2\n  },\n  \"skipped\": [],\n  \"remaining\": {\n    \"suites\": 0,\n    \"testCases\": 0,\n    \"runs\": 0\n  }\n}"},{"name":"202 — The job that empties the Bin — opened now, or the one already at it","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": \"default\",\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/empty","host":["{{baseUrl}}"],"path":["v1","bin","empty"]},"description":"#1388: the server empties the Bin as a job. The answer is 202 with the job (`kind: bin-empty`) — `progress` counts entries of the Bin, `total` what it held — and the job goes on whether or not anybody waits for it: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. One a project: while it runs, another call answers that job. It ends `completed` when the Bin is empty, or `failed` with the reason — a suite that has to stay, or the caller no longer an owner. The deletions are the ones below, made one piece at a time and audited as the caller; so are the job’s start and end. With `sync: true`, or `dryRun`, the call is the one it was: it deletes forever the entries of the caller’s project’s Bin a piece at a time: the cases deleted on their own first, then the runs, then the suites, the oldest first within each kind. A call takes up to `limit` root items (500, also the default) from the front of that order — so a Bin bigger than that loses its cases before its runs and its runs before its suites — in transactions of at most 200, and answers how many are left of each kind. It also stops once the runs it took hold 100 000 results, taking the first run whatever it holds, so a Bin of big runs takes more calls of a few seconds each. A big Bin is emptied by calling again while `remaining` is not zero; a suite entry that had to stay (`skipped`) is still counted there, so a client stops when a call deleted nothing. `confirm` must be the name of the project, exactly — anything else is 400 and nothing is deleted. Who may, and what is and is not deleted, are as for `POST /v1/bin/purge`; so is `dryRun`, except that here it is one transaction over every entry the call would take — up to 500 root items — and holds the real locks until it rolls back, where the real call works in transactions of at most 200."},"status":"The job that empties the Bin — opened now, or the one already at it","code":202,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n  \"kind\": \"bin-empty\",\n  \"state\": \"in_progress\",\n  \"subjectId\": null,\n  \"subjectName\": null,\n  \"progress\": {\n    \"done\": 0,\n    \"total\": 412\n  },\n  \"reason\": null,\n  \"cursor\": \"0\",\n  \"attempt\": 1,\n  \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n  \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n  \"finishedAt\": null,\n  \"updatedAt\": \"2026-09-16T10:02:00.000Z\"\n}"},{"name":"400 — wrongConfirm","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": \"default\",\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/empty","host":["{{baseUrl}}"],"path":["v1","bin","empty"]},"description":"#1388: the server empties the Bin as a job. The answer is 202 with the job (`kind: bin-empty`) — `progress` counts entries of the Bin, `total` what it held — and the job goes on whether or not anybody waits for it: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. One a project: while it runs, another call answers that job. It ends `completed` when the Bin is empty, or `failed` with the reason — a suite that has to stay, or the caller no longer an owner. The deletions are the ones below, made one piece at a time and audited as the caller; so are the job’s start and end. With `sync: true`, or `dryRun`, the call is the one it was: it deletes forever the entries of the caller’s project’s Bin a piece at a time: the cases deleted on their own first, then the runs, then the suites, the oldest first within each kind. A call takes up to `limit` root items (500, also the default) from the front of that order — so a Bin bigger than that loses its cases before its runs and its runs before its suites — in transactions of at most 200, and answers how many are left of each kind. It also stops once the runs it took hold 100 000 results, taking the first run whatever it holds, so a Bin of big runs takes more calls of a few seconds each. A big Bin is emptied by calling again while `remaining` is not zero; a suite entry that had to stay (`skipped`) is still counted there, so a client stops when a call deleted nothing. `confirm` must be the name of the project, exactly — anything else is 400 and nothing is deleted. Who may, and what is and is not deleted, are as for `POST /v1/bin/purge`; so is `dryRun`, except that here it is one transaction over every entry the call would take — up to 500 root items — and holds the real locks until it rolls back, where the real call works in transactions of at most 200."},"status":"the confirmation is not the name of the project, or the body is invalid — a `limit` outside 1 to 500, a `dryRun` or `sync` that is not a boolean","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"confirm must be the exact name of the project\"\n}"},{"name":"400 — limitTooBig","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": \"default\",\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/empty","host":["{{baseUrl}}"],"path":["v1","bin","empty"]},"description":"#1388: the server empties the Bin as a job. The answer is 202 with the job (`kind: bin-empty`) — `progress` counts entries of the Bin, `total` what it held — and the job goes on whether or not anybody waits for it: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. One a project: while it runs, another call answers that job. It ends `completed` when the Bin is empty, or `failed` with the reason — a suite that has to stay, or the caller no longer an owner. The deletions are the ones below, made one piece at a time and audited as the caller; so are the job’s start and end. With `sync: true`, or `dryRun`, the call is the one it was: it deletes forever the entries of the caller’s project’s Bin a piece at a time: the cases deleted on their own first, then the runs, then the suites, the oldest first within each kind. A call takes up to `limit` root items (500, also the default) from the front of that order — so a Bin bigger than that loses its cases before its runs and its runs before its suites — in transactions of at most 200, and answers how many are left of each kind. It also stops once the runs it took hold 100 000 results, taking the first run whatever it holds, so a Bin of big runs takes more calls of a few seconds each. A big Bin is emptied by calling again while `remaining` is not zero; a suite entry that had to stay (`skipped`) is still counted there, so a client stops when a call deleted nothing. `confirm` must be the name of the project, exactly — anything else is 400 and nothing is deleted. Who may, and what is and is not deleted, are as for `POST /v1/bin/purge`; so is `dryRun`, except that here it is one transaction over every entry the call would take — up to 500 root items — and holds the real locks until it rolls back, where the real call works in transactions of at most 200."},"status":"the confirmation is not the name of the project, or the body is invalid — a `limit` outside 1 to 500, a `dryRun` or `sync` that is not a boolean","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — limit: Too big: expected number to be <=500\"\n}"},{"name":"404 — the caller may not delete forever in this project — the same answer as for an id nobody has; nothing was deleted","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"confirm\": \"default\",\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/empty","host":["{{baseUrl}}"],"path":["v1","bin","empty"]},"description":"#1388: the server empties the Bin as a job. The answer is 202 with the job (`kind: bin-empty`) — `progress` counts entries of the Bin, `total` what it held — and the job goes on whether or not anybody waits for it: `GET /v1/jobs` shows how far it got, and Cancel there (`PATCH /v1/jobs/{id}/state`, `action: cancel`) stops it after the piece in hand. One a project: while it runs, another call answers that job. It ends `completed` when the Bin is empty, or `failed` with the reason — a suite that has to stay, or the caller no longer an owner. The deletions are the ones below, made one piece at a time and audited as the caller; so are the job’s start and end. With `sync: true`, or `dryRun`, the call is the one it was: it deletes forever the entries of the caller’s project’s Bin a piece at a time: the cases deleted on their own first, then the runs, then the suites, the oldest first within each kind. A call takes up to `limit` root items (500, also the default) from the front of that order — so a Bin bigger than that loses its cases before its runs and its runs before its suites — in transactions of at most 200, and answers how many are left of each kind. It also stops once the runs it took hold 100 000 results, taking the first run whatever it holds, so a Bin of big runs takes more calls of a few seconds each. A big Bin is emptied by calling again while `remaining` is not zero; a suite entry that had to stay (`skipped`) is still counted there, so a client stops when a call deleted nothing. `confirm` must be the name of the project, exactly — anything else is 400 and nothing is deleted. Who may, and what is and is not deleted, are as for `POST /v1/bin/purge`; so is `dryRun`, except that here it is one transaction over every entry the call would take — up to 500 root items — and holds the real locks until it rolls back, where the real call works in transactions of at most 200."},"status":"the caller may not delete forever in this project — the same answer as for an id nobody has; nothing was deleted","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"not found\"\n}"}]},{"name":"Delete forever the Bin entries named","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/purge","host":["{{baseUrl}}"],"path":["v1","bin","purge"]},"description":"The way out of the Bin for good (#1020), for what a person selected on the list: a `suite` entry (the node, its subtree and the cases that went with it), a `case` deleted on its own, a `run` (with its results, its queue entries and its files). Irreversible — nothing brings these rows back, and the sweep that would have taken them after 30 days only comes later. For the same people who may `restore`: an owner of the project, in it, and the owner of the deployment in a project they are a member of. A reader is refused at the boundary like every write (403); everybody else, and anything that is not an entry of the caller’s own Bin — a row that is live, another project’s, an id nobody has — is answered 404, the same 404 whichever it is, and nothing is deleted: all or nothing. The history stays, and a screen says so before the person confirms: it keeps the earlier versions of what was written in cases and suites, and changes made to a run after it was created, such as an edit, a corrected mark, a delete or a restore. The creation of a run, of its results and of their files was never in it, whether a reporter sent them or a person opened and marked a manual run. Every root item taken leaves one new `delete` entry with its id and the actor and none of the content. `dryRun: true` does the same work and rolls it back, answering the counts the real call would have; those are an estimate if the data changes between the two calls."},"response":[{"name":"200 — What was deleted — or, on a dry run, what would be","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/purge","host":["{{baseUrl}}"],"path":["v1","bin","purge"]},"description":"The way out of the Bin for good (#1020), for what a person selected on the list: a `suite` entry (the node, its subtree and the cases that went with it), a `case` deleted on its own, a `run` (with its results, its queue entries and its files). Irreversible — nothing brings these rows back, and the sweep that would have taken them after 30 days only comes later. For the same people who may `restore`: an owner of the project, in it, and the owner of the deployment in a project they are a member of. A reader is refused at the boundary like every write (403); everybody else, and anything that is not an entry of the caller’s own Bin — a row that is live, another project’s, an id nobody has — is answered 404, the same 404 whichever it is, and nothing is deleted: all or nothing. The history stays, and a screen says so before the person confirms: it keeps the earlier versions of what was written in cases and suites, and changes made to a run after it was created, such as an edit, a corrected mark, a delete or a restore. The creation of a run, of its results and of their files was never in it, whether a reporter sent them or a person opened and marked a manual run. Every root item taken leaves one new `delete` entry with its id and the actor and none of the content. `dryRun: true` does the same work and rolls it back, answering the counts the real call would have; those are an estimate if the data changes between the two calls."},"status":"What was deleted — or, on a dry run, what would be","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": true,\n  \"purged\": {\n    \"suites\": 3,\n    \"testCases\": 12,\n    \"runs\": 1,\n    \"results\": 48,\n    \"reviewItems\": 2\n  },\n  \"skipped\": []\n}"},{"name":"400 — noItems","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/purge","host":["{{baseUrl}}"],"path":["v1","bin","purge"]},"description":"The way out of the Bin for good (#1020), for what a person selected on the list: a `suite` entry (the node, its subtree and the cases that went with it), a `case` deleted on its own, a `run` (with its results, its queue entries and its files). Irreversible — nothing brings these rows back, and the sweep that would have taken them after 30 days only comes later. For the same people who may `restore`: an owner of the project, in it, and the owner of the deployment in a project they are a member of. A reader is refused at the boundary like every write (403); everybody else, and anything that is not an entry of the caller’s own Bin — a row that is live, another project’s, an id nobody has — is answered 404, the same 404 whichever it is, and nothing is deleted: all or nothing. The history stays, and a screen says so before the person confirms: it keeps the earlier versions of what was written in cases and suites, and changes made to a run after it was created, such as an edit, a corrected mark, a delete or a restore. The creation of a run, of its results and of their files was never in it, whether a reporter sent them or a person opened and marked a manual run. Every root item taken leaves one new `delete` entry with its id and the actor and none of the content. `dryRun: true` does the same work and rolls it back, answering the counts the real call would have; those are an estimate if the data changes between the two calls."},"status":"invalid body — no item, more than 200, an unknown `kind`, an id that is not text, a `dryRun` that is not a boolean","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — items: Too small: expected array to have >=1 items\"\n}"},{"name":"404 — the caller may not delete forever in this project, or an item is not an entry of its Bin — one answer for both, and for every item, so nothing says which; nothing was deleted","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/purge","host":["{{baseUrl}}"],"path":["v1","bin","purge"]},"description":"The way out of the Bin for good (#1020), for what a person selected on the list: a `suite` entry (the node, its subtree and the cases that went with it), a `case` deleted on its own, a `run` (with its results, its queue entries and its files). Irreversible — nothing brings these rows back, and the sweep that would have taken them after 30 days only comes later. For the same people who may `restore`: an owner of the project, in it, and the owner of the deployment in a project they are a member of. A reader is refused at the boundary like every write (403); everybody else, and anything that is not an entry of the caller’s own Bin — a row that is live, another project’s, an id nobody has — is answered 404, the same 404 whichever it is, and nothing is deleted: all or nothing. The history stays, and a screen says so before the person confirms: it keeps the earlier versions of what was written in cases and suites, and changes made to a run after it was created, such as an edit, a corrected mark, a delete or a restore. The creation of a run, of its results and of their files was never in it, whether a reporter sent them or a person opened and marked a manual run. Every root item taken leaves one new `delete` entry with its id and the actor and none of the content. `dryRun: true` does the same work and rolls it back, answering the counts the real call would have; those are an estimate if the data changes between the two calls."},"status":"the caller may not delete forever in this project, or an item is not an entry of its Bin — one answer for both, and for every item, so nothing says which; nothing was deleted","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"not found\"\n}"}]},{"name":"Bring several Bin entries back at once","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/restore","host":["{{baseUrl}}"],"path":["v1","bin","restore"]},"description":"The selection of `POST /v1/bin/purge` (one to 200 entries, the same `items`), for the same people, and the reverse of the three single restores — in ONE transaction, all or nothing: a suite entry comes back with its subtree and the cases that went with it, a `case` on its own, a `run` with the queue entries that went with it. The cases that come back — those named and those of the suites — are live cases again, so the project ceiling (`testCasesPerProject`) is asked ONCE for all of them: a pick that would cross it answers 429 `Cannot restore: you reached the limit …` and nothing changes. Two calls racing for the last free slot: one wins, the other is the 429. An item that is gone, already restored, or not in the Bin of the caller’s project makes the whole call 404 and restores nothing; the same 404 for a caller who may not restore. A case whose suite stays in the Bin (and is not in the pick) answers 409 `suite_deleted`, and a run whose key a live run holds answers 409 `run_key_live`, as the single restores do; nothing is restored then either. One `restore` audit entry per record named. `dryRun: true` does the same work, ceiling check included, and rolls it back, answering the counts the real call would have."},"response":[{"name":"200 — What came back — or, on a dry run, what would","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/restore","host":["{{baseUrl}}"],"path":["v1","bin","restore"]},"description":"The selection of `POST /v1/bin/purge` (one to 200 entries, the same `items`), for the same people, and the reverse of the three single restores — in ONE transaction, all or nothing: a suite entry comes back with its subtree and the cases that went with it, a `case` on its own, a `run` with the queue entries that went with it. The cases that come back — those named and those of the suites — are live cases again, so the project ceiling (`testCasesPerProject`) is asked ONCE for all of them: a pick that would cross it answers 429 `Cannot restore: you reached the limit …` and nothing changes. Two calls racing for the last free slot: one wins, the other is the 429. An item that is gone, already restored, or not in the Bin of the caller’s project makes the whole call 404 and restores nothing; the same 404 for a caller who may not restore. A case whose suite stays in the Bin (and is not in the pick) answers 409 `suite_deleted`, and a run whose key a live run holds answers 409 `run_key_live`, as the single restores do; nothing is restored then either. One `restore` audit entry per record named. `dryRun: true` does the same work, ceiling check included, and rolls it back, answering the counts the real call would have."},"status":"What came back — or, on a dry run, what would","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"dryRun\": true,\n  \"restored\": {\n    \"suites\": 3,\n    \"testCases\": 12,\n    \"runs\": 1\n  }\n}"},{"name":"400 — noItems","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/restore","host":["{{baseUrl}}"],"path":["v1","bin","restore"]},"description":"The selection of `POST /v1/bin/purge` (one to 200 entries, the same `items`), for the same people, and the reverse of the three single restores — in ONE transaction, all or nothing: a suite entry comes back with its subtree and the cases that went with it, a `case` on its own, a `run` with the queue entries that went with it. The cases that come back — those named and those of the suites — are live cases again, so the project ceiling (`testCasesPerProject`) is asked ONCE for all of them: a pick that would cross it answers 429 `Cannot restore: you reached the limit …` and nothing changes. Two calls racing for the last free slot: one wins, the other is the 429. An item that is gone, already restored, or not in the Bin of the caller’s project makes the whole call 404 and restores nothing; the same 404 for a caller who may not restore. A case whose suite stays in the Bin (and is not in the pick) answers 409 `suite_deleted`, and a run whose key a live run holds answers 409 `run_key_live`, as the single restores do; nothing is restored then either. One `restore` audit entry per record named. `dryRun: true` does the same work, ceiling check included, and rolls it back, answering the counts the real call would have."},"status":"invalid body — no item, more than 200, an unknown `kind`, an id that is not text, a `dryRun` that is not a boolean","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — items: Too small: expected array to have >=1 items\"\n}"},{"name":"404 — the caller may not restore in this project, or an item is not (any more) an entry of its Bin; nothing was restored","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"items\": [\n    {\n      \"kind\": \"suite\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a1\"\n    },\n    {\n      \"kind\": \"case\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\"\n    },\n    {\n      \"kind\": \"run\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000d1\"\n    }\n  ],\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/bin/restore","host":["{{baseUrl}}"],"path":["v1","bin","restore"]},"description":"The selection of `POST /v1/bin/purge` (one to 200 entries, the same `items`), for the same people, and the reverse of the three single restores — in ONE transaction, all or nothing: a suite entry comes back with its subtree and the cases that went with it, a `case` on its own, a `run` with the queue entries that went with it. The cases that come back — those named and those of the suites — are live cases again, so the project ceiling (`testCasesPerProject`) is asked ONCE for all of them: a pick that would cross it answers 429 `Cannot restore: you reached the limit …` and nothing changes. Two calls racing for the last free slot: one wins, the other is the 429. An item that is gone, already restored, or not in the Bin of the caller’s project makes the whole call 404 and restores nothing; the same 404 for a caller who may not restore. A case whose suite stays in the Bin (and is not in the pick) answers 409 `suite_deleted`, and a run whose key a live run holds answers 409 `run_key_live`, as the single restores do; nothing is restored then either. One `restore` audit entry per record named. `dryRun: true` does the same work, ceiling check included, and rolls it back, answering the counts the real call would have."},"status":"the caller may not restore in this project, or an item is not (any more) an entry of its Bin; nothing was restored","code":404,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"nothing restored: an item is not in the Bin of this project, or it was restored or deleted for good meanwhile\"\n}"}]}]},{"name":"admin","description":"Deployment owner only (`users.role = owner`) — the purge, the one-off sweeps and tariff grants; everyone else is answered 404. An admin token (`plune_adm_…`, #1641) opens the doors it was given","item":[{"name":"What the API sees of the caller’s address","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/client-ip","host":["{{baseUrl}}"],"path":["v1","admin","client-ip"]},"description":"For the operator to find out what the edge (the reverse proxy) sends: call it through the real edge with a forged `X-Forwarded-For` and read which entry the API keys its per-IP limits on (`docs/RUNBOOK.md` §4e). `chosen` is that entry (`null` when there is nothing to key on: the limit is then off for the request), `hops` is `TRUSTED_PROXY_HOPS` (`null` = unset, the first entry is read). The header is returned as received and is never logged. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The address as the API sees it","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/client-ip","host":["{{baseUrl}}"],"path":["v1","admin","client-ip"]},"description":"For the operator to find out what the edge (the reverse proxy) sends: call it through the real edge with a forged `X-Forwarded-For` and read which entry the API keys its per-IP limits on (`docs/RUNBOOK.md` §4e). `chosen` is that entry (`null` when there is nothing to key on: the limit is then off for the request), `hops` is `TRUSTED_PROXY_HOPS` (`null` = unset, the first entry is read). The header is returned as received and is never logged. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The address as the API sees it","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"socketAddress\": \"172.18.0.4\",\n  \"xForwardedFor\": \"1.2.3.4, 203.0.113.7\",\n  \"chosen\": \"203.0.113.7\",\n  \"hops\": 1\n}"}]},{"name":"File every automated case that has no suite, in every project, by its specRef","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/file-unfiled","host":["{{baseUrl}}"],"path":["v1","admin","file-unfiled"]},"description":"A deployment-level operation like the purge, run once after the wave-1 deploy of B-7 (AC-17). Walks every project — each in its own transaction — and files every `automated` case without a suite through the same seam a freshly created case goes through: folders and suites are found or created, the case gets its `suiteId`, and the derived nodes and the case are audited under the caller. A case whose path meets a node of the same name but the other kind stays in the root and is reported under `skipped` with the reason (AC-14). Idempotent: a second run reports `filed: 0` everywhere and the same `skipped`. Restricted to the owner of the deployment; everyone else gets 404 — the route does not exist for them."},"response":[{"name":"200 — What was filed and what was skipped, per project","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/file-unfiled","host":["{{baseUrl}}"],"path":["v1","admin","file-unfiled"]},"description":"A deployment-level operation like the purge, run once after the wave-1 deploy of B-7 (AC-17). Walks every project — each in its own transaction — and files every `automated` case without a suite through the same seam a freshly created case goes through: folders and suites are found or created, the case gets its `suiteId`, and the derived nodes and the case are audited under the caller. A case whose path meets a node of the same name but the other kind stays in the root and is reported under `skipped` with the reason (AC-14). Idempotent: a second run reports `filed: 0` everywhere and the same `skipped`. Restricted to the owner of the deployment; everyone else gets 404 — the route does not exist for them."},"status":"What was filed and what was skipped, per project","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projects\": [\n    {\n      \"projectId\": \"userA::default\",\n      \"filed\": 2027,\n      \"skipped\": [\n        {\n          \"caseId\": \"6f1c2b3a-0000-4000-8000-0000000000c3\",\n          \"reason\": \"filing skipped: 'e2e' exists as a suite, expected a folder\"\n        }\n      ]\n    },\n    {\n      \"projectId\": \"userB::default\",\n      \"filed\": 0,\n      \"skipped\": []\n    }\n  ]\n}"}]},{"name":"When the purge last ran, and whether that is recent enough","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/purge","host":["{{baseUrl}}"],"path":["v1","admin","purge"]},"description":"For the schedule to read back after sweeping, not for a screen. `stale` is true when the last sweep is over 48 hours old OR none has ever run — a deployment that has never purged is exactly the state worth an alarm."},"response":[]},{"name":"Delete for good every run, case, suite and company whose 30 days have passed","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/purge","host":["{{baseUrl}}"],"path":["v1","admin","purge"]},"description":"The second half of the run delete: `DELETE /v1/runs/{id}` hides a run and keeps it, this removes what has aged out. Irreversible — nothing brings these rows back, which is why they wait 30 days first. Restricted to the owner of the deployment; everyone else gets 404. Called by a schedule, not by a person: `0` everywhere is the normal answer."},"response":[]},{"name":"Rebuild the Steps Database of every project from its live manual cases","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/reindex-steps","host":["{{baseUrl}}"],"path":["v1","admin","reindex-steps"]},"description":"The migration creates the Steps Database empty while the manual cases already carry their steps. This re-syncs every live manual case of every project through the same writer the case save path uses (D10) — once after the deploy that brings the table, like `reindex-tags` after its own. Steps made by hand that no case carries are left alone. `reindexed` counts the cases walked. Owner-only, like `file-unfiled`; everyone else gets 404."},"response":[{"name":"200 — How many cases were walked, per project","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/reindex-steps","host":["{{baseUrl}}"],"path":["v1","admin","reindex-steps"]},"description":"The migration creates the Steps Database empty while the manual cases already carry their steps. This re-syncs every live manual case of every project through the same writer the case save path uses (D10) — once after the deploy that brings the table, like `reindex-tags` after its own. Steps made by hand that no case carries are left alone. `reindexed` counts the cases walked. Owner-only, like `file-unfiled`; everyone else gets 404."},"status":"How many cases were walked, per project","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projects\": [\n    {\n      \"projectId\": \"userA::default\",\n      \"reindexed\": 212\n    },\n    {\n      \"projectId\": \"userB::default\",\n      \"reindexed\": 0\n    }\n  ]\n}"}]},{"name":"Rebuild the tag index for every case in every project from its title","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/reindex-tags","host":["{{baseUrl}}"],"path":["v1","admin","reindex-tags"]},"description":"The migration creates the tag index empty — the parser exists only in TypeScript — while the titles already there carry `@tag`s. This walks every case of every project and rewrites its tag rows with the same parser the case write path uses (AC-27). `reindexed` counts the cases whose set of tags changed; a second run reports 0 everywhere. Owner-only, like `file-unfiled`; everyone else gets 404."},"response":[{"name":"200 — How many cases changed, per project","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/reindex-tags","host":["{{baseUrl}}"],"path":["v1","admin","reindex-tags"]},"description":"The migration creates the tag index empty — the parser exists only in TypeScript — while the titles already there carry `@tag`s. This walks every case of every project and rewrites its tag rows with the same parser the case write path uses (AC-27). `reindexed` counts the cases whose set of tags changed; a second run reports 0 everywhere. Owner-only, like `file-unfiled`; everyone else gets 404."},"status":"How many cases changed, per project","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"projects\": [\n    {\n      \"projectId\": \"userA::default\",\n      \"reindexed\": 1804\n    },\n    {\n      \"projectId\": \"userB::default\",\n      \"reindexed\": 0\n    }\n  ]\n}"}]},{"name":"Free tier cleanup: report or delete","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/retention","host":["{{baseUrl}}"],"path":["v1","admin","retention"]},"description":"The Free tier keeps 30 days of runs and 200 000 results per account (#1049). A run of a Free account is deleted for good when it is older than 30 days by `started_at`, or when it and the runs newer than it, in all the account’s projects, add up to more than 200 000 results. The newest run and any run still going are kept, and an account on a paid tier (also in its grace, or switched off by hand) is never touched. `dryRun: true` deletes nothing and answers per project how many runs, results and bytes of files a pass would remove. `dryRun: false` deletes, through the cascade the Bin and the nightly purge share, and writes one summary row to each project’s log; it is refused with 409 `retention_not_enabled` until the operator sets `TIER_RETENTION_ENFORCE=true`. The worker runs the same pass every night at 03:00 UTC, as a report only until that switch is on. The owner’s session or an admin token with the `retention` door; anyone else is told 404. Ten a minute."},"response":[{"name":"200 — What the pass would delete (`mode: report`) or deleted (`mode: delete`)","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"dryRun\": true\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/retention","host":["{{baseUrl}}"],"path":["v1","admin","retention"]},"description":"The Free tier keeps 30 days of runs and 200 000 results per account (#1049). A run of a Free account is deleted for good when it is older than 30 days by `started_at`, or when it and the runs newer than it, in all the account’s projects, add up to more than 200 000 results. The newest run and any run still going are kept, and an account on a paid tier (also in its grace, or switched off by hand) is never touched. `dryRun: true` deletes nothing and answers per project how many runs, results and bytes of files a pass would remove. `dryRun: false` deletes, through the cascade the Bin and the nightly purge share, and writes one summary row to each project’s log; it is refused with 409 `retention_not_enabled` until the operator sets `TIER_RETENTION_ENFORCE=true`. The worker runs the same pass every night at 03:00 UTC, as a report only until that switch is on. The owner’s session or an admin token with the `retention` door; anyone else is told 404. Ten a minute."},"status":"What the pass would delete (`mode: report`) or deleted (`mode: delete`)","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"mode\": \"report\",\n  \"accounts\": 3,\n  \"projects\": [\n    {\n      \"projectId\": \"proj_1\",\n      \"runs\": 12,\n      \"results\": 5400,\n      \"fileBytes\": 10485760,\n      \"byAge\": 9,\n      \"byLimit\": 3\n    }\n  ],\n  \"totals\": {\n    \"runs\": 12,\n    \"results\": 5400,\n    \"fileBytes\": 10485760\n  }\n}"}]},{"name":"End every session of one account","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"colleague@example.com\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/revoke-sessions","host":["{{baseUrl}}"],"path":["v1","admin","revoke-sessions"]},"description":"For a session cookie that leaked (#1820): sets the account’s «sessions ended at» mark to now, so every cookie issued to it before this moment is refused with the 401 an expired one gets; a sign-in after the call works. Other accounts, API tokens and admin tokens are not touched. The call is a row of the admin journal (target: the account id) and the Slack channel is told when a webhook is set. The response carries no cookie or token. The owner’s session alone: an admin token is told 403, anyone else 404. Ten a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Give or take the application owner role","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"colleague@example.com\",\n  \"role\": \"owner\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/roles","host":["{{baseUrl}}"],"path":["v1","admin","roles"]},"description":"In place of an `UPDATE users SET role` on the host (ADR 0055, C). The change and its row in the admin journal are one transaction, and the Slack channel is told when a webhook is set (a Slack that is down does not stop it; the failure is a journal row). The last owner cannot be taken (409). The first owner is still made outside the API. The owner’s session alone: an admin token is told 403, anyone else 404. Ten a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Switch a company’s single-sign-on requirement (or its single sign-on) off","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"companyId\": \"6f1c2b3a-0000-4000-8000-0000000000a5\",\n  \"what\": \"enforcement\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/sso/switch-off","host":["{{baseUrl}}"],"path":["v1","admin","sso","switch-off"]},"description":"The application owner’s way out for a company whose identity provider is gone (operator decision of 17.10.2026), beside the company owner’s own emergency sign-in. `what: enforcement` ends the requirement; `all` ends single sign-on too. A row of the company’s log. The owner’s session alone; anyone else is told 404. Ten a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"The history of one subject’s tier","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants?subject=","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"],"query":[{"key":"subject","value":"","disabled":true,"description":"`user:<id>` or `company:<id>`"}]},"description":"Every grant of one account or company, newest first (up to 200), with the standing worked out from them now. A subject with no grant is Free and has an empty list. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The standing and the history","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants?subject=","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"],"query":[{"key":"subject","value":"","disabled":true,"description":"`user:<id>` or `company:<id>`"}]},"description":"Every grant of one account or company, newest first (up to 200), with the standing worked out from them now. A subject with no grant is Free and has an empty list. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The standing and the history","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"standing\": {\n    \"tier\": \"team\",\n    \"state\": \"active\",\n    \"options\": {\n      \"saml\": true\n    },\n    \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n    \"graceEndsAt\": null\n  },\n  \"grants\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a6\",\n      \"subject\": {\n        \"kind\": \"company\",\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n      },\n      \"tier\": \"team\",\n      \"options\": {\n        \"saml\": true\n      },\n      \"validFrom\": \"2026-09-16T09:12:04.151Z\",\n      \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n      \"source\": \"manual\",\n      \"grantedBy\": \"u-shop\",\n      \"note\": \"annual invoice 2026-114\",\n      \"disabled\": false,\n      \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n    }\n  ]\n}"},{"name":"400 — `subject` is not `user:<id>` or `company:<id>`","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants?subject=","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"],"query":[{"key":"subject","value":"","disabled":true,"description":"`user:<id>` or `company:<id>`"}]},"description":"Every grant of one account or company, newest first (up to 200), with the standing worked out from them now. A subject with no grant is Free and has an empty list. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"`subject` is not `user:<id>` or `company:<id>`","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"subject: must be user:<id> or company:<id>\"\n}"}]},{"name":"Grant, change, cancel or switch off a tier — Team to a company until a date, with SAML","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"subject\": {\n    \"kind\": \"company\",\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n  },\n  \"tier\": \"team\",\n  \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n  \"options\": {\n    \"saml\": true\n  },\n  \"note\": \"annual invoice 2026-114\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"]},"description":"Writes one row to the append-only history and answers it with the standing it leads to. The newest grant that covers now wins; the old rows stay. Cancelling is `tier: free` — the subject then keeps the tier’s limits for 14 days (`grace`); `free` with `disabled: true` switches it off at once (Free limits now, nothing deleted for 14 days). The account or company must exist (a company in the Bin does not), and `validUntil` must be in the future by the database’s clock. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else, a bearer token included, gets 404, before the body is read. Ten calls a minute. Not written to the audit log: its entries belong to a project, a grant does not.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"201 — The row written, and what the subject is on now","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"subject\": {\n    \"kind\": \"company\",\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n  },\n  \"tier\": \"team\",\n  \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n  \"options\": {\n    \"saml\": true\n  },\n  \"note\": \"annual invoice 2026-114\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"]},"description":"Writes one row to the append-only history and answers it with the standing it leads to. The newest grant that covers now wins; the old rows stay. Cancelling is `tier: free` — the subject then keeps the tier’s limits for 14 days (`grace`); `free` with `disabled: true` switches it off at once (Free limits now, nothing deleted for 14 days). The account or company must exist (a company in the Bin does not), and `validUntil` must be in the future by the database’s clock. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else, a bearer token included, gets 404, before the body is read. Ten calls a minute. Not written to the audit log: its entries belong to a project, a grant does not.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The row written, and what the subject is on now","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"grant\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a6\",\n    \"subject\": {\n      \"kind\": \"company\",\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n    },\n    \"tier\": \"team\",\n    \"options\": {\n      \"saml\": true\n    },\n    \"validFrom\": \"2026-09-16T09:12:04.151Z\",\n    \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n    \"source\": \"manual\",\n    \"grantedBy\": \"u-shop\",\n    \"note\": \"annual invoice 2026-114\",\n    \"disabled\": false,\n    \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n  },\n  \"standing\": {\n    \"tier\": \"team\",\n    \"state\": \"active\",\n    \"options\": {\n      \"saml\": true\n    },\n    \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n    \"graceEndsAt\": null\n  }\n}"},{"name":"400 — not JSON, or a field that does not hold — a tier that does not fit the subject, an end in the past, an option that does not exist, a note over 500 bytes","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"subject\": {\n    \"kind\": \"company\",\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n  },\n  \"tier\": \"team\",\n  \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n  \"options\": {\n    \"saml\": true\n  },\n  \"note\": \"annual invoice 2026-114\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"]},"description":"Writes one row to the append-only history and answers it with the standing it leads to. The newest grant that covers now wins; the old rows stay. Cancelling is `tier: free` — the subject then keeps the tier’s limits for 14 days (`grace`); `free` with `disabled: true` switches it off at once (Free limits now, nothing deleted for 14 days). The account or company must exist (a company in the Bin does not), and `validUntil` must be in the future by the database’s clock. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else, a bearer token included, gets 404, before the body is read. Ten calls a minute. Not written to the audit log: its entries belong to a project, a grant does not.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"not JSON, or a field that does not hold — a tier that does not fit the subject, an end in the past, an option that does not exist, a note over 500 bytes","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — tier: team belongs to a company, not an account\"\n}"}]},{"name":"Grant, change, cancel or switch off a tier — Personal to an account by its address, no end","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"subject\": {\n    \"email\": \"colleague@example.com\"\n  },\n  \"tier\": \"personal\",\n  \"note\": \"dogfood\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"]},"description":"Writes one row to the append-only history and answers it with the standing it leads to. The newest grant that covers now wins; the old rows stay. Cancelling is `tier: free` — the subject then keeps the tier’s limits for 14 days (`grace`); `free` with `disabled: true` switches it off at once (Free limits now, nothing deleted for 14 days). The account or company must exist (a company in the Bin does not), and `validUntil` must be in the future by the database’s clock. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else, a bearer token included, gets 404, before the body is read. Ten calls a minute. Not written to the audit log: its entries belong to a project, a grant does not.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Grant, change, cancel or switch off a tier — Cancel","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"subject\": {\n    \"kind\": \"company\",\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n  },\n  \"tier\": \"free\",\n  \"note\": \"cancelled at the customer’s request\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/tier-grants","host":["{{baseUrl}}"],"path":["v1","admin","tier-grants"]},"description":"Writes one row to the append-only history and answers it with the standing it leads to. The newest grant that covers now wins; the old rows stay. Cancelling is `tier: free` — the subject then keeps the tier’s limits for 14 days (`grace`); `free` with `disabled: true` switches it off at once (Free limits now, nothing deleted for 14 days). The account or company must exist (a company in the Bin does not), and `validUntil` must be in the future by the database’s clock. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else, a bearer token included, gets 404, before the body is read. Ten calls a minute. Not written to the audit log: its entries belong to a project, a grant does not.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Find an account or a company and see its tier","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tiers?query=","host":["{{baseUrl}}"],"path":["v1","admin","tiers"],"query":[{"key":"query","value":"","disabled":true}]},"description":"Accounts by part of the address and companies (not in the Bin) by part of the name, up to 25 of each: the tier each is on now, until when, and the newest grant — who gave it and why. Owner-only and session-only (the owner’s `plune_session` cookie; a bearer token is refused, as every token is minted inside a project); everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The accounts and companies that match","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tiers?query=","host":["{{baseUrl}}"],"path":["v1","admin","tiers"],"query":[{"key":"query","value":"","disabled":true}]},"description":"Accounts by part of the address and companies (not in the Bin) by part of the name, up to 25 of each: the tier each is on now, until when, and the newest grant — who gave it and why. Owner-only and session-only (the owner’s `plune_session` cookie; a bearer token is refused, as every token is minted inside a project); everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The accounts and companies that match","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"results\": [\n    {\n      \"subject\": {\n        \"kind\": \"company\",\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n      },\n      \"label\": \"Acme\",\n      \"standing\": {\n        \"tier\": \"team\",\n        \"state\": \"active\",\n        \"options\": {\n          \"saml\": true\n        },\n        \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n        \"graceEndsAt\": null\n      },\n      \"latest\": {\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a6\",\n        \"subject\": {\n          \"kind\": \"company\",\n          \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n        },\n        \"tier\": \"team\",\n        \"options\": {\n          \"saml\": true\n        },\n        \"validFrom\": \"2026-09-16T09:12:04.151Z\",\n        \"validUntil\": \"2099-12-31T23:59:59.000Z\",\n        \"source\": \"manual\",\n        \"grantedBy\": \"u-shop\",\n        \"note\": \"annual invoice 2026-114\",\n        \"disabled\": false,\n        \"createdAt\": \"2026-09-16T09:12:04.151Z\"\n      }\n    }\n  ]\n}"},{"name":"400 — no `query`, or one over 100 characters","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tiers?query=","host":["{{baseUrl}}"],"path":["v1","admin","tiers"],"query":[{"key":"query","value":"","disabled":true}]},"description":"Accounts by part of the address and companies (not in the Bin) by part of the name, up to 25 of each: the tier each is on now, until when, and the newest grant — who gave it and why. Owner-only and session-only (the owner’s `plune_session` cookie; a bearer token is refused, as every token is minted inside a project); everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"no `query`, or one over 100 characters","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"query: must be 1 to 100 characters\"\n}"}]},{"name":"Issue a short admin token","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"doors\": [\n    \"client-ip\"\n  ],\n  \"expiresInMinutes\": 30\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/admin/tokens","host":["{{baseUrl}}"],"path":["v1","admin","tokens"]},"description":"For the operator to call the admin doors without pasting the session cookie into a command (ADR 0055, B; docs/RUNBOOK.md §4d). Send it as `Authorization: Bearer plune_adm_…` to the doors it names. It lives an hour unless `expiresInMinutes` says otherwise, at most a day; it opens only the doors listed and can neither issue tokens nor change roles (403, `session_required`). Shown once. Every call made with it is a row of the admin journal. The owner's session alone; anyone else is told 404. Ten a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"End an admin token","request":{"method":"DELETE","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/tokens/:id","host":["{{baseUrl}}"],"path":["v1","admin","tokens",":id"],"variable":[{"key":"id","value":""}]},"description":"The next call with it is told 401. The owner’s session alone; a token that is not the caller’s, or is over already, is a 404. Ten a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[]},{"name":"Accounts and companies by what they have used","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/usage?query=&sort=results","host":["{{baseUrl}}"],"path":["v1","admin","usage"],"query":[{"key":"query","value":"","disabled":true,"description":"Part of an address or a company name"},{"key":"sort","value":"results","disabled":true,"description":"`files` sorts by bytes One of: results, files, cases, runs, projects, members."}]},"description":"Every account and company that has a project, heaviest first by `sort` (up to 100; `truncated` says there were more), each with its tier and its totals. A part of the address or the name in `query` narrows it. There is no “near the limit” mark yet: the limits of each tier are not in the server (#1048), and a figure is not compared with one that is not there. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"response":[{"name":"200 — The scopes, heaviest first","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/usage?query=&sort=results","host":["{{baseUrl}}"],"path":["v1","admin","usage"],"query":[{"key":"query","value":"","disabled":true,"description":"Part of an address or a company name"},{"key":"sort","value":"results","disabled":true,"description":"`files` sorts by bytes One of: results, files, cases, runs, projects, members."}]},"description":"Every account and company that has a project, heaviest first by `sort` (up to 100; `truncated` says there were more), each with its tier and its totals. A part of the address or the name in `query` narrows it. There is no “near the limit” mark yet: the limits of each tier are not in the server (#1048), and a figure is not compared with one that is not there. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"The scopes, heaviest first","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"results\": [\n    {\n      \"subject\": {\n        \"kind\": \"company\",\n        \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a5\"\n      },\n      \"label\": \"Acme\",\n      \"tier\": {\n        \"tier\": \"team\",\n        \"tierState\": \"active\",\n        \"options\": {\n          \"saml\": true\n        },\n        \"tierValidUntil\": \"2099-12-31T23:59:59.000Z\"\n      },\n      \"limits\": {\n        \"projects\": null,\n        \"writeSeats\": 50,\n        \"readerSeats\": 5,\n        \"fileBytes\": 10737418240,\n        \"fileBytesPer\": \"project\",\n        \"results\": 5000000,\n        \"resultsPer\": \"month-per-paid-user\",\n        \"historyDays\": null\n      },\n      \"usage\": {\n        \"projects\": 1,\n        \"members\": 3,\n        \"cases\": 214,\n        \"runs\": 41,\n        \"results\": 18902,\n        \"files\": 37,\n        \"fileBytes\": 5242880,\n        \"bucketFiles\": 0,\n        \"aiDraftsThisMonth\": 12\n      }\n    }\n  ],\n  \"truncated\": false,\n  \"enforcement\": \"report\",\n  \"note\": \"Rows are counted, not measured: results, cases and runs are numbers of rows, not the bytes they take in the database. Only files are in bytes, and only those kept in the database (a file in the customer’s bucket is counted, not measured). Cases and runs in the Bin are not counted; results and files are counted as long as they are stored.\"\n}"},{"name":"400 — a `query` over 100 characters, or a `sort` that is not listed","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/admin/usage?query=&sort=results","host":["{{baseUrl}}"],"path":["v1","admin","usage"],"query":[{"key":"query","value":"","disabled":true,"description":"Part of an address or a company name"},{"key":"sort","value":"results","disabled":true,"description":"`files` sorts by bytes One of: results, files, cases, runs, projects, members."}]},"description":"Every account and company that has a project, heaviest first by `sort` (up to 100; `truncated` says there were more), each with its tier and its totals. A part of the address or the name in `query` narrows it. There is no “near the limit” mark yet: the limits of each tier are not in the server (#1048), and a figure is not compared with one that is not there. Owner-only and session-only, as `GET /v1/admin/tiers`; everyone else gets 404. Ten calls a minute.\n\nSession cookie only — this route manages credentials, so an API token cannot call it. Sign in on the dashboard and reuse its `plune_session` cookie, or do this on the dashboard.","auth":{"type":"noauth"}},"status":"a `query` over 100 characters, or a `sort` that is not listed","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"sort: must be one of results, files, cases, runs, projects, members\"\n}"}]}]},{"name":"ai","description":"Automated-case description drafts and the project AI provider (issue #791, T24)","item":[{"name":"Whether a draft can be requested for this case, and the current or latest relevant one","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The read behind the \"Draft from code\" action (SCR-01) and behind SCR-02 re-reading the record after a member leaves and returns (AC-19). `available` is `false` with a `reason` when AI is off or no provider is connected (AC-07), the case has no code snapshot yet, or it is a manual/eval case (AC-08); `platform_not_configured` is the separate, operator-only condition of SAD §7/§11. `pending` is the newest call for this case in ANY state, or `null` — independent of `available`/`reason` (review F7/F8): AI being off, unreachable, or misconfigured never hides a call still waiting on a decision. For a pending call requested by somebody else, every content and cost field is withheld and `mine` is `false` (AC-19); a reader may still call this to see whether a draft exists, but never requests one (AC-06). Called by a session or a bearer token alike — reading is not restricted to a human session."},"response":[{"name":"200 — AC-01 — AI on, automated case with a snapshot, nothing pending","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The read behind the \"Draft from code\" action (SCR-01) and behind SCR-02 re-reading the record after a member leaves and returns (AC-19). `available` is `false` with a `reason` when AI is off or no provider is connected (AC-07), the case has no code snapshot yet, or it is a manual/eval case (AC-08); `platform_not_configured` is the separate, operator-only condition of SAD §7/§11. `pending` is the newest call for this case in ANY state, or `null` — independent of `available`/`reason` (review F7/F8): AI being off, unreachable, or misconfigured never hides a call still waiting on a decision. For a pending call requested by somebody else, every content and cost field is withheld and `mine` is `false` (AC-19); a reader may still call this to see whether a draft exists, but never requests one (AC-06). Called by a session or a bearer token alike — reading is not restricted to a human session."},"status":"Availability, and the current or latest relevant call if there is one","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"available\": true,\n  \"provider\": \"anthropic\",\n  \"model\": \"claude-sonnet-5\",\n  \"pending\": null\n}"},{"name":"200 — AC-19 — my own draft is waiting for a decision","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The read behind the \"Draft from code\" action (SCR-01) and behind SCR-02 re-reading the record after a member leaves and returns (AC-19). `available` is `false` with a `reason` when AI is off or no provider is connected (AC-07), the case has no code snapshot yet, or it is a manual/eval case (AC-08); `platform_not_configured` is the separate, operator-only condition of SAD §7/§11. `pending` is the newest call for this case in ANY state, or `null` — independent of `available`/`reason` (review F7/F8): AI being off, unreachable, or misconfigured never hides a call still waiting on a decision. For a pending call requested by somebody else, every content and cost field is withheld and `mine` is `false` (AC-19); a reader may still call this to see whether a draft exists, but never requests one (AC-06). Called by a session or a bearer token alike — reading is not restricted to a human session."},"status":"Availability, and the current or latest relevant call if there is one","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"available\": true,\n  \"provider\": \"anthropic\",\n  \"model\": \"claude-sonnet-5\",\n  \"pending\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n    \"state\": \"awaiting_decision\",\n    \"mine\": true,\n    \"requestedBy\": \"u-member1\",\n    \"requestedAt\": \"2026-09-16T09:12:04.151Z\",\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\",\n    \"unseenNote\": \"Does not see the `beforeEach` hook, which navigates to /checkout and logs the user in.\",\n    \"codeChangedSinceRequest\": false,\n    \"inputTokens\": 812,\n    \"outputTokens\": 96,\n    \"unitPriceUsd\": 0.0000031,\n    \"costUsd\": 0.0028,\n    \"durationMs\": 4210\n  }\n}"},{"name":"200 — AC-19 — a draft is pending, but a colleague requested it","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The read behind the \"Draft from code\" action (SCR-01) and behind SCR-02 re-reading the record after a member leaves and returns (AC-19). `available` is `false` with a `reason` when AI is off or no provider is connected (AC-07), the case has no code snapshot yet, or it is a manual/eval case (AC-08); `platform_not_configured` is the separate, operator-only condition of SAD §7/§11. `pending` is the newest call for this case in ANY state, or `null` — independent of `available`/`reason` (review F7/F8): AI being off, unreachable, or misconfigured never hides a call still waiting on a decision. For a pending call requested by somebody else, every content and cost field is withheld and `mine` is `false` (AC-19); a reader may still call this to see whether a draft exists, but never requests one (AC-06). Called by a session or a bearer token alike — reading is not restricted to a human session."},"status":"Availability, and the current or latest relevant call if there is one","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"available\": true,\n  \"provider\": \"anthropic\",\n  \"model\": \"claude-sonnet-5\",\n  \"pending\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n    \"state\": \"awaiting_decision\",\n    \"mine\": false,\n    \"requestedBy\": \"u-member2\",\n    \"requestedAt\": \"2026-09-16T09:12:04.151Z\"\n  }\n}"},{"name":"200 — AC-07 — AI disabled or no provider connected","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The read behind the \"Draft from code\" action (SCR-01) and behind SCR-02 re-reading the record after a member leaves and returns (AC-19). `available` is `false` with a `reason` when AI is off or no provider is connected (AC-07), the case has no code snapshot yet, or it is a manual/eval case (AC-08); `platform_not_configured` is the separate, operator-only condition of SAD §7/§11. `pending` is the newest call for this case in ANY state, or `null` — independent of `available`/`reason` (review F7/F8): AI being off, unreachable, or misconfigured never hides a call still waiting on a decision. For a pending call requested by somebody else, every content and cost field is withheld and `mine` is `false` (AC-19); a reader may still call this to see whether a draft exists, but never requests one (AC-06). Called by a session or a bearer token alike — reading is not restricted to a human session."},"status":"Availability, and the current or latest relevant call if there is one","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"available\": false,\n  \"reason\": \"ai_disabled\",\n  \"pending\": null\n}"},{"name":"200 — AC-08 — automated case, no code snapshot yet","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The read behind the \"Draft from code\" action (SCR-01) and behind SCR-02 re-reading the record after a member leaves and returns (AC-19). `available` is `false` with a `reason` when AI is off or no provider is connected (AC-07), the case has no code snapshot yet, or it is a manual/eval case (AC-08); `platform_not_configured` is the separate, operator-only condition of SAD §7/§11. `pending` is the newest call for this case in ANY state, or `null` — independent of `available`/`reason` (review F7/F8): AI being off, unreachable, or misconfigured never hides a call still waiting on a decision. For a pending call requested by somebody else, every content and cost field is withheld and `mine` is `false` (AC-19); a reader may still call this to see whether a draft exists, but never requests one (AC-06). Called by a session or a bearer token alike — reading is not restricted to a human session."},"status":"Availability, and the current or latest relevant call if there is one","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"available\": false,\n  \"reason\": \"no_code_snapshot\",\n  \"pending\": null\n}"}]},{"name":"Request a draft description and precondition from the project's AI provider","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"response":[{"name":"200 — AC-01 — the provider answered in time","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The call’s outcome — `awaiting_decision` with the draft, or `failed`/`aborted` with no text (AC-09). A 200 either way: the request itself succeeded.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"state\": \"awaiting_decision\",\n  \"mine\": true,\n  \"requestedBy\": \"u-member1\",\n  \"requestedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n  \"precondition\": \"A logged-in user with an empty cart.\",\n  \"unseenNote\": \"Does not see the `beforeEach` hook, which navigates to /checkout and logs the user in.\",\n  \"codeChangedSinceRequest\": false,\n  \"inputTokens\": 812,\n  \"outputTokens\": 96,\n  \"unitPriceUsd\": 0.0000031,\n  \"costUsd\": 0.0028,\n  \"durationMs\": 4210\n}"},{"name":"200 — AC-09 — the provider did not answer within 60 s","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The call’s outcome — `awaiting_decision` with the draft, or `failed`/`aborted` with no text (AC-09). A 200 either way: the request itself succeeded.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"state\": \"aborted\",\n  \"mine\": true,\n  \"requestedBy\": \"u-member1\",\n  \"requestedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"durationMs\": 60000\n}"},{"name":"200 — AC-09 — the provider rejected the stored credentials","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The call’s outcome — `awaiting_decision` with the draft, or `failed`/`aborted` with no text (AC-09). A 200 either way: the request itself succeeded.","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n  \"state\": \"failed\",\n  \"mine\": true,\n  \"requestedBy\": \"u-member1\",\n  \"requestedAt\": \"2026-09-16T09:12:04.151Z\",\n  \"failureKind\": \"key_rejected\",\n  \"durationMs\": 340\n}"},{"name":"403 — a bearer token asked — requesting a draft is a human-session action only (AC-06)","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"a bearer token asked — requesting a draft is a human-session action only (AC-06)","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"requires a human session — a token cannot request or decide a draft\"\n}"},{"name":"409 — AC-07 — AI was turned off between the check and this request","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The action a client should not have been able to reach — the availability check (`GET` on this same path) already said no, and something changed the state in between.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI is disabled for this project\"\n}"},{"name":"409 — providerNotConnected","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The action a client should not have been able to reach — the availability check (`GET` on this same path) already said no, and something changed the state in between.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"no AI provider is connected for this project\"\n}"},{"name":"409 — AC-08","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The action a client should not have been able to reach — the availability check (`GET` on this same path) already said no, and something changed the state in between.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"this case has no code snapshot yet\"\n}"},{"name":"409 — AC-08 — manual or eval case","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The action a client should not have been able to reach — the availability check (`GET` on this same path) already said no, and something changed the state in between.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a draft from code is only possible for an automated case\"\n}"},{"name":"409 — A call for this case is already running (a double click, or a colleague asking at the same moment)","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The action a client should not have been able to reach — the availability check (`GET` on this same path) already said no, and something changed the state in between.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"a draft request for this case is already in progress\"\n}"},{"name":"409 — The saved key does not decrypt under this server’s key (a rotated key, a damaged row) — recorded as the owner’s last issue, `key_rejected`","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"The action a client should not have been able to reach — the availability check (`GET` on this same path) already said no, and something changed the state in between.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the saved AI provider key cannot be read — the project owner has to save it again\"\n}"},{"name":"429 — memberWindow","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"AC-10 — refused without calling the provider. Two independent windows: the member’s own (30 per 60 minutes) and the project’s (200 per 24 hours); both survive a restart (backed by `ai_calls`, ADR-0001) — unlike `#/components/responses/RateLimited` elsewhere in this document, this is not the in-memory per-instance limiter. `code` names the window that refused: `member_limit` or `project_limit`. A third refusal, `ai_budget_spent` (#1286), carries no `Retry-After`: the owner’s monthly dollar limit is used up until the 1st (UTC) — `capUsd`, `spentUsd` (known-price calls only) and `resetsOn` say so; the owner raises the limit with `PATCH /v1/settings/ai`. Calls already made stand.","code":429,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"rate limit exceeded — you can request another draft in 42 minutes\",\n  \"code\": \"member_limit\"\n}"},{"name":"429 — projectWindow","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"AC-10 — refused without calling the provider. Two independent windows: the member’s own (30 per 60 minutes) and the project’s (200 per 24 hours); both survive a restart (backed by `ai_calls`, ADR-0001) — unlike `#/components/responses/RateLimited` elsewhere in this document, this is not the in-memory per-instance limiter. `code` names the window that refused: `member_limit` or `project_limit`. A third refusal, `ai_budget_spent` (#1286), carries no `Retry-After`: the owner’s monthly dollar limit is used up until the 1st (UTC) — `capUsd`, `spentUsd` (known-price calls only) and `resetsOn` say so; the owner raises the limit with `PATCH /v1/settings/ai`. Calls already made stand.","code":429,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"rate limit exceeded — this project can request another draft in 3 hours\",\n  \"code\": \"project_limit\"\n}"},{"name":"503 — SAD §7/§11 — the server has no encryption key, so no project’s AI can work","originalRequest":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The call to the provider happens inside this request, up to 60 seconds (ADR-0001), so the answer already carries the outcome — drafted, the provider failed, or the call was aborted on timeout. With a fallback provider connected a request can take up to two such attempts (#829, ADR 0052), which a proxy may cut: send `Prefer: respond-async` (RFC 7240) to get `202` at once with the call `in_progress` (header `Preference-Applied: respond-async`) while the server finishes the chain on its own, then read `GET` on this path until `pending` is no longer `in_progress`. The checks, limits and cost are the same in both modes. Nothing about the case changes here (AC-01); a prior draft `awaiting_decision` for this case is replaced, whoever requested it (AC-19). One request per case at a time: while a call for this case is still running — anyone’s, for as long as the provider call can last — a new request is refused with 409 and nothing is paid for twice. The checks (that, and both rate-limit windows) and the start of the call are one atomic step, so requests that arrive together cannot all pass the same check. No request body: the code snapshot, provider, model and language all come from the case and the project's AI settings, never from the client."},"status":"SAD §7/§11 — the server has no encryption key, so no project’s AI can work","code":503,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"AI platform not configured — contact your operator\"\n}"}]},{"name":"Accept selected fields of the pending draft, or reject it — AC-02 — take both fields as the draft wrote them","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"response":[{"name":"200 — accepted","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"Decided — `testCase` is the updated case on `accepted`, `null` on `rejected`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"call\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n    \"state\": \"accepted\",\n    \"mine\": true,\n    \"requestedBy\": \"u-member1\",\n    \"requestedAt\": \"2026-09-16T09:12:04.151Z\"\n  },\n  \"testCase\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\"\n  }\n}"},{"name":"200 — rejected","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"Decided — `testCase` is the updated case on `accepted`, `null` on `rejected`","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"call\": {\n    \"id\": \"6f1c2b3a-0000-4000-8000-0000000000c1\",\n    \"state\": \"rejected\",\n    \"mine\": true,\n    \"requestedBy\": \"u-member1\",\n    \"requestedAt\": \"2026-09-16T09:12:04.151Z\"\n  },\n  \"testCase\": null\n}"},{"name":"400 — emptyFields","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"Invalid body — unknown state, `fields` empty on `accepted`, or a field over 16384 bytes (AC-18)","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — fields: at least one of description, precondition is required when accepting\"\n}"},{"name":"400 — tooLong","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"Invalid body — unknown state, `fields` empty on `accepted`, or a field over 16384 bytes (AC-18)","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"validation failed — fields.description: must be at most 16384 bytes\"\n}"},{"name":"403 — token","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"a bearer token asked, or a different member than the one who requested this draft","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"requires a human session — a token cannot request or decide a draft\"\n}"},{"name":"403 — notRequester","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"a bearer token asked, or a different member than the one who requested this draft","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"only the member who requested this draft may decide it\"\n}"},{"name":"409 — Already decided (a moment ago, from another tab, too), expired, or superseded by a newer request","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"AC-04 — the draft is not (or no longer) awaiting a decision, OR a field being taken changed on the case after the draft was requested, OR the case kept changing while the draft was being accepted (#1019): someone else saved it again and again, and nothing was written. The second carries `current` — the fields that changed, with their text as the case holds it now — so the client shows it beside the draft, per AC-04. The third carries `current` too, with every field being taken as it stands, which may be the very text the draft was requested against: what moved is the case, not necessarily those fields. The draft stays waiting, and accepting it again is the retry.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"this draft is no longer awaiting a decision\"\n}"},{"name":"409 — AC-04 — someone saved a change to a field being taken","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"AC-04 — the draft is not (or no longer) awaiting a decision, OR a field being taken changed on the case after the draft was requested, OR the case kept changing while the draft was being accepted (#1019): someone else saved it again and again, and nothing was written. The second carries `current` — the fields that changed, with their text as the case holds it now — so the client shows it beside the draft, per AC-04. The third carries `current` too, with every field being taken as it stands, which may be the very text the draft was requested against: what moved is the case, not necessarily those fields. The draft stays waiting, and accepting it again is the retry.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"precondition changed after the draft was requested\",\n  \"current\": {\n    \"precondition\": \"A logged-in user with two items already in the cart.\"\n  }\n}"},{"name":"409 — #1019 — the case was saved by someone else again and again while the draft was being accepted; nothing was written and the draft is still waiting","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"description\": \"Opens the checkout page with an empty cart and asserts the empty-state message.\",\n    \"precondition\": \"A logged-in user with an empty cart.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"status":"AC-04 — the draft is not (or no longer) awaiting a decision, OR a field being taken changed on the case after the draft was requested, OR the case kept changing while the draft was being accepted (#1019): someone else saved it again and again, and nothing was written. The second carries `current` — the fields that changed, with their text as the case holds it now — so the client shows it beside the draft, per AC-04. The third carries `current` too, with every field being taken as it stands, which may be the very text the draft was requested against: what moved is the case, not necessarily those fields. The draft stays waiting, and accepting it again is the retry.","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"the case kept changing while the draft was being accepted — reload and try again\",\n  \"current\": {\n    \"precondition\": \"A logged-in user with two items already in the cart.\"\n  },\n  \"caseKeptChanging\": true\n}"}]},{"name":"Accept selected fields of the pending draft, or reject it — AC-02, AC-03 — take one field, edited; the other stays as it was","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"accepted\",\n  \"fields\": {\n    \"precondition\": \"A logged-in user whose cart was emptied by the previous test.\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"response":[]},{"name":"Accept selected fields of the pending draft, or reject it — AC-03 — decline the whole draft","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"rejected\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/test-cases/:id/draft","host":["{{baseUrl}}"],"path":["v1","test-cases",":id","draft"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"Mirrors `PATCH /v1/review-items/{id}`'s `{ state }` shape. `state: accepted` takes `fields` — the final text of `description` and/or `precondition` (as the draft read it, or edited); a field left out of `fields` is not taken and the case keeps what it had (AC-03) — and so is a field sent empty or holding only whitespace: a field the member cleared takes nothing, and an accept with nothing left to take answers 400. Only the member who requested the pending draft may decide it (AC-19); a bearer token gets the same 403 as on POST. The server compares the CURRENT case text against the fingerprint it took when the draft was requested — the client never sends one (ADR-0002): a field someone changed in between answers 409 with the new text, and the draft is neither consumed nor billed again (AC-04). Accepting when the code changed since the request (not just whitespace) still succeeds; the accepted field is marked as drafted from a previous version of the code (AC-04b, AC-16) rather than refused. A draft is decided once: of two decisions arriving together — accept and reject from two tabs, or a double click on accept — one decides and the other answers 409, and the case carries only what the one that decided wrote."},"response":[]}]},{"name":"jobs","description":"The queue of long AI operations (#1279) — an ingest, a sync, the auto-analysis: how far each got, why it stopped, and the four buttons a person has (Resume, Retry, Cancel, Dismiss)","item":[{"name":"The project’s queue of AI operations","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/jobs","host":["{{baseUrl}}"],"path":["v1","jobs"]},"description":"Every job of the project that has not completed, the failed ones nobody dismissed, and those that completed or were cancelled in the last 24 hours — one list, what needs a person first: `action_needed`, `failed`, `blocked`, `paused`, `in_progress`, `completed`, `cancelled`, then the most recently updated. At most 100; when there are more, the finished ones are cut first, never a job that runs. The dashboard asks every few seconds while a job runs or the panel is open. Every member reads it, a reader too."},"response":[{"name":"200 — The queue","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/jobs","host":["{{baseUrl}}"],"path":["v1","jobs"]},"description":"Every job of the project that has not completed, the failed ones nobody dismissed, and those that completed or were cancelled in the last 24 hours — one list, what needs a person first: `action_needed`, `failed`, `blocked`, `paused`, `in_progress`, `completed`, `cancelled`, then the most recently updated. At most 100; when there are more, the finished ones are cut first, never a job that runs. The dashboard asks every few seconds while a job runs or the panel is open. Every member reads it, a reader too."},"status":"The queue","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"jobs\": [\n    {\n      \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n      \"kind\": \"ingest\",\n      \"state\": \"in_progress\",\n      \"subjectId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n      \"subjectName\": \"nightly-e2e\",\n      \"progress\": {\n        \"done\": 600,\n        \"total\": 800\n      },\n      \"reason\": null,\n      \"cursor\": \"result:600\",\n      \"attempt\": 1,\n      \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n      \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n      \"finishedAt\": null,\n      \"updatedAt\": \"2026-09-16T10:09:41.000Z\"\n    }\n  ]\n}"}]},{"name":"Open a job (the producer) — An ingest of 800 cases","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"kind\": \"ingest\",\n  \"subjectId\": \"cairn-run-7\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 0,\n    \"total\": 800\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs","host":["{{baseUrl}}"],"path":["v1","jobs"]},"description":"#1769: a producer outside the server — the CLI for a sync, an ingest from Cairn, the review queue’s auto-analysis — opens its job so the panel shows it, then reports with `PATCH /v1/jobs/{id}/progress` and `/state`. The job starts `in_progress`. One job an operation: with a `subjectId`, a second call while a job of the same `kind` and subject is running, waiting (`action_needed`, `blocked`) or `paused` answers that job with 200 and writes nothing — a producer that comes back after a pause finds its job and goes on from its `cursor`. A finished job (`completed`, `failed`, `cancelled`) is not that operation any more: the next call opens a new one. A report is the heartbeat: a `in_progress` job nobody reported on for 30 minutes ends `failed` with a reason (its count and cursor kept), and a person’s Retry goes on from the cursor — the producer sends `progress` at least that often. Only from an API token (a browser session: 403), not for a kind the server runs itself (403), and not for a reader (403 `read-only member`). Audited as `job/create`."},"response":[{"name":"200 — A job of this kind and subject is already unfinished: that job, as it is","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"kind\": \"ingest\",\n  \"subjectId\": \"cairn-run-7\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 0,\n    \"total\": 800\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs","host":["{{baseUrl}}"],"path":["v1","jobs"]},"description":"#1769: a producer outside the server — the CLI for a sync, an ingest from Cairn, the review queue’s auto-analysis — opens its job so the panel shows it, then reports with `PATCH /v1/jobs/{id}/progress` and `/state`. The job starts `in_progress`. One job an operation: with a `subjectId`, a second call while a job of the same `kind` and subject is running, waiting (`action_needed`, `blocked`) or `paused` answers that job with 200 and writes nothing — a producer that comes back after a pause finds its job and goes on from its `cursor`. A finished job (`completed`, `failed`, `cancelled`) is not that operation any more: the next call opens a new one. A report is the heartbeat: a `in_progress` job nobody reported on for 30 minutes ends `failed` with a reason (its count and cursor kept), and a person’s Retry goes on from the cursor — the producer sends `progress` at least that often. Only from an API token (a browser session: 403), not for a kind the server runs itself (403), and not for a reader (403 `read-only member`). Audited as `job/create`."},"status":"A job of this kind and subject is already unfinished: that job, as it is","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n  \"kind\": \"ingest\",\n  \"state\": \"in_progress\",\n  \"subjectId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 600,\n    \"total\": 800\n  },\n  \"reason\": null,\n  \"cursor\": \"result:600\",\n  \"attempt\": 1,\n  \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n  \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n  \"finishedAt\": null,\n  \"updatedAt\": \"2026-09-16T10:09:41.000Z\"\n}"},{"name":"201 — The job, opened","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"kind\": \"ingest\",\n  \"subjectId\": \"cairn-run-7\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 0,\n    \"total\": 800\n  }\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs","host":["{{baseUrl}}"],"path":["v1","jobs"]},"description":"#1769: a producer outside the server — the CLI for a sync, an ingest from Cairn, the review queue’s auto-analysis — opens its job so the panel shows it, then reports with `PATCH /v1/jobs/{id}/progress` and `/state`. The job starts `in_progress`. One job an operation: with a `subjectId`, a second call while a job of the same `kind` and subject is running, waiting (`action_needed`, `blocked`) or `paused` answers that job with 200 and writes nothing — a producer that comes back after a pause finds its job and goes on from its `cursor`. A finished job (`completed`, `failed`, `cancelled`) is not that operation any more: the next call opens a new one. A report is the heartbeat: a `in_progress` job nobody reported on for 30 minutes ends `failed` with a reason (its count and cursor kept), and a person’s Retry goes on from the cursor — the producer sends `progress` at least that often. Only from an API token (a browser session: 403), not for a kind the server runs itself (403), and not for a reader (403 `read-only member`). Audited as `job/create`."},"status":"The job, opened","code":201,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n  \"kind\": \"ingest\",\n  \"state\": \"in_progress\",\n  \"subjectId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 600,\n    \"total\": 800\n  },\n  \"reason\": null,\n  \"cursor\": \"result:600\",\n  \"attempt\": 1,\n  \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n  \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n  \"finishedAt\": null,\n  \"updatedAt\": \"2026-09-16T10:09:41.000Z\"\n}"}]},{"name":"Open a job (the producer) — A CLI sync, size not known yet","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"kind\": \"sync\",\n  \"subjectName\": \"plune sync main\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs","host":["{{baseUrl}}"],"path":["v1","jobs"]},"description":"#1769: a producer outside the server — the CLI for a sync, an ingest from Cairn, the review queue’s auto-analysis — opens its job so the panel shows it, then reports with `PATCH /v1/jobs/{id}/progress` and `/state`. The job starts `in_progress`. One job an operation: with a `subjectId`, a second call while a job of the same `kind` and subject is running, waiting (`action_needed`, `blocked`) or `paused` answers that job with 200 and writes nothing — a producer that comes back after a pause finds its job and goes on from its `cursor`. A finished job (`completed`, `failed`, `cancelled`) is not that operation any more: the next call opens a new one. A report is the heartbeat: a `in_progress` job nobody reported on for 30 minutes ends `failed` with a reason (its count and cursor kept), and a person’s Retry goes on from the cursor — the producer sends `progress` at least that often. Only from an API token (a browser session: 403), not for a kind the server runs itself (403), and not for a reader (403 `read-only member`). Audited as `job/create`."},"response":[]},{"name":"Report how far a job got","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"done\": 600,\n  \"total\": 800,\n  \"cursor\": \"result:600\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/progress","host":["{{baseUrl}}"],"path":["v1","jobs",":id","progress"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The producer, every N records — N is its own setting (AC-06). `done` of `total`, or both null while it is still counting. A report keeps a running job running and brings a waiting one back: an `action_needed`, `paused` or `blocked` job returns to `in_progress` and its reason is cleared. A finished job takes none — `completed` is the end, a `failed` one waits for a person’s Retry: 409 `job_finished` with its `state`. Not audited (machine intake, like results). Only from an API token — the producer’s: a browser session is refused with 403 `reports come from a producer token`, a reader with 403 `read-only member`."},"response":[{"name":"200 — The job, as it is now","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"done\": 600,\n  \"total\": 800,\n  \"cursor\": \"result:600\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/progress","host":["{{baseUrl}}"],"path":["v1","jobs",":id","progress"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The producer, every N records — N is its own setting (AC-06). `done` of `total`, or both null while it is still counting. A report keeps a running job running and brings a waiting one back: an `action_needed`, `paused` or `blocked` job returns to `in_progress` and its reason is cleared. A finished job takes none — `completed` is the end, a `failed` one waits for a person’s Retry: 409 `job_finished` with its `state`. Not audited (machine intake, like results). Only from an API token — the producer’s: a browser session is refused with 403 `reports come from a producer token`, a reader with 403 `read-only member`."},"status":"The job, as it is now","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n  \"kind\": \"ingest\",\n  \"state\": \"in_progress\",\n  \"subjectId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 600,\n    \"total\": 800\n  },\n  \"reason\": null,\n  \"cursor\": \"result:600\",\n  \"attempt\": 1,\n  \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n  \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n  \"finishedAt\": null,\n  \"updatedAt\": \"2026-09-16T10:09:41.000Z\"\n}"},{"name":"403 — reader","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"done\": 600,\n  \"total\": 800,\n  \"cursor\": \"result:600\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/progress","host":["{{baseUrl}}"],"path":["v1","jobs",":id","progress"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The producer, every N records — N is its own setting (AC-06). `done` of `total`, or both null while it is still counting. A report keeps a running job running and brings a waiting one back: an `action_needed`, `paused` or `blocked` job returns to `in_progress` and its reason is cleared. A finished job takes none — `completed` is the end, a `failed` one waits for a person’s Retry: 409 `job_finished` with its `state`. Not audited (machine intake, like results). Only from an API token — the producer’s: a browser session is refused with 403 `reports come from a producer token`, a reader with 403 `read-only member`."},"status":"a reader asked, or a browser session sent a report — reports come from the producer’s API token; a job the server runs itself (`bin-empty`, #1388) takes no report at all; Cancel, Retry and Resume of a TestRail import are the project owner’s (403 `project owner only`)","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"403 — session","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"done\": 600,\n  \"total\": 800,\n  \"cursor\": \"result:600\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/progress","host":["{{baseUrl}}"],"path":["v1","jobs",":id","progress"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The producer, every N records — N is its own setting (AC-06). `done` of `total`, or both null while it is still counting. A report keeps a running job running and brings a waiting one back: an `action_needed`, `paused` or `blocked` job returns to `in_progress` and its reason is cleared. A finished job takes none — `completed` is the end, a `failed` one waits for a person’s Retry: 409 `job_finished` with its `state`. Not audited (machine intake, like results). Only from an API token — the producer’s: a browser session is refused with 403 `reports come from a producer token`, a reader with 403 `read-only member`."},"status":"a reader asked, or a browser session sent a report — reports come from the producer’s API token; a job the server runs itself (`bin-empty`, #1388) takes no report at all; Cancel, Retry and Resume of a TestRail import are the project owner’s (403 `project owner only`)","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"reports come from a producer token\"\n}"},{"name":"409 — the job is `completed`, `failed` or `cancelled`, or the report is of an earlier try (`stale_attempt`)","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"done\": 600,\n  \"total\": 800,\n  \"cursor\": \"result:600\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/progress","host":["{{baseUrl}}"],"path":["v1","jobs",":id","progress"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"The producer, every N records — N is its own setting (AC-06). `done` of `total`, or both null while it is still counting. A report keeps a running job running and brings a waiting one back: an `action_needed`, `paused` or `blocked` job returns to `in_progress` and its reason is cleared. A finished job takes none — `completed` is the end, a `failed` one waits for a person’s Retry: 409 `job_finished` with its `state`. Not audited (machine intake, like results). Only from an API token — the producer’s: a browser session is refused with 403 `reports come from a producer token`, a reader with 403 `read-only member`."},"status":"the job is `completed`, `failed` or `cancelled`, or the report is of an earlier try (`stale_attempt`)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"job '6f1c2b3a-0000-4000-8000-0000000000a9' is failed: a finished job takes no progress\",\n  \"code\": \"job_finished\",\n  \"state\": \"failed\"\n}"}]},{"name":"Move a job — Resume, Retry, Cancel, Dismiss, or the producer’s report — The producer: the AI quota is spent","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"paused\",\n  \"reason\": \"Today’s AI quota is spent. It resumes by itself in 2 h.\",\n  \"cursor\": \"failure:41\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"response":[{"name":"200 — The job, moved","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"paused\",\n  \"reason\": \"Today’s AI quota is spent. It resumes by itself in 2 h.\",\n  \"cursor\": \"failure:41\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"status":"The job, moved","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"id\": \"6f1c2b3a-0000-4000-8000-0000000000a9\",\n  \"kind\": \"ingest\",\n  \"state\": \"in_progress\",\n  \"subjectId\": \"6f1c2b3a-0000-4000-8000-0000000000d1\",\n  \"subjectName\": \"nightly-e2e\",\n  \"progress\": {\n    \"done\": 600,\n    \"total\": 800\n  },\n  \"reason\": null,\n  \"cursor\": \"result:600\",\n  \"attempt\": 1,\n  \"createdAt\": \"2026-09-16T10:02:00.000Z\",\n  \"startedAt\": \"2026-09-16T10:02:00.000Z\",\n  \"finishedAt\": null,\n  \"updatedAt\": \"2026-09-16T10:09:41.000Z\"\n}"},{"name":"403 — reader","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"paused\",\n  \"reason\": \"Today’s AI quota is spent. It resumes by itself in 2 h.\",\n  \"cursor\": \"failure:41\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"status":"a reader asked, or a browser session sent a report — reports come from the producer’s API token; a job the server runs itself (`bin-empty`, #1388) takes no report at all; Cancel, Retry and Resume of a TestRail import are the project owner’s (403 `project owner only`)","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"read-only member\"\n}"},{"name":"403 — session","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"paused\",\n  \"reason\": \"Today’s AI quota is spent. It resumes by itself in 2 h.\",\n  \"cursor\": \"failure:41\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"status":"a reader asked, or a browser session sent a report — reports come from the producer’s API token; a job the server runs itself (`bin-empty`, #1388) takes no report at all; Cancel, Retry and Resume of a TestRail import are the project owner’s (403 `project owner only`)","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"reports come from a producer token\"\n}"},{"name":"409 — the move is not in the table (`illegal_transition`; a report of `cancelled` too), or the report is of an earlier try (`stale_attempt`)","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"paused\",\n  \"reason\": \"Today’s AI quota is spent. It resumes by itself in 2 h.\",\n  \"cursor\": \"failure:41\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"status":"the move is not in the table (`illegal_transition`; a report of `cancelled` too), or the report is of an earlier try (`stale_attempt`)","code":409,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"error\": \"job '6f1c2b3a-0000-4000-8000-0000000000a9' is blocked: it cannot move to in_progress this way\",\n  \"code\": \"illegal_transition\",\n  \"state\": \"blocked\"\n}"}]},{"name":"Move a job — Resume, Retry, Cancel, Dismiss, or the producer’s report — A person: Resume","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"action\": \"resume\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"response":[]},{"name":"Move a job — Resume, Retry, Cancel, Dismiss, or the producer’s report — The producer: an outside service is down","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"state\": \"blocked\",\n  \"reason\": \"Blocked by GitHub API — it has answered 503 since 14:10.\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"response":[]},{"name":"Move a job — Resume, Retry, Cancel, Dismiss, or the producer’s report — A person: Cancel","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"action\": \"cancel\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"response":[]},{"name":"Move a job — Resume, Retry, Cancel, Dismiss, or the producer’s report — A person: Retry","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"action\": \"retry\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"response":[]},{"name":"Move a job — Resume, Retry, Cancel, Dismiss, or the producer’s report — A person: Dismiss a failed job","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"action\": \"dismiss\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{baseUrl}}/v1/jobs/:id/state","host":["{{baseUrl}}"],"path":["v1","jobs",":id","state"],"variable":[{"key":"id","value":"","description":"Format: uuid."}]},"description":"A person: `{ action: resume }` on a paused job (from its cursor — the same job, AC-03), `{ action: retry }` on a failed one (from its cursor if it has one, else from the start; no new job), `{ action: cancel }` on a running, paused, action-needed or blocked one (it becomes `cancelled`, the reason «Cancelled by <name>»; a blocked job has no other button), `{ action: dismiss }` on a failed one (hidden from the list and the count; the second is the same 200, nothing written; audited as `job/update`, `dismissed`). The producer, from its API token only (a browser session is refused with 403 `reports come from a producer token`): `{ state, reason?, cursor? }` — a stopped state says why; the state the job is already in, sent again with its reason and cursor or without them, is answered 200 with the job as it is and writes nothing. Any other move outside the table is 409 `illegal_transition` with the job’s `state`. Audited as `job/state_change`, the state and the reason before and after. A reader is refused with 403 `read-only member`."},"response":[]}]},{"name":"notifications","description":"In-app notifications (#1063) - the bell: your own list in the project, the unread counter, marking one or all read. Made by events, never by a route","item":[{"name":"Your notifications in this project","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/notifications?limit=30&cursor=","host":["{{baseUrl}}"],"path":["v1","notifications"],"query":[{"key":"limit","value":"30","disabled":true},{"key":"cursor","value":"","disabled":true,"description":"The `nextCursor` of the previous page, as given."}]},"description":"The bell’s list (#1063): only yours, in the project the request runs in; a person in two projects has two lists. Unread first, then read, each newest first. `limit` is 1 to 100 (30 when left out); `nextCursor` is opaque and `null` at the end. A notification read between two pages can show again in the read half, never be missed. The words are not stored finished: `textKey` names a dictionary entry of the dashboard and `params` (a flat object) fills it, so the text follows the person’s language. `entity` is what it is about (`bug` and an id). Notifications are made by the event they tell of, in that event’s transaction - a rolled-back event leaves none - and there is no route that creates one. A reader is told nothing in this version: the list is empty. Read ones are deleted 30 days after they were read, unread ones 90 days after they were made."},"response":[]},{"name":"Mark one notification as read","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/notifications/:id/read","host":["{{baseUrl}}"],"path":["v1","notifications",":id","read"],"variable":[{"key":"id","value":""}]},"description":"Yours only, in the project the request runs in (#1063). Marking one that is already read changes nothing and answers 200 with its first `readAt`. Someone else’s notification, one of another project and an invented id are the same 404. Not an audit entry: it is the person’s own state. A reader is refused at the boundary with 403 `read-only member`."},"response":[]},{"name":"Mark all your notifications in this project as read","request":{"method":"POST","header":[],"url":{"raw":"{{baseUrl}}/v1/notifications/read-all","host":["{{baseUrl}}"],"path":["v1","notifications","read-all"]},"description":"Yours only, in the project the request runs in (#1063). `updated` is how many were unread. Not an audit entry: it is the person’s own state. A reader is refused at the boundary with 403 `read-only member`."},"response":[]},{"name":"How many notifications you have not read","request":{"method":"GET","header":[],"url":{"raw":"{{baseUrl}}/v1/notifications/unread-count","host":["{{baseUrl}}"],"path":["v1","notifications","unread-count"]},"description":"The number on the bell (#1063): yours, unread, in the project the request runs in. 0 for a reader."},"response":[]}]}]}