Ingest a Cairn run
const url = 'https://beta-api.plune.ai/v1/ingest/cairn';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"schemaVersion":1,"run":{"runId":"cairn-2026-08-30-0001","mode":"api","target":"https://shop.example/api"},"cases":[{"stableId":"9f2a4c1e","title":"health check answers 200","execution":"auto","steps":["GET /health"],"expected":"HTTP 200","verdict":"passed","technique":"happy-path"},{"stableId":"3b7d0e55","title":"order with a negative quantity is refused","execution":"auto","steps":["POST /orders"],"expected":"HTTP 422"}]}'};
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/ingest/cairn \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "schemaVersion": 1, "run": { "runId": "cairn-2026-08-30-0001", "mode": "api", "target": "https://shop.example/api" }, "cases": [ { "stableId": "9f2a4c1e", "title": "health check answers 200", "execution": "auto", "steps": [ "GET /health" ], "expected": "HTTP 200", "verdict": "passed", "technique": "happy-path" }, { "stableId": "3b7d0e55", "title": "order with a negative quantity is refused", "execution": "auto", "steps": [ "POST /orders" ], "expected": "HTTP 422" } ] }'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).
Authorizations
Section titled “Authorizations ”Request Body required
Section titled “Request Body required ”object
object
Idempotency key — the same run ingested twice changes nothing.
Page URL or API base URL that was tested.
object
object
Cairn’s content-derived identity — the dedup key.
Automatable, or a person runs it.
ABSENT means the run never executed this case (a design run writes cases and runs nothing) — not “no result yet”. Only an executed case can become a Result. blocked: the request got no answer (a transport error after the retries), so the result is blocked, not failed.
object
Why the agent thinks this case is worth having. Read by opening an item: at 400 characters it does not fit a queue row.
Identifiers this test already has elsewhere, so the queue can say which existing case a proposal looks like. An unknown kind refuses the WHOLE ingest and names the accepted ones: a partial acceptance would leave the agent believing it had sent something matchable, and the only symptom would be an entry with no comparison — indistinguishable from a case that genuinely is new.
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
Example
{ "schemaVersion": 1, "run": { "runId": "cairn-2026-08-30-0001", "mode": "api", "target": "https://shop.example/api" }, "cases": [ { "stableId": "9f2a4c1e", "title": "health check answers 200", "execution": "auto", "steps": [ "GET /health" ], "expected": "HTTP 200", "verdict": "passed", "technique": "happy-path" }, { "stableId": "3b7d0e55", "title": "order with a negative quantity is refused", "execution": "auto", "steps": [ "POST /orders" ], "expected": "HTTP 422" } ]}Responses
Section titled “ Responses ”Already recorded — the same answer as the first time
The three counters always add up to the number of cases sent, so a caller can see nothing was dropped.
object
The run row this ingest recorded.
Verdicts attached to cases the project already has.
Cases queued for a human to approve — none were created.
Already queued, already rejected, repeated, or never executed.
Example generated
{ "runId": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "linked": 1, "proposed": 1, "skipped": 1}Recorded
The three counters always add up to the number of cases sent, so a caller can see nothing was dropped.
object
The run row this ingest recorded.
Verdicts attached to cases the project already has.
Cases queued for a human to approve — none were created.
Already queued, already rejected, repeated, or never executed.
Example generated
{ "runId": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "linked": 1, "proposed": 1, "skipped": 1}Unreadable body, or a payload version this server does not know
object
Example generated
{ "error": "example"}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
The API could not take the request now, and nothing was stored: code pool_overloaded — every database connection is busy and the queue for one is long (refused at once, or the wait for a connection ran out); statement_timeout — Postgres cancelled a statement that ran for 60 s. Retry-After carries the seconds to wait (a few of jitter on top, so that what was refused together does not come back together). The CLI waits that long and sends the batch again, then falls back to its file.
object
Example
{ "error": "the server is busy — the database has no free connection; retry after the time in Retry-After", "code": "pool_overloaded"}Headers
Section titled “Headers ”Seconds to wait before the same request is sent again; grows with the length of the queue for a connection