Create a test case
const url = 'https://beta-api.plune.ai/v1/test-cases';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"title":"Checkout — empty cart @smoke @checkout","type":"automated","specRef":"e2e/checkout.spec.ts#empty-cart","suiteId":"6f1c2b3a-0000-4000-8000-0000000000a1","priority":"high","description":"An empty cart must not reach the payment step.","externalKeys":[{"kind":"path-title","value":"e2e/checkout.spec.ts > Checkout — empty cart"}]}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://beta-api.plune.ai/v1/test-cases \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "title": "Checkout — empty cart @smoke @checkout", "type": "automated", "specRef": "e2e/checkout.spec.ts#empty-cart", "suiteId": "6f1c2b3a-0000-4000-8000-0000000000a1", "priority": "high", "description": "An empty cart must not reach the payment step.", "externalKeys": [ { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — empty cart" } ] }'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.
Authorizations
Section titled “Authorizations ”Request Body required
Section titled “Request Body required ”Title (+ optional suite) plus the authoring body of the chosen type.
object
May carry @tags. What is left once they are removed must not be empty.
Must be an existing SUITE in your project — a folder is refused with 400 (AC-03).
The eval id in plune.yaml that runs this case.
Rewrites the title’s tail: the clean title, then @a @b, each once. Every entry must be a tag on its own.
Up to 16 384 bytes of UTF-8.
Up to 16 384 bytes of UTF-8.
The case’s parameters (D7) — one table, as Testomat has it: params are the column names, every row of rows is one iteration, with exactly one value per column. ${Name} (or {{Name}}) in the steps, description, preconditions and prompt stands for that column’s value; in the title only ${Name}. Names are case-sensitive and unique, 1–16 of them, at most 64 characters, without $, { or }; 1–100 rows; the whole table at most 16 384 bytes. In the Markdown document (D9) this is the <!-- example --> table. Absent when the case has none.
object
The slot among the cases of the suite; omitted = after the last one.
This test is unstable by nature (#1246).
Its failures are not to count toward a run (#1246, #1247).
Values of the project’s custom fields (#1250), field id → value. Checked against the definitions: an unknown field, a value of the wrong type, an option not in the list, or a required field left out is a 400 that names the field. A field with a default the body does not mention gets it.
object
isFlaky as @plune-ai/cli 0.17.1 spells it on its TestCase — a case the runner validated is a body this takes. isFlaky wins when both are sent.
isMuted as the runner spells it. isMuted wins when both are sent.
object
Title (+ optional suite) plus the authoring body of the chosen type.
object
May carry @tags. What is left once they are removed must not be empty.
Must be an existing SUITE in your project — a folder is refused with 400 (AC-03).
The eval id in plune.yaml that runs this case.
Rewrites the title’s tail: the clean title, then @a @b, each once. Every entry must be a tag on its own.
Up to 16 384 bytes of UTF-8.
Up to 16 384 bytes of UTF-8.
The case’s parameters (D7) — one table, as Testomat has it: params are the column names, every row of rows is one iteration, with exactly one value per column. ${Name} (or {{Name}}) in the steps, description, preconditions and prompt stands for that column’s value; in the title only ${Name}. Names are case-sensitive and unique, 1–16 of them, at most 64 characters, without $, { or }; 1–100 rows; the whole table at most 16 384 bytes. In the Markdown document (D9) this is the <!-- example --> table. Absent when the case has none.
object
The slot among the cases of the suite; omitted = after the last one.
This test is unstable by nature (#1246).
Its failures are not to count toward a run (#1246, #1247).
Values of the project’s custom fields (#1250), field id → value. Checked against the definitions: an unknown field, a value of the wrong type, an option not in the list, or a required field left out is a 400 that names the field. A field with a default the body does not mention gets it.
object
isFlaky as @plune-ai/cli 0.17.1 spells it on its TestCase — a case the runner validated is a body this takes. isFlaky wins when both are sent.
isMuted as the runner spells it. isMuted wins when both are sent.
Title (+ optional suite) plus the authoring body of the chosen type.
object
May carry @tags. What is left once they are removed must not be empty.
Must be an existing SUITE in your project — a folder is refused with 400 (AC-03).
The eval id in plune.yaml that runs this case.
Rewrites the title’s tail: the clean title, then @a @b, each once. Every entry must be a tag on its own.
Up to 16 384 bytes of UTF-8.
Up to 16 384 bytes of UTF-8.
The case’s parameters (D7) — one table, as Testomat has it: params are the column names, every row of rows is one iteration, with exactly one value per column. ${Name} (or {{Name}}) in the steps, description, preconditions and prompt stands for that column’s value; in the title only ${Name}. Names are case-sensitive and unique, 1–16 of them, at most 64 characters, without $, { or }; 1–100 rows; the whole table at most 16 384 bytes. In the Markdown document (D9) this is the <!-- example --> table. Absent when the case has none.
object
The slot among the cases of the suite; omitted = after the last one.
This test is unstable by nature (#1246).
Its failures are not to count toward a run (#1246, #1247).
Values of the project’s custom fields (#1250), field id → value. Checked against the definitions: an unknown field, a value of the wrong type, an option not in the list, or a required field left out is a 400 that names the field. A field with a default the body does not mention gets it.
object
isFlaky as @plune-ai/cli 0.17.1 spells it on its TestCase — a case the runner validated is a body this takes. isFlaky wins when both are sent.
isMuted as the runner spells it. isMuted wins when both are sent.
Stored with the case and returned unchanged. It links to nothing: golden sets were removed in ADR 0013, so this is a label the platform keeps, not a reference it resolves.
One assertion: a type plus whatever that type needs (ADR 0012). The remaining fields depend on type (e.g. values for contains-all, criteria for llm-judge).
object
Examples
An automated case, filed into a suite, with the key its reporter resolves it by
{ "title": "Checkout — empty cart @smoke @checkout", "type": "automated", "specRef": "e2e/checkout.spec.ts#empty-cart", "suiteId": "6f1c2b3a-0000-4000-8000-0000000000a1", "priority": "high", "description": "An empty cart must not reach the payment step.", "externalKeys": [ { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — empty cart" } ]}A manual case at the root, with steps and the expected outcome
{ "title": "Refund — partial amount @manual @payments", "type": "manual", "priority": "normal", "precondition": "An order paid by card, delivered, within the 14-day window.", "steps": [ { "action": "Open the order and choose “Refund”." }, { "action": "Enter ${Amount} and confirm.", "expected": "The refund is listed as ${Outcome} with the entered amount." } ], "expected": "The customer receives the partial refund; the order stays delivered.", "examples": { "params": [ "Amount", "Outcome" ], "rows": [ [ "10.00", "pending" ], [ "0.00", "refused" ] ] }}Responses
Section titled “ Responses ”Created — for an automated case without a suite, already filed under the suite its specRef names
A stored test case — the metadata plus the body of its type.
object
Accepted: 1 or 2. Responses are always 2 — a v1 record is upgraded on read.
Carries its @tag tail — the one source of tags (ADR 0032). The dashboard shows the clean title and the tags as chips.
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
Only when reading ONE case (#1064): its defect links, oldest first, at most 20; [] for a case without any. The list carries a count per case instead (defectCounts).
A defect (#1064): the address of a ticket somebody already filed, kept beside a case or a failed result. https only, at most 2000 characters; the label is plain text, at most 200, and is shown as text, never as markup. The author is a userId; the dashboard names them from GET /v1/members. No type: Bugs (#1065) will read the same links as external tickets.
object
Only when reading ONE case: the states the caller may move it to from here, per the lifecycle table and who the caller is - a session is a person, a bearer token is automation. A screen renders exactly these as actions and never lists moves itself. The current state is not included.
When it last moved. Absent on a case that has not moved.
The suite this case is filed under, if any. Always a suite, never a folder (AC-03).
Five levels in rank order — low < normal < important < high < critical. Metadata: changing it does not bump the text version.
The time the case takes by hand (#1881), as 1h 30m, 5m or 45s — the spelling a write takes back. Absent while nobody estimated it (stored as NULL, which is not 0). Metadata: changing it does not bump the text version.
estimate in whole seconds, for a report that adds them up. Absent with estimate; send estimate to change it.
Order among the cases of its suite (or the unfiled ones) — dense, 0-based, renumbered on every write that places a case (AC-05).
An assertion about the case (#1246): this test is unstable by nature. Diagnostics — a person or a machine may set it. Always present, false until set. Metadata: changing it does not bump the text version. ?flaky=1 (or 0) on the list filters on it.
Field id → value, as stored on the entity. A string field takes text (1–1 000 characters), a number field a number, a list field the id of one option — or, when multiple, an array of option ids without repeats (up to 50), kept in the order written. At most 16 384 bytes of JSON.
object
A person’s decision about the case (#1246): it still runs and its results are recorded, and a failure is not to count toward the run (#1247). Not the same as isFlaky — a flaky case need not be muted, and a muted one need not be flaky. Always present, false until set; a parameterized case is one case, so muting it mutes every row of its parameters. A mute may come from a token; the audit names who — a person, or the token (a runner’s is_muted is an annotation in the code, written and reviewed like test.skip). Every set and clear is an entry in the case’s audit feed. ?muted=1 (or 0) on the list filters on it.
Read from the title by the server, each once, in first-seen order. Derived: sent back, never taken on the way in as a field of its own — tags on a write rewrites the title.
Free text beside the steps. Part of what a result was produced against, so a change bumps the text version (AC-09). Absent when never set. 16 384 bytes of UTF-8 at most.
What must hold before the steps. Same rules as description.
The parameters table (D7). Content like the two texts: a change bumps the version.
object
The identifiers this case has elsewhere (D2) — attached on every read; absent when it has none.
An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.
object
The id of this case’s eval in plune.yaml. An ingested run attaches its results to the case carrying the same id (ADR 0010); absent for cases the CLI does not run.
Only when reading ONE case: the source text of the one test() call an automated case references — the call itself, header and body, exactly as written in the file. Absent on a case that has never had one; present-and-unchanged when the file most recently failed to parse (the last known snapshot, not empty). 16 384 bytes of UTF-8 at most, the same ceiling description/precondition already carry.
SHA-256 hex digest of codeSnapshot, recomputed by the server on every write that touches it (ADR-0005) — never accepted from a client. Present whenever codeSnapshot is, and on the list too even though the text itself is not: a re-importer hashes its own freshly parsed text the same way and compares against this to decide changed vs unchanged without a second request per case.
Who wrote it (B8-team T3): the userId of the caller of POST /v1/test-cases or of a duplicate. Set by the server, never taken from a body, never changed by a PATCH. Absent on a case from before names were kept and on one the review queue created. Resolve it through GET /v1/members.
Who is to run it (B8-team T4): the userId of a member of the project. Set by PATCH {assigneeId} or the bulk assign action, never on create; null on a PATCH takes it away; leaving the project takes it away too. Absent means unassigned. GET /v1/test-cases?assignee=me|none|<userId> filters on it.
Where the text of the case came from (#1879), derived on read - no column, so every case has one, the ones from before included. manual: a person wrote it. ai-generated: created from an approved proposal in the review queue, or a field accepted from an AI draft, and not corrected since. ai-edited: a person corrected AI-written content - the reviewer before approving, or anyone after (the version moved), or the text of an accepted draft. Read-only; on the list and on the single read. GET /v1/test-cases?origin= filters on it.
The facts behind origin, on the single read only (#1879) - for a tooltip. Only what is already stored; ids of people, never addresses.
object
The userId that put the proposal in the review queue.
What the supplier said the proposal was derived from.
object
Fields a person changed on AI text: title, steps, expected (the reviewer, #1878), description, precondition (after an accepted draft).
A case made from a proposal whose content (title, texts, steps, parameters) has changed since.
The userId of the latest approval of the standing revision; absent when the policy merged it by itself.
Where the case came from, present only on one an approved proposal created (B-1). Read through that proposal rather than stored on the case, so it answers for every case ever created this way and can never disagree with the queue entry beside it. Absent on a case a person wrote — and absent, too, when the proposal named neither field: both mean nothing was reported.
object
object
The test-design technique the supplier named, as they named it.
Where a field’s text came from, once it was accepted from an AI draft (automated-case-description-draft, AC-17): per field, the model, the date of the draft, who asked, who accepted and when, and — if a person edited the text afterwards — who and when. A field has an entry only while its text is the one a draft produced: the next accepted draft replaces it and clearing the field drops it. The key is absent when no field was ever accepted from a draft. Written only by the case store’s single write path — never taken from a client body, on a PATCH or on Markdown import alike.
object
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
#829: the provider that made the draft, as recorded on the call. Absent on text accepted before the fallback existed.
#829: true when the fallback provider made the draft. Absent (not false) on text accepted before the fallback existed.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
#829: the provider that made the draft, as recorded on the call. Absent on text accepted before the fallback existed.
#829: true when the fallback provider made the draft. Absent (not false) on text accepted before the fallback existed.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
object
A stored test case — the metadata plus the body of its type.
object
Accepted: 1 or 2. Responses are always 2 — a v1 record is upgraded on read.
Carries its @tag tail — the one source of tags (ADR 0032). The dashboard shows the clean title and the tags as chips.
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
Only when reading ONE case (#1064): its defect links, oldest first, at most 20; [] for a case without any. The list carries a count per case instead (defectCounts).
A defect (#1064): the address of a ticket somebody already filed, kept beside a case or a failed result. https only, at most 2000 characters; the label is plain text, at most 200, and is shown as text, never as markup. The author is a userId; the dashboard names them from GET /v1/members. No type: Bugs (#1065) will read the same links as external tickets.
object
Only when reading ONE case: the states the caller may move it to from here, per the lifecycle table and who the caller is - a session is a person, a bearer token is automation. A screen renders exactly these as actions and never lists moves itself. The current state is not included.
When it last moved. Absent on a case that has not moved.
The suite this case is filed under, if any. Always a suite, never a folder (AC-03).
Five levels in rank order — low < normal < important < high < critical. Metadata: changing it does not bump the text version.
The time the case takes by hand (#1881), as 1h 30m, 5m or 45s — the spelling a write takes back. Absent while nobody estimated it (stored as NULL, which is not 0). Metadata: changing it does not bump the text version.
estimate in whole seconds, for a report that adds them up. Absent with estimate; send estimate to change it.
Order among the cases of its suite (or the unfiled ones) — dense, 0-based, renumbered on every write that places a case (AC-05).
An assertion about the case (#1246): this test is unstable by nature. Diagnostics — a person or a machine may set it. Always present, false until set. Metadata: changing it does not bump the text version. ?flaky=1 (or 0) on the list filters on it.
Field id → value, as stored on the entity. A string field takes text (1–1 000 characters), a number field a number, a list field the id of one option — or, when multiple, an array of option ids without repeats (up to 50), kept in the order written. At most 16 384 bytes of JSON.
object
A person’s decision about the case (#1246): it still runs and its results are recorded, and a failure is not to count toward the run (#1247). Not the same as isFlaky — a flaky case need not be muted, and a muted one need not be flaky. Always present, false until set; a parameterized case is one case, so muting it mutes every row of its parameters. A mute may come from a token; the audit names who — a person, or the token (a runner’s is_muted is an annotation in the code, written and reviewed like test.skip). Every set and clear is an entry in the case’s audit feed. ?muted=1 (or 0) on the list filters on it.
Read from the title by the server, each once, in first-seen order. Derived: sent back, never taken on the way in as a field of its own — tags on a write rewrites the title.
Free text beside the steps. Part of what a result was produced against, so a change bumps the text version (AC-09). Absent when never set. 16 384 bytes of UTF-8 at most.
What must hold before the steps. Same rules as description.
The parameters table (D7). Content like the two texts: a change bumps the version.
object
The identifiers this case has elsewhere (D2) — attached on every read; absent when it has none.
An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.
object
The id of this case’s eval in plune.yaml. An ingested run attaches its results to the case carrying the same id (ADR 0010); absent for cases the CLI does not run.
Only when reading ONE case: the source text of the one test() call an automated case references — the call itself, header and body, exactly as written in the file. Absent on a case that has never had one; present-and-unchanged when the file most recently failed to parse (the last known snapshot, not empty). 16 384 bytes of UTF-8 at most, the same ceiling description/precondition already carry.
SHA-256 hex digest of codeSnapshot, recomputed by the server on every write that touches it (ADR-0005) — never accepted from a client. Present whenever codeSnapshot is, and on the list too even though the text itself is not: a re-importer hashes its own freshly parsed text the same way and compares against this to decide changed vs unchanged without a second request per case.
Who wrote it (B8-team T3): the userId of the caller of POST /v1/test-cases or of a duplicate. Set by the server, never taken from a body, never changed by a PATCH. Absent on a case from before names were kept and on one the review queue created. Resolve it through GET /v1/members.
Who is to run it (B8-team T4): the userId of a member of the project. Set by PATCH {assigneeId} or the bulk assign action, never on create; null on a PATCH takes it away; leaving the project takes it away too. Absent means unassigned. GET /v1/test-cases?assignee=me|none|<userId> filters on it.
Where the text of the case came from (#1879), derived on read - no column, so every case has one, the ones from before included. manual: a person wrote it. ai-generated: created from an approved proposal in the review queue, or a field accepted from an AI draft, and not corrected since. ai-edited: a person corrected AI-written content - the reviewer before approving, or anyone after (the version moved), or the text of an accepted draft. Read-only; on the list and on the single read. GET /v1/test-cases?origin= filters on it.
The facts behind origin, on the single read only (#1879) - for a tooltip. Only what is already stored; ids of people, never addresses.
object
The userId that put the proposal in the review queue.
What the supplier said the proposal was derived from.
object
Fields a person changed on AI text: title, steps, expected (the reviewer, #1878), description, precondition (after an accepted draft).
A case made from a proposal whose content (title, texts, steps, parameters) has changed since.
The userId of the latest approval of the standing revision; absent when the policy merged it by itself.
Where the case came from, present only on one an approved proposal created (B-1). Read through that proposal rather than stored on the case, so it answers for every case ever created this way and can never disagree with the queue entry beside it. Absent on a case a person wrote — and absent, too, when the proposal named neither field: both mean nothing was reported.
object
object
The test-design technique the supplier named, as they named it.
Where a field’s text came from, once it was accepted from an AI draft (automated-case-description-draft, AC-17): per field, the model, the date of the draft, who asked, who accepted and when, and — if a person edited the text afterwards — who and when. A field has an entry only while its text is the one a draft produced: the next accepted draft replaces it and clearing the field drops it. The key is absent when no field was ever accepted from a draft. Written only by the case store’s single write path — never taken from a client body, on a PATCH or on Markdown import alike.
object
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
#829: the provider that made the draft, as recorded on the call. Absent on text accepted before the fallback existed.
#829: true when the fallback provider made the draft. Absent (not false) on text accepted before the fallback existed.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
#829: the provider that made the draft, as recorded on the call. Absent on text accepted before the fallback existed.
#829: true when the fallback provider made the draft. Absent (not false) on text accepted before the fallback existed.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
A stored test case — the metadata plus the body of its type.
object
Accepted: 1 or 2. Responses are always 2 — a v1 record is upgraded on read.
Carries its @tag tail — the one source of tags (ADR 0032). The dashboard shows the clean title and the tags as chips.
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
Only when reading ONE case (#1064): its defect links, oldest first, at most 20; [] for a case without any. The list carries a count per case instead (defectCounts).
A defect (#1064): the address of a ticket somebody already filed, kept beside a case or a failed result. https only, at most 2000 characters; the label is plain text, at most 200, and is shown as text, never as markup. The author is a userId; the dashboard names them from GET /v1/members. No type: Bugs (#1065) will read the same links as external tickets.
object
Only when reading ONE case: the states the caller may move it to from here, per the lifecycle table and who the caller is - a session is a person, a bearer token is automation. A screen renders exactly these as actions and never lists moves itself. The current state is not included.
When it last moved. Absent on a case that has not moved.
The suite this case is filed under, if any. Always a suite, never a folder (AC-03).
Five levels in rank order — low < normal < important < high < critical. Metadata: changing it does not bump the text version.
The time the case takes by hand (#1881), as 1h 30m, 5m or 45s — the spelling a write takes back. Absent while nobody estimated it (stored as NULL, which is not 0). Metadata: changing it does not bump the text version.
estimate in whole seconds, for a report that adds them up. Absent with estimate; send estimate to change it.
Order among the cases of its suite (or the unfiled ones) — dense, 0-based, renumbered on every write that places a case (AC-05).
An assertion about the case (#1246): this test is unstable by nature. Diagnostics — a person or a machine may set it. Always present, false until set. Metadata: changing it does not bump the text version. ?flaky=1 (or 0) on the list filters on it.
Field id → value, as stored on the entity. A string field takes text (1–1 000 characters), a number field a number, a list field the id of one option — or, when multiple, an array of option ids without repeats (up to 50), kept in the order written. At most 16 384 bytes of JSON.
object
A person’s decision about the case (#1246): it still runs and its results are recorded, and a failure is not to count toward the run (#1247). Not the same as isFlaky — a flaky case need not be muted, and a muted one need not be flaky. Always present, false until set; a parameterized case is one case, so muting it mutes every row of its parameters. A mute may come from a token; the audit names who — a person, or the token (a runner’s is_muted is an annotation in the code, written and reviewed like test.skip). Every set and clear is an entry in the case’s audit feed. ?muted=1 (or 0) on the list filters on it.
Read from the title by the server, each once, in first-seen order. Derived: sent back, never taken on the way in as a field of its own — tags on a write rewrites the title.
Free text beside the steps. Part of what a result was produced against, so a change bumps the text version (AC-09). Absent when never set. 16 384 bytes of UTF-8 at most.
What must hold before the steps. Same rules as description.
The parameters table (D7). Content like the two texts: a change bumps the version.
object
The identifiers this case has elsewhere (D2) — attached on every read; absent when it has none.
An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.
object
The id of this case’s eval in plune.yaml. An ingested run attaches its results to the case carrying the same id (ADR 0010); absent for cases the CLI does not run.
Only when reading ONE case: the source text of the one test() call an automated case references — the call itself, header and body, exactly as written in the file. Absent on a case that has never had one; present-and-unchanged when the file most recently failed to parse (the last known snapshot, not empty). 16 384 bytes of UTF-8 at most, the same ceiling description/precondition already carry.
SHA-256 hex digest of codeSnapshot, recomputed by the server on every write that touches it (ADR-0005) — never accepted from a client. Present whenever codeSnapshot is, and on the list too even though the text itself is not: a re-importer hashes its own freshly parsed text the same way and compares against this to decide changed vs unchanged without a second request per case.
Who wrote it (B8-team T3): the userId of the caller of POST /v1/test-cases or of a duplicate. Set by the server, never taken from a body, never changed by a PATCH. Absent on a case from before names were kept and on one the review queue created. Resolve it through GET /v1/members.
Who is to run it (B8-team T4): the userId of a member of the project. Set by PATCH {assigneeId} or the bulk assign action, never on create; null on a PATCH takes it away; leaving the project takes it away too. Absent means unassigned. GET /v1/test-cases?assignee=me|none|<userId> filters on it.
Where the text of the case came from (#1879), derived on read - no column, so every case has one, the ones from before included. manual: a person wrote it. ai-generated: created from an approved proposal in the review queue, or a field accepted from an AI draft, and not corrected since. ai-edited: a person corrected AI-written content - the reviewer before approving, or anyone after (the version moved), or the text of an accepted draft. Read-only; on the list and on the single read. GET /v1/test-cases?origin= filters on it.
The facts behind origin, on the single read only (#1879) - for a tooltip. Only what is already stored; ids of people, never addresses.
object
The userId that put the proposal in the review queue.
What the supplier said the proposal was derived from.
object
Fields a person changed on AI text: title, steps, expected (the reviewer, #1878), description, precondition (after an accepted draft).
A case made from a proposal whose content (title, texts, steps, parameters) has changed since.
The userId of the latest approval of the standing revision; absent when the policy merged it by itself.
Where the case came from, present only on one an approved proposal created (B-1). Read through that proposal rather than stored on the case, so it answers for every case ever created this way and can never disagree with the queue entry beside it. Absent on a case a person wrote — and absent, too, when the proposal named neither field: both mean nothing was reported.
object
object
The test-design technique the supplier named, as they named it.
Where a field’s text came from, once it was accepted from an AI draft (automated-case-description-draft, AC-17): per field, the model, the date of the draft, who asked, who accepted and when, and — if a person edited the text afterwards — who and when. A field has an entry only while its text is the one a draft produced: the next accepted draft replaces it and clearing the field drops it. The key is absent when no field was ever accepted from a draft. Written only by the case store’s single write path — never taken from a client body, on a PATCH or on Markdown import alike.
object
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
#829: the provider that made the draft, as recorded on the call. Absent on text accepted before the fallback existed.
#829: true when the fallback provider made the draft. Absent (not false) on text accepted before the fallback existed.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
#829: the provider that made the draft, as recorded on the call. Absent on text accepted before the fallback existed.
#829: true when the fallback provider made the draft. Absent (not false) on text accepted before the fallback existed.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
Stored with the case and returned unchanged. It links to nothing: golden sets were removed in ADR 0013, so this is a label the platform keeps, not a reference it resolves.
One assertion: a type plus whatever that type needs (ADR 0012). The remaining fields depend on type (e.g. values for contains-all, criteria for llm-judge).
object
Example
{ "schemaVersion": 2, "id": "6f1c2b3a-0000-4000-8000-0000000000c1", "type": "automated", "specRef": "e2e/checkout.spec.ts#empty-cart", "projectId": "u-shop::default", "title": "Checkout — empty cart @smoke @checkout", "state": "draft", "suiteId": "6f1c2b3a-0000-4000-8000-0000000000a1", "description": "An empty cart must not reach the payment step.", "priority": "high", "position": 0, "isFlaky": false, "isMuted": false, "createdBy": "u-shop", "createdAt": "2026-09-16T09:12:04.151Z", "updatedAt": "2026-09-16T09:12:04.151Z", "externalKeys": [ { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — empty cart" } ], "status": "draft", "tags": [ "smoke", "checkout" ]}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
object
Examples
AC-03 — the target is a folder
{ "error": "suite '6f1c2b3a-0000-4000-8000-0000000000f1' is a folder — cases go in suites"}AC-06 — nothing is left of the title once the tags are removed
{ "error": "validation failed — title: a title cannot consist of tags only"}AC-06 — the refusal names the tag
{ "error": "validation failed — tags.1: invalid tag 'smoke test' — a tag is 1 to 119 characters from \\w = - _ ( ) . : &"}AC-06 — the five levels are listed
{ "error": "validation failed — priority: must be one of low, normal, important, high, critical"}No / invalid token or session
Two refusals share this status, and what to do about them is opposite. Rate-limited — the per-route throttle, or the per-minute request limit of the plan an API token counts by (#1442, #2092): Retry-After carries the seconds until the window rolls over, and waiting that long is the way out. Quota reached — a ceiling on what the project holds (runs in the last 24 hours, test cases, items waiting in the review queue): { error, quota }. A retry straight away frees nothing, so do not loop on it; the message says what makes room — deleting cases, working the queue down, an operator raising the limit — and the run quota, a rolling 24 hours, frees its oldest runs as they age out. Tell the two apart by the body: a 429 whose body has a quota object is a quota, one without it is a throttle. quota carries kind, limit, used, remaining and resetsAt: the figures behind the sentence, and for the run quota the instant its oldest run ages out (#1258). Retry-After is sent on a quota only where there is such an instant (the run quota: the whole seconds, at least 1, until resetsAt); the case, review-queue and project quotas free only by an action, have resetsAt: null, and send no Retry-After. GET /v1/quotas reads the same figures without a refusal.
object
The body of a 429 that is a quota and not a throttle: the sentence, and the figures behind it.
object
What was reached and the way out.
object
The ceiling. A write that would take used past it is refused with 429.
What the project holds now, counted by the same query that refuses a write.
limit - used, never below 0.
When the oldest counted run ages out of the rolling 24 hours and a slot frees on its own. null for the two quotas only an action frees (delete cases, decide queue items), and for a run quota with no runs in the window.
Example
{ "quota": { "kind": "runsPerDay" }}Headers
Section titled “Headers ”Seconds until the window rolls over on a throttle; on a quota, until quota.resetsAt — sent only for the run quota, which has one